@consilioweb/payload-support 2.0.1 → 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 (246) hide show
  1. package/README.md +670 -402
  2. package/dist/components/RichTextEditor/index.d.ts +19 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
  4. package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
  5. package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
  7. package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
  8. package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
  9. package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
  10. package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
  11. package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
  12. package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
  13. package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
  14. package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
  15. package/dist/components/TicketConversation/config.d.ts +10 -0
  16. package/dist/components/TicketConversation/config.js +1 -40
  17. package/dist/components/TicketConversation/constants.d.ts +56 -0
  18. package/dist/components/TicketConversation/context.d.ts +53 -0
  19. package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
  20. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
  21. package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
  22. package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
  23. package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
  24. package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
  25. package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
  26. package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
  27. package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
  28. package/dist/components/TicketConversation/index.d.ts +4 -0
  29. package/dist/components/TicketConversation/index.js +2 -2
  30. package/dist/components/TicketConversation/types.d.ts +56 -0
  31. package/dist/components/TicketConversation/utils.d.ts +2 -0
  32. package/dist/index.cjs +940 -404
  33. package/dist/index.d.cts +119 -7
  34. package/dist/index.d.ts +119 -7
  35. package/dist/index.js +936 -405
  36. package/dist/utils/features.d.ts +123 -0
  37. package/dist/utils/features.js +147 -0
  38. package/dist/views/BillingView/client.d.ts +2 -0
  39. package/dist/views/BillingView/index.d.ts +4 -0
  40. package/dist/views/ChatView/client.d.ts +2 -0
  41. package/dist/views/ChatView/client.js +9 -9
  42. package/dist/views/ChatView/index.d.ts +4 -0
  43. package/dist/views/CrmView/client.d.ts +2 -0
  44. package/dist/views/CrmView/client.js +6 -9
  45. package/dist/views/CrmView/index.d.ts +4 -0
  46. package/dist/views/EmailTrackingView/client.d.ts +2 -0
  47. package/dist/views/EmailTrackingView/index.d.ts +4 -0
  48. package/dist/views/ImportConversationView/client.d.ts +2 -0
  49. package/dist/views/ImportConversationView/index.d.ts +4 -0
  50. package/dist/views/LogsView/client.d.ts +2 -0
  51. package/dist/views/LogsView/index.d.ts +4 -0
  52. package/dist/views/NewTicketView/client.d.ts +2 -0
  53. package/dist/views/NewTicketView/index.d.ts +4 -0
  54. package/dist/views/PendingEmailsView/client.d.ts +2 -0
  55. package/dist/views/PendingEmailsView/index.d.ts +4 -0
  56. package/dist/views/SupportDashboardView/client.d.ts +2 -0
  57. package/dist/views/SupportDashboardView/index.d.ts +4 -0
  58. package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
  59. package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
  60. package/dist/views/TicketDetailView/client.d.ts +2 -0
  61. package/dist/views/TicketDetailView/client.js +2 -2
  62. package/dist/views/TicketDetailView/constants.d.ts +13 -0
  63. package/dist/views/TicketDetailView/helpers.d.ts +2 -0
  64. package/dist/views/TicketDetailView/index.d.ts +4 -0
  65. package/dist/views/TicketDetailView/types.d.ts +40 -0
  66. package/dist/views/TicketInboxView/client.d.ts +2 -0
  67. package/dist/views/TicketInboxView/client.js +5 -5
  68. package/dist/views/TicketInboxView/index.d.ts +4 -0
  69. package/dist/views/TicketingSettingsView/client.d.ts +2 -0
  70. package/dist/views/TicketingSettingsView/client.js +5 -4
  71. package/dist/views/TicketingSettingsView/index.d.ts +4 -0
  72. package/dist/views/TimeDashboardView/client.d.ts +2 -0
  73. package/dist/views/TimeDashboardView/index.d.ts +4 -0
  74. package/dist/views/shared/AdminViewHeader.d.ts +13 -0
  75. package/dist/views/shared/ErrorBoundary.d.ts +17 -0
  76. package/dist/views/shared/Skeleton.d.ts +21 -0
  77. package/dist/views/shared/StatusPill.d.ts +8 -0
  78. package/dist/views/shared/adminTokens.d.ts +19 -0
  79. package/dist/views/shared/config.d.ts +8 -0
  80. package/dist/views/shared/config.js +1 -40
  81. package/dist/views/shared/dateLocale.d.ts +5 -0
  82. package/dist/views/shared/index.d.ts +12 -0
  83. package/dist/views/shared/index.js +2 -1
  84. package/dist/views/shared/sla.d.ts +14 -0
  85. package/dist/views.d.ts +13 -13
  86. package/package.json +16 -31
  87. package/src/collections/ChatMessages.ts +59 -2
  88. package/src/collections/ClientSummaries.ts +10 -4
  89. package/src/collections/SupportClients.ts +29 -0
  90. package/src/collections/TicketCollaborators.ts +6 -1
  91. package/src/collections/TicketMessages.ts +49 -24
  92. package/src/collections/Tickets.ts +117 -79
  93. package/src/collections/TimeEntries.ts +57 -32
  94. package/src/collections/WebhookEndpoints.ts +44 -2
  95. package/src/components/TicketConversation/config.ts +16 -80
  96. package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
  97. package/src/components/TicketConversation/index.tsx +2 -2
  98. package/src/endpoints/admin-chat.ts +3 -3
  99. package/src/endpoints/ai-agent.ts +3 -3
  100. package/src/endpoints/ai.ts +5 -4
  101. package/src/endpoints/auth-2fa.ts +16 -4
  102. package/src/endpoints/capabilities.ts +6 -6
  103. package/src/endpoints/chat.ts +7 -5
  104. package/src/endpoints/chatbot.ts +48 -2
  105. package/src/endpoints/client-intelligence.ts +6 -5
  106. package/src/endpoints/email-stats.ts +19 -3
  107. package/src/endpoints/import-conversation.ts +1 -1
  108. package/src/endpoints/index.ts +1 -1
  109. package/src/endpoints/invite-collaborator.ts +29 -3
  110. package/src/endpoints/login.ts +15 -2
  111. package/src/endpoints/oauth-google.ts +130 -8
  112. package/src/endpoints/process-scheduled.ts +13 -8
  113. package/src/endpoints/resend-notification.ts +3 -3
  114. package/src/endpoints/round-robin-config.ts +30 -20
  115. package/src/endpoints/send-reminder.ts +3 -3
  116. package/src/endpoints/settings.ts +28 -77
  117. package/src/endpoints/signature.ts +9 -2
  118. package/src/endpoints/ticket-synthesis.ts +3 -3
  119. package/src/endpoints/transfer-ticket.ts +28 -3
  120. package/src/endpoints/typing.ts +117 -14
  121. package/src/endpoints/user-prefs.ts +5 -2
  122. package/src/index.ts +6 -2
  123. package/src/plugin.ts +12 -0
  124. package/src/portal/LiveChat.tsx +33 -22
  125. package/src/portal/auth/layout.tsx +19 -1
  126. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  127. package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
  128. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  129. package/src/portal/login/page.tsx +11 -3
  130. package/src/types/lucide-react.d.ts +10 -2
  131. package/src/utils/aiAgent.ts +2 -1
  132. package/src/utils/aiProvider.ts +21 -0
  133. package/src/utils/features.ts +297 -0
  134. package/src/utils/fireWebhooks.ts +10 -1
  135. package/src/utils/generateTicketSynthesis.ts +2 -1
  136. package/src/utils/index.ts +4 -2
  137. package/src/utils/rateLimiter.ts +39 -5
  138. package/src/utils/readSettings.ts +199 -16
  139. package/src/utils/ticketAccess.ts +16 -1
  140. package/src/utils/twoFactorChallenge.ts +80 -0
  141. package/src/utils/urlSafety.ts +230 -0
  142. package/src/utils/webhookDispatcher.ts +24 -8
  143. package/src/views/ChatView/client.tsx +13 -9
  144. package/src/views/CrmView/client.tsx +10 -11
  145. package/src/views/TicketDetailView/client.tsx +2 -2
  146. package/src/views/TicketInboxView/client.tsx +10 -5
  147. package/src/views/TicketingSettingsView/client.tsx +8 -6
  148. package/src/views/shared/config.ts +14 -80
  149. package/src/views/shared/index.ts +3 -1
  150. package/dist/components/RichTextEditor/index.cjs +0 -279
  151. package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
  152. package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
  153. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
  154. package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
  155. package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
  156. package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
  157. package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
  158. package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
  159. package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
  160. package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
  161. package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
  162. package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
  163. package/dist/components/TicketConversation/config.cjs +0 -44
  164. package/dist/components/TicketConversation/constants.cjs +0 -127
  165. package/dist/components/TicketConversation/context.cjs +0 -13
  166. package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
  167. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
  168. package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
  169. package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
  170. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
  171. package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
  172. package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
  173. package/dist/components/TicketConversation/index.cjs +0 -1114
  174. package/dist/components/TicketConversation/types.cjs +0 -2
  175. package/dist/components/TicketConversation/utils.cjs +0 -27
  176. package/dist/views/BillingView/client.cjs +0 -469
  177. package/dist/views/BillingView/index.cjs +0 -34
  178. package/dist/views/ChatView/client.cjs +0 -301
  179. package/dist/views/ChatView/index.cjs +0 -34
  180. package/dist/views/CrmView/client.cjs +0 -458
  181. package/dist/views/CrmView/index.cjs +0 -34
  182. package/dist/views/EmailTrackingView/client.cjs +0 -171
  183. package/dist/views/EmailTrackingView/index.cjs +0 -34
  184. package/dist/views/ImportConversationView/client.cjs +0 -238
  185. package/dist/views/ImportConversationView/index.cjs +0 -34
  186. package/dist/views/LogsView/client.cjs +0 -148
  187. package/dist/views/LogsView/index.cjs +0 -32
  188. package/dist/views/NewTicketView/client.cjs +0 -213
  189. package/dist/views/NewTicketView/index.cjs +0 -32
  190. package/dist/views/PendingEmailsView/client.cjs +0 -471
  191. package/dist/views/PendingEmailsView/index.cjs +0 -34
  192. package/dist/views/SupportDashboardView/client.cjs +0 -437
  193. package/dist/views/SupportDashboardView/index.cjs +0 -34
  194. package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
  195. package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
  196. package/dist/views/TicketDetailView/client.cjs +0 -1412
  197. package/dist/views/TicketDetailView/constants.cjs +0 -20
  198. package/dist/views/TicketDetailView/helpers.cjs +0 -23
  199. package/dist/views/TicketDetailView/index.cjs +0 -34
  200. package/dist/views/TicketDetailView/types.cjs +0 -2
  201. package/dist/views/TicketInboxView/client.cjs +0 -359
  202. package/dist/views/TicketInboxView/index.cjs +0 -32
  203. package/dist/views/TicketingSettingsView/client.cjs +0 -740
  204. package/dist/views/TicketingSettingsView/index.cjs +0 -34
  205. package/dist/views/TimeDashboardView/client.cjs +0 -180
  206. package/dist/views/TimeDashboardView/index.cjs +0 -34
  207. package/dist/views/shared/AdminViewHeader.cjs +0 -68
  208. package/dist/views/shared/ErrorBoundary.cjs +0 -55
  209. package/dist/views/shared/Skeleton.cjs +0 -77
  210. package/dist/views/shared/StatusPill.cjs +0 -47
  211. package/dist/views/shared/adminTokens.cjs +0 -34
  212. package/dist/views/shared/config.cjs +0 -44
  213. package/dist/views/shared/dateLocale.cjs +0 -5
  214. package/dist/views/shared/index.cjs +0 -72
  215. package/dist/views/shared/sla.cjs +0 -37
  216. package/dist/views.cjs +0 -86
  217. package/dist/views.d.cts +0 -13
  218. package/src/__tests__/aiSummaryRendering.test.ts +0 -21
  219. package/src/__tests__/authResponses.test.ts +0 -69
  220. package/src/__tests__/capabilities.test.ts +0 -71
  221. package/src/__tests__/generateTrackingToken.test.ts +0 -67
  222. package/src/__tests__/integration/ai-agent.test.ts +0 -46
  223. package/src/__tests__/integration/auth.test.ts +0 -43
  224. package/src/__tests__/integration/automation-rules.test.ts +0 -68
  225. package/src/__tests__/integration/buildTestPayload.ts +0 -52
  226. package/src/__tests__/integration/channels.test.ts +0 -52
  227. package/src/__tests__/integration/core-behaviors.test.ts +0 -81
  228. package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
  229. package/src/__tests__/integration/digest.test.ts +0 -48
  230. package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
  231. package/src/__tests__/integration/isolation.test.ts +0 -81
  232. package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
  233. package/src/__tests__/integration/nps.test.ts +0 -37
  234. package/src/__tests__/integration/per-team-sla.test.ts +0 -44
  235. package/src/__tests__/integration/push.test.ts +0 -75
  236. package/src/__tests__/integration/sanitization.test.ts +0 -35
  237. package/src/__tests__/integration/sla-pause.test.ts +0 -39
  238. package/src/__tests__/integration/smoke.test.ts +0 -26
  239. package/src/__tests__/integration/snooze.test.ts +0 -68
  240. package/src/__tests__/integration/teams.test.ts +0 -38
  241. package/src/__tests__/rateLimiter.test.ts +0 -83
  242. package/src/__tests__/sanitizeHtml.test.ts +0 -165
  243. package/src/__tests__/sla.test.ts +0 -258
  244. package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
  245. package/src/__tests__/translations.test.ts +0 -67
  246. package/src/__tests__/webhookSecurity.test.ts +0 -58
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",
@@ -368,55 +391,201 @@ function dbDelete(payload, slug, options) {
368
391
  return payload.delete({ collection: slug, ...options });
369
392
  }
