@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.cjs CHANGED
@@ -3,6 +3,7 @@
3
3
  var payload = require('payload');
4
4
  var crypto3 = require('crypto');
5
5
  var PDFDocument = require('pdfkit');
6
+ var promises = require('dns/promises');
6
7
  var webpush = require('web-push');
7
8
  var sanitizeHtml = require('sanitize-html');
8
9
 
@@ -167,22 +168,44 @@ var PayloadRateLimitStore = class {
167
168
  return { payload: context };
168
169
  }
169
170
  };
171
+ function principalRateKey(user) {
172
+ if (!user || user.id === void 0 || user.id === null) return "anonymous";
173
+ const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
174
+ return `${collection}:${String(user.id)}`;
175
+ }
170
176
  var RateLimiter = class {
171
- constructor(windowMs, maxRequests, store) {
177
+ /**
178
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
179
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
180
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
181
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
182
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
183
+ * by five different endpoints. Always pass one; it is optional only because
184
+ * `RateLimiter` is part of the published API surface.
185
+ */
186
+ constructor(windowMs, maxRequests, store, namespace) {
172
187
  this.windowMs = windowMs;
173
188
  this.maxRequests = maxRequests;
174
189
  this.store = store ?? new MemoryRateLimitStore();
190
+ this.prefix = namespace ? `${namespace}:` : "";
175
191
  }
176
192
  windowMs;
177
193
  maxRequests;
178
194
  store;
195
+ prefix;
196
+ /** The key actually written to the store. Exposed for assertions in tests. */
197
+ scopedKey(key) {
198
+ return `${this.prefix}${key}`;
199
+ }
179
200
  async check(key, context) {
180
- const entry = context === void 0 ? await this.store.increment(key, this.windowMs) : await this.store.increment(key, this.windowMs, context);
201
+ const scoped = this.scopedKey(key);
202
+ const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
181
203
  return entry.count > this.maxRequests;
182
204
  }
183
205
  async reset(key, context) {
184
- if (context === void 0) await this.store.reset(key);
185
- else await this.store.reset(key, context);
206
+ const scoped = this.scopedKey(key);
207
+ if (context === void 0) await this.store.reset(scoped);
208
+ else await this.store.reset(scoped, context);
186
209
  }
187
210
  };
188
211
  var DEFAULT_INBOUND_EMAIL_LIMITS = {
@@ -220,7 +243,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
220
243
 
221
244
  // src/endpoints/capabilities.ts
222
245
  function createInboundEmailEndpoint(capability, store) {
223
- const limiter = new RateLimiter(6e4, 60, store);
246
+ const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
224
247
  return {
225
248
  path: "/support-webhook/inbound-email",
226
249
  method: "post",
@@ -251,7 +274,7 @@ function createInboundEmailEndpoint(capability, store) {
251
274
  };
252
275
  }
253
276
  function createProjectSuggestionsEndpoint(slugs, capability, store) {
254
- const limiter = new RateLimiter(6e4, 20, store);
277
+ const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
255
278
  return {
256
279
  path: "/support/suggest-projects",
257
280
  method: "post",
@@ -259,7 +282,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
259
282
  if (!req.user || req.user.collection !== slugs.users) {
260
283
  return Response.json({ error: "Unauthorized" }, { status: 401 });
261
284
  }
262
- const key = req.user?.id ? String(req.user.id) : "anonymous";
285
+ const key = principalRateKey(req.user);
263
286
  if (await limiter.check(key, req)) {
264
287
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
265
288
  }
@@ -268,7 +291,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
268
291
  };
269
292
  }
270
293
  function createTicketTitleEndpoint(slugs, capability, store) {
271
- const limiter = new RateLimiter(6e4, 20, store);
294
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
272
295
  return {
273
296
  path: "/support/ticket-title",
274
297
  method: "post",
@@ -290,7 +313,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
290
313
  };
291
314
  }
292
315
  function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
293
- const limiter = new RateLimiter(6e4, 5, store);
316
+ const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
294
317
  return {
295
318
  path: "/support/generate-missing-titles",
296
319
  method: "post",
@@ -377,55 +400,201 @@ function dbDelete(payload, slug, options) {
377
400
  return payload.delete({ collection: slug, ...options });
378
401
  }
379
402
 
403
+ // src/utils/features.ts
404
+ var DEFAULT_TICKETING_FEATURES = {
405
+ timeTracking: true,
406
+ ai: true,
407
+ satisfaction: true,
408
+ chat: true,
409
+ emailTracking: true,
410
+ canned: true,
411
+ merge: true,
412
+ snooze: true,
413
+ externalMessages: true,
414
+ clientHistory: true,
415
+ activityLog: true,
416
+ splitTicket: true,
417
+ scheduledReplies: true,
418
+ autoClose: true,
419
+ autoCloseDays: 7,
420
+ roundRobin: false
421
+ };
422
+ var BOOLEAN_FEATURE_KEYS = [
423
+ "timeTracking",
424
+ "ai",
425
+ "satisfaction",
426
+ "chat",
427
+ "emailTracking",
428
+ "canned",
429
+ "merge",
430
+ "snooze",
431
+ "externalMessages",
432
+ "clientHistory",
433
+ "activityLog",
434
+ "splitTicket",
435
+ "scheduledReplies",
436
+ "autoClose",
437
+ "roundRobin"
438
+ ];
439
+ var PROJECTED_FEATURE_KEYS = ["autoClose", "autoCloseDays"];
440
+ function normalizeFeatures(raw) {
441
+ const out = { ...DEFAULT_TICKETING_FEATURES };
442
+ if (!raw || typeof raw !== "object") return out;
443
+ const src = raw;
444
+ for (const key of BOOLEAN_FEATURE_KEYS) {
445
+ if (typeof src[key] === "boolean") out[key] = src[key];
446
+ }
447
+ const days = src.autoCloseDays;
448
+ if (typeof days === "number" && Number.isFinite(days) && days >= 1) {
449
+ out.autoCloseDays = Math.floor(days);
450
+ }
451
+ return out;
452
+ }
453
+ function projectAutoClose(features, autoClose) {
454
+ if (!autoClose) return features;
455
+ return {
456
+ ...features,
457
+ autoClose: typeof autoClose.enabled === "boolean" ? autoClose.enabled : features.autoClose,
458
+ autoCloseDays: typeof autoClose.daysBeforeClose === "number" && autoClose.daysBeforeClose >= 1 ? Math.floor(autoClose.daysBeforeClose) : features.autoCloseDays
459
+ };
460
+ }
461
+ function stripProjectedFeatures(features) {
462
+ const out = { ...features };
463
+ for (const key of PROJECTED_FEATURE_KEYS) delete out[key];
464
+ return out;
465
+ }
466
+
380
467
  // src/utils/readSettings.ts
381
- var PREF_KEY = "support-settings";
468
+ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
469
+ var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
382
470
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
471
+ var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
472
+ var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
473
+ function resolveStaffPrefSlug(payload, staffSlug) {
474
+ if (staffSlug) return staffSlug;
475
+ const config = payload.config;
476
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
477
+ if (typeof registered === "string" && registered) return registered;
478
+ return config?.admin?.user || "users";
479
+ }
383
480
  var DEFAULT_SETTINGS = {
384
481
  email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
385
482
  ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
386
483
  sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: "" },
387
- autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
484
+ autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
485
+ features: { ...DEFAULT_TICKETING_FEATURES }
388
486
  };
389
487
  var DEFAULT_USER_PREFS = {
390
488
  locale: "fr",
391
489
  signature: ""
392
490
  };
393
- var settingsCache = null;
491
+ var settingsCache = /* @__PURE__ */ new Map();
394
492
  var SETTINGS_TTL_MS = 6e4;
493
+ var SETTINGS_CACHE_MAX = 8;
494
+ var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
395
495
  function invalidateSupportSettingsCache() {
396
- settingsCache = null;
496
+ settingsCache.clear();
497
+ warnedForeignSettingsRow.clear();
397
498
  }
398
- async function readSupportSettings(payload) {
399
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
400
- return settingsCache.value;
499
+ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
500
+ const autoClose = { ...base.autoClose, ...stored?.autoClose };
501
+ return {
502
+ email: { ...base.email, ...stored?.email },
503
+ ai: { ...base.ai, ...stored?.ai },
504
+ sla: { ...base.sla, ...stored?.sla },
505
+ autoClose,
506
+ // `autoClose` / `autoCloseDays` are projections of the block above, so they
507
+ // are recomputed here rather than trusted from whatever was persisted.
508
+ features: projectAutoClose(
509
+ normalizeFeatures({ ...base.features, ...stored?.features }),
510
+ autoClose
511
+ )
512
+ };
513
+ }
514
+ async function readSupportSettingsState(payload, staffSlug) {
515
+ const staff = resolveStaffPrefSlug(payload, staffSlug);
516
+ const cached = settingsCache.get(staff);
517
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
518
+ return cached.value;
401
519
  }
402
- let value = { ...DEFAULT_SETTINGS };
520
+ let value = {
521
+ settings: mergeSupportSettings(null),
522
+ featuresConfigured: false
523
+ };
403
524
  try {
404
525
  const prefs = await dbFind(payload, "payload-preferences", {
405
- where: { key: { equals: PREF_KEY } },
526
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
527
+ // security boundary: without it any authenticated principal can plant a
528
+ // `support-settings` row and own the plugin's server settings.
529
+ where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
530
+ // The upsert is scoped per admin user, so several rows can share the key.
531
+ // Sorting makes "last write wins" deterministic instead of arbitrary.
532
+ sort: "-updatedAt",
406
533
  limit: 1,
407
534
  depth: 0,
408
535
  overrideAccess: true
409
536
  });
410
537
  if (prefs.docs.length > 0) {
411
538
  const stored = prefs.docs[0].value;
412
- value = {
413
- email: { ...DEFAULT_SETTINGS.email, ...stored.email },
414
- ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
415
- sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
416
- autoClose: { ...DEFAULT_SETTINGS.autoClose, ...stored.autoClose }
417
- };
539
+ const featuresConfigured = !!stored.features && typeof stored.features === "object";
540
+ const settings = mergeSupportSettings(stored);
541
+ if (!featuresConfigured) {
542
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
543
+ }
544
+ value = { settings, featuresConfigured };
545
+ } else {
546
+ await warnOnForeignSettingsRow(payload, staff);
418
547
  }
419
548
  } catch {
420
549
  }
421
- settingsCache = { value, ts: Date.now() };
550
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
551
+ settingsCache.set(staff, { value, ts: Date.now() });
422
552
  return value;
423
553
  }
424
- async function readUserPrefs(payload, userId) {
554
+ async function warnOnForeignSettingsRow(payload, staff) {
555
+ if (warnedForeignSettingsRow.has(staff)) return;
556
+ try {
557
+ const any = await dbFind(payload, "payload-preferences", {
558
+ where: { key: { equals: PREF_KEY } },
559
+ limit: 1,
560
+ depth: 0,
561
+ overrideAccess: true
562
+ });
563
+ if (any.docs.length === 0) return;
564
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
565
+ warnedForeignSettingsRow.add(staff);
566
+ console.warn(
567
+ `[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: the plugin is running on its DEFAULT settings. Either the staff auth collection differs from \`admin.user\`, or the row was written by a principal that is not staff \u2014 in which case it is ignored on purpose.`
568
+ );
569
+ } catch {
570
+ }
571
+ }
572
+ async function readSupportSettings(payload, staffSlug) {
573
+ return (await readSupportSettingsState(payload, staffSlug)).settings;
574
+ }
575
+ async function readLegacyRoundRobin(payload, staff) {
576
+ try {
577
+ const prefs = await dbFind(payload, "payload-preferences", {
578
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
579
+ limit: 1,
580
+ depth: 0,
581
+ overrideAccess: true
582
+ });
583
+ if (prefs.docs.length > 0) {
584
+ return prefs.docs[0].value?.enabled === true;
585
+ }
586
+ } catch {
587
+ }
588
+ return DEFAULT_TICKETING_FEATURES.roundRobin;
589
+ }
590
+ async function readUserPrefs(payload, userId, staffSlug) {
425
591
  try {
426
592
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
427
593
  const prefs = await dbFind(payload, "payload-preferences", {
428
- where: { key: { equals: key } },
594
+ where: {
595
+ key: { equals: key },
596
+ "user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
597
+ },
429
598
  limit: 1,
430
599
  depth: 0,
431
600
  overrideAccess: true
@@ -441,13 +610,20 @@ async function readUserPrefs(payload, userId) {
441
610
  }
442
611
  return { ...DEFAULT_USER_PREFS };
443
612
  }
613
+ function resolveOllamaBaseUrl() {
614
+ const baseURL = process.env.OLLAMA_API_URL;
615
+ if (!baseURL) {
616
+ throw new payload.APIError("provider 'ollama' requires OLLAMA_API_URL", 500);
617
+ }
618
+ return baseURL;
619
+ }
444
620
 
445
621
  // src/endpoints/ai.ts
446
622
  async function getClient(aiSettings) {
447
623
  const moduleName = "@anthropic-ai/sdk";
448
624
  const { default: Anthropic } = await import(moduleName);
449
625
  if (aiSettings.provider === "ollama") {
450
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
626
+ const baseURL = resolveOllamaBaseUrl();
451
627
  return new Anthropic({ apiKey: "ollama", baseURL });
452
628
  }
453
629
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
@@ -456,7 +632,7 @@ function getModel(aiSettings) {
456
632
  return aiSettings.model || "claude-haiku-4-5-20251001";
457
633
  }
458
634
  function createAiEndpoint(slugs, store) {
459
- const limiter = new RateLimiter(6e4, 30, store);
635
+ const limiter = new RateLimiter(6e4, 30, store, "ai");
460
636
  return {
461
637
  path: "/support/ai",
462
638
  method: "post",
@@ -464,7 +640,7 @@ function createAiEndpoint(slugs, store) {
464
640
  try {
465
641
  const payload = req.payload;
466
642
  requireAdmin(req, slugs);
467
- if (await limiter.check(String(req.user.id), req)) {
643
+ if (await limiter.check(principalRateKey(req.user), req)) {
468
644
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
469
645
  }
470
646
  const settings = await readSupportSettings(payload);
@@ -646,7 +822,7 @@ async function applyAgentDecision(payload, slugs, ticketId, decision) {
646
822
  function getClient2(aiSettings) {
647
823
  const Anthropic = __require("@anthropic-ai/sdk").default;
648
824
  if (aiSettings.provider === "ollama") {
649
- return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
825
+ return new Anthropic({ apiKey: "ollama", baseURL: resolveOllamaBaseUrl() });
650
826
  }
651
827
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
652
828
  }
@@ -709,14 +885,14 @@ ${kbText || "(vide)"}`;
709
885
 
710
886
  // src/endpoints/ai-agent.ts
711
887
  function createAiAgentEndpoint(slugs, store) {
712
- const limiter = new RateLimiter(6e4, 10, store);
888
+ const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
713
889
  return {
714
890
  path: "/support/ai-agent",
715
891
  method: "post",
716
892
  handler: async (req) => {
717
893
  try {
718
894
  requireAdmin(req, slugs);
719
- if (await limiter.check(String(req.user.id), req)) {
895
+ if (await limiter.check(principalRateKey(req.user), req)) {
720
896
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
721
897
  }
722
898
  let body = {};
@@ -746,7 +922,7 @@ async function getClient3(aiSettings) {
746
922
  const moduleName = "@anthropic-ai/sdk";
747
923
  const { default: Anthropic } = await import(moduleName);
748
924
  if (aiSettings.provider === "ollama") {
749
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
925
+ const baseURL = resolveOllamaBaseUrl();
750
926
  return new Anthropic({ apiKey: "ollama", baseURL });
751
927
  }
752
928
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
@@ -756,11 +932,11 @@ function getModel2(aiSettings) {
756
932
  }
757
933
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
758
934
  function createClientIntelligenceEndpoint(slugs, store) {
759
- const limiter = new RateLimiter(6e4, 20, store);
935
+ const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
760
936
  const getHandler = async (req) => {
761
937
  try {
762
938
  requireAdmin(req, slugs);
763
- if (await limiter.check(String(req.user.id), req)) {
939
+ if (await limiter.check(principalRateKey(req.user), req)) {
764
940
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
765
941
  }
766
942
  const payload = req.payload;
@@ -792,7 +968,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
792
968
  const postHandler = async (req) => {
793
969
  try {
794
970
  requireAdmin(req, slugs);
795
- if (await limiter.check(String(req.user.id), req)) {
971
+ if (await limiter.check(principalRateKey(req.user), req)) {
796
972
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
797
973
  }
798
974
  const payload = req.payload;
@@ -1421,6 +1597,14 @@ function createSplitTicketEndpoint(slugs) {
1421
1597
  // src/endpoints/typing.ts
1422
1598
  var typingState = /* @__PURE__ */ new Map();
1423
1599
  var TYPING_TTL = 5e3;
1600
+ var MAX_TYPING_KEYS = 500;
1601
+ var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
1602
+ function normalizeTicketId(raw) {
1603
+ if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
1604
+ if (typeof raw !== "string") return null;
1605
+ const value = raw.trim();
1606
+ return TICKET_ID_PATTERN.test(value) ? value : null;
1607
+ }
1424
1608
  function cleanExpired(ticketId) {
1425
1609
  const state = typingState.get(ticketId);
1426
1610
  if (!state) return;
@@ -1435,6 +1619,36 @@ function cleanExpired(ticketId) {
1435
1619
  }
1436
1620
  if (!state.admin && !state.client) typingState.delete(ticketId);
1437
1621
  }
1622
+ function sweepExpired() {
1623
+ for (const key of Array.from(typingState.keys())) cleanExpired(key);
1624
+ }
1625
+ function evictOldest() {
1626
+ let oldestKey = null;
1627
+ let oldestTs = Infinity;
1628
+ for (const [key, state] of typingState) {
1629
+ const ts = Math.max(state.admin || 0, state.client || 0);
1630
+ if (ts < oldestTs) {
1631
+ oldestTs = ts;
1632
+ oldestKey = key;
1633
+ }
1634
+ }
1635
+ if (oldestKey !== null) typingState.delete(oldestKey);
1636
+ }
1637
+ async function mayAccessTicket(req, slugs, ticketId) {
1638
+ const collection = req.user?.collection;
1639
+ if (collection !== slugs.users && collection !== slugs.supportClients) return false;
1640
+ try {
1641
+ const doc = await dbFindByID(req.payload, slugs.tickets, {
1642
+ id: ticketId,
1643
+ depth: 0,
1644
+ overrideAccess: false,
1645
+ user: req.user
1646
+ });
1647
+ return !!doc;
1648
+ } catch {
1649
+ return false;
1650
+ }
1651
+ }
1438
1652
  function createTypingPostEndpoint(slugs) {
1439
1653
  return {
1440
1654
  path: "/support/typing",
@@ -1445,11 +1659,18 @@ function createTypingPostEndpoint(slugs) {
1445
1659
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1446
1660
  }
1447
1661
  const { ticketId } = await req.json();
1448
- if (!ticketId) {
1662
+ const key = normalizeTicketId(ticketId);
1663
+ if (!key) {
1449
1664
  return Response.json({ error: "ticketId required" }, { status: 400 });
1450
1665
  }
1451
- const key = String(ticketId);
1666
+ if (!await mayAccessTicket(req, slugs, key)) {
1667
+ return Response.json({ error: "Forbidden" }, { status: 403 });
1668
+ }
1452
1669
  const state = typingState.get(key) || {};
1670
+ if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
1671
+ sweepExpired();
1672
+ if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
1673
+ }
1453
1674
  if (req.user.collection === slugs.users) {
1454
1675
  state.admin = Date.now();
1455
1676
  state.adminName = req.user.firstName || "Support";
@@ -1470,30 +1691,33 @@ function createTypingGetEndpoint(slugs) {
1470
1691
  path: "/support/typing",
1471
1692
  method: "get",
1472
1693
  handler: async (req) => {
1694
+ const idle = { typing: false, name: null };
1473
1695
  try {
1474
1696
  if (!req.user) {
1475
1697
  return Response.json({ error: "Unauthorized" }, { status: 401 });
1476
1698
  }
1477
1699
  const url = new URL(req.url);
1478
- const ticketId = url.searchParams.get("ticketId");
1479
- if (!ticketId) {
1700
+ const key = normalizeTicketId(url.searchParams.get("ticketId"));
1701
+ if (!key) {
1480
1702
  return Response.json({ error: "ticketId required" }, { status: 400 });
1481
1703
  }
1482
- cleanExpired(ticketId);
1483
- const state = typingState.get(ticketId);
1704
+ cleanExpired(key);
1705
+ const state = typingState.get(key);
1706
+ if (!state) return Response.json(idle);
1707
+ if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
1484
1708
  if (req.user.collection === slugs.users) {
1485
1709
  return Response.json({
1486
- typing: !!state?.client,
1487
- name: state?.clientName || null
1710
+ typing: !!state.client,
1711
+ name: state.clientName || null
1488
1712
  });
1489
1713
  } else {
1490
1714
  return Response.json({
1491
- typing: !!state?.admin,
1492
- name: state?.adminName || null
1715
+ typing: !!state.admin,
1716
+ name: state.adminName || null
1493
1717
  });
1494
1718
  }
1495
1719
  } catch {
1496
- return Response.json({ typing: false, name: null });
1720
+ return Response.json(idle);
1497
1721
  }
1498
1722
  }
1499
1723
  };
@@ -1582,25 +1806,6 @@ function createPresenceGetEndpoint(slugs) {
1582
1806
  }
1583
1807
 
1584
1808
  // src/endpoints/settings.ts
1585
- var PREF_KEY2 = "support-settings";
1586
- var DEFAULT_SUPPORT_SETTINGS = {
1587
- email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
1588
- ai: {
1589
- provider: "anthropic",
1590
- model: "claude-haiku-4-5-20251001",
1591
- enableSentiment: true,
1592
- enableSynthesis: true,
1593
- enableSuggestion: true,
1594
- enableRewrite: true
1595
- },
1596
- sla: {
1597
- firstResponseMinutes: 120,
1598
- resolutionMinutes: 1440,
1599
- businessHoursOnly: true,
1600
- escalationEmail: ""
1601
- },
1602
- autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
1603
- };
1604
1809
  function createSettingsGetEndpoint(slugs) {
1605
1810
  return {
1606
1811
  path: "/support/settings",
@@ -1609,23 +1814,8 @@ function createSettingsGetEndpoint(slugs) {
1609
1814
  try {
1610
1815
  const payload = req.payload;
1611
1816
  requireAdmin(req, slugs);
1612
- const prefs = await dbFind(payload, "payload-preferences", {
1613
- where: { key: { equals: PREF_KEY2 } },
1614
- limit: 1,
1615
- depth: 0,
1616
- overrideAccess: true
1617
- });
1618
- let settings = { ...DEFAULT_SUPPORT_SETTINGS };
1619
- if (prefs.docs.length > 0) {
1620
- const stored = prefs.docs[0].value;
1621
- settings = {
1622
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...stored.email },
1623
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...stored.ai },
1624
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...stored.sla },
1625
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...stored.autoClose }
1626
- };
1627
- }
1628
- return Response.json(settings);
1817
+ const { settings, featuresConfigured } = await readSupportSettingsState(payload);
1818
+ return Response.json({ ...settings, featuresConfigured });
1629
1819
  } catch (error) {
1630
1820
  const authResponse = handleAuthError(error);
1631
1821
  if (authResponse) return authResponse;
@@ -1644,30 +1834,31 @@ function createSettingsPostEndpoint(slugs) {
1644
1834
  const payload = req.payload;
1645
1835
  requireAdmin(req, slugs);
1646
1836
  const body = await req.json();
1647
- const merged = {
1648
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...body.email },
1649
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...body.ai },
1650
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...body.sla },
1651
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...body.autoClose }
1652
- };
1837
+ const { settings: current } = await readSupportSettingsState(payload);
1838
+ const merged = mergeSupportSettings(body, current);
1653
1839
  await payload.db.upsert({
1654
1840
  collection: "payload-preferences",
1655
1841
  data: {
1656
- key: PREF_KEY2,
1842
+ key: SUPPORT_SETTINGS_PREF_KEY,
1657
1843
  user: { relationTo: req.user.collection, value: req.user.id },
1658
- value: merged
1844
+ value: {
1845
+ ...merged,
1846
+ // Persist the flags without the two projected ones, so `features`
1847
+ // and the `autoClose` block can never drift apart.
1848
+ features: stripProjectedFeatures(merged.features)
1849
+ }
1659
1850
  },
1660
1851
  req: { payload, user: req.user },
1661
1852
  where: {
1662
1853
  and: [
1663
- { key: { equals: PREF_KEY2 } },
1854
+ { key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
1664
1855
  { "user.value": { equals: req.user.id } },
1665
1856
  { "user.relationTo": { equals: req.user.collection } }
1666
1857
  ]
1667
1858
  }
1668
1859
  });
1669
1860
  invalidateSupportSettingsCache();
1670
- return Response.json(merged);
1861
+ return Response.json({ ...merged, featuresConfigured: true });
1671
1862
  } catch (error) {
1672
1863
  const authResponse = handleAuthError(error);
1673
1864
  if (authResponse) return authResponse;
@@ -1679,7 +1870,7 @@ function createSettingsPostEndpoint(slugs) {
1679
1870
  }
1680
1871
 
1681
1872
  // src/endpoints/signature.ts
1682
- var PREF_KEY3 = "email-signature";
1873
+ var PREF_KEY2 = "email-signature";
1683
1874
  function createSignatureGetEndpoint(slugs) {
1684
1875
  return {
1685
1876
  path: "/support/signature",
@@ -1689,7 +1880,14 @@ function createSignatureGetEndpoint(slugs) {
1689
1880
  const payload = req.payload;
1690
1881
  requireAdmin(req, slugs);
1691
1882
  const prefs = await dbFind(payload, "payload-preferences", {
1692
- where: { key: { equals: `${PREF_KEY3}-${req.user.id}` } },
1883
+ // Scope to the staff auth collection: `payload-preferences` accepts a
1884
+ // write from ANY authenticated principal, and ids collide between auth
1885
+ // collections — a support-client with the same id would otherwise own
1886
+ // the `email-signature-<id>` row read back for the agent.
1887
+ where: {
1888
+ key: { equals: `${PREF_KEY2}-${req.user.id}` },
1889
+ "user.relationTo": { equals: slugs.users }
1890
+ },
1693
1891
  limit: 1,
1694
1892
  depth: 0,
1695
1893
  overrideAccess: true
@@ -1714,9 +1912,9 @@ function createSignaturePostEndpoint(slugs) {
1714
1912
  const payload = req.payload;
1715
1913
  requireAdmin(req, slugs);
1716
1914
  const { signature } = await req.json();
1717
- const key = `${PREF_KEY3}-${req.user.id}`;
1915
+ const key = `${PREF_KEY2}-${req.user.id}`;
1718
1916
  const existing = await dbFind(payload, "payload-preferences", {
1719
- where: { key: { equals: key } },
1917
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
1720
1918
  limit: 1,
1721
1919
  depth: 0,
1722
1920
  overrideAccess: true
@@ -1749,7 +1947,6 @@ function createSignaturePostEndpoint(slugs) {
1749
1947
  }
1750
1948
 
1751
1949
  // src/endpoints/round-robin-config.ts
1752
- var PREF_KEY4 = "support-round-robin";
1753
1950
  function createRoundRobinConfigGetEndpoint(slugs) {
1754
1951
  return {
1755
1952
  path: "/support/round-robin-config",
@@ -1758,14 +1955,8 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1758
1955
  try {
1759
1956
  const payload = req.payload;
1760
1957
  requireAdmin(req, slugs);
1761
- const prefs = await dbFind(payload, "payload-preferences", {
1762
- where: { key: { equals: PREF_KEY4 } },
1763
- limit: 1,
1764
- depth: 0,
1765
- overrideAccess: true
1766
- });
1767
- const enabled = prefs.docs.length > 0 ? prefs.docs[0].value?.enabled === true : false;
1768
- return Response.json({ enabled });
1958
+ const { settings } = await readSupportSettingsState(payload);
1959
+ return Response.json({ enabled: settings.features.roundRobin });
1769
1960
  } catch (error) {
1770
1961
  const authResponse = handleAuthError(error);
1771
1962
  if (authResponse) return authResponse;
@@ -1784,28 +1975,31 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1784
1975
  const payload = req.payload;
1785
1976
  requireAdmin(req, slugs);
1786
1977
  const { enabled } = await req.json();
1787
- const existing = await dbFind(payload, "payload-preferences", {
1788
- where: { key: { equals: PREF_KEY4 } },
1789
- limit: 1,
1790
- depth: 0,
1791
- overrideAccess: true
1792
- });
1978
+ const { settings: current } = await readSupportSettingsState(payload);
1979
+ const merged = mergeSupportSettings(
1980
+ { features: { ...current.features, roundRobin: !!enabled } },
1981
+ current
1982
+ );
1793
1983
  await payload.db.upsert({
1794
1984
  collection: "payload-preferences",
1795
1985
  data: {
1796
- key: PREF_KEY4,
1986
+ key: SUPPORT_SETTINGS_PREF_KEY,
1797
1987
  user: { relationTo: req.user.collection, value: req.user.id },
1798
- value: { enabled: !!enabled }
1988
+ value: {
1989
+ ...merged,
1990
+ features: stripProjectedFeatures(merged.features)
1991
+ }
1799
1992
  },
1800
1993
  req: { payload, user: req.user },
1801
1994
  where: {
1802
1995
  and: [
1803
- { key: { equals: PREF_KEY4 } },
1996
+ { key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
1804
1997
  { "user.value": { equals: req.user.id } },
1805
1998
  { "user.relationTo": { equals: req.user.collection } }
1806
1999
  ]
1807
2000
  }
1808
2001
  });
2002
+ invalidateSupportSettingsCache();
1809
2003
  return Response.json({ enabled: !!enabled });
1810
2004
  } catch (error) {
1811
2005
  const authResponse = handleAuthError(error);
@@ -2481,7 +2675,7 @@ function formatFr(date, withTime) {
2481
2675
  });
2482
2676
  }
2483
2677
  function createSendReminderEndpoint(slugs, store) {
2484
- const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
2678
+ const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
2485
2679
  return {
2486
2680
  path: "/support/send-reminder",
2487
2681
  method: "post",
@@ -2489,7 +2683,7 @@ function createSendReminderEndpoint(slugs, store) {
2489
2683
  try {
2490
2684
  const payload = req.payload;
2491
2685
  requireAdmin(req, slugs);
2492
- if (await reminderLimiter.check(String(req.user.id), req)) {
2686
+ if (await reminderLimiter.check(principalRateKey(req.user), req)) {
2493
2687
  return Response.json(
2494
2688
  { error: "Trop de relances. R\xE9essayez dans une heure." },
2495
2689
  { status: 429 }
@@ -2792,8 +2986,14 @@ function createPurgeLogsEndpoint(slugs) {
2792
2986
  }
2793
2987
 
2794
2988
  // src/endpoints/chatbot.ts
2795
- function createChatbotEndpoint(slugs, store) {
2796
- const chatbotLimiter = new RateLimiter(6e4, 10, store);
2989
+ var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
2990
+ function resolveMaxPerHour(explicit) {
2991
+ const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
2992
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
2993
+ }
2994
+ function createChatbotEndpoint(slugs, store, maxPerHour) {
2995
+ const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
2996
+ const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
2797
2997
  return {
2798
2998
  path: "/support/chatbot",
2799
2999
  method: "post",
@@ -2813,6 +3013,15 @@ function createChatbotEndpoint(slugs, store) {
2813
3013
  if (!question?.trim() || question.trim().length < 5) {
2814
3014
  return Response.json({ error: "Question too short" }, { status: 400 });
2815
3015
  }
3016
+ if (await globalLimiter.check("all", req)) {
3017
+ return Response.json({
3018
+ answer: null,
3019
+ confidence: 0,
3020
+ suggestion: "create_ticket",
3021
+ aiUnavailable: true,
3022
+ message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
3023
+ });
3024
+ }
2816
3025
  const payload = req.payload;
2817
3026
  const articles = await dbFind(payload, slugs.knowledgeBase, {
2818
3027
  where: { published: { equals: true } },
@@ -2925,8 +3134,8 @@ function createChatGetEndpoint(slugs) {
2925
3134
  };
2926
3135
  }
2927
3136
  function createChatPostEndpoint(slugs, store) {
2928
- const chatSessionLimiter = new RateLimiter(36e5, 5, store);
2929
- const chatMessageLimiter = new RateLimiter(6e4, 15, store);
3137
+ const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
3138
+ const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
2930
3139
  return {
2931
3140
  path: "/support/chat",
2932
3141
  method: "post",
@@ -2942,8 +3151,9 @@ function createChatPostEndpoint(slugs, store) {
2942
3151
  }
2943
3152
  const { action, session, message } = body;
2944
3153
  const userId = String(req.user.id);
3154
+ const rateKey = principalRateKey(req.user);
2945
3155
  if (action === "start") {
2946
- if (await chatSessionLimiter.check(userId, req)) {
3156
+ if (await chatSessionLimiter.check(rateKey, req)) {
2947
3157
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2948
3158
  }
2949
3159
  const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
@@ -2960,7 +3170,7 @@ function createChatPostEndpoint(slugs, store) {
2960
3170
  return Response.json({ session: sessionId, messages: [systemMsg] });
2961
3171
  }
2962
3172
  if (action === "send" && session && message) {
2963
- if (await chatMessageLimiter.check(userId, req)) {
3173
+ if (await chatMessageLimiter.check(rateKey, req)) {
2964
3174
  return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
2965
3175
  }
2966
3176
  const trimmedMessage = String(message).trim();
@@ -3200,7 +3410,7 @@ function createAdminChatGetEndpoint(slugs) {
3200
3410
  };
3201
3411
  }
3202
3412
  function createAdminChatPostEndpoint(slugs, store) {
3203
- const adminChatLimiter = new RateLimiter(6e4, 30, store);
3413
+ const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
3204
3414
  return {
3205
3415
  path: "/support/admin-chat",
3206
3416
  method: "post",
@@ -3229,7 +3439,7 @@ function createAdminChatPostEndpoint(slugs, store) {
3229
3439
  }
3230
3440
  const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
3231
3441
  if (action === "send" && message) {
3232
- if (await adminChatLimiter.check(String(req.user.id), req)) {
3442
+ if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
3233
3443
  return Response.json({ error: "Rate limit atteint." }, { status: 429 });
3234
3444
  }
3235
3445
  const trimmedMessage = String(message).trim();
@@ -4067,7 +4277,7 @@ function createInvoiceEndpoint(slugs) {
4067
4277
  function getClient4(aiSettings) {
4068
4278
  const Anthropic = __require("@anthropic-ai/sdk").default;
4069
4279
  if (aiSettings.provider === "ollama") {
4070
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
4280
+ const baseURL = resolveOllamaBaseUrl();
4071
4281
  return new Anthropic({ apiKey: "ollama", baseURL });
4072
4282
  }
4073
4283
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
@@ -4184,14 +4394,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
4184
4394
 
4185
4395
  // src/endpoints/ticket-synthesis.ts
4186
4396
  function createTicketSynthesisEndpoint(slugs, generator, store) {
4187
- const limiter = new RateLimiter(6e4, 20, store);
4397
+ const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
4188
4398
  return {
4189
4399
  path: "/support/ticket-synthesis",
4190
4400
  method: "post",
4191
4401
  handler: async (req) => {
4192
4402
  try {
4193
4403
  requireAdmin(req, slugs);
4194
- if (await limiter.check(String(req.user.id), req)) {
4404
+ if (await limiter.check(principalRateKey(req.user), req)) {
4195
4405
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
4196
4406
  }
4197
4407
  const payload = req.payload;
@@ -4235,15 +4445,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
4235
4445
  }
4236
4446
 
4237
4447
  // src/endpoints/email-stats.ts
4238
- function createEmailStatsEndpoint(slugs) {
4448
+ function createEmailStatsEndpoint(slugs, store) {
4449
+ const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
4239
4450
  return {
4240
4451
  path: "/support/email-stats",
4241
4452
  method: "get",
4242
4453
  handler: async (req) => {
4243
4454
  try {
4244
4455
  const payload = req.payload;
4245
- if (!req.user) {
4246
- return Response.json({ error: "Unauthorized" }, { status: 401 });
4456
+ requireAdmin(req, slugs);
4457
+ if (await statsLimiter.check(principalRateKey(req.user), req)) {
4458
+ return Response.json({ error: "Too many requests." }, { status: 429 });
4247
4459
  }
4248
4460
  const url = new URL(req.url);
4249
4461
  const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
@@ -4310,6 +4522,8 @@ function createEmailStatsEndpoint(slugs) {
4310
4522
  actions: Object.fromEntries(actionMap)
4311
4523
  });
4312
4524
  } catch (err) {
4525
+ const authResponse = handleAuthError(err);
4526
+ if (authResponse) return authResponse;
4313
4527
  console.error("[email-stats] Error:", err);
4314
4528
  return Response.json({ error: "Internal server error" }, { status: 500 });
4315
4529
  }
@@ -4742,7 +4956,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4742
4956
 
4743
4957
  // src/endpoints/resend-notification.ts
4744
4958
  function createResendNotificationEndpoint(slugs, store) {
4745
- const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
4959
+ const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
4746
4960
  return {
4747
4961
  path: "/support/resend-notification",
4748
4962
  method: "post",
@@ -4750,7 +4964,7 @@ function createResendNotificationEndpoint(slugs, store) {
4750
4964
  try {
4751
4965
  const payload = req.payload;
4752
4966
  requireAdmin(req, slugs);
4753
- if (await resendLimiter.check(String(req.user.id), req)) {
4967
+ if (await resendLimiter.check(principalRateKey(req.user), req)) {
4754
4968
  return Response.json(
4755
4969
  { error: "Trop de renvois. R\xE9essayez dans une heure." },
4756
4970
  { status: 429 }
@@ -4951,10 +5165,48 @@ function createSeedKbEndpoint(slugs) {
4951
5165
  }
4952
5166
  };
4953
5167
  }
5168
+ var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
5169
+ function challengeSecret() {
5170
+ const secret = process.env.PAYLOAD_SECRET;
5171
+ if (!secret) {
5172
+ throw new Error(
5173
+ "[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
5174
+ );
5175
+ }
5176
+ return secret;
5177
+ }
5178
+ function normalizeEmail(email) {
5179
+ return String(email).trim().toLowerCase();
5180
+ }
5181
+ function sign(email, expiresAt) {
5182
+ return crypto3.createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
5183
+ }
5184
+ function issueTwoFactorChallenge(email, now = Date.now()) {
5185
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
5186
+ return `${expiresAt}.${sign(email, expiresAt)}`;
5187
+ }
5188
+ function verifyTwoFactorChallenge(email, token, now = Date.now()) {
5189
+ if (typeof email !== "string" || !email || typeof token !== "string") return false;
5190
+ const separator = token.indexOf(".");
5191
+ if (separator <= 0) return false;
5192
+ const expiresAt = Number(token.slice(0, separator));
5193
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
5194
+ const received = token.slice(separator + 1);
5195
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false;
5196
+ let expected;
5197
+ try {
5198
+ expected = sign(email, expiresAt);
5199
+ } catch {
5200
+ return false;
5201
+ }
5202
+ const a = Buffer.from(expected, "hex");
5203
+ const b = Buffer.from(received.toLowerCase(), "hex");
5204
+ return a.length === b.length && crypto3.timingSafeEqual(a, b);
5205
+ }
4954
5206
 
4955
5207
  // src/endpoints/login.ts
4956
5208
  function createLoginEndpoint(slugs, store) {
4957
- const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
5209
+ const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
4958
5210
  return {
4959
5211
  path: "/support/login",
4960
5212
  method: "post",
@@ -5007,7 +5259,12 @@ function createLoginEndpoint(slugs, store) {
5007
5259
  } catch (err) {
5008
5260
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
5009
5261
  if (errorMessage.includes("2FA_REQUIRED")) {
5010
- return Response.json({ requires2FA: true }, { status: 200 });
5262
+ let challenge;
5263
+ try {
5264
+ challenge = issueTwoFactorChallenge(email);
5265
+ } catch {
5266
+ }
5267
+ return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
5011
5268
  }
5012
5269
  let errorReason = "Identifiants incorrects";
5013
5270
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
@@ -5039,8 +5296,8 @@ function hashCode(code) {
5039
5296
  return crypto3.createHmac("sha256", secret).update(code).digest("hex");
5040
5297
  }
5041
5298
  function createAuth2faEndpoint(slugs, store) {
5042
- const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
5043
- const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
5299
+ const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
5300
+ const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
5044
5301
  return {
5045
5302
  path: "/support/2fa",
5046
5303
  method: "post",
@@ -5053,12 +5310,18 @@ function createAuth2faEndpoint(slugs, store) {
5053
5310
  } catch {
5054
5311
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5055
5312
  }
5056
- const { action, email, code } = body;
5313
+ const { action, email, code, challenge } = body;
5057
5314
  if (!action || !email) {
5058
5315
  return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
5059
5316
  }
5060
5317
  const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
5061
5318
  if (action === "send") {
5319
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5320
+ return Response.json(
5321
+ { error: "Authentification requise avant l'envoi d'un code." },
5322
+ { status: 401 }
5323
+ );
5324
+ }
5062
5325
  if (await sendLimiter.check(email, req)) {
5063
5326
  return Response.json(genericSendResponse);
5064
5327
  }
@@ -5152,6 +5415,33 @@ function createAuth2faEndpoint(slugs, store) {
5152
5415
  }
5153
5416
  };
5154
5417
  }
5418
+ var OAUTH_STATE_COOKIE = "support-oauth-state";
5419
+ var OAUTH_STATE_MAX_AGE = 600;
5420
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
5421
+ function readCookie(header, name) {
5422
+ if (!header) return null;
5423
+ for (const part of header.split(";")) {
5424
+ const eq = part.indexOf("=");
5425
+ if (eq === -1) continue;
5426
+ if (part.slice(0, eq).trim() !== name) continue;
5427
+ try {
5428
+ return decodeURIComponent(part.slice(eq + 1).trim());
5429
+ } catch {
5430
+ return part.slice(eq + 1).trim();
5431
+ }
5432
+ }
5433
+ return null;
5434
+ }
5435
+ function clearedStateCookie() {
5436
+ const secure = process.env.NODE_ENV === "production";
5437
+ return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
5438
+ }
5439
+ function statesMatch(a, b) {
5440
+ if (!a || !b) return false;
5441
+ const left = crypto3__default.default.createHash("sha256").update(a).digest();
5442
+ const right = crypto3__default.default.createHash("sha256").update(b).digest();
5443
+ return crypto3__default.default.timingSafeEqual(left, right);
5444
+ }
5155
5445
  function createOAuthGoogleEndpoint(slugs, options) {
5156
5446
  return {
5157
5447
  path: "/support/oauth/google",
@@ -5168,7 +5458,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5168
5458
  }
5169
5459
  try {
5170
5460
  const body = await req.json();
5171
- const { action, code, state: queryState, cookieState } = body;
5461
+ const { action, code, state: queryState } = body;
5172
5462
  if (action === "login") {
5173
5463
  const oauthState = crypto3__default.default.randomBytes(32).toString("hex");
5174
5464
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5180,13 +5470,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
5180
5470
  state: oauthState,
5181
5471
  prompt: "select_account"
5182
5472
  });
5183
- return Response.json({
5184
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5185
- state: oauthState
5186
- });
5473
+ const secure = process.env.NODE_ENV === "production";
5474
+ const loginHeaders = new Headers({ "Content-Type": "application/json" });
5475
+ loginHeaders.append(
5476
+ "Set-Cookie",
5477
+ `${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
5478
+ );
5479
+ return new Response(
5480
+ JSON.stringify({
5481
+ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
5482
+ // Kept for callers that echo it back in the redirect URL; the
5483
+ // server no longer trusts anything the caller returns.
5484
+ state: oauthState
5485
+ }),
5486
+ { status: 200, headers: loginHeaders }
5487
+ );
5187
5488
  }
5188
5489
  if (code) {
5189
- if (!cookieState || !queryState || cookieState !== queryState) {
5490
+ const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
5491
+ if (!statesMatch(issuedState, queryState)) {
5190
5492
  return Response.json({ error: "state_mismatch" }, { status: 400 });
5191
5493
  }
5192
5494
  const redirectUri = `${baseUrl}/api/support/oauth/google`;
@@ -5268,6 +5570,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
5268
5570
  overrideAccess: true
5269
5571
  });
5270
5572
  }
5573
+ const twoFactorDoc = await dbFindByID(payload$1, slugs.supportClients, {
5574
+ id: clientDoc.id,
5575
+ depth: 0,
5576
+ overrideAccess: true,
5577
+ showHiddenFields: true
5578
+ });
5579
+ if (twoFactorDoc?.twoFactorEnabled) {
5580
+ const raw = twoFactorDoc.twoFactorVerifiedAt;
5581
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
5582
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
5583
+ let challenge;
5584
+ try {
5585
+ challenge = issueTwoFactorChallenge(clientDoc.email);
5586
+ } catch {
5587
+ }
5588
+ return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
5589
+ status: 200,
5590
+ headers: new Headers({
5591
+ "Content-Type": "application/json",
5592
+ "Set-Cookie": clearedStateCookie()
5593
+ })
5594
+ });
5595
+ }
5596
+ await dbUpdate(payload$1, slugs.supportClients, {
5597
+ id: clientDoc.id,
5598
+ data: { twoFactorVerifiedAt: null },
5599
+ overrideAccess: true
5600
+ });
5601
+ }
5271
5602
  const secret = process.env.PAYLOAD_SECRET;
5272
5603
  if (!secret) {
5273
5604
  return Response.json({ error: "server_misconfigured" }, { status: 500 });
@@ -5311,6 +5642,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
5311
5642
  "Set-Cookie",
5312
5643
  `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5313
5644
  );
5645
+ headers.append("Set-Cookie", clearedStateCookie());
5314
5646
  return new Response(JSON.stringify({ user: clientDoc, exp }), {
5315
5647
  status: 200,
5316
5648
  headers
@@ -5617,7 +5949,7 @@ ONLY JSON, nothing else.`
5617
5949
  }
5618
5950
  }
5619
5951
  function createImportConversationEndpoint(slugs, store) {
5620
- const importLimiter = new RateLimiter(36e5, 10, store);
5952
+ const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
5621
5953
  return {
5622
5954
  path: "/support/import-conversation",
5623
5955
  method: "post",
@@ -5760,62 +6092,204 @@ function createImportConversationEndpoint(slugs, store) {
5760
6092
  }
5761
6093
  };
5762
6094
  }
6095
+ function httpAllowed() {
6096
+ return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
6097
+ }
6098
+ var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
6099
+ function parseIPv4(host) {
6100
+ const parts = host.split(".");
6101
+ if (parts.length !== 4) return null;
6102
+ const octets = [];
6103
+ for (const part of parts) {
6104
+ if (!/^\d{1,3}$/.test(part)) return null;
6105
+ const n = Number(part);
6106
+ if (n > 255) return null;
6107
+ octets.push(n);
6108
+ }
6109
+ return octets;
6110
+ }
6111
+ function isPrivateIPv4(octets) {
6112
+ const [a, b] = octets;
6113
+ if (a === 0) return true;
6114
+ if (a === 10) return true;
6115
+ if (a === 127) return true;
6116
+ if (a === 169 && b === 254) return true;
6117
+ if (a === 172 && b >= 16 && b <= 31) return true;
6118
+ if (a === 192 && b === 168) return true;
6119
+ if (a === 192 && b === 0) return true;
6120
+ if (a === 198 && (b === 18 || b === 19)) return true;
6121
+ if (a === 100 && b >= 64 && b <= 127) return true;
6122
+ if (a >= 224) return true;
6123
+ return false;
6124
+ }
6125
+ function isPrivateIPv6(host) {
6126
+ const lower = host.toLowerCase();
6127
+ if (lower === "::" || lower === "::1") return true;
6128
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
6129
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
6130
+ if (lower.startsWith("ff")) return true;
6131
+ const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
6132
+ if (dotted) {
6133
+ const octets = parseIPv4(dotted[1]);
6134
+ return octets ? isPrivateIPv4(octets) : true;
6135
+ }
6136
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
6137
+ if (hex) {
6138
+ const high = parseInt(hex[1], 16);
6139
+ const low = parseInt(hex[2], 16);
6140
+ return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
6141
+ }
6142
+ return false;
6143
+ }
6144
+ function normalizeHost(hostname) {
6145
+ const host = hostname.trim().toLowerCase();
6146
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
6147
+ }
6148
+ function isBlockedHost(hostname) {
6149
+ const host = normalizeHost(hostname);
6150
+ if (!host) return true;
6151
+ if (host === "localhost") return true;
6152
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
6153
+ const v4 = parseIPv4(host);
6154
+ if (v4) return isPrivateIPv4(v4);
6155
+ if (host.includes(":")) return isPrivateIPv6(host);
6156
+ return false;
6157
+ }
6158
+ function validateWebhookUrl(raw) {
6159
+ if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
6160
+ let url;
6161
+ try {
6162
+ url = new URL(raw.trim());
6163
+ } catch {
6164
+ return { ok: false, reason: "invalid_url" };
6165
+ }
6166
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
6167
+ return { ok: false, reason: "scheme_not_allowed" };
6168
+ }
6169
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6170
+ return { ok: true, url };
6171
+ }
6172
+ var WEBHOOK_URL_MESSAGES = {
6173
+ invalid_url: "URL invalide.",
6174
+ scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
6175
+ private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
6176
+ };
6177
+ var LOOKUP_TIMEOUT_MS = 5e3;
6178
+ var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
6179
+ var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
6180
+ async function assertPublicHost(hostname) {
6181
+ const host = normalizeHost(hostname);
6182
+ if (isBlockedHost(host)) return false;
6183
+ if (parseIPv4(host) || host.includes(":")) return true;
6184
+ try {
6185
+ const addresses = await Promise.race([
6186
+ promises.lookup(host, { all: true }),
6187
+ new Promise(
6188
+ (resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
6189
+ )
6190
+ ]);
6191
+ if (addresses === LOOKUP_TIMED_OUT) return false;
6192
+ if (!Array.isArray(addresses) || addresses.length === 0) return false;
6193
+ return addresses.every((entry) => !isBlockedHost(entry.address));
6194
+ } catch (error) {
6195
+ const code = error?.code;
6196
+ return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
6197
+ }
6198
+ }
6199
+ var BlockedRequestError = class extends Error {
6200
+ constructor(reason) {
6201
+ super(`Blocked outbound request: ${reason}`);
6202
+ this.reason = reason;
6203
+ this.name = "BlockedRequestError";
6204
+ }
6205
+ reason;
6206
+ };
6207
+ var MAX_REDIRECTS = 3;
6208
+ async function safeFetch(rawUrl, init) {
6209
+ let current = rawUrl;
6210
+ let body = init.body;
6211
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
6212
+ const validation = validateWebhookUrl(current);
6213
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
6214
+ if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
6215
+ const response = await fetch(current, { ...init, body, redirect: "manual" });
6216
+ if (response.status < 300 || response.status > 399) return response;
6217
+ const location = response.headers.get("location");
6218
+ if (!location) return response;
6219
+ current = new URL(location, current).toString();
6220
+ if (response.status === 303) body = void 0;
6221
+ }
6222
+ throw new BlockedRequestError("too_many_redirects");
6223
+ }
5763
6224
 
5764
- // src/utils/fireWebhooks.ts
5765
- async function fireWebhooks(payload, slugs, event, data) {
6225
+ // src/utils/webhookDispatcher.ts
6226
+ function dispatchWebhook(data, event, payload, slugs) {
6227
+ const done = _dispatch(data, event, payload, slugs);
6228
+ return done;
6229
+ }
6230
+ async function _dispatch(data, event, payload, slugs) {
5766
6231
  try {
5767
- const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
6232
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
5768
6233
  where: {
5769
- active: { equals: true },
5770
- events: { contains: event }
6234
+ and: [
6235
+ { active: { equals: true } },
6236
+ { events: { contains: event } }
6237
+ ]
5771
6238
  },
5772
6239
  limit: 50,
5773
6240
  depth: 0,
5774
6241
  overrideAccess: true
5775
6242
  });
5776
- if (endpoints.docs.length === 0) return;
5777
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
6243
+ if (endpoints.length === 0) return;
6244
+ const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
5778
6245
  await Promise.allSettled(
5779
- endpoints.docs.map(async (endpoint) => {
5780
- try {
5781
- const res = await fetch(endpoint.url, {
5782
- method: "POST",
5783
- headers: {
5784
- "Content-Type": "application/json",
5785
- ...endpoint.secret ? { "X-Webhook-Secret": endpoint.secret } : {}
5786
- },
5787
- body: JSON.stringify({ event, data, timestamp }),
5788
- signal: AbortSignal.timeout(1e4)
5789
- });
5790
- try {
5791
- await dbUpdate(payload, slugs.webhookEndpoints, {
5792
- id: endpoint.id,
5793
- data: {
5794
- lastTriggeredAt: timestamp,
5795
- lastStatus: res.status
5796
- },
5797
- overrideAccess: true
5798
- });
5799
- } catch {
5800
- }
5801
- } catch (error) {
5802
- console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5803
- try {
5804
- await dbUpdate(payload, slugs.webhookEndpoints, {
5805
- id: endpoint.id,
5806
- data: {
5807
- lastTriggeredAt: timestamp,
5808
- lastStatus: 0
5809
- },
5810
- overrideAccess: true
5811
- });
5812
- } catch {
5813
- }
5814
- }
5815
- })
6246
+ endpoints.map((endpoint) => _sendToEndpoint(endpoint, body, payload, slugs))
5816
6247
  );
5817
- } catch (error) {
5818
- console.error("[support] Failed to fire webhooks:", error);
6248
+ } catch (err) {
6249
+ console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
6250
+ }
6251
+ }
6252
+ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6253
+ try {
6254
+ const headers = {
6255
+ "Content-Type": "application/json",
6256
+ "User-Agent": "PayloadSupport-Webhook/1.0"
6257
+ };
6258
+ if (endpoint.secret) {
6259
+ const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
6260
+ headers["X-Webhook-Signature"] = signature;
6261
+ }
6262
+ const response = await safeFetch(endpoint.url, {
6263
+ method: "POST",
6264
+ headers,
6265
+ body,
6266
+ signal: AbortSignal.timeout(1e4)
6267
+ // 10s timeout
6268
+ });
6269
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6270
+ id: endpoint.id,
6271
+ data: {
6272
+ lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6273
+ lastStatus: response.status
6274
+ },
6275
+ overrideAccess: true
6276
+ });
6277
+ if (!response.ok) {
6278
+ console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
6279
+ }
6280
+ } catch (err) {
6281
+ console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6282
+ try {
6283
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6284
+ id: endpoint.id,
6285
+ data: {
6286
+ lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6287
+ lastStatus: 0
6288
+ },
6289
+ overrideAccess: true
6290
+ });
6291
+ } catch {
6292
+ }
5819
6293
  }
5820
6294
  }
