@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.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
 
@@ -158,22 +159,44 @@ var PayloadRateLimitStore = class {
158
159
  return { payload: context };
159
160
  }
160
161
  };
162
+ function principalRateKey(user) {
163
+ if (!user || user.id === void 0 || user.id === null) return "anonymous";
164
+ const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
165
+ return `${collection}:${String(user.id)}`;
166
+ }
161
167
  var RateLimiter = class {
162
- constructor(windowMs, maxRequests, store) {
168
+ /**
169
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
170
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
171
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
172
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
173
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
174
+ * by five different endpoints. Always pass one; it is optional only because
175
+ * `RateLimiter` is part of the published API surface.
176
+ */
177
+ constructor(windowMs, maxRequests, store, namespace) {
163
178
  this.windowMs = windowMs;
164
179
  this.maxRequests = maxRequests;
165
180
  this.store = store ?? new MemoryRateLimitStore();
181
+ this.prefix = namespace ? `${namespace}:` : "";
166
182
  }
167
183
  windowMs;
168
184
  maxRequests;
169
185
  store;
186
+ prefix;
187
+ /** The key actually written to the store. Exposed for assertions in tests. */
188
+ scopedKey(key) {
189
+ return `${this.prefix}${key}`;
190
+ }
170
191
  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);
192
+ const scoped = this.scopedKey(key);
193
+ const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
172
194
  return entry.count > this.maxRequests;
173
195
  }
174
196
  async reset(key, context) {
175
- if (context === void 0) await this.store.reset(key);
176
- else await this.store.reset(key, context);
197
+ const scoped = this.scopedKey(key);
198
+ if (context === void 0) await this.store.reset(scoped);
199
+ else await this.store.reset(scoped, context);
177
200
  }
178
201
  };