370
393
 
394
+ // src/utils/features.ts
395
+ var DEFAULT_TICKETING_FEATURES = {
396
+ timeTracking: true,
397
+ ai: true,
398
+ satisfaction: true,
399
+ chat: true,
400
+ emailTracking: true,
401
+ canned: true,
402
+ merge: true,
403
+ snooze: true,
404
+ externalMessages: true,
405
+ clientHistory: true,
406
+ activityLog: true,
407
+ splitTicket: true,
408
+ scheduledReplies: true,
409
+ autoClose: true,
410
+ autoCloseDays: 7,
411
+ roundRobin: false
412
+ };
413
+ var BOOLEAN_FEATURE_KEYS = [
414
+ "timeTracking",
415
+ "ai",
416
+ "satisfaction",
417
+ "chat",
418
+ "emailTracking",
419
+ "canned",
420
+ "merge",
421
+ "snooze",
422
+ "externalMessages",
423
+ "clientHistory",
424
+ "activityLog",
425
+ "splitTicket",
426
+ "scheduledReplies",
427
+ "autoClose",
428
+ "roundRobin"
429
+ ];
430
+ var PROJECTED_FEATURE_KEYS = ["autoClose", "autoCloseDays"];
431
+ function normalizeFeatures(raw) {
432
+ const out = { ...DEFAULT_TICKETING_FEATURES };
433
+ if (!raw || typeof raw !== "object") return out;
434
+ const src = raw;
435
+ for (const key of BOOLEAN_FEATURE_KEYS) {
436
+ if (typeof src[key] === "boolean") out[key] = src[key];
437
+ }
438
+ const days = src.autoCloseDays;
439
+ if (typeof days === "number" && Number.isFinite(days) && days >= 1) {
440
+ out.autoCloseDays = Math.floor(days);
441
+ }
442
+ return out;
443
+ }
444
+ function projectAutoClose(features, autoClose) {
445
+ if (!autoClose) return features;
446
+ return {
447
+ ...features,
448
+ autoClose: typeof autoClose.enabled === "boolean" ? autoClose.enabled : features.autoClose,
449
+ autoCloseDays: typeof autoClose.daysBeforeClose === "number" && autoClose.daysBeforeClose >= 1 ? Math.floor(autoClose.daysBeforeClose) : features.autoCloseDays
450
+ };
451
+ }
452
+ function stripProjectedFeatures(features) {
453
+ const out = { ...features };
454
+ for (const key of PROJECTED_FEATURE_KEYS) delete out[key];
455
+ return out;
456
+ }
457
+
371
458
  // src/utils/readSettings.ts
372
- var PREF_KEY = "support-settings";
459
+ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
460
+ var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
373
461
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
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
+ }
374
471
  var DEFAULT_SETTINGS = {
375
472
  email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
376
473
  ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
377
474
  sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: "" },
378
- autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
475
+ autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
476
+ features: { ...DEFAULT_TICKETING_FEATURES }
379
477
  };
380
478
  var DEFAULT_USER_PREFS = {
381
479
  locale: "fr",
382
480
  signature: ""
383
481
  };
384
- var settingsCache = null;
482
+ var settingsCache = /* @__PURE__ */ new Map();
385
483
  var SETTINGS_TTL_MS = 6e4;
484
+ var SETTINGS_CACHE_MAX = 8;
485
+ var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
386
486
  function invalidateSupportSettingsCache() {
387
- settingsCache = null;
487
+ settingsCache.clear();
488
+ warnedForeignSettingsRow.clear();
388
489
  }
389
- async function readSupportSettings(payload) {
390
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
391
- return settingsCache.value;
490
+ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
491
+ const autoClose = { ...base.autoClose, ...stored?.autoClose };
492
+ return {
493
+ email: { ...base.email, ...stored?.email },
494
+ ai: { ...base.ai, ...stored?.ai },
495
+ sla: { ...base.sla, ...stored?.sla },
496
+ autoClose,
497
+ // `autoClose` / `autoCloseDays` are projections of the block above, so they
498
+ // are recomputed here rather than trusted from whatever was persisted.
499
+ features: projectAutoClose(
500
+ normalizeFeatures({ ...base.features, ...stored?.features }),
501
+ autoClose
502
+ )
503
+ };
504
+ }
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;
392
510
  }
393
- let value = { ...DEFAULT_SETTINGS };
511
+ let value = {
512
+ settings: mergeSupportSettings(null),
513
+ featuresConfigured: false
514
+ };
394
515
  try {
395
516
  const prefs = await dbFind(payload, "payload-preferences", {
396
- 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 } },
521
+ // The upsert is scoped per admin user, so several rows can share the key.
522
+ // Sorting makes "last write wins" deterministic instead of arbitrary.
523
+ sort: "-updatedAt",
397
524
  limit: 1,
398
525
  depth: 0,
399
526
  overrideAccess: true
400
527
  });
401
528
  if (prefs.docs.length > 0) {
402
529
  const stored = prefs.docs[0].value;
403
- value = {
404
- email: { ...DEFAULT_SETTINGS.email, ...stored.email },
405
- ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
406
- sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
407
- autoClose: { ...DEFAULT_SETTINGS.autoClose, ...stored.autoClose }
408
- };
530
+ const featuresConfigured = !!stored.features && typeof stored.features === "object";
531
+ const settings = mergeSupportSettings(stored);
532
+ if (!featuresConfigured) {
533
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
534
+ }
535
+ value = { settings, featuresConfigured };
536
+ } else {
537
+ await warnOnForeignSettingsRow(payload, staff);
409
538
  }
410
539
  } catch {
411
540
  }
412
- 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() });
413
543
  return value;
414
544
  }