5821
6295
 
@@ -5902,13 +6376,18 @@ function createProcessScheduledEndpoint(slugs) {
5902
6376
  overrideAccess: true
5903
6377
  });
5904
6378
  }
5905
- fireWebhooks(payload, slugs, "ticket_replied", {
5906
- ticketId,
5907
- messageId: msg.id,
5908
- authorType: msg.authorType,
5909
- scheduled: true,
5910
- body: msg.body?.length > 500 ? msg.body.slice(0, 500) + "..." : msg.body
5911
- });
6379
+ dispatchWebhook(
6380
+ {
6381
+ ticketId,
6382
+ messageId: msg.id,
6383
+ authorType: msg.authorType,
6384
+ scheduled: true,
6385
+ body: msg.body?.length > 500 ? msg.body.slice(0, 500) + "..." : msg.body
6386
+ },
6387
+ "ticket_replied",
6388
+ payload,
6389
+ slugs
6390
+ );
5912
6391
  results.processed++;
5913
6392
  } catch (err) {
5914
6393
  console.error(`[process-scheduled] Error processing message ${message.id}:`, err);
@@ -6234,7 +6713,7 @@ function createUserPrefsGetEndpoint(slugs) {
6234
6713
  requireAdmin(req, slugs);
6235
6714
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6236
6715
  const prefs = await dbFind(payload, "payload-preferences", {
6237
- where: { key: { equals: key } },
6716
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6238
6717
  limit: 1,
6239
6718
  depth: 0,
6240
6719
  overrideAccess: true
@@ -6268,7 +6747,7 @@ function createUserPrefsPostEndpoint(slugs) {
6268
6747
  const body = await req.json();
6269
6748
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
6270
6749
  const existing = await dbFind(payload, "payload-preferences", {
6271
- where: { key: { equals: key } },
6750
+ where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
6272
6751
  limit: 1,
6273
6752
  depth: 0,
6274
6753
  overrideAccess: true
@@ -6424,8 +6903,10 @@ function createTicketFeedbackEndpoint(slugs) {
6424
6903
  // src/endpoints/transfer-ticket.ts
6425
6904
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6426
6905
  var MAX_TRANSFERS_PER_DAY = 5;
6906
+ var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
6427
6907
  function createTransferTicketEndpoint(slugs, store) {
6428
- const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
6908
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
6909
+ const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
6429
6910
  return {
6430
6911
  path: "/support/tickets/:id/transfer",
6431
6912
  method: "post",
@@ -6460,12 +6941,18 @@ function createTransferTicketEndpoint(slugs, store) {
6460
6941
  if (!isAdmin && !isOwner) {
6461
6942
  return Response.json({ error: "Forbidden" }, { status: 403 });
6462
6943
  }
6463
- if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
6944
+ if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
6464
6945
  return Response.json(
6465
6946
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6466
6947
  { status: 429 }
6467
6948
  );
6468
6949
  }
6950
+ if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
6951
+ return Response.json(
6952
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
6953
+ { status: 429 }
6954
+ );
6955
+ }
6469
6956
  try {
6470
6957
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
6471
6958
  const existing = await dbCount(payload, slugs.emailLogs, {
@@ -6607,8 +7094,11 @@ function statusToLabel(status) {
6607
7094
  }
6608
7095
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6609
7096
  var MAX_COLLABORATORS_PER_TICKET = 20;
7097
+ var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
7098
+ var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
6610
7099
  function createInviteCollaboratorEndpoint(slugs, store) {
6611
- const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
7100
+ const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
7101
+ const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
6612
7102
  return {
6613
7103
  path: "/support/tickets/:id/invite",
6614
7104
  method: "post",
@@ -6643,7 +7133,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6643
7133
  if (!isAdmin && !isOwner) {
6644
7134
  return Response.json({ error: "Forbidden" }, { status: 403 });
6645
7135
  }
6646
- if (await inviteLimiter.check(String(req.user.id), req)) {
7136
+ if (await inviteLimiter.check(principalRateKey(req.user), req)) {
6647
7137
  return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6648
7138
  }
6649
7139
  const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
@@ -6667,6 +7157,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
6667
7157
  if (existing.docs.length > 0) {
6668
7158
  inviteeId = existing.docs[0].id;
6669
7159
  } else {
7160
+ if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
7161
+ return Response.json(
7162
+ { error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
7163
+ { status: 429 }
7164
+ );
7165
+ }
6670
7166
  const tempPassword = crypto3.randomBytes(16).toString("hex");
6671
7167
  const created = await dbCreate(payload, slugs.supportClients, {
6672
7168
  data: {
@@ -6838,7 +7334,7 @@ function createSupportEndpoints(slugs, options) {
6838
7334
  }
6839
7335
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6840
7336
  if (!f || f.emailTracking !== false) {
6841
- endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
7337
+ endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
6842
7338
  }
6843
7339
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6844
7340
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
@@ -6878,74 +7374,6 @@ async function createAdminNotification(payload, data, collectionSlug = "admin-no
6878
7374
  console.error("[notification] Failed to create:", err);
6879
7375
  }
6880
7376
  }
6881
- function dispatchWebhook(data, event, payload, slugs) {
6882
- void _dispatch(data, event, payload, slugs);
6883
- }
6884
- async function _dispatch(data, event, payload, slugs) {
6885
- try {
6886
- const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
6887
- where: {
6888
- and: [
6889
- { active: { equals: true } },
6890
- { events: { contains: event } }
6891
- ]
6892
- },
6893
- limit: 50,
6894
- depth: 0,
6895
- overrideAccess: true
6896
- });
6897
- if (endpoints.length === 0) return;
6898
- const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
6899
- for (const endpoint of endpoints) {
6900
- void _sendToEndpoint(endpoint, body, payload, slugs);
6901
- }
6902
- } catch (err) {
6903
- console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
6904
- }
6905
- }
6906
- async function _sendToEndpoint(endpoint, body, payload, slugs) {
6907
- try {
6908
- const headers = {
6909
- "Content-Type": "application/json",
6910
- "User-Agent": "PayloadSupport-Webhook/1.0"
6911
- };
6912
- if (endpoint.secret) {
6913
- const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
6914
- headers["X-Webhook-Signature"] = signature;
6915
- }
6916
- const response = await fetch(endpoint.url, {
6917
- method: "POST",
6918
- headers,
6919
- body,
6920
- signal: AbortSignal.timeout(1e4)
6921
- // 10s timeout
6922
- });
6923
- await dbUpdate(payload, slugs.webhookEndpoints, {
6924
- id: endpoint.id,
6925
- data: {
6926
- lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6927
- lastStatus: response.status
6928
- },
6929
- overrideAccess: true
6930
- });
6931
- if (!response.ok) {
6932
- console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
6933
- }
6934
- } catch (err) {
6935
- console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6936
- try {
6937
- await dbUpdate(payload, slugs.webhookEndpoints, {
6938
- id: endpoint.id,
6939
- data: {
6940
- lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
6941
- lastStatus: 0
6942
- },
6943
- overrideAccess: true
6944
- });
6945
- } catch {
6946
- }
6947
- }
6948
- }
6949
7377
 
6950
7378
  // src/hooks/ticketStatusEmail.ts
6951
7379
  var STATUS_LABELS = {
@@ -7562,12 +7990,28 @@ function createAssignTicketNumber(slugs, options) {
7562
7990
  }
7563
7991
  function createAssignClientOnCreate(slugs) {
7564
7992
  return async ({ data, operation, req }) => {
7565
- if (operation === "create" && req.user?.collection === slugs.supportClients && !data.client) {
7993
+ if (operation === "create" && req.user?.collection === slugs.supportClients) {
7566
7994
  data.client = req.user.id;
7567
7995
  }
7568
7996
  return data;
7569
7997
  };
7570
7998
  }
7999
+ var CLIENT_CREATABLE_TICKET_FIELDS = ["subject", "category", "priority", "project"];
8000
+ function createRestrictClientCreate(slugs) {
8001
+ return async ({ data, operation, req }) => {
8002
+ if (operation !== "create") return data;
8003
+ if (req.user?.collection !== slugs.supportClients) return data;
8004
+ const incoming = data ?? {};
8005
+ const sanitized = {};
8006
+ for (const field2 of CLIENT_CREATABLE_TICKET_FIELDS) {
8007
+ if (incoming[field2] !== void 0) sanitized[field2] = incoming[field2];
8008
+ }
8009
+ sanitized.status = "open";
8010
+ sanitized.client = req.user.id;
8011
+ sanitized.source = "portal";
8012
+ return sanitized;
8013
+ };
8014
+ }
7571
8015
  var autoPaidAt = async ({ data, operation, originalDoc }) => {
7572
8016
  if (operation !== "update") return data;
7573
8017
  if (data.paymentStatus === "paid" && originalDoc?.paymentStatus !== "paid" && !data.paidAt) {
@@ -7593,16 +8037,8 @@ function createAutoAssignAdmin(slugs) {
7593
8037
  const { payload } = req;
7594
8038
  let roundRobinEnabled = false;
7595
8039
  try {
7596
- const prefs = await payload.find({
7597
- collection: "payload-preferences",
7598
- where: { key: { equals: "support-round-robin" } },
7599
- limit: 1,
7600
- depth: 0,
7601
- overrideAccess: true
7602
- });
7603
- if (prefs.docs.length > 0) {
7604
- roundRobinEnabled = prefs.docs[0].value?.enabled === true;
7605
- }
8040
+ const settings = await readSupportSettings(payload);
8041
+ roundRobinEnabled = settings.features.roundRobin === true;
7606
8042
  } catch (err) {
7607
8043
  console.warn("[support] Failed to read round-robin preferences:", err);
7608
8044
  }
@@ -7811,54 +8247,6 @@ function createNotifyClientOnResolve(slugs) {
7811
8247
  return doc;
7812
8248
  };
7813
8249
  }
7814
- function createFireTicketWebhooks(slugs) {
7815
- return async ({ doc, previousDoc, operation, req }) => {
7816
- const { payload } = req;
7817
- if (operation === "create") {
7818
- fireWebhooks(payload, slugs, "ticket_created", {
7819
- id: doc.id,
7820
- ticketNumber: doc.ticketNumber,
7821
- subject: doc.subject,
7822
- status: doc.status,
7823
- priority: doc.priority,
7824
- category: doc.category
7825
- });
7826
- return doc;
7827
- }
7828
- if (operation === "update" && previousDoc) {
7829
- if (previousDoc.status !== doc.status && doc.status === "resolved") {
7830
- fireWebhooks(payload, slugs, "ticket_resolved", {
7831
- id: doc.id,
7832
- ticketNumber: doc.ticketNumber,
7833
- subject: doc.subject,
7834
- previousStatus: previousDoc.status
7835
- });
7836
- const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
7837
- if (clientId) {
7838
- payload.update({
7839
- collection: "client-summaries",
7840
- where: { client: { equals: clientId } },
7841
- data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
7842
- // Force cache expiry
7843
- overrideAccess: true
7844
- }).catch(() => {
7845
- });
7846
- }
7847
- }
7848
- const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
7849
- const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
7850
- if (newAssigned && oldAssigned !== newAssigned) {
7851
- fireWebhooks(payload, slugs, "ticket_assigned", {
7852
- id: doc.id,
7853
- ticketNumber: doc.ticketNumber,
7854
- subject: doc.subject,
7855
- assignedTo: newAssigned
7856
- });
7857
- }
7858
- }
7859
- return doc;
7860
- };
7861
- }
7862
8250
  function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
7863
8251
  return async ({ doc, operation, req }) => {
7864
8252
  if (operation !== "create") return doc;
@@ -7879,21 +8267,66 @@ function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
7879
8267
  }
7880
8268
  function createDispatchWebhookOnTicket(slugs) {
7881
8269
  return async ({ doc, previousDoc, operation, req }) => {
8270
+ const { payload } = req;
7882
8271
  if (operation === "create") {
7883
8272
  dispatchWebhook(
7884
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
8273
+ {
8274
+ ticketId: doc.id,
8275
+ id: doc.id,
8276
+ ticketNumber: doc.ticketNumber,
8277
+ subject: doc.subject,
8278
+ status: doc.status,
8279
+ priority: doc.priority,
8280
+ category: doc.category
8281
+ },
7885
8282
  "ticket_created",
7886
- req.payload,
8283
+ payload,
7887
8284
  slugs
7888
8285
  );
8286
+ return doc;
7889
8287
  }
7890
- if (operation === "update" && previousDoc?.status !== doc.status && doc.status === "resolved") {
7891
- dispatchWebhook(
7892
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
7893
- "ticket_resolved",
7894
- req.payload,
7895
- slugs
7896
- );
8288
+ if (operation === "update" && previousDoc) {
8289
+ if (previousDoc.status !== doc.status && doc.status === "resolved") {
8290
+ dispatchWebhook(
8291
+ {
8292
+ ticketId: doc.id,
8293
+ id: doc.id,
8294
+ ticketNumber: doc.ticketNumber,
8295
+ subject: doc.subject,
8296
+ previousStatus: previousDoc.status
8297
+ },
8298
+ "ticket_resolved",
8299
+ payload,
8300
+ slugs
8301
+ );
8302
+ const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
8303
+ if (clientId) {
8304
+ payload.update({
8305
+ collection: "client-summaries",
8306
+ where: { client: { equals: clientId } },
8307
+ data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
8308
+ // Force cache expiry
8309
+ overrideAccess: true
8310
+ }).catch(() => {
8311
+ });
8312
+ }
8313
+ }
8314
+ const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
8315
+ const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
8316
+ if (newAssigned && oldAssigned !== newAssigned) {
8317
+ dispatchWebhook(
8318
+ {
8319
+ ticketId: doc.id,
8320
+ id: doc.id,
8321
+ ticketNumber: doc.ticketNumber,
8322
+ subject: doc.subject,
8323
+ assignedTo: newAssigned
8324
+ },
8325
+ "ticket_assigned",
8326
+ payload,
8327
+ slugs
8328
+ );
8329
+ }
7897
8330
  }
7898
8331
  return doc;
7899
8332
  };
@@ -8019,7 +8452,10 @@ function createTicketsCollection(slugs, options) {
8019
8452
  { label: "Paiement partiel", value: "partial" },
8020
8453
  { label: "Pay\xE9", value: "paid" }
8021
8454
  ],
8022
- access: { update: ({ req }) => req.user?.collection === "users" },
8455
+ // `slugs.users`, not the literal 'users': the users collection slug is
8456
+ // configurable, and a hard-coded one silently opens this field to everybody
8457
+ // on a host that renamed it.
8458
+ access: { update: ({ req }) => req.user?.collection === slugs.users },
8023
8459
  admin: { width: "50%", condition: (data) => !!data?.invoice }
8024
8460
  },
8025
8461
  {
@@ -8301,6 +8737,7 @@ function createTicketsCollection(slugs, options) {
8301
8737
  { name: "totalTimeMinutes", type: "number", defaultValue: 0, label: "Temps total (minutes)", admin: { readOnly: true, position: "sidebar" } }
8302
8738
  ],
8303
8739
  hooks: {
8740
+ beforeValidate: [createRestrictClientCreate(slugs)],
8304
8741
  beforeChange: [
8305
8742
  createAssignTicketNumber(slugs, options?.ticketNumber),
8306
8743
  createAssignClientOnCreate(slugs),
@@ -8321,7 +8758,6 @@ function createTicketsCollection(slugs, options) {
8321
8758
  createNotifyClientOnResolve(slugs),
8322
8759
  createTicketStatusEmail(slugs),
8323
8760
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
8324
- createFireTicketWebhooks(slugs),
8325
8761
  createDispatchWebhookOnTicket(slugs),
8326
8762
  createApplyAutomationRules(slugs)
8327
8763
  ],
@@ -8383,7 +8819,7 @@ function createTicketsCollection(slugs, options) {
8383
8819
  }
8384
8820
 
8385
8821
  // src/utils/ticketAccess.ts
8386
- async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8822
+ async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
8387
8823
  const ids = /* @__PURE__ */ new Set();
8388
8824
  try {
8389
8825
  const owned = await dbFind(payload, slugs.tickets, {
@@ -8404,6 +8840,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8404
8840
  });
8405
8841
  for (const r of collab.docs) {
8406
8842
  const row = r;
8843
+ if (mode === "write" && row.role !== "collaborator") continue;
8407
8844
  const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8408
8845
  if (tid !== void 0 && tid !== null) ids.add(tid);
8409
8846
  }
@@ -8502,6 +8939,21 @@ async function queueClientNotification(payload, slugs, item) {
8502
8939
  }
8503
8940
 
8504
8941
  // src/collections/TicketMessages.ts
8942
+ function createRestrictClientTicketTarget(slugs) {
8943
+ return async ({ data, operation, req, originalDoc }) => {
8944
+ if (req.user?.collection !== slugs.supportClients) return data;
8945
+ const raw = data.ticket ?? (operation === "update" ? originalDoc?.ticket : void 0);
8946
+ const targetId = raw && typeof raw === "object" ? raw.id : raw;
8947
+ if (targetId === void 0 || targetId === null || targetId === "") {
8948
+ throw new payload.APIError("Ticket cible requis.", 400);
8949
+ }
8950
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
8951
+ if (!accessible.some((id) => String(id) === String(targetId))) {
8952
+ throw new payload.APIError("Ticket inaccessible.", 403);
8953
+ }
8954
+ return data;
8955
+ };
8956
+ }
8505
8957
  function createAssignAuthor(slugs) {
8506
8958
  return async ({ data, operation, req }) => {
8507
8959
  if (operation === "create" && req.user?.collection === slugs.supportClients) {
@@ -8736,21 +9188,6 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
8736
9188
  return doc;
8737
9189
  };
8738
9190
  }
8739
- function createFireMessageWebhooks(slugs) {
8740
- return async ({ doc, operation, req }) => {
8741
- if (operation !== "create") return doc;
8742
- if (doc.scheduledAt && !doc.scheduledSent) return doc;
8743
- if (doc.isInternal) return doc;
8744
- const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8745
- fireWebhooks(req.payload, slugs, "ticket_replied", {
8746
- ticketId,
8747
- messageId: doc.id,
8748
- authorType: doc.authorType,
8749
- body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
8750
- });
8751
- return doc;
8752
- };
8753
- }
8754
9191
  function createDispatchWebhookOnReply(slugs) {
8755
9192
  return async ({ doc, operation, req }) => {
8756
9193
  if (operation !== "create") return doc;
@@ -8758,7 +9195,12 @@ function createDispatchWebhookOnReply(slugs) {
8758
9195
  if (doc.scheduledAt && !doc.scheduledSent) return doc;
8759
9196
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8760
9197
  dispatchWebhook(
8761
- { ticketId, messageId: doc.id, authorType: doc.authorType },
9198
+ {
9199
+ ticketId,
9200
+ messageId: doc.id,
9201
+ authorType: doc.authorType,
9202
+ body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
9203
+ },
8762
9204
  "ticket_replied",
8763
9205
  req.payload,
8764
9206
  slugs
@@ -8941,7 +9383,7 @@ function createTicketMessagesCollection(slugs, options) {
8941
9383
  ] : []
8942
9384
  ],
8943
9385
  hooks: {
8944
- beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
9386
+ beforeChange: [createRestrictClientTicketTarget(slugs), createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
8945
9387
  afterChange: [
8946
9388
  ...options?.capabilities?.sms ? [createSmsNotification(slugs, options.capabilities.sms)] : [],
8947
9389
  createAutoUpdateStatus(slugs),
@@ -8950,7 +9392,6 @@ function createTicketMessagesCollection(slugs, options) {
8950
9392
  createCheckSlaOnReply(slugs, notificationSlug),
8951
9393
  createSyncTicketReplyToChat(slugs),
8952
9394
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
8953
- createFireMessageWebhooks(slugs),
8954
9395
  createDispatchWebhookOnReply(slugs),
8955
9396
  createNotifyMentions(slugs),
8956
9397
  ...options?.capabilities?.threadCleanup ? [createThreadCleanup(options.capabilities.threadCleanup)] : []
@@ -9023,13 +9464,13 @@ function createSendInvitationOnCreate(slugs) {
9023
9464
  return doc;
9024
9465
  };
9025
9466
  }
9026
- var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
9467
+ var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
9027
9468
  function createEnforce2FA(slugs) {
9028
9469
  return async ({ req, user }) => {
9029
9470
  if (!user?.twoFactorEnabled) return user;
9030
9471
  const raw = user.twoFactorVerifiedAt;
9031
9472
  const verifiedAt = raw ? new Date(raw).getTime() : 0;
9032
- if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
9473
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
9033
9474
  throw new payload.APIError("2FA_REQUIRED", 401);
9034
9475
  }
9035
9476
  await req.payload.update({
@@ -9043,7 +9484,11 @@ function createEnforce2FA(slugs) {
9043
9484
  return user;
9044
9485
  };
9045
9486
  }
9487
+ function createAdminOnlyFieldUpdate(slugs) {
9488
+ return ({ req }) => req.user?.collection === slugs.users;
9489
+ }
9046
9490
  function createSupportClientsCollection(slugs, options) {
9491
+ const adminOnlyFieldUpdate = createAdminOnlyFieldUpdate(slugs);
9047
9492
  return {
9048
9493
  slug: slugs.supportClients,
9049
9494
  labels: {
@@ -9173,7 +9618,8 @@ function createSupportClientsCollection(slugs, options) {
9173
9618
  label: "Opportunites commerciales",
9174
9619
  access: {
9175
9620
  // Admin-only: never serialize commercial notes onto the client's own doc.
9176
- read: ({ req }) => req.user?.collection === slugs.users
9621
+ read: ({ req }) => req.user?.collection === slugs.users,
9622
+ update: adminOnlyFieldUpdate
9177
9623
  },
9178
9624
  admin: {
9179
9625
  description: "Notes commerciales (visible uniquement par les admins)"
@@ -9183,6 +9629,7 @@ function createSupportClientsCollection(slugs, options) {
9183
9629
  name: "tier",
9184
9630
  type: "select",
9185
9631
  label: "Plan",
9632
+ access: { update: adminOnlyFieldUpdate },
9186
9633
  options: [
9187
9634
  { label: "Free", value: "free" },
9188
9635
  { label: "Basic", value: "basic" },
@@ -9196,6 +9643,7 @@ function createSupportClientsCollection(slugs, options) {
9196
9643
  type: "relationship",
9197
9644
  relationTo: slugs.users,
9198
9645
  label: "Agent referent",
9646
+ access: { update: adminOnlyFieldUpdate },
9199
9647
  admin: { position: "sidebar" }
9200
9648
  },
9201
9649
  {
@@ -9211,17 +9659,20 @@ function createSupportClientsCollection(slugs, options) {
9211
9659
  {
9212
9660
  name: "twoFactorCode",
9213
9661
  type: "text",
9662
+ access: { update: adminOnlyFieldUpdate },
9214
9663
  admin: { hidden: true }
9215
9664
  },
9216
9665
  {
9217
9666
  name: "twoFactorExpiry",
9218
9667
  type: "date",
9668
+ access: { update: adminOnlyFieldUpdate },
9219
9669
  admin: { hidden: true }
9220
9670
  },
9221
9671
  {
9222
9672
  // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
9223
9673
  name: "twoFactorVerifiedAt",
9224
9674
  type: "date",
9675
+ access: { update: adminOnlyFieldUpdate },
9225
9676
  admin: { hidden: true }
9226
9677
  },
9227
9678
  {
@@ -9229,6 +9680,7 @@ function createSupportClientsCollection(slugs, options) {
9229
9680
  name: "googleId",
9230
9681
  type: "text",
9231
9682
  index: true,
9683
+ access: { update: adminOnlyFieldUpdate },
9232
9684
  admin: { hidden: true }
9233
9685
  },
9234
9686
  {
@@ -9299,7 +9751,8 @@ function createSupportClientsCollection(slugs, options) {
9299
9751
  label: "Notes internes",
9300
9752
  access: {
9301
9753
  // Admin-only: never serialize internal notes onto the client's own doc.
9302
- read: ({ req }) => req.user?.collection === slugs.users
9754
+ read: ({ req }) => req.user?.collection === slugs.users,
9755
+ update: adminOnlyFieldUpdate
9303
9756
  },
9304
9757
  admin: {
9305
9758
  description: "Visible uniquement par les admins",
@@ -9372,36 +9825,49 @@ function createSupportClientsCollection(slugs, options) {
9372
9825
  }
9373
9826
 
9374
9827
  // src/collections/TimeEntries.ts
9828
+ async function recalculateTicketTime(payload, slugs, ticketId) {
9829
+ let totalMinutes = 0;
9830
+ let page = 1;
9831
+ let hasMore = true;
9832
+ while (hasMore) {
9833
+ const entries = await payload.find({
9834
+ collection: slugs.timeEntries,
9835
+ where: { ticket: { equals: ticketId } },
9836
+ limit: 100,
9837
+ page,
9838
+ depth: 0,
9839
+ overrideAccess: true,
9840
+ select: { duration: true }
9841
+ });
9842
+ for (const entry of entries.docs) {
9843
+ totalMinutes += entry.duration || 0;
9844
+ }
9845
+ hasMore = entries.hasNextPage ?? false;
9846
+ page++;
9847
+ }
9848
+ await payload.update({
9849
+ collection: slugs.tickets,
9850
+ id: ticketId,
9851
+ data: { totalTimeMinutes: totalMinutes },
9852
+ overrideAccess: true
9853
+ });
9854
+ }
9375
9855
  function createRecalculateTicketTime(slugs) {
9376
9856
  return async ({ doc, req }) => {
9377
9857
  if (!doc.ticket) return;
9378
- const { payload } = req;
9379
9858
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
9380
- let totalMinutes = 0;
9381
- let page = 1;
9382
- let hasMore = true;
9383
- while (hasMore) {
9384
- const entries = await payload.find({
9385
- collection: slugs.timeEntries,
9386
- where: { ticket: { equals: ticketId } },
9387
- limit: 100,
9388
- page,
9389
- depth: 0,
9390
- overrideAccess: true,
9391
- select: { duration: true }
9392
- });
9393
- for (const entry of entries.docs) {
9394
- totalMinutes += entry.duration || 0;
9395
- }
9396
- hasMore = entries.hasNextPage ?? false;
9397
- page++;
9859
+ await recalculateTicketTime(req.payload, slugs, ticketId);
9860
+ };
9861
+ }
9862
+ function createRecalculateTicketTimeOnDelete(slugs) {
9863
+ return async ({ doc, req }) => {
9864
+ if (!doc?.ticket) return;
9865
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
9866
+ try {
9867
+ await recalculateTicketTime(req.payload, slugs, ticketId);
9868
+ } catch (err) {
9869
+ console.error("[support] Failed to recalculate ticket time after delete:", err);
9398
9870
  }
9399
- await payload.update({
9400
- collection: slugs.tickets,
9401
- id: ticketId,
9402
- data: { totalTimeMinutes: totalMinutes },
9403
- overrideAccess: true
9404
- });
9405
9871
  };
9406
9872
  }
9407
9873
  function createTimeEntriesCollection(slugs) {
@@ -9463,7 +9929,8 @@ function createTimeEntriesCollection(slugs) {
9463
9929
  }
9464
9930
  ],
9465
9931
  hooks: {
9466
- afterChange: [createRecalculateTicketTime(slugs)]
9932
+ afterChange: [createRecalculateTicketTime(slugs)],
9933
+ afterDelete: [createRecalculateTicketTimeOnDelete(slugs)]
9467
9934
  },
9468
9935
  access: {
9469
9936
  create: ({ req }) => req.user?.collection === slugs.users,
@@ -9844,8 +10311,28 @@ function createKnowledgeBaseCollection(slugs) {
9844
10311
  timestamps: true
9845
10312
  };
9846
10313
  }
9847
-
9848
- // src/collections/ChatMessages.ts
10314
+ function createRestrictClientChatWrite(slugs) {
10315
+ return async ({ data, req }) => {
10316
+ if (req.user?.collection !== slugs.supportClients) return data;
10317
+ data.client = req.user.id;
10318
+ data.senderType = "client";
10319
+ delete data.agent;
10320
+ const session = typeof data.session === "string" ? data.session : null;
10321
+ if (!session) return data;
10322
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
10323
+ where: { session: { equals: session } },
10324
+ limit: 1,
10325
+ depth: 0,
10326
+ overrideAccess: true
10327
+ });
10328
+ const owner = existing.docs[0]?.client;
10329
+ const ownerId = owner && typeof owner === "object" ? owner.id : owner;
10330
+ if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
10331
+ throw new payload.APIError("Session inaccessible.", 403);
10332
+ }
10333
+ return data;
10334
+ };
10335
+ }
9849
10336
  function createChatMessagesCollection(slugs) {
9850
10337
  return {
9851
10338
  slug: slugs.chatMessages,
@@ -9867,7 +10354,9 @@ function createChatMessagesCollection(slugs) {
9867
10354
  }
9868
10355
  return false;
9869
10356
  },
9870
- create: ({ req }) => !!req.user,
10357
+ // Staff and support-clients only — NOT "any authenticated principal":
10358
+ // a user of any other auth collection of the host app satisfied `!!req.user`.
10359
+ create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
9871
10360
  update: ({ req }) => req.user?.collection === slugs.users,
9872
10361
  delete: ({ req }) => req.user?.collection === slugs.users
9873
10362
  },
@@ -9936,6 +10425,9 @@ function createChatMessagesCollection(slugs) {
9936
10425
  }
9937
10426
  }
9938
10427
  ],
10428
+ hooks: {
10429
+ beforeChange: [createRestrictClientChatWrite(slugs)]
10430
+ },
9939
10431
  timestamps: true
9940
10432
  };
9941
10433
  }
@@ -10213,8 +10705,19 @@ function createAuthLogsCollection(slugs) {
10213
10705
  timestamps: true
10214
10706
  };
10215
10707
  }
10216
-
10217
- // src/collections/WebhookEndpoints.ts
10708
+ function createValidateWebhookUrl() {
10709
+ return ({ data, operation, originalDoc }) => {
10710
+ const incoming = data?.url;
10711
+ if (incoming === void 0 || incoming === null) return data;
10712
+ const previous = originalDoc?.url;
10713
+ if (operation === "update" && incoming === previous) return data;
10714
+ const result = validateWebhookUrl(incoming);
10715
+ if (!result.ok) {
10716
+ throw new payload.APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
10717
+ }
10718
+ return data;
10719
+ };
10720
+ }
10218
10721
  function createWebhookEndpointsCollection(slugs) {
10219
10722
  return {
10220
10723
  slug: slugs.webhookEndpoints,
@@ -10248,8 +10751,11 @@ function createWebhookEndpointsCollection(slugs) {
10248
10751
  type: "text",
10249
10752
  required: true,
10250
10753
  label: "URL",
10754
+ // The SSRF check lives in the collection `beforeValidate` above, NOT in a
10755
+ // field `validate`: the latter re-runs on the merged document and would
10756
+ // freeze every pre-existing row on any unrelated edit.
10251
10757
  admin: {
10252
- description: "URL du webhook \xE0 appeler (POST)"
10758
+ description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
10253
10759
  }
10254
10760
  },
10255
10761
  {
@@ -10303,6 +10809,9 @@ function createWebhookEndpointsCollection(slugs) {
10303
10809
  }
10304
10810
  }
10305
10811
  ],
10812
+ hooks: {
10813
+ beforeValidate: [createValidateWebhookUrl()]
10814
+ },
10306
10815
  timestamps: true
10307
10816
  };
10308
10817
  }
@@ -10754,11 +11263,17 @@ function createClientSummariesCollection(slugs) {
10754
11263
  admin: { readOnly: true }
10755
11264
  }
10756
11265
  ],
11266
+ // Staff-only, on the SAME source of truth as every other collection and as
11267
+ // `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
11268
+ // against is the DEFAULT slug, not the configured one — on a host app whose
11269
+ // staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
11270
+ // the front-office collection instead, opening read/create/update/delete on
11271
+ // AI-generated client intelligence to it while locking the real agents out.
10757
11272
  access: {
10758
- create: ({ req }) => req.user?.collection === "users",
10759
- read: ({ req }) => req.user?.collection === "users",
10760
- update: ({ req }) => req.user?.collection === "users",
10761
- delete: ({ req }) => req.user?.collection === "users"
11273
+ create: ({ req }) => req.user?.collection === slugs.users,
11274
+ read: ({ req }) => req.user?.collection === slugs.users,
11275
+ update: ({ req }) => req.user?.collection === slugs.users,
11276
+ delete: ({ req }) => req.user?.collection === slugs.users
10762
11277
  },
10763
11278
  timestamps: true
10764
11279
  };
@@ -10889,7 +11404,12 @@ function createTicketCollaboratorsCollection(slugs) {
10889
11404
  }
10890
11405
  return false;
10891
11406
  },
10892
- create: ({ req }) => !!req.user,
11407
+ // Staff only. This collection is the source of truth for
11408
+ // resolveAccessibleTicketIds(), which widens `read` on tickets, messages
11409
+ // and the activity log — so letting any authenticated principal POST here
11410
+ // let a support client grant themselves access to any ticket by id.
11411
+ // The /invite endpoint is unaffected: it writes with overrideAccess: true.
11412
+ create: ({ req }) => req.user?.collection === slugs.users,
10893
11413
  update: () => false,
10894
11414
  delete: ({ req }) => req.user?.collection === slugs.users
10895
11415
  },
@@ -11217,6 +11737,17 @@ function supportPlugin(config) {
11217
11737
  });
11218
11738
  return {
11219
11739
  ...incomingConfig,
11740
+ // Publish the resolved staff collection so the server-side readers share
11741
+ // ONE source of truth with the writers. `requireAdmin` compares against
11742
+ // `slugs.users`; the `payload-preferences` reads used to scope themselves
11743
+ // on `config.admin.user`, which Payload silently defaults to the first
11744
+ // auth collection of the host app — a different collection on any app
11745
+ // that declares `collectionSlugs.users`, and the settings-poisoning hole
11746
+ // reopened right there.
11747
+ custom: {
11748
+ ...incomingConfig.custom,
11749
+ [SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
11750
+ },
11220
11751
  collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
11221
11752
  endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
11222
11753
  admin: {
@@ -11234,6 +11765,7 @@ exports.DEFAULT_FEATURES = DEFAULT_FEATURES;
11234
11765
  exports.DEFAULT_INBOUND_EMAIL_LIMITS = DEFAULT_INBOUND_EMAIL_LIMITS;
11235
11766
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
11236
11767
  exports.DEFAULT_SLUGS = DEFAULT_SLUGS;
11768
+ exports.DEFAULT_TICKETING_FEATURES = DEFAULT_TICKETING_FEATURES;
11237
11769
  exports.DEFAULT_USER_PREFS = DEFAULT_USER_PREFS;
11238
11770
  exports.MemoryRateLimitStore = MemoryRateLimitStore;
11239
11771
  exports.PayloadRateLimitStore = PayloadRateLimitStore;
@@ -11266,9 +11798,13 @@ exports.createTrackOpenEndpoint = createTrackOpenEndpoint;
11266
11798
  exports.createWebhookEndpointsCollection = createWebhookEndpointsCollection;
11267
11799
  exports.dispatchWebhook = dispatchWebhook;
11268
11800
  exports.generateTicketSynthesis = generateTicketSynthesis;
11801
+ exports.normalizeFeatures = normalizeFeatures;
11802
+ exports.projectAutoClose = projectAutoClose;
11269
11803
  exports.readSupportSettings = readSupportSettings;
11804
+ exports.readSupportSettingsState = readSupportSettingsState;
11270
11805
  exports.readUserPrefs = readUserPrefs;
11271
11806
  exports.resolveSlugs = resolveSlugs;
11807
+ exports.stripProjectedFeatures = stripProjectedFeatures;
11272
11808
  exports.supportPlugin = supportPlugin;
11273
11809
  exports.validateInboundEmailPayload = validateInboundEmailPayload;
11274
11810
  exports.verifySecret = verifySecret;