179
202
  var DEFAULT_INBOUND_EMAIL_LIMITS = {
@@ -211,7 +234,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
211
234
 
212
235
  // src/endpoints/capabilities.ts
213
236
  function createInboundEmailEndpoint(capability, store) {
214
- const limiter = new RateLimiter(6e4, 60, store);
237
+ const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
215
238
  return {
216
239
  path: "/support-webhook/inbound-email",
217
240
  method: "post",
@@ -242,7 +265,7 @@ function createInboundEmailEndpoint(capability, store) {
242
265
  };
243
266
  }
244
267
  function createProjectSuggestionsEndpoint(slugs, capability, store) {
245
- const limiter = new RateLimiter(6e4, 20, store);
268
+ const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
246
269
  return {
247
270
  path: "/support/suggest-projects",
248
271
  method: "post",
@@ -250,7 +273,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
250
273
  if (!req.user || req.user.collection !== slugs.users) {
251
274
  return Response.json({ error: "Unauthorized" }, { status: 401 });
252
275
  }
253
- const key = req.user?.id ? String(req.user.id) : "anonymous";
276
+ const key = principalRateKey(req.user);
254
277
  if (await limiter.check(key, req)) {
255
278
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
256
279
  }
@@ -259,7 +282,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
259
282
  };
260
283
  }
261
284
  function createTicketTitleEndpoint(slugs, capability, store) {
262
- const limiter = new RateLimiter(6e4, 20, store);
285
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
263
286
  return {
264
287
  path: "/support/ticket-title",
265
288
  method: "post",
@@ -281,7 +304,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
281
304
  };
282
305
  }
283
306
  function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
284
- const limiter = new RateLimiter(6e4, 5, store);
307
+ const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
285
308
  return {
286
309
  path: "/support/generate-missing-titles",
287
310
  method: "post",
@@ -437,6 +460,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
437
460
  var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
438
461
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
439
462
  var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
463
+ var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
464
+ function resolveStaffPrefSlug(payload, staffSlug) {
465
+ if (staffSlug) return staffSlug;
466
+ const config = payload.config;
467
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
468
+ if (typeof registered === "string" && registered) return registered;
469
+ return config?.admin?.user || "users";
470
+ }
440
471
  var DEFAULT_SETTINGS = {
441
472
  email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
442
473
  ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
@@ -448,10 +479,13 @@ var DEFAULT_USER_PREFS = {
448
479
  locale: "fr",
449
480
  signature: ""
450
481
  };
451
- var settingsCache = null;
482
+ var settingsCache = /* @__PURE__ */ new Map();
452
483
  var SETTINGS_TTL_MS = 6e4;
484
+ var SETTINGS_CACHE_MAX = 8;
485
+ var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
453
486
  function invalidateSupportSettingsCache() {
454
- settingsCache = null;
487
+ settingsCache.clear();
488
+ warnedForeignSettingsRow.clear();
455
489
  }
456
490
  function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
457
491
  const autoClose = { ...base.autoClose, ...stored?.autoClose };
@@ -468,9 +502,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
468
502
  )
469
503
  };
470
504
  }
471
- async function readSupportSettingsState(payload) {
472
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
473
- return settingsCache.value;
505
+ async function readSupportSettingsState(payload, staffSlug) {
506
+ const staff = resolveStaffPrefSlug(payload, staffSlug);
507
+ const cached = settingsCache.get(staff);
508
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
509
+ return cached.value;
474
510
  }
475
511
  let value = {
476
512
  settings: mergeSupportSettings(null),
@@ -478,7 +514,10 @@ async function readSupportSettingsState(payload) {
478
514
  };
479
515
  try {
480
516
  const prefs = await dbFind(payload, "payload-preferences", {
481
- where: { key: { equals: PREF_KEY } },
517
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
518
+ // security boundary: without it any authenticated principal can plant a
519
+ // `support-settings` row and own the plugin's server settings.
520
+ where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
482
521
  // The upsert is scoped per admin user, so several rows can share the key.
483
522
  // Sorting makes "last write wins" deterministic instead of arbitrary.
484
523
  sort: "-updatedAt",
@@ -491,22 +530,43 @@ async function readSupportSettingsState(payload) {
491
530
  const featuresConfigured = !!stored.features && typeof stored.features === "object";
492
531
  const settings = mergeSupportSettings(stored);
493
532
  if (!featuresConfigured) {
494
- settings.features.roundRobin = await readLegacyRoundRobin(payload);
533
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
495
534
  }
496
535
  value = { settings, featuresConfigured };
536
+ } else {
537
+ await warnOnForeignSettingsRow(payload, staff);
497
538
  }
498
539
  } catch {
499
540
  }
500
- settingsCache = { value, ts: Date.now() };
541
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
542
+ settingsCache.set(staff, { value, ts: Date.now() });
501
543
  return value;
502
544
  }
503
- async function readSupportSettings(payload) {
504
- return (await readSupportSettingsState(payload)).settings;
545
+ async function warnOnForeignSettingsRow(payload, staff) {
546
+ if (warnedForeignSettingsRow.has(staff)) return;
547
+ try {
548
+ const any = await dbFind(payload, "payload-preferences", {
549
+ where: { key: { equals: PREF_KEY } },
550
+ limit: 1,
551
+ depth: 0,
552
+ overrideAccess: true
553
+ });
554
+ if (any.docs.length === 0) return;
555
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
556
+ warnedForeignSettingsRow.add(staff);
557
+ console.warn(
558
+ `[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.`
559
+ );
560
+ } catch {
561
+ }
562
+ }
563
+ async function readSupportSettings(payload, staffSlug) {
564
+ return (await readSupportSettingsState(payload, staffSlug)).settings;
505
565
  }
506
- async function readLegacyRoundRobin(payload) {
566
+ async function readLegacyRoundRobin(payload, staff) {
507
567
  try {
508
568
  const prefs = await dbFind(payload, "payload-preferences", {
509
- where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
569
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
510
570
  limit: 1,
511
571
  depth: 0,
512
572
  overrideAccess: true
@@ -518,11 +578,14 @@ async function readLegacyRoundRobin(payload) {
518
578
  }
519
579
  return DEFAULT_TICKETING_FEATURES.roundRobin;
520
580
  }
521
- async function readUserPrefs(payload, userId) {
581
+ async function readUserPrefs(payload, userId, staffSlug) {
522
582
  try {
523
583
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
524
584
  const prefs = await dbFind(payload, "payload-preferences", {
525
- where: { key: { equals: key } },
585
+ where: {
586
+ key: { equals: key },
587
+ "user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
588
+ },
526
589
  limit: 1,
527
590
  depth: 0,
528
591
  overrideAccess: true
@@ -560,7 +623,7 @@ function getModel(aiSettings) {
560
623
  return aiSettings.model || "claude-haiku-4-5-20251001";
561
624
  }
562
625
  function createAiEndpoint(slugs, store) {
563
- const limiter = new RateLimiter(6e4, 30, store);
626
+ const limiter = new RateLimiter(6e4, 30, store, "ai");
564
627
  return {
565
628
  path: "/support/ai",
566
629
  method: "post",
@@ -568,7 +631,7 @@ function createAiEndpoint(slugs, store) {
568
631
  try {
569
632
  const payload = req.payload;
570
633
  requireAdmin(req, slugs);
571
- if (await limiter.check(String(req.user.id), req)) {
634
+ if (await limiter.check(principalRateKey(req.user), req)) {
572
635
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
573
636
  }
574
637
  const settings = await readSupportSettings(payload);
@@ -813,14 +876,14 @@ ${kbText || "(vide)"}`;
813
876
 
814
877
  // src/endpoints/ai-agent.ts
815
878
  function createAiAgentEndpoint(slugs, store) {
816
- const limiter = new RateLimiter(6e4, 10, store);
879
+ const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
817
880
  return {
818
881
  path: "/support/ai-agent",
819
882
  method: "post",
820
883
  handler: async (req) => {
821
884
  try {
822
885
  requireAdmin(req, slugs);
823
- if (await limiter.check(String(req.user.id), req)) {
886
+ if (await limiter.check(principalRateKey(req.user), req)) {
824
887
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
825
888
  }
826
889
  let body = {};
@@ -860,11 +923,11 @@ function getModel2(aiSettings) {
860
923
  }
861
924
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
862
925
  function createClientIntelligenceEndpoint(slugs, store) {
863
- const limiter = new RateLimiter(6e4, 20, store);
926
+ const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
864
927
  const getHandler = async (req) => {
865
928
  try {
866
929
  requireAdmin(req, slugs);
867
- if (await limiter.check(String(req.user.id), req)) {
930
+ if (await limiter.check(principalRateKey(req.user), req)) {
868
931
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
869
932
  }
870
933
  const payload = req.payload;
@@ -896,7 +959,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
896
959
  const postHandler = async (req) => {
897
960
  try {
898
961
  requireAdmin(req, slugs);
899
- if (await limiter.check(String(req.user.id), req)) {
962
+ if (await limiter.check(principalRateKey(req.user), req)) {
900
963
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
901
964
  }
902
965
  const payload = req.payload;
@@ -1525,6 +1588,14 @@ function createSplitTicketEndpoint(slugs) {
1525
1588
  // src/endpoints/typing.ts
1526
1589
  var typingState = /* @__PURE__ */ new Map();
1527
1590
  var TYPING_TTL = 5e3;
1591
+ var MAX_TYPING_KEYS = 500;
1592
+ var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
1593
+ function normalizeTicketId(raw) {
1594
+ if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
1595
+ if (typeof raw !== "string") return null;
1596
+ const value = raw.trim();
1597
+ return TICKET_ID_PATTERN.test(value) ? value : null;
1598
+ }
1528
1599
  function cleanExpired(ticketId) {
1529
1600
  const state = typingState.get(ticketId);
1530
1601
  if (!state) return;
@@ -1539,6 +1610,36 @@ function cleanExpired(ticketId) {
1539
1610
  }
1540
1611
  if (!state.admin && !state.client) typingState.delete(ticketId);
1541
1612
  }
1613
+ function sweepExpired() {
1614
+ for (const key of Array.from(typingState.keys())) cleanExpired(key);
1615
+ }
1616
+ function evictOldest() {
1617
+ let oldestKey = null;
1618
+ let oldestTs = Infinity;
1619
+ for (const [key, state] of typingState) {
1620
+ const ts = Math.max(state.admin || 0, state.client || 0);
1621
+ if (ts < oldestTs) {
1622
+ oldestTs = ts;
1623
+ oldestKey = key;
1624
+ }
1625
+ }
1626
+ if (oldestKey !== null) typingState.delete(oldestKey);
1627
+ }
1628
+ async function mayAccessTicket(req, slugs, ticketId) {
1629
+ const collection = req.user?.collection;
1630
+ if (collection !== slugs.users && collection !== slugs.supportClients) return false;
1631
+ try {
1632
+ const doc = await dbFindByID(req.payload, slugs.tickets, {
1633
+ id: ticketId,
1634
+ depth: 0,
1635
+ overrideAccess: false,
1636
+ user: req.user
1637
+ });
1638
+ return !!doc;
1639
+ } catch {
1640
+ return false;
1641
+ }
1642
+ }
1542
1643
  function createTypingPostEndpoint(slugs) {
1543
1644
  return {
1544
1645
  path: "/support/typing",
@@ -1549,11 +1650,18 @@ function createTypingPostEndpoint(slugs) {
1549
1650
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1550
1651
  }
1551
1652
  const { ticketId } = await req.json();
1552
- if (!ticketId) {
1653
+ const key = normalizeTicketId(ticketId);
1654
+ if (!key) {
1553
1655
  return Response.json({ error: "ticketId required" }, { status: 400 });
1554
1656
  }
1555
- const key = String(ticketId);
1657
+ if (!await mayAccessTicket(req, slugs, key)) {
1658
+ return Response.json({ error: "Forbidden" }, { status: 403 });
1659
+ }
1556
1660
  const state = typingState.get(key) || {};
1661
+ if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
1662
+ sweepExpired();
1663
+ if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
1664
+ }
1557
1665
  if (req.user.collection === slugs.users) {
1558
1666
  state.admin = Date.now();
1559
1667
  state.adminName = req.user.firstName || "Support";
@@ -1574,30 +1682,33 @@ function createTypingGetEndpoint(slugs) {
1574
1682
  path: "/support/typing",
1575
1683
  method: "get",
1576
1684
  handler: async (req) => {
1685
+ const idle = { typing: false, name: null };
1577
1686
  try {
1578
1687
  if (!req.user) {
1579
1688
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1580
1689
  }
1581
1690
  const url = new URL(req.url);
1582
- const ticketId = url.searchParams.get("ticketId");
1583
- if (!ticketId) {
1691
+ const key = normalizeTicketId(url.searchParams.get("ticketId"));
1692
+ if (!key) {
1584
1693
  return Response.json({ error: "ticketId required" }, { status: 400 });
1585
1694
  }
1586
- cleanExpired(ticketId);
1587
- const state = typingState.get(ticketId);
1695
+ cleanExpired(key);
1696
+ const state = typingState.get(key);
1697
+ if (!state) return Response.json(idle);
1698
+ if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
1588
1699
  if (req.user.collection === slugs.users) {
1589
1700
  return Response.json({
1590
- typing: !!state?.client,
1591
- name: state?.clientName || null
1701
+ typing: !!state.client,
1702
+ name: state.clientName || null
1592
1703
  });
1593
1704
  } else {
1594
1705
  return Response.json({
1595
- typing: !!state?.admin,
1596
- name: state?.adminName || null
1706
+ typing: !!state.admin,
1707
+ name: state.adminName || null
1597
1708
  });
1598
1709
  }
1599
1710
  } catch {
1600
- return Response.json({ typing: false, name: null });
1711
+ return Response.json(idle);
1601
1712
  }
1602
1713
  }
1603
1714
  };
@@ -1760,7 +1871,14 @@ function createSignatureGetEndpoint(slugs) {
1760
1871
  const payload = req.payload;
1761
1872
  requireAdmin(req, slugs);
1762
1873
  const prefs = await dbFind(payload, "payload-preferences", {
1763
- where: { key: { equals: `${PREF_KEY2}-${req.user.id}` } },
1874
+ // Scope to the staff auth collection: `payload-preferences` accepts a
1875
+ // write from ANY authenticated principal, and ids collide between auth
1876
+ // collections — a support-client with the same id would otherwise own
1877
+ // the `email-signature-<id>` row read back for the agent.
1878
+ where: {
1879
+ key: { equals: `${PREF_KEY2}-${req.user.id}` },
1880
+ "user.relationTo": { equals: slugs.users }
1881
+ },
1764
1882
  limit: 1,
1765
1883
  depth: 0,
1766
1884
  overrideAccess: true
@@ -1787,7 +1905,7 @@ function createSignaturePostEndpoint(slugs) {
1787
1905
  const { signature } = await req.json();
1788
1906
  const key = `${PREF_KEY2}-${req.user.id}`;
1789
1907
  const existing = await dbFind(payload, "payload-preferences", {
1790
- where: { key: { equals: key } },
1908
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
1791
1909
  limit: 1,
1792
1910
  depth: 0,
1793
1911
  overrideAccess: true
@@ -2548,7 +2666,7 @@ function formatFr(date, withTime) {
2548
2666
  });
2549
2667
  }
2550
2668
  function createSendReminderEndpoint(slugs, store) {
2551
- const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
2669
+ const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
2552
2670
  return {
2553
2671
  path: "/support/send-reminder",
2554
2672
  method: "post",
@@ -2556,7 +2674,7 @@ function createSendReminderEndpoint(slugs, store) {
2556
2674
  try {
2557
2675
  const payload = req.payload;
2558
2676
  requireAdmin(req, slugs);
2559
- if (await reminderLimiter.check(String(req.user.id), req)) {
2677
+ if (await reminderLimiter.check(principalRateKey(req.user), req)) {
2560
2678
  return Response.json(
2561
2679
  { error: "Trop de relances. R\xE9essayez dans une heure." },
2562
2680
  { status: 429 }
@@ -2859,8 +2977,14 @@ function createPurgeLogsEndpoint(slugs) {
2859
2977
  }
2860
2978
 
2861
2979
  // src/endpoints/chatbot.ts
2862
- function createChatbotEndpoint(slugs, store) {
2863
- const chatbotLimiter = new RateLimiter(6e4, 10, store);
2980
+ var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
2981
+ function resolveMaxPerHour(explicit) {
2982
+ const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
2983
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
2984
+ }
2985
+ function createChatbotEndpoint(slugs, store, maxPerHour) {
2986
+ const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
2987
+ const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
2864
2988
  return {
2865
2989
  path: "/support/chatbot",
2866
2990
  method: "post",
@@ -2880,6 +3004,15 @@ function createChatbotEndpoint(slugs, store) {
2880
3004
  if (!question?.trim() || question.trim().length < 5) {
2881
3005
  return Response.json({ error: "Question too short" }, { status: 400 });
2882
3006
  }
3007
+ if (await globalLimiter.check("all", req)) {
3008
+ return Response.json({
3009
+ answer: null,
3010
+ confidence: 0,
3011
+ suggestion: "create_ticket",
3012
+ aiUnavailable: true,
3013
+ message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
3014
+ });
3015
+ }
2883
3016
  const payload = req.payload;
2884
3017
  const articles = await dbFind(payload, slugs.knowledgeBase, {
2885
3018
  where: { published: { equals: true } },
@@ -2992,8 +3125,8 @@ function createChatGetEndpoint(slugs) {
2992
3125
  };
2993
3126
  }
2994
3127
  function createChatPostEndpoint(slugs, store) {
2995
- const chatSessionLimiter = new RateLimiter(36e5, 5, store);
2996
- const chatMessageLimiter = new RateLimiter(6e4, 15, store);
3128
+ const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
3129
+ const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
2997
3130
  return {
2998
3131
  path: "/support/chat",
2999
3132
  method: "post",
@@ -3009,8 +3142,9 @@ function createChatPostEndpoint(slugs, store) {
3009
3142
  }
3010
3143
  const { action, session, message } = body;
3011
3144
  const userId = String(req.user.id);
3145
+ const rateKey = principalRateKey(req.user);
3012
3146
  if (action === "start") {
3013
- if (await chatSessionLimiter.check(userId, req)) {
3147
+ if (await chatSessionLimiter.check(rateKey, req)) {
3014
3148
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
3015
3149
  }
3016
3150
  const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
@@ -3027,7 +3161,7 @@ function createChatPostEndpoint(slugs, store) {
3027
3161
  return Response.json({ session: sessionId, messages: [systemMsg] });
3028
3162
  }
3029
3163
  if (action === "send" && session && message) {
3030
- if (await chatMessageLimiter.check(userId, req)) {
3164
+ if (await chatMessageLimiter.check(rateKey, req)) {
3031
3165
  return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
3032
3166
  }
3033
3167
  const trimmedMessage = String(message).trim();
@@ -3267,7 +3401,7 @@ function createAdminChatGetEndpoint(slugs) {
3267
3401
  };
3268
3402
  }
3269
3403
  function createAdminChatPostEndpoint(slugs, store) {
3270
- const adminChatLimiter = new RateLimiter(6e4, 30, store);
3404
+ const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
3271
3405
  return {
3272
3406
  path: "/support/admin-chat",
3273
3407
  method: "post",
@@ -3296,7 +3430,7 @@ function createAdminChatPostEndpoint(slugs, store) {
3296
3430
  }
3297
3431
  const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
3298
3432
  if (action === "send" && message) {
3299
- if (await adminChatLimiter.check(String(req.user.id), req)) {
3433
+ if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
3300
3434
  return Response.json({ error: "Rate limit atteint." }, { status: 429 });
3301
3435
  }
3302
3436
  const trimmedMessage = String(message).trim();
@@ -4251,14 +4385,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
4251
4385
 
4252
4386
  // src/endpoints/ticket-synthesis.ts
4253
4387
  function createTicketSynthesisEndpoint(slugs, generator, store) {
4254
- const limiter = new RateLimiter(6e4, 20, store);
4388
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
4255
4389
  return {
4256
4390
  path: "/support/ticket-synthesis",
4257
4391
  method: "post",
4258
4392
  handler: async (req) => {
4259
4393
  try {
4260
4394
  requireAdmin(req, slugs);
4261
- if (await limiter.check(String(req.user.id), req)) {
4395
+ if (await limiter.check(principalRateKey(req.user), req)) {
4262
4396
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
4263
4397
  }
4264
4398
  const payload = req.payload;
@@ -4302,15 +4436,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
4302
4436
  }
4303
4437
 
4304
4438
  // src/endpoints/email-stats.ts
4305
- function createEmailStatsEndpoint(slugs) {
4439
+ function createEmailStatsEndpoint(slugs, store) {
4440
+ const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
4306
4441
  return {
4307
4442
  path: "/support/email-stats",
4308
4443
  method: "get",
4309
4444
  handler: async (req) => {
4310
4445
  try {
4311
4446
  const payload = req.payload;
4312
- if (!req.user) {
4313
- return Response.json({ error: "Unauthorized" }, { status: 401 });
4447
+ requireAdmin(req, slugs);
4448
+ if (await statsLimiter.check(principalRateKey(req.user), req)) {
4449
+ return Response.json({ error: "Too many requests." }, { status: 429 });
4314
4450
  }
4315
4451
  const url = new URL(req.url);
4316
4452
  const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
@@ -4377,6 +4513,8 @@ function createEmailStatsEndpoint(slugs) {
4377
4513
  actions: Object.fromEntries(actionMap)
4378
4514
  });
4379
4515
  } catch (err) {
4516
+ const authResponse = handleAuthError(err);
4517
+ if (authResponse) return authResponse;
4380
4518
  console.error("[email-stats] Error:", err);
4381
4519
  return Response.json({ error: "Internal server error" }, { status: 500 });
4382
4520
  }
@@ -4809,7 +4947,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4809
4947
 
4810
4948
  // src/endpoints/resend-notification.ts
4811
4949
  function createResendNotificationEndpoint(slugs, store) {
4812
- const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
4950
+ const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
4813
4951
  return {
4814
4952
  path: "/support/resend-notification",
4815
4953
  method: "post",
@@ -4817,7 +4955,7 @@ function createResendNotificationEndpoint(slugs, store) {
4817
4955
  try {
4818
4956
  const payload = req.payload;
4819
4957
  requireAdmin(req, slugs);
4820
- if (await resendLimiter.check(String(req.user.id), req)) {
4958
+ if (await resendLimiter.check(principalRateKey(req.user), req)) {
4821
4959
  return Response.json(
4822
4960
  { error: "Trop de renvois. R\xE9essayez dans une heure." },
4823
4961
  { status: 429 }
@@ -5018,10 +5156,48 @@ function createSeedKbEndpoint(slugs) {
5018
5156
  }
5019
5157
  };
5020
5158
  }
5159
+ var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
5160
+ function challengeSecret() {
5161
+ const secret = process.env.PAYLOAD_SECRET;
5162
+ if (!secret) {
5163
+ throw new Error(
5164
+ "[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
5165
+ );
5166
+ }
5167
+ return secret;
5168
+ }
5169
+ function normalizeEmail(email) {
5170
+ return String(email).trim().toLowerCase();
5171
+ }
5172
+ function sign(email, expiresAt) {
5173
+ return createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
5174
+ }
5175
+ function issueTwoFactorChallenge(email, now = Date.now()) {
5176
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
5177
+ return `${expiresAt}.${sign(email, expiresAt)}`;
5178
+ }
5179
+ function verifyTwoFactorChallenge(email, token, now = Date.now()) {
5180
+ if (typeof email !== "string" || !email || typeof token !== "string") return false;
5181
+ const separator = token.indexOf(".");
5182
+ if (separator <= 0) return false;
5183
+ const expiresAt = Number(token.slice(0, separator));
5184
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
5185
+ const received = token.slice(separator + 1);
5186
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false;
5187
+ let expected;
5188
+ try {
5189
+ expected = sign(email, expiresAt);
5190
+ } catch {
5191
+ return false;
5192
+ }
5193
+ const a = Buffer.from(expected, "hex");
5194
+ const b = Buffer.from(received.toLowerCase(), "hex");
5195
+ return a.length === b.length && timingSafeEqual(a, b);
5196
+ }
5021
5197
 
5022
5198
  // src/endpoints/login.ts
5023
5199
  function createLoginEndpoint(slugs, store) {
5024
- const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
5200
+ const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
5025
5201
  return {
5026
5202
  path: "/support/login",
5027
5203
  method: "post",
@@ -5074,7 +5250,12 @@ function createLoginEndpoint(slugs, store) {
5074
5250
  } catch (err) {
5075
5251
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
5076
5252
  if (errorMessage.includes("2FA_REQUIRED")) {
5077
- return Response.json({ requires2FA: true }, { status: 200 });
5253
+ let challenge;
5254
+ try {
5255
+ challenge = issueTwoFactorChallenge(email);
5256
+ } catch {
5257
+ }
5258
+ return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
5078
5259
  }
5079
5260
  let errorReason = "Identifiants incorrects";
5080
5261
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
@@ -5106,8 +5287,8 @@ function hashCode(code) {
5106
5287
  return createHmac("sha256", secret).update(code).digest("hex");
5107
5288
  }
5108
5289
  function createAuth2faEndpoint(slugs, store) {
5109
- const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
5110
- const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
5290
+ const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
5291
+ const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
5111
5292
  return {
5112
5293
  path: "/support/2fa",
5113
5294
  method: "post",
@@ -5120,12 +5301,18 @@ function createAuth2faEndpoint(slugs, store) {
5120
5301
  } catch {
5121
5302
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5122
5303
  }
5123
- const { action, email, code } = body;
5304
+ const { action, email, code, challenge } = body;
5124
5305
  if (!action || !email) {
5125
5306
  return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
5126
5307
  }
5127
5308
  const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
5128
5309
  if (action === "send") {
5310
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5311
+ return Response.json(
5312
+ { error: "Authentification requise avant l'envoi d'un code." },
5313
+ { status: 401 }
5314
+ );
5315
+ }
5129
5316
  if (await sendLimiter.check(email, req)) {
5130
5317
  return Response.json(genericSendResponse);
5131
5318
  }
@@ -5219,6 +5406,33 @@ function createAuth2faEndpoint(slugs, store) {
5219
5406
  }
5220
5407
  };
5221
5408
  }
5409
+ var OAUTH_STATE_COOKIE = "support-oauth-state";
5410
+ var OAUTH_STATE_MAX_AGE = 600;
5411
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
5412
+ function readCookie(header, name) {
5413
+ if (!header) return null;
5414
+ for (const part of header.split(";")) {
5415
+ const eq = part.indexOf("=");
5416
+ if (eq === -1) continue;
5417
+ if (part.slice(0, eq).trim() !== name) continue;
5418
+ try {
5419
+ return decodeURIComponent(part.slice(eq + 1).trim());
5420
+ } catch {
5421
+ return part.slice(eq + 1).trim();
5422
+ }
5423
+ }
5424
+ return null;
5425
+ }
5426
+ function clearedStateCookie() {
5427
+ const secure = process.env.NODE_ENV === "production";
5428
+ return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
5429
+ }
5430
+ function statesMatch(a, b) {
5431
+ if (!a || !b) return false;
5432
+ const left = crypto3.createHash("sha256").update(a).digest();
5433
+ const right = crypto3.createHash("sha256").update(b).digest();
5434
+ return crypto3.timingSafeEqual(left, right);
5435
+ }
5222
5436
  function createOAuthGoogleEndpoint(slugs, options) {
5223
5437
  return {
5224
5438
  path: "/support/oauth/google",
@@ -5235,7 +5449,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5235
5449
  }
5236
5450
  try {
5237
5451
  const body = await req.json();
5238
- const { action, code, state: queryState, cookieState } = body;
5452
+ const { action, code, state: queryState } = body;
5239
5453
  if (action === "login") {
5240
5454
  const oauthState = crypto3.randomBytes(32).toString("hex");
5241
5455
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5247,13 +5461,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
5247
5461
  state: oauthState,
5248
5462
  prompt: "select_account"
5249
5463
  });
5250
- return Response.json({
5251
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5252
- state: oauthState
5253
- });
5464
+ const secure = process.env.NODE_ENV === "production";
5465
+ const loginHeaders = new Headers({ "Content-Type": "application/json" });
5466
+ loginHeaders.append(
5467
+ "Set-Cookie",
5468
+ `${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
5469
+ );
5470
+ return new Response(
5471
+ JSON.stringify({
5472
+ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5473
+ // Kept for callers that echo it back in the redirect URL; the
5474
+ // server no longer trusts anything the caller returns.
5475
+ state: oauthState
5476
+ }),
5477
+ { status: 200, headers: loginHeaders }
5478
+ );
5254
5479
  }
5255
5480
  if (code) {
5256
- if (!cookieState || !queryState || cookieState !== queryState) {
5481
+ const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
5482
+ if (!statesMatch(issuedState, queryState)) {
5257
5483
  return Response.json({ error: "state_mismatch" }, { status: 400 });
5258
5484
  }
5259
5485
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5335,6 +5561,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
5335
5561
  overrideAccess: true
5336
5562
  });
5337
5563
  }
5564
+ const twoFactorDoc = await dbFindByID(payload, slugs.supportClients, {
5565
+ id: clientDoc.id,
5566
+ depth: 0,
5567
+ overrideAccess: true,
5568
+ showHiddenFields: true
5569
+ });
5570
+ if (twoFactorDoc?.twoFactorEnabled) {
5571
+ const raw = twoFactorDoc.twoFactorVerifiedAt;
5572
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
5573
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
5574
+ let challenge;
5575
+ try {
5576
+ challenge = issueTwoFactorChallenge(clientDoc.email);
5577
+ } catch {
5578
+ }
5579
+ return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
5580
+ status: 200,
5581
+ headers: new Headers({
5582
+ "Content-Type": "application/json",
5583
+ "Set-Cookie": clearedStateCookie()
5584
+ })
5585
+ });
5586
+ }
5587
+ await dbUpdate(payload, slugs.supportClients, {
5588
+ id: clientDoc.id,
5589
+ data: { twoFactorVerifiedAt: null },
5590
+ overrideAccess: true
5591
+ });
5592
+ }
5338
5593
  const secret = process.env.PAYLOAD_SECRET;
5339
5594
  if (!secret) {
5340
5595
  return Response.json({ error: "server_misconfigured" }, { status: 500 });
@@ -5378,6 +5633,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5378
5633
  "Set-Cookie",
5379
5634
  `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5380
5635
  );
5636
+ headers.append("Set-Cookie", clearedStateCookie());
5381
5637
  return new Response(JSON.stringify({ user: clientDoc, exp }), {
5382
5638
  status: 200,
5383
5639
  headers
@@ -5684,7 +5940,7 @@ ONLY JSON, nothing else.`
5684
5940
  }
5685
5941
  }
5686
5942
  function createImportConversationEndpoint(slugs, store) {
5687
- const importLimiter = new RateLimiter(36e5, 10, store);
5943
+ const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
5688
5944
  return {
5689
5945
  path: "/support/import-conversation",
5690
5946
  method: "post",
@@ -5827,6 +6083,137 @@ function createImportConversationEndpoint(slugs, store) {
5827
6083
  }
5828
6084
  };
5829
6085
  }
6086
+ function httpAllowed() {
6087
+ return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
6088
+ }
6089
+ var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
6090
+ function parseIPv4(host) {
6091
+ const parts = host.split(".");
6092
+ if (parts.length !== 4) return null;
6093
+ const octets = [];
6094
+ for (const part of parts) {
6095
+ if (!/^\d{1,3}$/.test(part)) return null;
6096
+ const n = Number(part);
6097
+ if (n > 255) return null;
6098
+ octets.push(n);
6099
+ }
6100
+ return octets;
6101
+ }
6102
+ function isPrivateIPv4(octets) {
6103
+ const [a, b] = octets;
6104
+ if (a === 0) return true;
6105
+ if (a === 10) return true;
6106
+ if (a === 127) return true;
6107
+ if (a === 169 && b === 254) return true;
6108
+ if (a === 172 && b >= 16 && b <= 31) return true;
6109
+ if (a === 192 && b === 168) return true;
6110
+ if (a === 192 && b === 0) return true;
6111
+ if (a === 198 && (b === 18 || b === 19)) return true;
6112
+ if (a === 100 && b >= 64 && b <= 127) return true;
6113
+ if (a >= 224) return true;
6114
+ return false;
6115
+ }
6116
+ function isPrivateIPv6(host) {
6117
+ const lower = host.toLowerCase();
6118
+ if (lower === "::" || lower === "::1") return true;
6119
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
6120
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
6121
+ if (lower.startsWith("ff")) return true;
6122
+ const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
6123
+ if (dotted) {
6124
+ const octets = parseIPv4(dotted[1]);
6125
+ return octets ? isPrivateIPv4(octets) : true;
6126
+ }
6127
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
6128
+ if (hex) {
6129
+ const high = parseInt(hex[1], 16);
6130
+ const low = parseInt(hex[2], 16);
6131
+ return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
6132
+ }
6133
+ return false;
6134
+ }
6135
+ function normalizeHost(hostname) {
6136
+ const host = hostname.trim().toLowerCase();
6137
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
6138
+ }
6139
+ function isBlockedHost(hostname) {
6140
+ const host = normalizeHost(hostname);
6141
+ if (!host) return true;
6142
+ if (host === "localhost") return true;
6143
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
6144
+ const v4 = parseIPv4(host);
6145
+ if (v4) return isPrivateIPv4(v4);
6146
+ if (host.includes(":")) return isPrivateIPv6(host);
6147
+ return false;
6148
+ }
6149
+ function validateWebhookUrl(raw) {
6150
+ if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
6151
+ let url;
6152
+ try {
6153
+ url = new URL(raw.trim());
6154
+ } catch {
6155
+ return { ok: false, reason: "invalid_url" };
6156
+ }
6157
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
6158
+ return { ok: false, reason: "scheme_not_allowed" };
6159
+ }
6160
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6161
+ return { ok: true, url };
6162
+ }
6163
+ var WEBHOOK_URL_MESSAGES = {
6164
+ invalid_url: "URL invalide.",
6165
+ scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
6166
+ private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
6167
+ };
6168
+ var LOOKUP_TIMEOUT_MS = 5e3;
6169
+ var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
6170
+ var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
6171
+ async function assertPublicHost(hostname) {
6172
+ const host = normalizeHost(hostname);
6173
+ if (isBlockedHost(host)) return false;
6174
+ if (parseIPv4(host) || host.includes(":")) return true;
6175
+ try {
6176
+ const addresses = await Promise.race([
6177
+ lookup(host, { all: true }),
6178
+ new Promise(
6179
+ (resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
6180
+ )
6181
+ ]);
6182
+ if (addresses === LOOKUP_TIMED_OUT) return false;
6183
+ if (!Array.isArray(addresses) || addresses.length === 0) return false;
6184
+ return addresses.every((entry) => !isBlockedHost(entry.address));
6185
+ } catch (error) {
6186
+ const code = error?.code;
6187
+ return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
6188
+ }
6189
+ }
6190
+ var BlockedRequestError = class extends Error {
6191
+ constructor(reason) {
6192
+ super(`Blocked outbound request: ${reason}`);
6193
+ this.reason = reason;
6194
+ this.name = "BlockedRequestError";
6195
+ }
6196
+ reason;
6197
+ };
6198
+ var MAX_REDIRECTS = 3;
6199
+ async function safeFetch(rawUrl, init) {
6200
+ let current = rawUrl;
6201
+ let body = init.body;
6202
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
6203
+ const validation = validateWebhookUrl(current);
6204
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
6205
+ if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
6206
+ const response = await fetch(current, { ...init, body, redirect: "manual" });
6207
+ if (response.status < 300 || response.status > 399) return response;
6208
+ const location = response.headers.get("location");
6209
+ if (!location) return response;
6210
+ current = new URL(location, current).toString();
6211
+ if (response.status === 303) body = void 0;
6212
+ }
6213
+ throw new BlockedRequestError("too_many_redirects");
6214
+ }
6215
+
6216
+ // src/utils/webhookDispatcher.ts
5830
6217
  function dispatchWebhook(data, event, payload, slugs) {
5831
6218
  const done = _dispatch(data, event, payload, slugs);
5832
6219
  return done;
@@ -5863,7 +6250,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5863
6250
  const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
5864
6251
  headers["X-Webhook-Signature"] = signature;
5865
6252
  }
5866
- const response = await fetch(endpoint.url, {
6253
+ const response = await safeFetch(endpoint.url, {
5867
6254
  method: "POST",
5868
6255
  headers,
5869
6256
  body,
@@ -6317,7 +6704,7 @@ function createUserPrefsGetEndpoint(slugs) {
6317
6704
  requireAdmin(req, slugs);
6318
6705
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6319
6706
  const prefs = await dbFind(payload, "payload-preferences", {
6320
- where: { key: { equals: key } },
6707
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6321
6708
  limit: 1,
6322
6709
  depth: 0,
6323
6710
  overrideAccess: true
@@ -6351,7 +6738,7 @@ function createUserPrefsPostEndpoint(slugs) {
6351
6738
  const body = await req.json();
6352
6739
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6353
6740
  const existing = await dbFind(payload, "payload-preferences", {
6354
- where: { key: { equals: key } },
6741
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6355
6742
  limit: 1,
6356
6743
  depth: 0,
6357
6744
  overrideAccess: true
@@ -6507,8 +6894,10 @@ function createTicketFeedbackEndpoint(slugs) {
6507
6894
  // src/endpoints/transfer-ticket.ts
6508
6895
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6509
6896
  var MAX_TRANSFERS_PER_DAY = 5;
6897
+ var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
6510
6898
  function createTransferTicketEndpoint(slugs, store) {
6511
- const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
6899
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
6900
+ const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
6512
6901
  return {
6513
6902
  path: "/support/tickets/:id/transfer",
6514
6903
  method: "post",
@@ -6543,12 +6932,18 @@ function createTransferTicketEndpoint(slugs, store) {
6543
6932
  if (!isAdmin && !isOwner) {
6544
6933
  return Response.json({ error: "Forbidden" }, { status: 403 });
6545
6934
  }
6546
- if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
6935
+ if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
6547
6936
  return Response.json(
6548
6937
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6549
6938
  { status: 429 }
6550
6939
  );
6551
6940
  }
6941
+ if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
6942
+ return Response.json(
6943
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
6944
+ { status: 429 }
6945
+ );
6946
+ }
6552
6947
  try {
6553
6948
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
6554
6949
  const existing = await dbCount(payload, slugs.emailLogs, {
@@ -6690,8 +7085,11 @@ function statusToLabel(status) {
6690
7085
  }
6691
7086
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6692
7087
  var MAX_COLLABORATORS_PER_TICKET = 20;
7088
+ var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
7089
+ var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
6693
7090
  function createInviteCollaboratorEndpoint(slugs, store) {
6694
- const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
7091
+ const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
7092
+ const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
6695
7093
  return {
6696
7094
  path: "/support/tickets/:id/invite",
6697
7095
  method: "post",
@@ -6726,7 +7124,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6726
7124
  if (!isAdmin && !isOwner) {
6727
7125
  return Response.json({ error: "Forbidden" }, { status: 403 });
6728
7126
  }
6729
- if (await inviteLimiter.check(String(req.user.id), req)) {
7127
+ if (await inviteLimiter.check(principalRateKey(req.user), req)) {
6730
7128
  return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6731
7129
  }
6732
7130
  const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
@@ -6750,6 +7148,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6750
7148
  if (existing.docs.length > 0) {
6751
7149
  inviteeId = existing.docs[0].id;
6752
7150
  } else {
7151
+ if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
7152
+ return Response.json(
7153
+ { error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
7154
+ { status: 429 }
7155
+ );
7156
+ }
6753
7157
  const tempPassword = randomBytes(16).toString("hex");
6754
7158
  const created = await dbCreate(payload, slugs.supportClients, {
6755
7159
  data: {
@@ -6921,7 +7325,7 @@ function createSupportEndpoints(slugs, options) {
6921
7325
  }
6922
7326
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6923
7327
  if (!f || f.emailTracking !== false) {
6924
- endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
7328
+ endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
6925
7329
  }
6926
7330
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6927
7331
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
@@ -8406,7 +8810,7 @@ function createTicketsCollection(slugs, options) {
8406
8810
  }
8407
8811
 
8408
8812
  // src/utils/ticketAccess.ts
8409
- async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8813
+ async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
8410
8814
  const ids = /* @__PURE__ */ new Set();
8411
8815
  try {
8412
8816
  const owned = await dbFind(payload, slugs.tickets, {
@@ -8427,6 +8831,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8427
8831
  });
8428
8832
  for (const r of collab.docs) {
8429
8833
  const row = r;
8834
+ if (mode === "write" && row.role !== "collaborator") continue;
8430
8835
  const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8431
8836
  if (tid !== void 0 && tid !== null) ids.add(tid);
8432
8837
  }
@@ -8533,7 +8938,7 @@ function createRestrictClientTicketTarget(slugs) {
8533
8938
  if (targetId === void 0 || targetId === null || targetId === "") {
8534
8939
  throw new APIError("Ticket cible requis.", 400);
8535
8940
  }
8536
- const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
8941
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
8537
8942
  if (!accessible.some((id) => String(id) === String(targetId))) {
8538
8943
  throw new APIError("Ticket inaccessible.", 403);
8539
8944
  }
@@ -9050,13 +9455,13 @@ function createSendInvitationOnCreate(slugs) {
9050
9455
  return doc;
9051
9456
  };
9052
9457
  }
9053
- var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
9458
+ var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
9054
9459
  function createEnforce2FA(slugs) {
9055
9460
  return async ({ req, user }) => {
9056
9461
  if (!user?.twoFactorEnabled) return user;
9057
9462
  const raw = user.twoFactorVerifiedAt;
9058
9463
  const verifiedAt = raw ? new Date(raw).getTime() : 0;
9059
- if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
9464
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
9060
9465
  throw new APIError("2FA_REQUIRED", 401);
9061
9466
  }
9062
9467
  await req.payload.update({
@@ -9897,8 +10302,28 @@ function createKnowledgeBaseCollection(slugs) {
9897
10302
  timestamps: true
9898
10303
  };
9899
10304
  }
9900
-
9901
- // src/collections/ChatMessages.ts
10305
+ function createRestrictClientChatWrite(slugs) {
10306
+ return async ({ data, req }) => {
10307
+ if (req.user?.collection !== slugs.supportClients) return data;
10308
+ data.client = req.user.id;
10309
+ data.senderType = "client";
10310
+ delete data.agent;
10311
+ const session = typeof data.session === "string" ? data.session : null;
10312
+ if (!session) return data;
10313
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
10314
+ where: { session: { equals: session } },
10315
+ limit: 1,
10316
+ depth: 0,
10317
+ overrideAccess: true
10318
+ });
10319
+ const owner = existing.docs[0]?.client;
10320
+ const ownerId = owner && typeof owner === "object" ? owner.id : owner;
10321
+ if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
10322
+ throw new APIError("Session inaccessible.", 403);
10323
+ }
10324
+ return data;
10325
+ };
10326
+ }
9902
10327
  function createChatMessagesCollection(slugs) {
9903
10328
  return {
9904
10329
  slug: slugs.chatMessages,
@@ -9920,7 +10345,9 @@ function createChatMessagesCollection(slugs) {
9920
10345
  }
9921
10346
  return false;
9922
10347
  },
9923
- create: ({ req }) => !!req.user,
10348
+ // Staff and support-clients only — NOT "any authenticated principal":
10349
+ // a user of any other auth collection of the host app satisfied `!!req.user`.
10350
+ create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
9924
10351
  update: ({ req }) => req.user?.collection === slugs.users,
9925
10352
  delete: ({ req }) => req.user?.collection === slugs.users
9926
10353
  },
@@ -9989,6 +10416,9 @@ function createChatMessagesCollection(slugs) {
9989
10416
  }
9990
10417
  }
9991
10418
  ],
10419
+ hooks: {
10420
+ beforeChange: [createRestrictClientChatWrite(slugs)]
10421
+ },
9992
10422
  timestamps: true
9993
10423
  };
9994
10424
  }
@@ -10266,8 +10696,19 @@ function createAuthLogsCollection(slugs) {
10266
10696
  timestamps: true
10267
10697
  };
10268
10698
  }
10269
-
10270
- // src/collections/WebhookEndpoints.ts
10699
+ function createValidateWebhookUrl() {
10700
+ return ({ data, operation, originalDoc }) => {
10701
+ const incoming = data?.url;
10702
+ if (incoming === void 0 || incoming === null) return data;
10703
+ const previous = originalDoc?.url;
10704
+ if (operation === "update" && incoming === previous) return data;
10705
+ const result = validateWebhookUrl(incoming);
10706
+ if (!result.ok) {
10707
+ throw new APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
10708
+ }
10709
+ return data;
10710
+ };
10711
+ }
10271
10712
  function createWebhookEndpointsCollection(slugs) {
10272
10713
  return {
10273
10714
  slug: slugs.webhookEndpoints,
@@ -10301,8 +10742,11 @@ function createWebhookEndpointsCollection(slugs) {
10301
10742
  type: "text",
10302
10743
  required: true,
10303
10744
  label: "URL",
10745
+ // The SSRF check lives in the collection `beforeValidate` above, NOT in a
10746
+ // field `validate`: the latter re-runs on the merged document and would
10747
+ // freeze every pre-existing row on any unrelated edit.
10304
10748
  admin: {
10305
- description: "URL du webhook \xE0 appeler (POST)"
10749
+ description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
10306
10750
  }
10307
10751
  },
10308
10752
  {
@@ -10356,6 +10800,9 @@ function createWebhookEndpointsCollection(slugs) {
10356
10800
  }
10357
10801
  }
10358
10802
  ],
10803
+ hooks: {
10804
+ beforeValidate: [createValidateWebhookUrl()]
10805
+ },
10359
10806
  timestamps: true
10360
10807
  };
10361
10808
  }
@@ -10807,11 +11254,17 @@ function createClientSummariesCollection(slugs) {
10807
11254
  admin: { readOnly: true }
10808
11255
  }
10809
11256
  ],
11257
+ // Staff-only, on the SAME source of truth as every other collection and as
11258
+ // `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
11259
+ // against is the DEFAULT slug, not the configured one — on a host app whose
11260
+ // staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
11261
+ // the front-office collection instead, opening read/create/update/delete on
11262
+ // AI-generated client intelligence to it while locking the real agents out.
10810
11263
  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"
11264
+ create: ({ req }) => req.user?.collection === slugs.users,
11265
+ read: ({ req }) => req.user?.collection === slugs.users,
11266
+ update: ({ req }) => req.user?.collection === slugs.users,
11267
+ delete: ({ req }) => req.user?.collection === slugs.users
10815
11268
  },
10816
11269
  timestamps: true
10817
11270
  };
@@ -11275,6 +11728,17 @@ function supportPlugin(config) {
11275
11728
  });
11276
11729
  return {
11277
11730
  ...incomingConfig,
11731
+ // Publish the resolved staff collection so the server-side readers share
11732
+ // ONE source of truth with the writers. `requireAdmin` compares against
11733
+ // `slugs.users`; the `payload-preferences` reads used to scope themselves
11734
+ // on `config.admin.user`, which Payload silently defaults to the first
11735
+ // auth collection of the host app — a different collection on any app
11736
+ // that declares `collectionSlugs.users`, and the settings-poisoning hole
11737
+ // reopened right there.
11738
+ custom: {
11739
+ ...incomingConfig.custom,
11740
+ [SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
11741
+ },
11278
11742
  collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
11279
11743
  endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
11280
11744
  admin: {