415
- async function readUserPrefs(payload, userId) {
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;
565
+ }
566
+ async function readLegacyRoundRobin(payload, staff) {
567
+ try {
568
+ const prefs = await dbFind(payload, "payload-preferences", {
569
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
570
+ limit: 1,
571
+ depth: 0,
572
+ overrideAccess: true
573
+ });
574
+ if (prefs.docs.length > 0) {
575
+ return prefs.docs[0].value?.enabled === true;
576
+ }
577
+ } catch {
578
+ }
579
+ return DEFAULT_TICKETING_FEATURES.roundRobin;
580
+ }
581
+ async function readUserPrefs(payload, userId, staffSlug) {
416
582
  try {
417
583
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
418
584
  const prefs = await dbFind(payload, "payload-preferences", {
419
- where: { key: { equals: key } },
585
+ where: {
586
+ key: { equals: key },
587
+ "user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
588
+ },
420
589
  limit: 1,
421
590
  depth: 0,
422
591
  overrideAccess: true
@@ -432,13 +601,20 @@ async function readUserPrefs(payload, userId) {
432
601
  }
433
602
  return { ...DEFAULT_USER_PREFS };
434
603
  }
604
+ function resolveOllamaBaseUrl() {
605
+ const baseURL = process.env.OLLAMA_API_URL;
606
+ if (!baseURL) {
607
+ throw new APIError("provider 'ollama' requires OLLAMA_API_URL", 500);
608
+ }
609
+ return baseURL;
610
+ }
435
611
 
436
612
  // src/endpoints/ai.ts
437
613
  async function getClient(aiSettings) {
438
614
  const moduleName = "@anthropic-ai/sdk";
439
615
  const { default: Anthropic } = await import(moduleName);
440
616
  if (aiSettings.provider === "ollama") {
441
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
617
+ const baseURL = resolveOllamaBaseUrl();
442
618
  return new Anthropic({ apiKey: "ollama", baseURL });
443
619
  }
444
620
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
@@ -447,7 +623,7 @@ function getModel(aiSettings) {
447
623
  return aiSettings.model || "claude-haiku-4-5-20251001";
448
624
  }
449
625
  function createAiEndpoint(slugs, store) {
450
- const limiter = new RateLimiter(6e4, 30, store);
626
+ const limiter = new RateLimiter(6e4, 30, store, "ai");
451
627
  return {
452
628
  path: "/support/ai",
453
629
  method: "post",
@@ -455,7 +631,7 @@ function createAiEndpoint(slugs, store) {
455
631
  try {
456
632
  const payload = req.payload;
457
633
  requireAdmin(req, slugs);
458
- if (await limiter.check(String(req.user.id), req)) {
634
+ if (await limiter.check(principalRateKey(req.user), req)) {
459
635
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
460
636
  }
461
637
  const settings = await readSupportSettings(payload);
@@ -637,7 +813,7 @@ async function applyAgentDecision(payload, slugs, ticketId, decision) {
637
813
  function getClient2(aiSettings) {
638
814
  const Anthropic = __require("@anthropic-ai/sdk").default;
639
815
  if (aiSettings.provider === "ollama") {
640
- return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
816
+ return new Anthropic({ apiKey: "ollama", baseURL: resolveOllamaBaseUrl() });
641
817
  }
642
818
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
643
819
  }
@@ -700,14 +876,14 @@ ${kbText || "(vide)"}`;
700
876
 
701
877
  // src/endpoints/ai-agent.ts
702
878
  function createAiAgentEndpoint(slugs, store) {
703
- const limiter = new RateLimiter(6e4, 10, store);
879
+ const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
704
880
  return {
705
881
  path: "/support/ai-agent",
706
882
  method: "post",
707
883
  handler: async (req) => {
708
884
  try {
709
885
  requireAdmin(req, slugs);
710
- if (await limiter.check(String(req.user.id), req)) {
886
+ if (await limiter.check(principalRateKey(req.user), req)) {
711
887
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
712
888
  }
713
889
  let body = {};
@@ -737,7 +913,7 @@ async function getClient3(aiSettings) {
737
913
  const moduleName = "@anthropic-ai/sdk";
738
914
  const { default: Anthropic } = await import(moduleName);
739
915
  if (aiSettings.provider === "ollama") {
740
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
916
+ const baseURL = resolveOllamaBaseUrl();
741
917
  return new Anthropic({ apiKey: "ollama", baseURL });
742
918
  }
743
919
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
@@ -747,11 +923,11 @@ function getModel2(aiSettings) {
747
923
  }
748
924
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
749
925
  function createClientIntelligenceEndpoint(slugs, store) {
750
- const limiter = new RateLimiter(6e4, 20, store);
926
+ const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
751
927
  const getHandler = async (req) => {
752
928
  try {
753
929
  requireAdmin(req, slugs);
754
- if (await limiter.check(String(req.user.id), req)) {
930
+ if (await limiter.check(principalRateKey(req.user), req)) {
755
931
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
756
932
  }
757
933
  const payload = req.payload;
@@ -783,7 +959,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
783
959
  const postHandler = async (req) => {
784
960
  try {
785
961
  requireAdmin(req, slugs);
786
- if (await limiter.check(String(req.user.id), req)) {
962
+ if (await limiter.check(principalRateKey(req.user), req)) {
787
963
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
788
964
  }
789
965
  const payload = req.payload;
@@ -1412,6 +1588,14 @@ function createSplitTicketEndpoint(slugs) {
1412
1588
  // src/endpoints/typing.ts
1413
1589
  var typingState = /* @__PURE__ */ new Map();
1414
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
+ }
1415
1599
  function cleanExpired(ticketId) {
1416
1600
  const state = typingState.get(ticketId);
1417
1601
  if (!state) return;
@@ -1426,6 +1610,36 @@ function cleanExpired(ticketId) {
1426
1610
  }
1427
1611
  if (!state.admin && !state.client) typingState.delete(ticketId);
1428
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
+ }
1429
1643
  function createTypingPostEndpoint(slugs) {
1430
1644
  return {
1431
1645
  path: "/support/typing",
@@ -1436,11 +1650,18 @@ function createTypingPostEndpoint(slugs) {
1436
1650
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1437
1651
  }
1438
1652
  const { ticketId } = await req.json();
1439
- if (!ticketId) {
1653
+ const key = normalizeTicketId(ticketId);
1654
+ if (!key) {
1440
1655
  return Response.json({ error: "ticketId required" }, { status: 400 });
1441
1656
  }
1442
- const key = String(ticketId);
1657
+ if (!await mayAccessTicket(req, slugs, key)) {
1658
+ return Response.json({ error: "Forbidden" }, { status: 403 });
1659
+ }
1443
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
+ }
1444
1665
  if (req.user.collection === slugs.users) {
1445
1666
  state.admin = Date.now();
1446
1667
  state.adminName = req.user.firstName || "Support";
@@ -1461,30 +1682,33 @@ function createTypingGetEndpoint(slugs) {
1461
1682
  path: "/support/typing",
1462
1683
  method: "get",
1463
1684
  handler: async (req) => {
1685
+ const idle = { typing: false, name: null };
1464
1686
  try {
1465
1687
  if (!req.user) {
1466
1688
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1467
1689
  }
1468
1690
  const url = new URL(req.url);
1469
- const ticketId = url.searchParams.get("ticketId");
1470
- if (!ticketId) {
1691
+ const key = normalizeTicketId(url.searchParams.get("ticketId"));
1692
+ if (!key) {
1471
1693
  return Response.json({ error: "ticketId required" }, { status: 400 });
1472
1694
  }
1473
- cleanExpired(ticketId);
1474
- 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);
1475
1699
  if (req.user.collection === slugs.users) {
1476
1700
  return Response.json({
1477
- typing: !!state?.client,
1478
- name: state?.clientName || null
1701
+ typing: !!state.client,
1702
+ name: state.clientName || null
1479
1703
  });
1480
1704
  } else {
1481
1705
  return Response.json({
1482
- typing: !!state?.admin,
1483
- name: state?.adminName || null
1706
+ typing: !!state.admin,
1707
+ name: state.adminName || null
1484
1708
  });
1485
1709
  }
1486
1710
  } catch {
1487
- return Response.json({ typing: false, name: null });
1711
+ return Response.json(idle);
1488
1712
  }
1489
1713
  }
1490
1714
  };
@@ -1573,25 +1797,6 @@ function createPresenceGetEndpoint(slugs) {
1573
1797
  }
1574
1798
 
1575
1799
  // src/endpoints/settings.ts
1576
- var PREF_KEY2 = "support-settings";
1577
- var DEFAULT_SUPPORT_SETTINGS = {
1578
- email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
1579
- ai: {
1580
- provider: "anthropic",
1581
- model: "claude-haiku-4-5-20251001",
1582
- enableSentiment: true,
1583
- enableSynthesis: true,
1584
- enableSuggestion: true,
1585
- enableRewrite: true
1586
- },
1587
- sla: {
1588
- firstResponseMinutes: 120,
1589
- resolutionMinutes: 1440,
1590
- businessHoursOnly: true,
1591
- escalationEmail: ""
1592
- },
1593
- autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
1594
- };
1595
1800
  function createSettingsGetEndpoint(slugs) {
1596
1801
  return {
1597
1802
  path: "/support/settings",
@@ -1600,23 +1805,8 @@ function createSettingsGetEndpoint(slugs) {
1600
1805
  try {
1601
1806
  const payload = req.payload;
1602
1807
  requireAdmin(req, slugs);
1603
- const prefs = await dbFind(payload, "payload-preferences", {
1604
- where: { key: { equals: PREF_KEY2 } },
1605
- limit: 1,
1606
- depth: 0,
1607
- overrideAccess: true
1608
- });
1609
- let settings = { ...DEFAULT_SUPPORT_SETTINGS };
1610
- if (prefs.docs.length > 0) {
1611
- const stored = prefs.docs[0].value;
1612
- settings = {
1613
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...stored.email },
1614
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...stored.ai },
1615
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...stored.sla },
1616
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...stored.autoClose }
1617
- };
1618
- }
1619
- return Response.json(settings);
1808
+ const { settings, featuresConfigured } = await readSupportSettingsState(payload);
1809
+ return Response.json({ ...settings, featuresConfigured });
1620
1810
  } catch (error) {
1621
1811
  const authResponse = handleAuthError(error);
1622
1812
  if (authResponse) return authResponse;
@@ -1635,30 +1825,31 @@ function createSettingsPostEndpoint(slugs) {
1635
1825
  const payload = req.payload;
1636
1826
  requireAdmin(req, slugs);
1637
1827
  const body = await req.json();
1638
- const merged = {
1639
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...body.email },
1640
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...body.ai },
1641
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...body.sla },
1642
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...body.autoClose }
1643
- };
1828
+ const { settings: current } = await readSupportSettingsState(payload);
1829
+ const merged = mergeSupportSettings(body, current);
1644
1830
  await payload.db.upsert({
1645
1831
  collection: "payload-preferences",
1646
1832
  data: {
1647
- key: PREF_KEY2,
1833
+ key: SUPPORT_SETTINGS_PREF_KEY,
1648
1834
  user: { relationTo: req.user.collection, value: req.user.id },
1649
- value: merged
1835
+ value: {
1836
+ ...merged,
1837
+ // Persist the flags without the two projected ones, so `features`
1838
+ // and the `autoClose` block can never drift apart.
1839
+ features: stripProjectedFeatures(merged.features)
1840
+ }
1650
1841
  },
1651
1842
  req: { payload, user: req.user },
1652
1843
  where: {
1653
1844
  and: [
1654
- { key: { equals: PREF_KEY2 } },
1845
+ { key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
1655
1846
  { "user.value": { equals: req.user.id } },
1656
1847
  { "user.relationTo": { equals: req.user.collection } }
1657
1848
  ]
1658
1849
  }
1659
1850
  });
1660
1851
  invalidateSupportSettingsCache();
1661
- return Response.json(merged);
1852
+ return Response.json({ ...merged, featuresConfigured: true });
1662
1853
  } catch (error) {
1663
1854
  const authResponse = handleAuthError(error);
1664
1855
  if (authResponse) return authResponse;
@@ -1670,7 +1861,7 @@ function createSettingsPostEndpoint(slugs) {
1670
1861
  }
1671
1862
 
1672
1863
  // src/endpoints/signature.ts
1673
- var PREF_KEY3 = "email-signature";
1864
+ var PREF_KEY2 = "email-signature";
1674
1865
  function createSignatureGetEndpoint(slugs) {
1675
1866
  return {
1676
1867
  path: "/support/signature",
@@ -1680,7 +1871,14 @@ function createSignatureGetEndpoint(slugs) {
1680
1871
  const payload = req.payload;
1681
1872
  requireAdmin(req, slugs);
1682
1873
  const prefs = await dbFind(payload, "payload-preferences", {
1683
- where: { key: { equals: `${PREF_KEY3}-${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
+ },
1684
1882
  limit: 1,
1685
1883
  depth: 0,
1686
1884
  overrideAccess: true
@@ -1705,9 +1903,9 @@ function createSignaturePostEndpoint(slugs) {
1705
1903
  const payload = req.payload;
1706
1904
  requireAdmin(req, slugs);
1707
1905
  const { signature } = await req.json();
1708
- const key = `${PREF_KEY3}-${req.user.id}`;
1906
+ const key = `${PREF_KEY2}-${req.user.id}`;
1709
1907
  const existing = await dbFind(payload, "payload-preferences", {
1710
- where: { key: { equals: key } },
1908
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
1711
1909
  limit: 1,
1712
1910
  depth: 0,
1713
1911
  overrideAccess: true
@@ -1740,7 +1938,6 @@ function createSignaturePostEndpoint(slugs) {
1740
1938
  }
1741
1939
 
1742
1940
  // src/endpoints/round-robin-config.ts
1743
- var PREF_KEY4 = "support-round-robin";
1744
1941
  function createRoundRobinConfigGetEndpoint(slugs) {
1745
1942
  return {
1746
1943
  path: "/support/round-robin-config",
@@ -1749,14 +1946,8 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1749
1946
  try {
1750
1947
  const payload = req.payload;
1751
1948
  requireAdmin(req, slugs);
1752
- const prefs = await dbFind(payload, "payload-preferences", {
1753
- where: { key: { equals: PREF_KEY4 } },
1754
- limit: 1,
1755
- depth: 0,
1756
- overrideAccess: true
1757
- });
1758
- const enabled = prefs.docs.length > 0 ? prefs.docs[0].value?.enabled === true : false;
1759
- return Response.json({ enabled });
1949
+ const { settings } = await readSupportSettingsState(payload);
1950
+ return Response.json({ enabled: settings.features.roundRobin });
1760
1951
  } catch (error) {
1761
1952
  const authResponse = handleAuthError(error);
1762
1953
  if (authResponse) return authResponse;
@@ -1775,28 +1966,31 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1775
1966
  const payload = req.payload;
1776
1967
  requireAdmin(req, slugs);
1777
1968
  const { enabled } = await req.json();
1778
- const existing = await dbFind(payload, "payload-preferences", {
1779
- where: { key: { equals: PREF_KEY4 } },
1780
- limit: 1,
1781
- depth: 0,
1782
- overrideAccess: true
1783
- });
1969
+ const { settings: current } = await readSupportSettingsState(payload);
1970
+ const merged = mergeSupportSettings(
1971
+ { features: { ...current.features, roundRobin: !!enabled } },
1972
+ current
1973
+ );
1784
1974
  await payload.db.upsert({
1785
1975
  collection: "payload-preferences",
1786
1976
  data: {
1787
- key: PREF_KEY4,
1977
+ key: SUPPORT_SETTINGS_PREF_KEY,
1788
1978
  user: { relationTo: req.user.collection, value: req.user.id },
1789
- value: { enabled: !!enabled }
1979
+ value: {
1980
+ ...merged,
1981
+ features: stripProjectedFeatures(merged.features)
1982
+ }
1790
1983
  },
1791
1984
  req: { payload, user: req.user },
1792
1985
  where: {
1793
1986
  and: [
1794
- { key: { equals: PREF_KEY4 } },
1987
+ { key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
1795
1988
  { "user.value": { equals: req.user.id } },
1796
1989
  { "user.relationTo": { equals: req.user.collection } }
1797
1990
  ]
1798
1991
  }
1799
1992
  });
1993
+ invalidateSupportSettingsCache();
1800
1994
  return Response.json({ enabled: !!enabled });
1801
1995
  } catch (error) {
1802
1996
  const authResponse = handleAuthError(error);
@@ -2472,7 +2666,7 @@ function formatFr(date, withTime) {
2472
2666
  });
2473
2667
  }
2474
2668
  function createSendReminderEndpoint(slugs, store) {
2475
- const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
2669
+ const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
2476
2670
  return {
2477
2671
  path: "/support/send-reminder",
2478
2672
  method: "post",
@@ -2480,7 +2674,7 @@ function createSendReminderEndpoint(slugs, store) {
2480
2674
  try {
2481
2675
  const payload = req.payload;
2482
2676
  requireAdmin(req, slugs);
2483
- if (await reminderLimiter.check(String(req.user.id), req)) {
2677
+ if (await reminderLimiter.check(principalRateKey(req.user), req)) {
2484
2678
  return Response.json(
2485
2679
  { error: "Trop de relances. R\xE9essayez dans une heure." },
2486
2680
  { status: 429 }
@@ -2783,8 +2977,14 @@ function createPurgeLogsEndpoint(slugs) {
2783
2977
  }
2784
2978
 
2785
2979
  // src/endpoints/chatbot.ts
2786
- function createChatbotEndpoint(slugs, store) {
2787
- 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");
2788
2988
  return {
2789
2989
  path: "/support/chatbot",
2790
2990
  method: "post",
@@ -2804,6 +3004,15 @@ function createChatbotEndpoint(slugs, store) {
2804
3004
  if (!question?.trim() || question.trim().length < 5) {
2805
3005
  return Response.json({ error: "Question too short" }, { status: 400 });
2806
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
+ }
2807
3016
  const payload = req.payload;
2808
3017
  const articles = await dbFind(payload, slugs.knowledgeBase, {
2809
3018
  where: { published: { equals: true } },
@@ -2916,8 +3125,8 @@ function createChatGetEndpoint(slugs) {
2916
3125
  };
2917
3126
  }
2918
3127
  function createChatPostEndpoint(slugs, store) {
2919
- const chatSessionLimiter = new RateLimiter(36e5, 5, store);
2920
- 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");
2921
3130
  return {
2922
3131
  path: "/support/chat",
2923
3132
  method: "post",
@@ -2933,8 +3142,9 @@ function createChatPostEndpoint(slugs, store) {
2933
3142
  }
2934
3143
  const { action, session, message } = body;
2935
3144
  const userId = String(req.user.id);
3145
+ const rateKey = principalRateKey(req.user);
2936
3146
  if (action === "start") {
2937
- if (await chatSessionLimiter.check(userId, req)) {
3147
+ if (await chatSessionLimiter.check(rateKey, req)) {
2938
3148
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2939
3149
  }
2940
3150
  const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
@@ -2951,7 +3161,7 @@ function createChatPostEndpoint(slugs, store) {
2951
3161
  return Response.json({ session: sessionId, messages: [systemMsg] });
2952
3162
  }
2953
3163
  if (action === "send" && session && message) {
2954
- if (await chatMessageLimiter.check(userId, req)) {
3164
+ if (await chatMessageLimiter.check(rateKey, req)) {
2955
3165
  return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
2956
3166
  }
2957
3167
  const trimmedMessage = String(message).trim();
@@ -3191,7 +3401,7 @@ function createAdminChatGetEndpoint(slugs) {
3191
3401
  };
3192
3402
  }
3193
3403
  function createAdminChatPostEndpoint(slugs, store) {
3194
- const adminChatLimiter = new RateLimiter(6e4, 30, store);
3404
+ const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
3195
3405
  return {
3196
3406
  path: "/support/admin-chat",
3197
3407
  method: "post",
@@ -3220,7 +3430,7 @@ function createAdminChatPostEndpoint(slugs, store) {
3220
3430
  }
3221
3431
  const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
3222
3432
  if (action === "send" && message) {
3223
- if (await adminChatLimiter.check(String(req.user.id), req)) {
3433
+ if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
3224
3434
  return Response.json({ error: "Rate limit atteint." }, { status: 429 });
3225
3435
  }
3226
3436
  const trimmedMessage = String(message).trim();
@@ -4058,7 +4268,7 @@ function createInvoiceEndpoint(slugs) {
4058
4268
  function getClient4(aiSettings) {
4059
4269
  const Anthropic = __require("@anthropic-ai/sdk").default;
4060
4270
  if (aiSettings.provider === "ollama") {
4061
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
4271
+ const baseURL = resolveOllamaBaseUrl();
4062
4272
  return new Anthropic({ apiKey: "ollama", baseURL });
4063
4273
  }
4064
4274
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
@@ -4175,14 +4385,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
4175
4385
 
4176
4386
  // src/endpoints/ticket-synthesis.ts
4177
4387
  function createTicketSynthesisEndpoint(slugs, generator, store) {
4178
- const limiter = new RateLimiter(6e4, 20, store);
4388
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
4179
4389
  return {
4180
4390
  path: "/support/ticket-synthesis",
4181
4391
  method: "post",
4182
4392
  handler: async (req) => {
4183
4393
  try {
4184
4394
  requireAdmin(req, slugs);
4185
- if (await limiter.check(String(req.user.id), req)) {
4395
+ if (await limiter.check(principalRateKey(req.user), req)) {
4186
4396
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
4187
4397
  }
4188
4398
  const payload = req.payload;
@@ -4226,15 +4436,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
4226
4436
  }
4227
4437
 
4228
4438
  // src/endpoints/email-stats.ts
4229
- function createEmailStatsEndpoint(slugs) {
4439
+ function createEmailStatsEndpoint(slugs, store) {
4440
+ const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
4230
4441
  return {
4231
4442
  path: "/support/email-stats",
4232
4443
  method: "get",
4233
4444
  handler: async (req) => {
4234
4445
  try {
4235
4446
  const payload = req.payload;
4236
- if (!req.user) {
4237
- 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 });
4238
4450
  }
4239
4451
  const url = new URL(req.url);
4240
4452
  const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
@@ -4301,6 +4513,8 @@ function createEmailStatsEndpoint(slugs) {
4301
4513
  actions: Object.fromEntries(actionMap)
4302
4514
  });
4303
4515
  } catch (err) {
4516
+ const authResponse = handleAuthError(err);
4517
+ if (authResponse) return authResponse;
4304
4518
  console.error("[email-stats] Error:", err);
4305
4519
  return Response.json({ error: "Internal server error" }, { status: 500 });
4306
4520
  }
@@ -4733,7 +4947,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4733
4947
 
4734
4948
  // src/endpoints/resend-notification.ts
4735
4949
  function createResendNotificationEndpoint(slugs, store) {
4736
- const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
4950
+ const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
4737
4951
  return {
4738
4952
  path: "/support/resend-notification",
4739
4953
  method: "post",
@@ -4741,7 +4955,7 @@ function createResendNotificationEndpoint(slugs, store) {
4741
4955
  try {
4742
4956
  const payload = req.payload;
4743
4957
  requireAdmin(req, slugs);
4744
- if (await resendLimiter.check(String(req.user.id), req)) {
4958
+ if (await resendLimiter.check(principalRateKey(req.user), req)) {
4745
4959
  return Response.json(
4746
4960
  { error: "Trop de renvois. R\xE9essayez dans une heure." },
4747
4961
  { status: 429 }
@@ -4942,10 +5156,48 @@ function createSeedKbEndpoint(slugs) {
4942
5156
  }
4943
5157
  };
4944
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
+ }
4945
5197
 
4946
5198
  // src/endpoints/login.ts
4947
5199
  function createLoginEndpoint(slugs, store) {
4948
- const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
5200
+ const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
4949
5201
  return {
4950
5202
  path: "/support/login",
4951
5203
  method: "post",
@@ -4998,7 +5250,12 @@ function createLoginEndpoint(slugs, store) {
4998
5250
  } catch (err) {
4999
5251
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
5000
5252
  if (errorMessage.includes("2FA_REQUIRED")) {
5001
- 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 });
5002
5259
  }
5003
5260
  let errorReason = "Identifiants incorrects";
5004
5261
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
@@ -5030,8 +5287,8 @@ function hashCode(code) {
5030
5287
  return createHmac("sha256", secret).update(code).digest("hex");
5031
5288
  }
5032
5289
  function createAuth2faEndpoint(slugs, store) {
5033
- const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
5034
- 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");
5035
5292
  return {
5036
5293
  path: "/support/2fa",
5037
5294
  method: "post",
@@ -5044,12 +5301,18 @@ function createAuth2faEndpoint(slugs, store) {
5044
5301
  } catch {
5045
5302
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5046
5303
  }
5047
- const { action, email, code } = body;
5304
+ const { action, email, code, challenge } = body;
5048
5305
  if (!action || !email) {
5049
5306
  return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
5050
5307
  }
5051
5308
  const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
5052
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
+ }
5053
5316
  if (await sendLimiter.check(email, req)) {
5054
5317
  return Response.json(genericSendResponse);
5055
5318
  }
@@ -5143,6 +5406,33 @@ function createAuth2faEndpoint(slugs, store) {
5143
5406
  }
5144
5407
  };
5145
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
+ }
5146
5436
  function createOAuthGoogleEndpoint(slugs, options) {
5147
5437
  return {
5148
5438
  path: "/support/oauth/google",
@@ -5159,7 +5449,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5159
5449
  }
5160
5450
  try {
5161
5451
  const body = await req.json();
5162
- const { action, code, state: queryState, cookieState } = body;
5452
+ const { action, code, state: queryState } = body;
5163
5453
  if (action === "login") {
5164
5454
  const oauthState = crypto3.randomBytes(32).toString("hex");
5165
5455
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5171,13 +5461,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
5171
5461
  state: oauthState,
5172
5462
  prompt: "select_account"
5173
5463
  });
5174
- return Response.json({
5175
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5176
- state: oauthState
5177
- });
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
+ );
5178
5479
  }
5179
5480
  if (code) {
5180
- if (!cookieState || !queryState || cookieState !== queryState) {
5481
+ const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
5482
+ if (!statesMatch(issuedState, queryState)) {
5181
5483
  return Response.json({ error: "state_mismatch" }, { status: 400 });
5182
5484
  }
5183
5485
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5259,6 +5561,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
5259
5561
  overrideAccess: true
5260
5562
  });
5261
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
+ }
5262
5593
  const secret = process.env.PAYLOAD_SECRET;
5263
5594
  if (!secret) {
5264
5595
  return Response.json({ error: "server_misconfigured" }, { status: 500 });
@@ -5302,6 +5633,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5302
5633
  "Set-Cookie",
5303
5634
  `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5304
5635
  );
5636
+ headers.append("Set-Cookie", clearedStateCookie());
5305
5637
  return new Response(JSON.stringify({ user: clientDoc, exp }), {
5306
5638
  status: 200,
5307
5639
  headers
@@ -5608,7 +5940,7 @@ ONLY JSON, nothing else.`
5608
5940
  }
5609
5941
  }
5610
5942
  function createImportConversationEndpoint(slugs, store) {
5611
- const importLimiter = new RateLimiter(36e5, 10, store);
5943
+ const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
5612
5944
  return {
5613
5945
  path: "/support/import-conversation",
5614
5946
  method: "post",
@@ -5751,62 +6083,204 @@ function createImportConversationEndpoint(slugs, store) {
5751
6083
  }
5752
6084
  };
5753
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
+ }
5754
6215
 
5755
- // src/utils/fireWebhooks.ts
5756
- async function fireWebhooks(payload, slugs, event, data) {
6216
+ // src/utils/webhookDispatcher.ts
6217
+ function dispatchWebhook(data, event, payload, slugs) {
6218
+ const done = _dispatch(data, event, payload, slugs);
6219
+ return done;
6220
+ }
6221
+ async function _dispatch(data, event, payload, slugs) {
5757
6222
  try {
5758
- const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
6223
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
5759
6224
  where: {
5760
- active: { equals: true },
5761
- events: { contains: event }
6225
+ and: [
6226
+ { active: { equals: true } },
6227
+ { events: { contains: event } }
6228
+ ]
5762
6229
  },
5763
6230
  limit: 50,
5764
6231
  depth: 0,
5765
6232
  overrideAccess: true
5766
6233
  });
5767
- if (endpoints.docs.length === 0) return;
5768
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
6234
+ if (endpoints.length === 0) return;
6235
+ const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
5769
6236
  await Promise.allSettled(
5770
- endpoints.docs.map(async (endpoint) => {
5771
- try {
5772
- const res = await fetch(endpoint.url, {
5773
- method: "POST",
5774
- headers: {
5775
- "Content-Type": "application/json",
5776
- ...endpoint.secret ? { "X-Webhook-Secret": endpoint.secret } : {}
5777
- },
5778
- body: JSON.stringify({ event, data, timestamp }),
5779
- signal: AbortSignal.timeout(1e4)
5780
- });
5781
- try {
5782
- await dbUpdate(payload, slugs.webhookEndpoints, {
5783
- id: endpoint.id,
5784
- data: {
5785
- lastTriggeredAt: timestamp,
5786
- lastStatus: res.status
5787
- },
5788
- overrideAccess: true
5789
- });
5790
- } catch {
5791
- }
5792
- } catch (error) {
5793
- console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5794
- try {
5795
- await dbUpdate(payload, slugs.webhookEndpoints, {
5796
- id: endpoint.id,
5797
- data: {
5798
- lastTriggeredAt: timestamp,
5799
- lastStatus: 0
5800
- },
5801
- overrideAccess: true
5802
- });
5803
- } catch {
5804
- }
5805
- }
5806
- })
6237
+ endpoints.map((endpoint) => _sendToEndpoint(endpoint, body, payload, slugs))
5807
6238
  );
5808
- } catch (error) {
5809
- console.error("[support] Failed to fire webhooks:", error);
6239
+ } catch (err) {
6240
+ console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
6241
+ }
6242
+ }
6243
+ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6244
+ try {
6245
+ const headers = {
6246
+ "Content-Type": "application/json",
6247
+ "User-Agent": "PayloadSupport-Webhook/1.0"
6248
+ };
6249
+ if (endpoint.secret) {
6250
+ const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
6251
+ headers["X-Webhook-Signature"] = signature;
6252
+ }
6253
+ const response = await safeFetch(endpoint.url, {
6254
+ method: "POST",
6255
+ headers,
6256
+ body,
6257
+ signal: AbortSignal.timeout(1e4)
6258
+ // 10s timeout
6259
+ });
6260
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6261
+ id: endpoint.id,
6262
+ data: {
6263
+ lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6264
+ lastStatus: response.status
6265
+ },
6266
+ overrideAccess: true
6267
+ });
6268
+ if (!response.ok) {
6269
+ console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
6270
+ }
6271
+ } catch (err) {
6272
+ console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6273
+ try {
6274
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6275
+ id: endpoint.id,
6276
+ data: {
6277
+ lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6278
+ lastStatus: 0
6279
+ },
6280
+ overrideAccess: true
6281
+ });
6282
+ } catch {
6283
+ }
5810
6284
  }
5811
6285
  }
5812
6286
 
@@ -5893,13 +6367,18 @@ function createProcessScheduledEndpoint(slugs) {
5893
6367
  overrideAccess: true
5894
6368
  });
5895
6369
  }
5896
- fireWebhooks(payload, slugs, "ticket_replied", {
5897
- ticketId,
5898
- messageId: msg.id,
5899
- authorType: msg.authorType,
5900
- scheduled: true,
5901
- body: msg.body?.length > 500 ? msg.body.slice(0, 500) + "..." : msg.body
5902
- });
6370
+ dispatchWebhook(
6371
+ {
6372
+ ticketId,
6373
+ messageId: msg.id,
6374
+ authorType: msg.authorType,
6375
+ scheduled: true,
6376
+ body: msg.body?.length > 500 ? msg.body.slice(0, 500) + "..." : msg.body
6377
+ },
6378
+ "ticket_replied",
6379
+ payload,
6380
+ slugs
6381
+ );
5903
6382
  results.processed++;
5904
6383
  } catch (err) {
5905
6384
  console.error(`[process-scheduled] Error processing message ${message.id}:`, err);
@@ -6225,7 +6704,7 @@ function createUserPrefsGetEndpoint(slugs) {
6225
6704
  requireAdmin(req, slugs);
6226
6705
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6227
6706
  const prefs = await dbFind(payload, "payload-preferences", {
6228
- where: { key: { equals: key } },
6707
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6229
6708
  limit: 1,
6230
6709
  depth: 0,
6231
6710
  overrideAccess: true
@@ -6259,7 +6738,7 @@ function createUserPrefsPostEndpoint(slugs) {
6259
6738
  const body = await req.json();
6260
6739
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6261
6740
  const existing = await dbFind(payload, "payload-preferences", {
6262
- where: { key: { equals: key } },
6741
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6263
6742
  limit: 1,
6264
6743
  depth: 0,
6265
6744
  overrideAccess: true
@@ -6415,8 +6894,10 @@ function createTicketFeedbackEndpoint(slugs) {
6415
6894
  // src/endpoints/transfer-ticket.ts
6416
6895
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6417
6896
  var MAX_TRANSFERS_PER_DAY = 5;
6897
+ var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
6418
6898
  function createTransferTicketEndpoint(slugs, store) {
6419
- 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");
6420
6901
  return {
6421
6902
  path: "/support/tickets/:id/transfer",
6422
6903
  method: "post",
@@ -6451,12 +6932,18 @@ function createTransferTicketEndpoint(slugs, store) {
6451
6932
  if (!isAdmin && !isOwner) {
6452
6933
  return Response.json({ error: "Forbidden" }, { status: 403 });
6453
6934
  }
6454
- if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
6935
+ if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
6455
6936
  return Response.json(
6456
6937
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6457
6938
  { status: 429 }
6458
6939
  );
6459
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
+ }
6460
6947
  try {
6461
6948
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
6462
6949
  const existing = await dbCount(payload, slugs.emailLogs, {
@@ -6598,8 +7085,11 @@ function statusToLabel(status) {
6598
7085
  }
6599
7086
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6600
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;
6601
7090
  function createInviteCollaboratorEndpoint(slugs, store) {
6602
- 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");
6603
7093
  return {
6604
7094
  path: "/support/tickets/:id/invite",
6605
7095
  method: "post",
@@ -6634,7 +7124,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6634
7124
  if (!isAdmin && !isOwner) {
6635
7125
  return Response.json({ error: "Forbidden" }, { status: 403 });
6636
7126
  }
6637
- if (await inviteLimiter.check(String(req.user.id), req)) {
7127
+ if (await inviteLimiter.check(principalRateKey(req.user), req)) {
6638
7128
  return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6639
7129
  }
6640
7130
  const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
@@ -6658,6 +7148,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6658
7148
  if (existing.docs.length > 0) {
6659
7149
  inviteeId = existing.docs[0].id;
6660
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
+ }
6661
7157
  const tempPassword = randomBytes(16).toString("hex");
6662
7158
  const created = await dbCreate(payload, slugs.supportClients, {
6663
7159
  data: {
@@ -6829,7 +7325,7 @@ function createSupportEndpoints(slugs, options) {
6829
7325
  }
6830
7326
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6831
7327
  if (!f || f.emailTracking !== false) {
6832
- endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
7328
+ endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
6833
7329
  }
6834
7330
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6835
7331
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
@@ -6869,74 +7365,6 @@ async function createAdminNotification(payload, data, collectionSlug = "admin-no
6869
7365
  console.error("[notification] Failed to create:", err);
6870
7366
  }
6871
7367
  }
6872
- function dispatchWebhook(data, event, payload, slugs) {
6873
- void _dispatch(data, event, payload, slugs);
6874
- }
6875
- async function _dispatch(data, event, payload, slugs) {
6876
- try {
6877
- const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
6878
- where: {
6879
- and: [
6880
- { active: { equals: true } },
6881
- { events: { contains: event } }
6882
- ]
6883
- },
6884
- limit: 50,
6885
- depth: 0,
6886
- overrideAccess: true
6887
- });
6888
- if (endpoints.length === 0) return;
6889
- const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
6890
- for (const endpoint of endpoints) {
6891
- void _sendToEndpoint(endpoint, body, payload, slugs);
6892
- }
6893
- } catch (err) {
6894
- console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
6895
- }
6896
- }
6897
- async function _sendToEndpoint(endpoint, body, payload, slugs) {
6898
- try {
6899
- const headers = {
6900
- "Content-Type": "application/json",
6901
- "User-Agent": "PayloadSupport-Webhook/1.0"
6902
- };
6903
- if (endpoint.secret) {
6904
- const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
6905
- headers["X-Webhook-Signature"] = signature;
6906
- }
6907
- const response = await fetch(endpoint.url, {
6908
- method: "POST",
6909
- headers,
6910
- body,
6911
- signal: AbortSignal.timeout(1e4)
6912
- // 10s timeout
6913
- });
6914
- await dbUpdate(payload, slugs.webhookEndpoints, {
6915
- id: endpoint.id,
6916
- data: {
6917
- lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6918
- lastStatus: response.status
6919
- },
6920
- overrideAccess: true
6921
- });
6922
- if (!response.ok) {
6923
- console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
6924
- }
6925
- } catch (err) {
6926
- console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6927
- try {
6928
- await dbUpdate(payload, slugs.webhookEndpoints, {
6929
- id: endpoint.id,
6930
- data: {
6931
- lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6932
- lastStatus: 0
6933
- },
6934
- overrideAccess: true
6935
- });
6936
- } catch {
6937
- }
6938
- }
6939
- }
6940
7368
 
6941
7369
  // src/hooks/ticketStatusEmail.ts
6942
7370
  var STATUS_LABELS = {
@@ -7553,12 +7981,28 @@ function createAssignTicketNumber(slugs, options) {
7553
7981
  }
7554
7982
  function createAssignClientOnCreate(slugs) {
7555
7983
  return async ({ data, operation, req }) => {
7556
- if (operation === "create" && req.user?.collection === slugs.supportClients && !data.client) {
7984
+ if (operation === "create" && req.user?.collection === slugs.supportClients) {
7557
7985
  data.client = req.user.id;
7558
7986
  }
7559
7987
  return data;
7560
7988
  };
7561
7989
  }
7990
+ var CLIENT_CREATABLE_TICKET_FIELDS = ["subject", "category", "priority", "project"];
7991
+ function createRestrictClientCreate(slugs) {
7992
+ return async ({ data, operation, req }) => {
7993
+ if (operation !== "create") return data;
7994
+ if (req.user?.collection !== slugs.supportClients) return data;
7995
+ const incoming = data ?? {};
7996
+ const sanitized = {};
7997
+ for (const field2 of CLIENT_CREATABLE_TICKET_FIELDS) {
7998
+ if (incoming[field2] !== void 0) sanitized[field2] = incoming[field2];
7999
+ }
8000
+ sanitized.status = "open";
8001
+ sanitized.client = req.user.id;
8002
+ sanitized.source = "portal";
8003
+ return sanitized;
8004
+ };
8005
+ }
7562
8006
  var autoPaidAt = async ({ data, operation, originalDoc }) => {
7563
8007
  if (operation !== "update") return data;
7564
8008
  if (data.paymentStatus === "paid" && originalDoc?.paymentStatus !== "paid" && !data.paidAt) {
@@ -7584,16 +8028,8 @@ function createAutoAssignAdmin(slugs) {
7584
8028
  const { payload } = req;
7585
8029
  let roundRobinEnabled = false;
7586
8030
  try {
7587
- const prefs = await payload.find({
7588
- collection: "payload-preferences",
7589
- where: { key: { equals: "support-round-robin" } },
7590
- limit: 1,
7591
- depth: 0,
7592
- overrideAccess: true
7593
- });
7594
- if (prefs.docs.length > 0) {
7595
- roundRobinEnabled = prefs.docs[0].value?.enabled === true;
7596
- }
8031
+ const settings = await readSupportSettings(payload);
8032
+ roundRobinEnabled = settings.features.roundRobin === true;
7597
8033
  } catch (err) {
7598
8034
  console.warn("[support] Failed to read round-robin preferences:", err);
7599
8035
  }
@@ -7802,54 +8238,6 @@ function createNotifyClientOnResolve(slugs) {
7802
8238
  return doc;
7803
8239
  };
7804
8240
  }
7805
- function createFireTicketWebhooks(slugs) {
7806
- return async ({ doc, previousDoc, operation, req }) => {
7807
- const { payload } = req;
7808
- if (operation === "create") {
7809
- fireWebhooks(payload, slugs, "ticket_created", {
7810
- id: doc.id,
7811
- ticketNumber: doc.ticketNumber,
7812
- subject: doc.subject,
7813
- status: doc.status,
7814
- priority: doc.priority,
7815
- category: doc.category
7816
- });
7817
- return doc;
7818
- }
7819
- if (operation === "update" && previousDoc) {
7820
- if (previousDoc.status !== doc.status && doc.status === "resolved") {
7821
- fireWebhooks(payload, slugs, "ticket_resolved", {
7822
- id: doc.id,
7823
- ticketNumber: doc.ticketNumber,
7824
- subject: doc.subject,
7825
- previousStatus: previousDoc.status
7826
- });
7827
- const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
7828
- if (clientId) {
7829
- payload.update({
7830
- collection: "client-summaries",
7831
- where: { client: { equals: clientId } },
7832
- data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
7833
- // Force cache expiry
7834
- overrideAccess: true
7835
- }).catch(() => {
7836
- });
7837
- }
7838
- }
7839
- const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
7840
- const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
7841
- if (newAssigned && oldAssigned !== newAssigned) {
7842
- fireWebhooks(payload, slugs, "ticket_assigned", {
7843
- id: doc.id,
7844
- ticketNumber: doc.ticketNumber,
7845
- subject: doc.subject,
7846
- assignedTo: newAssigned
7847
- });
7848
- }
7849
- }
7850
- return doc;
7851
- };
7852
- }
7853
8241
  function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
7854
8242
  return async ({ doc, operation, req }) => {
7855
8243
  if (operation !== "create") return doc;
@@ -7870,21 +8258,66 @@ function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
7870
8258
  }
7871
8259
  function createDispatchWebhookOnTicket(slugs) {
7872
8260
  return async ({ doc, previousDoc, operation, req }) => {
8261
+ const { payload } = req;
7873
8262
  if (operation === "create") {
7874
8263
  dispatchWebhook(
7875
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
8264
+ {
8265
+ ticketId: doc.id,
8266
+ id: doc.id,
8267
+ ticketNumber: doc.ticketNumber,
8268
+ subject: doc.subject,
8269
+ status: doc.status,
8270
+ priority: doc.priority,
8271
+ category: doc.category
8272
+ },
7876
8273
  "ticket_created",
7877
- req.payload,
8274
+ payload,
7878
8275
  slugs
7879
8276
  );
8277
+ return doc;
7880
8278
  }
7881
- if (operation === "update" && previousDoc?.status !== doc.status && doc.status === "resolved") {
7882
- dispatchWebhook(
7883
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
7884
- "ticket_resolved",
7885
- req.payload,
7886
- slugs
7887
- );
8279
+ if (operation === "update" && previousDoc) {
8280
+ if (previousDoc.status !== doc.status && doc.status === "resolved") {
8281
+ dispatchWebhook(
8282
+ {
8283
+ ticketId: doc.id,
8284
+ id: doc.id,
8285
+ ticketNumber: doc.ticketNumber,
8286
+ subject: doc.subject,
8287
+ previousStatus: previousDoc.status
8288
+ },
8289
+ "ticket_resolved",
8290
+ payload,
8291
+ slugs
8292
+ );
8293
+ const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
8294
+ if (clientId) {
8295
+ payload.update({
8296
+ collection: "client-summaries",
8297
+ where: { client: { equals: clientId } },
8298
+ data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
8299
+ // Force cache expiry
8300
+ overrideAccess: true
8301
+ }).catch(() => {
8302
+ });
8303
+ }
8304
+ }
8305
+ const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
8306
+ const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
8307
+ if (newAssigned && oldAssigned !== newAssigned) {
8308
+ dispatchWebhook(
8309
+ {
8310
+ ticketId: doc.id,
8311
+ id: doc.id,
8312
+ ticketNumber: doc.ticketNumber,
8313
+ subject: doc.subject,
8314
+ assignedTo: newAssigned
8315
+ },
8316
+ "ticket_assigned",
8317
+ payload,
8318
+ slugs
8319
+ );
8320
+ }
7888
8321
  }
7889
8322
  return doc;
7890
8323
  };
@@ -8010,7 +8443,10 @@ function createTicketsCollection(slugs, options) {
8010
8443
  { label: "Paiement partiel", value: "partial" },
8011
8444
  { label: "Pay\xE9", value: "paid" }
8012
8445
  ],
8013
- access: { update: ({ req }) => req.user?.collection === "users" },
8446
+ // `slugs.users`, not the literal 'users': the users collection slug is
8447
+ // configurable, and a hard-coded one silently opens this field to everybody
8448
+ // on a host that renamed it.
8449
+ access: { update: ({ req }) => req.user?.collection === slugs.users },
8014
8450
  admin: { width: "50%", condition: (data) => !!data?.invoice }
8015
8451
  },
8016
8452
  {
@@ -8292,6 +8728,7 @@ function createTicketsCollection(slugs, options) {
8292
8728
  { name: "totalTimeMinutes", type: "number", defaultValue: 0, label: "Temps total (minutes)", admin: { readOnly: true, position: "sidebar" } }
8293
8729
  ],
8294
8730
  hooks: {
8731
+ beforeValidate: [createRestrictClientCreate(slugs)],
8295
8732
  beforeChange: [
8296
8733
  createAssignTicketNumber(slugs, options?.ticketNumber),
8297
8734
  createAssignClientOnCreate(slugs),
@@ -8312,7 +8749,6 @@ function createTicketsCollection(slugs, options) {
8312
8749
  createNotifyClientOnResolve(slugs),
8313
8750
  createTicketStatusEmail(slugs),
8314
8751
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
8315
- createFireTicketWebhooks(slugs),
8316
8752
  createDispatchWebhookOnTicket(slugs),
8317
8753
  createApplyAutomationRules(slugs)
8318
8754
  ],
@@ -8374,7 +8810,7 @@ function createTicketsCollection(slugs, options) {
8374
8810
  }
8375
8811
 
8376
8812
  // src/utils/ticketAccess.ts
8377
- async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8813
+ async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
8378
8814
  const ids = /* @__PURE__ */ new Set();
8379
8815
  try {
8380
8816
  const owned = await dbFind(payload, slugs.tickets, {
@@ -8395,6 +8831,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8395
8831
  });
8396
8832
  for (const r of collab.docs) {
8397
8833
  const row = r;
8834
+ if (mode === "write" && row.role !== "collaborator") continue;
8398
8835
  const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8399
8836
  if (tid !== void 0 && tid !== null) ids.add(tid);
8400
8837
  }
@@ -8493,6 +8930,21 @@ async function queueClientNotification(payload, slugs, item) {
8493
8930
  }
8494
8931
 
8495
8932
  // src/collections/TicketMessages.ts
8933
+ function createRestrictClientTicketTarget(slugs) {
8934
+ return async ({ data, operation, req, originalDoc }) => {
8935
+ if (req.user?.collection !== slugs.supportClients) return data;
8936
+ const raw = data.ticket ?? (operation === "update" ? originalDoc?.ticket : void 0);
8937
+ const targetId = raw && typeof raw === "object" ? raw.id : raw;
8938
+ if (targetId === void 0 || targetId === null || targetId === "") {
8939
+ throw new APIError("Ticket cible requis.", 400);
8940
+ }
8941
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
8942
+ if (!accessible.some((id) => String(id) === String(targetId))) {
8943
+ throw new APIError("Ticket inaccessible.", 403);
8944
+ }
8945
+ return data;
8946
+ };
8947
+ }
8496
8948
  function createAssignAuthor(slugs) {
8497
8949
  return async ({ data, operation, req }) => {
8498
8950
  if (operation === "create" && req.user?.collection === slugs.supportClients) {
@@ -8727,21 +9179,6 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
8727
9179
  return doc;
8728
9180
  };
8729
9181
  }
8730
- function createFireMessageWebhooks(slugs) {
8731
- return async ({ doc, operation, req }) => {
8732
- if (operation !== "create") return doc;
8733
- if (doc.scheduledAt && !doc.scheduledSent) return doc;
8734
- if (doc.isInternal) return doc;
8735
- const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8736
- fireWebhooks(req.payload, slugs, "ticket_replied", {
8737
- ticketId,
8738
- messageId: doc.id,
8739
- authorType: doc.authorType,
8740
- body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
8741
- });
8742
- return doc;
8743
- };
8744
- }
8745
9182
  function createDispatchWebhookOnReply(slugs) {
8746
9183
  return async ({ doc, operation, req }) => {
8747
9184
  if (operation !== "create") return doc;
@@ -8749,7 +9186,12 @@ function createDispatchWebhookOnReply(slugs) {
8749
9186
  if (doc.scheduledAt && !doc.scheduledSent) return doc;
8750
9187
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8751
9188
  dispatchWebhook(
8752
- { ticketId, messageId: doc.id, authorType: doc.authorType },
9189
+ {
9190
+ ticketId,
9191
+ messageId: doc.id,
9192
+ authorType: doc.authorType,
9193
+ body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
9194
+ },
8753
9195
  "ticket_replied",
8754
9196
  req.payload,
8755
9197
  slugs
@@ -8932,7 +9374,7 @@ function createTicketMessagesCollection(slugs, options) {
8932
9374
  ] : []
8933
9375
  ],
8934
9376
  hooks: {
8935
- beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
9377
+ beforeChange: [createRestrictClientTicketTarget(slugs), createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
8936
9378
  afterChange: [
8937
9379
  ...options?.capabilities?.sms ? [createSmsNotification(slugs, options.capabilities.sms)] : [],
8938
9380
  createAutoUpdateStatus(slugs),
@@ -8941,7 +9383,6 @@ function createTicketMessagesCollection(slugs, options) {
8941
9383
  createCheckSlaOnReply(slugs, notificationSlug),
8942
9384
  createSyncTicketReplyToChat(slugs),
8943
9385
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
8944
- createFireMessageWebhooks(slugs),
8945
9386
  createDispatchWebhookOnReply(slugs),
8946
9387
  createNotifyMentions(slugs),
8947
9388
  ...options?.capabilities?.threadCleanup ? [createThreadCleanup(options.capabilities.threadCleanup)] : []
@@ -9014,13 +9455,13 @@ function createSendInvitationOnCreate(slugs) {
9014
9455
  return doc;
9015
9456
  };
9016
9457
  }
9017
- var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
9458
+ var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
9018
9459
  function createEnforce2FA(slugs) {
9019
9460
  return async ({ req, user }) => {
9020
9461
  if (!user?.twoFactorEnabled) return user;
9021
9462
  const raw = user.twoFactorVerifiedAt;
9022
9463
  const verifiedAt = raw ? new Date(raw).getTime() : 0;
9023
- if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
9464
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
9024
9465
  throw new APIError("2FA_REQUIRED", 401);
9025
9466
  }
9026
9467
  await req.payload.update({
@@ -9034,7 +9475,11 @@ function createEnforce2FA(slugs) {
9034
9475
  return user;
9035
9476
  };
9036
9477
  }
9478
+ function createAdminOnlyFieldUpdate(slugs) {
9479
+ return ({ req }) => req.user?.collection === slugs.users;
9480
+ }
9037
9481
  function createSupportClientsCollection(slugs, options) {
9482
+ const adminOnlyFieldUpdate = createAdminOnlyFieldUpdate(slugs);
9038
9483
  return {
9039
9484
  slug: slugs.supportClients,
9040
9485
  labels: {
@@ -9164,7 +9609,8 @@ function createSupportClientsCollection(slugs, options) {
9164
9609
  label: "Opportunites commerciales",
9165
9610
  access: {
9166
9611
  // Admin-only: never serialize commercial notes onto the client's own doc.
9167
- read: ({ req }) => req.user?.collection === slugs.users
9612
+ read: ({ req }) => req.user?.collection === slugs.users,
9613
+ update: adminOnlyFieldUpdate
9168
9614
  },
9169
9615
  admin: {
9170
9616
  description: "Notes commerciales (visible uniquement par les admins)"
@@ -9174,6 +9620,7 @@ function createSupportClientsCollection(slugs, options) {
9174
9620
  name: "tier",
9175
9621
  type: "select",
9176
9622
  label: "Plan",
9623
+ access: { update: adminOnlyFieldUpdate },
9177
9624
  options: [
9178
9625
  { label: "Free", value: "free" },
9179
9626
  { label: "Basic", value: "basic" },
@@ -9187,6 +9634,7 @@ function createSupportClientsCollection(slugs, options) {
9187
9634
  type: "relationship",
9188
9635
  relationTo: slugs.users,
9189
9636
  label: "Agent referent",
9637
+ access: { update: adminOnlyFieldUpdate },
9190
9638
  admin: { position: "sidebar" }
9191
9639
  },
9192
9640
  {
@@ -9202,17 +9650,20 @@ function createSupportClientsCollection(slugs, options) {
9202
9650
  {
9203
9651
  name: "twoFactorCode",
9204
9652
  type: "text",
9653
+ access: { update: adminOnlyFieldUpdate },
9205
9654
  admin: { hidden: true }
9206
9655
  },
9207
9656
  {
9208
9657
  name: "twoFactorExpiry",
9209
9658
  type: "date",
9659
+ access: { update: adminOnlyFieldUpdate },
9210
9660
  admin: { hidden: true }
9211
9661
  },
9212
9662
  {
9213
9663
  // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
9214
9664
  name: "twoFactorVerifiedAt",
9215
9665
  type: "date",
9666
+ access: { update: adminOnlyFieldUpdate },
9216
9667
  admin: { hidden: true }
9217
9668
  },
9218
9669
  {
@@ -9220,6 +9671,7 @@ function createSupportClientsCollection(slugs, options) {
9220
9671
  name: "googleId",
9221
9672
  type: "text",
9222
9673
  index: true,
9674
+ access: { update: adminOnlyFieldUpdate },
9223
9675
  admin: { hidden: true }
9224
9676
  },
9225
9677
  {
@@ -9290,7 +9742,8 @@ function createSupportClientsCollection(slugs, options) {
9290
9742
  label: "Notes internes",
9291
9743
  access: {
9292
9744
  // Admin-only: never serialize internal notes onto the client's own doc.
9293
- read: ({ req }) => req.user?.collection === slugs.users
9745
+ read: ({ req }) => req.user?.collection === slugs.users,
9746
+ update: adminOnlyFieldUpdate
9294
9747
  },
9295
9748
  admin: {
9296
9749
  description: "Visible uniquement par les admins",
@@ -9363,36 +9816,49 @@ function createSupportClientsCollection(slugs, options) {
9363
9816
  }
9364
9817
 
9365
9818
  // src/collections/TimeEntries.ts
9819
+ async function recalculateTicketTime(payload, slugs, ticketId) {
9820
+ let totalMinutes = 0;
9821
+ let page = 1;
9822
+ let hasMore = true;
9823
+ while (hasMore) {
9824
+ const entries = await payload.find({
9825
+ collection: slugs.timeEntries,
9826
+ where: { ticket: { equals: ticketId } },
9827
+ limit: 100,
9828
+ page,
9829
+ depth: 0,
9830
+ overrideAccess: true,
9831
+ select: { duration: true }
9832
+ });
9833
+ for (const entry of entries.docs) {
9834
+ totalMinutes += entry.duration || 0;
9835
+ }
9836
+ hasMore = entries.hasNextPage ?? false;
9837
+ page++;
9838
+ }
9839
+ await payload.update({
9840
+ collection: slugs.tickets,
9841
+ id: ticketId,
9842
+ data: { totalTimeMinutes: totalMinutes },
9843
+ overrideAccess: true
9844
+ });
9845
+ }
9366
9846
  function createRecalculateTicketTime(slugs) {
9367
9847
  return async ({ doc, req }) => {
9368
9848
  if (!doc.ticket) return;
9369
- const { payload } = req;
9370
9849
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
9371
- let totalMinutes = 0;
9372
- let page = 1;
9373
- let hasMore = true;
9374
- while (hasMore) {
9375
- const entries = await payload.find({
9376
- collection: slugs.timeEntries,
9377
- where: { ticket: { equals: ticketId } },
9378
- limit: 100,
9379
- page,
9380
- depth: 0,
9381
- overrideAccess: true,
9382
- select: { duration: true }
9383
- });
9384
- for (const entry of entries.docs) {
9385
- totalMinutes += entry.duration || 0;
9386
- }
9387
- hasMore = entries.hasNextPage ?? false;
9388
- page++;
9850
+ await recalculateTicketTime(req.payload, slugs, ticketId);
9851
+ };
9852
+ }
9853
+ function createRecalculateTicketTimeOnDelete(slugs) {
9854
+ return async ({ doc, req }) => {
9855
+ if (!doc?.ticket) return;
9856
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
9857
+ try {
9858
+ await recalculateTicketTime(req.payload, slugs, ticketId);
9859
+ } catch (err) {
9860
+ console.error("[support] Failed to recalculate ticket time after delete:", err);
9389
9861
  }
9390
- await payload.update({
9391
- collection: slugs.tickets,
9392
- id: ticketId,
9393
- data: { totalTimeMinutes: totalMinutes },
9394
- overrideAccess: true
9395
- });
9396
9862
  };
9397
9863
  }
9398
9864
  function createTimeEntriesCollection(slugs) {
@@ -9454,7 +9920,8 @@ function createTimeEntriesCollection(slugs) {
9454
9920
  }
9455
9921
  ],
9456
9922
  hooks: {
9457
- afterChange: [createRecalculateTicketTime(slugs)]
9923
+ afterChange: [createRecalculateTicketTime(slugs)],
9924
+ afterDelete: [createRecalculateTicketTimeOnDelete(slugs)]
9458
9925
  },
9459
9926
  access: {
9460
9927
  create: ({ req }) => req.user?.collection === slugs.users,
@@ -9835,8 +10302,28 @@ function createKnowledgeBaseCollection(slugs) {
9835
10302
  timestamps: true
9836
10303
  };
9837
10304
  }
9838
-
9839
- // 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
+ }
9840
10327
  function createChatMessagesCollection(slugs) {
9841
10328
  return {
9842
10329
  slug: slugs.chatMessages,
@@ -9858,7 +10345,9 @@ function createChatMessagesCollection(slugs) {
9858
10345
  }
9859
10346
  return false;
9860
10347
  },
9861
- 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,
9862
10351
  update: ({ req }) => req.user?.collection === slugs.users,
9863
10352
  delete: ({ req }) => req.user?.collection === slugs.users
9864
10353
  },
@@ -9927,6 +10416,9 @@ function createChatMessagesCollection(slugs) {
9927
10416
  }
9928
10417
  }
9929
10418
  ],
10419
+ hooks: {
10420
+ beforeChange: [createRestrictClientChatWrite(slugs)]
10421
+ },
9930
10422
  timestamps: true
9931
10423
  };
9932
10424
  }
@@ -10204,8 +10696,19 @@ function createAuthLogsCollection(slugs) {
10204
10696
  timestamps: true
10205
10697
  };
10206
10698
  }
10207
-
10208
- // 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
+ }
10209
10712
  function createWebhookEndpointsCollection(slugs) {
10210
10713
  return {
10211
10714
  slug: slugs.webhookEndpoints,
@@ -10239,8 +10742,11 @@ function createWebhookEndpointsCollection(slugs) {
10239
10742
  type: "text",
10240
10743
  required: true,
10241
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.
10242
10748
  admin: {
10243
- 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."
10244
10750
  }
10245
10751
  },
10246
10752
  {
@@ -10294,6 +10800,9 @@ function createWebhookEndpointsCollection(slugs) {
10294
10800
  }
10295
10801
  }
10296
10802
  ],
10803
+ hooks: {
10804
+ beforeValidate: [createValidateWebhookUrl()]
10805
+ },
10297
10806
  timestamps: true
10298
10807
  };
10299
10808
  }
@@ -10745,11 +11254,17 @@ function createClientSummariesCollection(slugs) {
10745
11254
  admin: { readOnly: true }
10746
11255
  }
10747
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.
10748
11263
  access: {
10749
- create: ({ req }) => req.user?.collection === "users",
10750
- read: ({ req }) => req.user?.collection === "users",
10751
- update: ({ req }) => req.user?.collection === "users",
10752
- 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
10753
11268
  },
10754
11269
  timestamps: true
10755
11270
  };
@@ -10880,7 +11395,12 @@ function createTicketCollaboratorsCollection(slugs) {
10880
11395
  }
10881
11396
  return false;
10882
11397
  },
10883
- create: ({ req }) => !!req.user,
11398
+ // Staff only. This collection is the source of truth for
11399
+ // resolveAccessibleTicketIds(), which widens `read` on tickets, messages
11400
+ // and the activity log — so letting any authenticated principal POST here
11401
+ // let a support client grant themselves access to any ticket by id.
11402
+ // The /invite endpoint is unaffected: it writes with overrideAccess: true.
11403
+ create: ({ req }) => req.user?.collection === slugs.users,
10884
11404
  update: () => false,
10885
11405
  delete: ({ req }) => req.user?.collection === slugs.users
10886
11406
  },
@@ -11208,6 +11728,17 @@ function supportPlugin(config) {
11208
11728
  });
11209
11729
  return {
11210
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
+ },
11211
11742
  collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
11212
11743
  endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
11213
11744
  admin: {
@@ -11221,4 +11752,4 @@ function supportPlugin(config) {
11221
11752
  };
11222
11753
  }
11223
11754
 
11224
- export { DEFAULT_FEATURES, DEFAULT_INBOUND_EMAIL_LIMITS, DEFAULT_SETTINGS, DEFAULT_SLUGS, DEFAULT_USER_PREFS, MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter, calculateBusinessHoursDeadline, createAdminNotification, createAssignSlaDeadlines, createAuthLogsCollection, createCannedResponsesCollection, createChatMessagesCollection, createCheckSlaOnReply, createCheckSlaOnResolve, createEmailLogsCollection, createKnowledgeBaseCollection, createLoginEndpoint, createMacrosCollection, createOAuthGoogleEndpoint, createPendingEmailsCollection, createSatisfactionSurveysCollection, createSlaPoliciesCollection, createSupportClientsCollection, createTicketActivityLogCollection, createTicketCollaboratorsCollection, createTicketMessagesCollection, createTicketStatusEmail, createTicketStatusesCollection, createTicketsCollection, createTimeEntriesCollection, createTrackOpenEndpoint, createWebhookEndpointsCollection, dispatchWebhook, generateTicketSynthesis, readSupportSettings, readUserPrefs, resolveSlugs, supportPlugin, validateInboundEmailPayload, verifySecret };
11755
+ export { DEFAULT_FEATURES, DEFAULT_INBOUND_EMAIL_LIMITS, DEFAULT_SETTINGS, DEFAULT_SLUGS, DEFAULT_TICKETING_FEATURES, DEFAULT_USER_PREFS, MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter, calculateBusinessHoursDeadline, createAdminNotification, createAssignSlaDeadlines, createAuthLogsCollection, createCannedResponsesCollection, createChatMessagesCollection, createCheckSlaOnReply, createCheckSlaOnResolve, createEmailLogsCollection, createKnowledgeBaseCollection, createLoginEndpoint, createMacrosCollection, createOAuthGoogleEndpoint, createPendingEmailsCollection, createSatisfactionSurveysCollection, createSlaPoliciesCollection, createSupportClientsCollection, createTicketActivityLogCollection, createTicketCollaboratorsCollection, createTicketMessagesCollection, createTicketStatusEmail, createTicketStatusesCollection, createTicketsCollection, createTimeEntriesCollection, createTrackOpenEndpoint, createWebhookEndpointsCollection, dispatchWebhook, generateTicketSynthesis, normalizeFeatures, projectAutoClose, readSupportSettings, readSupportSettingsState, readUserPrefs, resolveSlugs, stripProjectedFeatures, supportPlugin, validateInboundEmailPayload, verifySecret };