@consilioweb/payload-support 1.1.1 → 2.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 (99) hide show
  1. package/README.md +4 -0
  2. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
  3. package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
  4. package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
  5. package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
  6. package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
  7. package/dist/components/TicketConversation/components/ClientBar.js +1 -1
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
  9. package/dist/components/TicketConversation/components/QuickActions.js +2 -2
  10. package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
  11. package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
  12. package/dist/components/TicketConversation/index.cjs +10 -10
  13. package/dist/components/TicketConversation/index.js +10 -10
  14. package/dist/index.cjs +709 -173
  15. package/dist/index.d.cts +165 -3
  16. package/dist/index.d.ts +165 -3
  17. package/dist/index.js +702 -175
  18. package/dist/styles/ChatView.module.scss +1 -1
  19. package/dist/styles/TicketDetail.module.scss +3 -3
  20. package/dist/styles/_tokens.scss +1 -1
  21. package/dist/styles/theme.css +6 -6
  22. package/dist/views/TicketingSettingsView/client.cjs +3 -3
  23. package/dist/views/TicketingSettingsView/client.js +3 -3
  24. package/package.json +9 -9
  25. package/src/__tests__/aiSummaryRendering.test.ts +21 -0
  26. package/src/__tests__/authResponses.test.ts +69 -0
  27. package/src/__tests__/capabilities.test.ts +71 -0
  28. package/src/__tests__/generateTrackingToken.test.ts +18 -4
  29. package/src/__tests__/integration/core-behaviors.test.ts +23 -0
  30. package/src/__tests__/rateLimiter.test.ts +60 -66
  31. package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
  32. package/src/__tests__/webhookSecurity.test.ts +58 -0
  33. package/src/collections/PendingEmails.ts +2 -1
  34. package/src/collections/SupportClients.ts +43 -1
  35. package/src/collections/SupportCounters.ts +14 -0
  36. package/src/collections/SupportRateLimits.ts +20 -0
  37. package/src/collections/TicketMessages.ts +76 -0
  38. package/src/collections/Tickets.ts +179 -39
  39. package/src/collections/index.ts +2 -0
  40. package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
  41. package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
  42. package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
  43. package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
  44. package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
  45. package/src/components/TicketConversation/index.tsx +10 -10
  46. package/src/endpoints/admin-chat.ts +4 -4
  47. package/src/endpoints/ai-agent.ts +6 -1
  48. package/src/endpoints/ai.ts +10 -5
  49. package/src/endpoints/auth-2fa.ts +6 -7
  50. package/src/endpoints/auto-close.ts +2 -1
  51. package/src/endpoints/capabilities.ts +144 -0
  52. package/src/endpoints/channels.ts +2 -1
  53. package/src/endpoints/chat.ts +6 -6
  54. package/src/endpoints/chatbot.ts +4 -4
  55. package/src/endpoints/client-intelligence.ts +13 -4
  56. package/src/endpoints/import-conversation.ts +6 -5
  57. package/src/endpoints/index.ts +35 -14
  58. package/src/endpoints/invite-collaborator.ts +4 -4
  59. package/src/endpoints/login.ts +4 -5
  60. package/src/endpoints/oauth-google.ts +1 -1
  61. package/src/endpoints/process-digests.ts +2 -1
  62. package/src/endpoints/process-scheduled.ts +2 -1
  63. package/src/endpoints/process-snooze.ts +2 -1
  64. package/src/endpoints/resend-notification.ts +4 -4
  65. package/src/endpoints/send-reminder.ts +4 -4
  66. package/src/endpoints/ticket-synthesis.ts +17 -2
  67. package/src/endpoints/track-open.ts +44 -21
  68. package/src/endpoints/transfer-ticket.ts +4 -4
  69. package/src/index.ts +7 -0
  70. package/src/plugin.ts +16 -1
  71. package/src/portal/LiveChat.tsx +1 -1
  72. package/src/portal/auth/ChatWidget.tsx +4 -4
  73. package/src/portal/auth/ChatbotWidget.tsx +4 -4
  74. package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
  75. package/src/portal/auth/faq/FAQSearch.tsx +1 -1
  76. package/src/portal/auth/faq/page.tsx +2 -2
  77. package/src/portal/auth/profile/page.tsx +17 -17
  78. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
  79. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
  80. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
  81. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
  82. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
  83. package/src/portal/auth/tickets/detail/page.tsx +5 -5
  84. package/src/portal/auth/tickets/new/page.tsx +8 -8
  85. package/src/portal/forgot-password/page.tsx +3 -3
  86. package/src/portal/layout.tsx +5 -2
  87. package/src/portal/login/page.tsx +7 -7
  88. package/src/portal/page.tsx +9 -9
  89. package/src/portal/register/page.tsx +4 -4
  90. package/src/portal/reset-password/page.tsx +3 -3
  91. package/src/styles/ChatView.module.scss +1 -1
  92. package/src/styles/TicketDetail.module.scss +3 -3
  93. package/src/styles/_tokens.scss +1 -1
  94. package/src/styles/theme.css +6 -6
  95. package/src/types.ts +71 -0
  96. package/src/utils/rateLimiter.ts +131 -38
  97. package/src/utils/slugs.ts +4 -0
  98. package/src/utils/webhookSecurity.ts +81 -0
  99. package/src/views/TicketingSettingsView/client.tsx +3 -3
@@ -1,6 +1,14 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import type { SupportFeatures } from '../types'
4
+ import type { RateLimitStore } from '../utils/rateLimiter'
5
+ import type { SupportCapabilities } from '../types'
6
+ import {
7
+ createGenerateMissingTitlesEndpoint,
8
+ createInboundEmailEndpoint,
9
+ createProjectSuggestionsEndpoint,
10
+ createTicketTitleEndpoint,
11
+ } from './capabilities'
4
12
 
5
13
  import { createAiEndpoint } from './ai'
6
14
  import { createAiAgentEndpoint } from './ai-agent'
@@ -112,6 +120,8 @@ export { createInviteCollaboratorEndpoint } from './invite-collaborator'
112
120
  export interface SupportEndpointOptions {
113
121
  oauth?: OAuthGoogleOptions
114
122
  features?: Required<SupportFeatures>
123
+ rateLimitStore?: RateLimitStore
124
+ capabilities?: SupportCapabilities
115
125
  }
116
126
 
117
127
  /**
@@ -121,6 +131,7 @@ export interface SupportEndpointOptions {
121
131
  */
122
132
  export function createSupportEndpoints(slugs: CollectionSlugs, options?: SupportEndpointOptions): Endpoint[] {
123
133
  const f = options?.features
134
+ const rateLimitStore = options?.rateLimitStore
124
135
 
125
136
  // Core endpoints (always present)
126
137
  const endpoints: Endpoint[] = [
@@ -132,28 +143,28 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
132
143
  createExportCsvEndpoint(slugs),
133
144
  createExportDataEndpoint(slugs),
134
145
  createSeedKbEndpoint(slugs),
135
- createLoginEndpoint(slugs),
136
- createAuth2faEndpoint(slugs),
146
+ createLoginEndpoint(slugs, rateLimitStore),
147
+ createAuth2faEndpoint(slugs, rateLimitStore),
137
148
  createOAuthGoogleEndpoint(slugs, options?.oauth),
138
149
  createDeleteAccountEndpoint(slugs),
139
150
  createMergeClientsEndpoint(slugs),
140
- createImportConversationEndpoint(slugs),
151
+ createImportConversationEndpoint(slugs, rateLimitStore),
141
152
  createPurgeLogsEndpoint(slugs),
142
- createResendNotificationEndpoint(slugs),
153
+ createResendNotificationEndpoint(slugs, rateLimitStore),
143
154
  createUserPrefsGetEndpoint(slugs),
144
155
  createUserPrefsPostEndpoint(slugs),
145
156
  createEscalateEndpoint(slugs),
146
157
  createTicketFeedbackEndpoint(slugs),
147
- createTransferTicketEndpoint(slugs),
148
- createInviteCollaboratorEndpoint(slugs),
158
+ createTransferTicketEndpoint(slugs, rateLimitStore),
159
+ createInviteCollaboratorEndpoint(slugs, rateLimitStore),
149
160
  ]
150
161
 
151
162
  // Conditional endpoints based on feature flags
152
163
  if (!f || f.ai !== false) {
153
- endpoints.push(createAiEndpoint(slugs))
154
- endpoints.push(createAiAgentEndpoint(slugs))
155
- endpoints.push(...createClientIntelligenceEndpoint(slugs))
156
- endpoints.push(createTicketSynthesisEndpoint(slugs))
164
+ endpoints.push(createAiEndpoint(slugs, rateLimitStore))
165
+ endpoints.push(createAiAgentEndpoint(slugs, rateLimitStore))
166
+ endpoints.push(...createClientIntelligenceEndpoint(slugs, rateLimitStore))
167
+ endpoints.push(createTicketSynthesisEndpoint(slugs, options?.capabilities?.aiSummaries, rateLimitStore))
157
168
  }
158
169
  if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
159
170
  if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
@@ -168,18 +179,18 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
168
179
  if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs))
169
180
  if (!f || f.autoClose !== false) {
170
181
  endpoints.push(createAutoCloseEndpoint(slugs))
171
- endpoints.push(createSendReminderEndpoint(slugs))
182
+ endpoints.push(createSendReminderEndpoint(slugs, rateLimitStore))
172
183
  }
173
184
  if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs))
174
185
  if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs))
175
186
  if (!f || f.roundRobin !== false) {
176
187
  endpoints.push(createRoundRobinConfigGetEndpoint(slugs), createRoundRobinConfigPostEndpoint(slugs))
177
188
  }
178
- if (!f || f.chatbot !== false) endpoints.push(createChatbotEndpoint(slugs))
189
+ if (!f || f.chatbot !== false) endpoints.push(createChatbotEndpoint(slugs, rateLimitStore))
179
190
  if (!f || f.chat !== false) {
180
- endpoints.push(createChatGetEndpoint(slugs), createChatPostEndpoint(slugs))
191
+ endpoints.push(createChatGetEndpoint(slugs), createChatPostEndpoint(slugs, rateLimitStore))
181
192
  endpoints.push(createChatStreamEndpoint(slugs))
182
- endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs))
193
+ endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs, rateLimitStore))
183
194
  endpoints.push(createAdminChatStreamEndpoint(slugs))
184
195
  }
185
196
  if (!f || f.timeTracking !== false) {
@@ -197,6 +208,16 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
197
208
  endpoints.push(createChannelsWebhookEndpoint(slugs))
198
209
  endpoints.push(createVapidKeyEndpoint())
199
210
  endpoints.push(createPushSubscribeEndpoint(slugs))
211
+ if (options?.capabilities?.inboundEmail) {
212
+ endpoints.push(createInboundEmailEndpoint(options.capabilities.inboundEmail, rateLimitStore))
213
+ }
214
+ if (options?.capabilities?.projectSuggestions) {
215
+ endpoints.push(createProjectSuggestionsEndpoint(slugs, options.capabilities.projectSuggestions, rateLimitStore))
216
+ }
217
+ if (options?.capabilities?.aiTitles) {
218
+ endpoints.push(createTicketTitleEndpoint(slugs, options.capabilities.aiTitles, rateLimitStore))
219
+ endpoints.push(createGenerateMissingTitlesEndpoint(slugs, options.capabilities.aiTitles, rateLimitStore))
220
+ }
200
221
 
201
222
  return endpoints
202
223
  }
@@ -4,7 +4,7 @@ import { handleAuthError, AuthError } from '../utils/auth'
4
4
  import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
6
  import { randomBytes } from 'crypto'
7
- import { RateLimiter } from '../utils/rateLimiter'
7
+ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
8
8
  import { dbFindByID, dbFind, dbCreate, dbUpdate, dbCount } from '../utils/db'
9
9
 
10
10
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -12,7 +12,6 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
12
12
  // Anti-abuse limits: an invite can create a placeholder account AND send an
13
13
  // email, so cap both the volume per user and the collaborators per ticket.
14
14
  const MAX_COLLABORATORS_PER_TICKET = 20
15
- const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10)
16
15
 
17
16
  /**
18
17
  * POST /api/support/tickets/:id/invite
@@ -31,7 +30,8 @@ const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10)
31
30
  * - Sends an invitation email with a magic link `/support/tickets/:id?inviteToken=...`.
32
31
  * - Returns { ok, invitedTo, role }.
33
32
  */
34
- export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoint {
33
+ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
34
+ const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10, store)
35
35
  return {
36
36
  path: '/support/tickets/:id/invite',
37
37
  method: 'post',
@@ -75,7 +75,7 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
75
75
  }
76
76
 
77
77
  // Anti-abuse: cap invite volume per user (in-memory, fail-closed).
78
- if (inviteLimiter.check(String(req.user.id))) {
78
+ if (await inviteLimiter.check(String(req.user.id), req)) {
79
79
  return Response.json({ error: 'Trop d\'invitations. Réessayez plus tard.' }, { status: 429 })
80
80
  }
81
81
 
@@ -1,15 +1,15 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
- import { RateLimiter } from '../utils/rateLimiter'
3
+ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
4
  import { dbCreate } from '../utils/db'
5
5
 
6
- const loginLimiter = new RateLimiter(15 * 60_000, 10) // 10 per 15 min
7
6
 
8
7
  /**
9
8
  * POST /api/support/login
10
9
  * Client login endpoint.
11
10
  */
12
- export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
11
+ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
12
+ const loginLimiter = new RateLimiter(15 * 60_000, 10, store)
13
13
  return {
14
14
  path: '/support/login',
15
15
  method: 'post',
@@ -19,7 +19,7 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
19
19
  // the primary brute-force control is Payload's account lock (maxLoginAttempts).
20
20
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
21
21
 
22
- if (loginLimiter.check(ip)) {
22
+ if (await loginLimiter.check(ip, req)) {
23
23
  return Response.json(
24
24
  { error: 'Trop de tentatives. Réessayez dans quelques minutes.' },
25
25
  { status: 429 },
@@ -66,7 +66,6 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
66
66
  JSON.stringify({
67
67
  message: 'Login successful',
68
68
  user: result.user,
69
- token: result.token,
70
69
  exp: result.exp,
71
70
  }),
72
71
  { status: 200, headers },
@@ -210,7 +210,7 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
210
210
  `payload-token=${token}; HttpOnly; ${cookieSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`,
211
211
  )
212
212
 
213
- return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
213
+ return new Response(JSON.stringify({ user: clientDoc, exp }), {
214
214
  status: 200,
215
215
  headers,
216
216
  })
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { dbFind, dbFindByID, dbDelete } from '../utils/db'
4
4
  import { emailWrapper, emailParagraph, escapeHtml } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
+ import { verifySecret } from '../utils/webhookSecurity'
6
7
 
7
8
  /**
8
9
  * POST /api/support/process-digests body: { frequency?: 'daily' | 'weekly' }
@@ -18,7 +19,7 @@ export function createProcessDigestsEndpoint(slugs: CollectionSlugs): Endpoint {
18
19
  method: 'post',
19
20
  handler: async (req) => {
20
21
  const secret = req.headers.get('x-cron-secret')
21
- if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
22
+ if (!verifySecret(secret, process.env.CRON_SECRET)) {
22
23
  return Response.json({ error: 'Non autorisé' }, { status: 401 })
23
24
  }
24
25
 
@@ -4,6 +4,7 @@ import { escapeHtml } from '../utils/emailTemplate'
4
4
  import { fireWebhooks } from '../utils/fireWebhooks'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
6
  import { dbFind, dbFindByID, dbUpdate } from '../utils/db'
7
+ import { verifySecret } from '../utils/webhookSecurity'
7
8
 
8
9
  /**
9
10
  * POST /api/support/process-scheduled
@@ -18,7 +19,7 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
18
19
  const secret = req.headers.get('x-cron-secret')
19
20
  const expectedSecret = process.env.CRON_SECRET
20
21
 
21
- if (!expectedSecret || secret !== expectedSecret) {
22
+ if (!verifySecret(secret, expectedSecret)) {
22
23
  return Response.json({ error: 'Non autorisé' }, { status: 401 })
23
24
  }
24
25
 
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { dbFind, dbUpdate, dbCreate } from '../utils/db'
4
+ import { verifySecret } from '../utils/webhookSecurity'
4
5
 
5
6
  /**
6
7
  * POST /api/support/process-snooze
@@ -20,7 +21,7 @@ export function createProcessSnoozeEndpoint(slugs: CollectionSlugs): Endpoint {
20
21
  handler: async (req) => {
21
22
  const secret = req.headers.get('x-cron-secret')
22
23
  const expectedSecret = process.env.CRON_SECRET
23
- if (!expectedSecret || secret !== expectedSecret) {
24
+ if (!verifySecret(secret, expectedSecret)) {
24
25
  return Response.json({ error: 'Non autorisé' }, { status: 401 })
25
26
  }
26
27
 
@@ -1,18 +1,18 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
- import { RateLimiter } from '../utils/rateLimiter'
4
+ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { escapeHtml } from '../utils/emailTemplate'
6
6
  import { readSupportSettings } from '../utils/readSettings'
7
7
  import { dbFindByID } from '../utils/db'
8
8
 
9
- const resendLimiter = new RateLimiter(60 * 60 * 1000, 10) // 10 per hour
10
9
 
11
10
  /**
12
11
  * POST /api/support/resend-notification
13
12
  * Resend the email notification for a specific ticket message. Admin-only.
14
13
  */
15
- export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoint {
14
+ export function createResendNotificationEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
15
+ const resendLimiter = new RateLimiter(60 * 60 * 1000, 10, store)
16
16
  return {
17
17
  path: '/support/resend-notification',
18
18
  method: 'post',
@@ -22,7 +22,7 @@ export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoi
22
22
 
23
23
  requireAdmin(req, slugs)
24
24
 
25
- if (resendLimiter.check(String(req.user.id))) {
25
+ if (await resendLimiter.check(String(req.user.id), req)) {
26
26
  return Response.json(
27
27
  { error: 'Trop de renvois. Réessayez dans une heure.' },
28
28
  { status: 429 },
@@ -1,14 +1,13 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
- import { RateLimiter } from '../utils/rateLimiter'
4
+ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
6
6
  import { readSupportSettings } from '../utils/readSettings'
7
7
  import { dbFindByID, dbFind, dbCreate, dbUpdate } from '../utils/db'
8
8
 
9
9
  // Manual reminders are a deliberate admin action, so the cap is generous —
10
10
  // it only exists to stop a runaway script, not normal usage.
11
- const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30) // 30 per hour per admin
12
11
 
13
12
  const MIN_HOURS = 1
14
13
  const MAX_HOURS = 24 * 30 // 30 days
@@ -38,7 +37,8 @@ function formatFr(date: Date, withTime: boolean): string {
38
37
  *
39
38
  * Admin-only, rate-limited.
40
39
  */
41
- export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
40
+ export function createSendReminderEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
41
+ const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30, store)
42
42
  return {
43
43
  path: '/support/send-reminder',
44
44
  method: 'post',
@@ -48,7 +48,7 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
48
48
 
49
49
  requireAdmin(req, slugs)
50
50
 
51
- if (reminderLimiter.check(String(req.user.id))) {
51
+ if (await reminderLimiter.check(String(req.user.id), req)) {
52
52
  return Response.json(
53
53
  { error: 'Trop de relances. Réessayez dans une heure.' },
54
54
  { status: 429 },
@@ -3,6 +3,8 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
5
5
  import { dbFindByID } from '../utils/db'
6
+ import type { SupportCapabilities } from '../types'
7
+ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
8
 
7
9
  /**
8
10
  * POST /api/support/ticket-synthesis?ticketId=X[&force=true]
@@ -16,13 +18,21 @@ import { dbFindByID } from '../utils/db'
16
18
  * - The hook on Tickets clears aiSummary when a ticket is reopened, so the next pass through
17
19
  * "resolved" status will trigger a regeneration automatically.
18
20
  */
19
- export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint {
21
+ export function createTicketSynthesisEndpoint(
22
+ slugs: CollectionSlugs,
23
+ generator?: SupportCapabilities['aiSummaries'],
24
+ store?: RateLimitStore,
25
+ ): Endpoint {
26
+ const limiter = new RateLimiter(60_000, 20, store)
20
27
  return {
21
28
  path: '/support/ticket-synthesis',
22
29
  method: 'post',
23
30
  handler: async (req) => {
24
31
  try {
25
32
  requireAdmin(req, slugs)
33
+ if (await limiter.check(String(req.user!.id), req)) {
34
+ return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
35
+ }
26
36
  const payload = req.payload
27
37
 
28
38
  const url = new URL(req.url || '', 'http://localhost')
@@ -54,7 +64,12 @@ export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint
54
64
  }
55
65
  }
56
66
 
57
- const result = await generateTicketSynthesis({ payload, slugs, ticketId })
67
+ const result = generator
68
+ ? await generator.generate(payload, ticketId)
69
+ : await generateTicketSynthesis({ payload, slugs, ticketId })
70
+ if (!result) {
71
+ return Response.json({ error: 'Generation unavailable' }, { status: 502 })
72
+ }
58
73
  return Response.json(result)
59
74
  } catch (err) {
60
75
  const authResponse = handleAuthError(err)
@@ -1,6 +1,6 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
- import { createHmac } from 'crypto'
3
+ import { createHmac, timingSafeEqual } from 'crypto'
4
4
  import { dbFindByID, dbUpdate, dbCreate } from '../utils/db'
5
5
 
6
6
  // 1x1 transparent GIF (43 bytes)
@@ -14,7 +14,20 @@ const TRANSPARENT_GIF = Buffer.from(
14
14
  * Use this when building tracking URLs in email templates.
15
15
  */
16
16
  export function generateTrackingToken(ticketId: string, messageId: string, secret: string): string {
17
- return createHmac('sha256', secret).update(`${ticketId}:${messageId}`).digest('hex').substring(0, 16)
17
+ return createHmac('sha256', secret).update(`${ticketId}:${messageId}`).digest('hex')
18
+ }
19
+
20
+ export function verifyTrackingToken(
21
+ ticketId: string,
22
+ messageId: string,
23
+ signature: string,
24
+ secret: string,
25
+ ): boolean {
26
+ if (!/^[0-9a-f]{64}$/i.test(signature)) return false
27
+
28
+ const expected = Buffer.from(generateTrackingToken(ticketId, messageId, secret), 'hex')
29
+ const received = Buffer.from(signature, 'hex')
30
+ return expected.length === received.length && timingSafeEqual(expected, received)
18
31
  }
19
32
 
20
33
  /**
@@ -40,8 +53,10 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
40
53
  // and the silent "tracking writes without auth" hole.
41
54
  const secret = process.env.PAYLOAD_SECRET || ''
42
55
  const validSig =
43
- !!secret && !!ticketId && !!messageId && !!sig &&
44
- sig === generateTrackingToken(ticketId, messageId, secret)
56
+ !!secret && Number.isInteger(parsedId) && parsedId > 0 &&
57
+ Number.isInteger(parsedMsgId) && parsedMsgId > 0 &&
58
+ !!ticketId && !!messageId && !!sig &&
59
+ verifyTrackingToken(ticketId, messageId, sig, secret)
45
60
 
46
61
  if (!validSig) {
47
62
  // Return transparent GIF silently (don't leak information, don't process)
@@ -59,6 +74,17 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
59
74
  try {
60
75
  const payload = req.payload
61
76
 
77
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
78
+ id: parsedMsgId,
79
+ depth: 0,
80
+ overrideAccess: true,
81
+ select: { ticket: true, emailOpenedAt: true },
82
+ }) as any
83
+ const messageTicketId = typeof msg?.ticket === 'object' ? msg.ticket?.id : msg?.ticket
84
+ if (!msg || String(messageTicketId) !== String(parsedId)) {
85
+ return transparentGifResponse()
86
+ }
87
+
62
88
  const ticket = await dbFindByID(payload, slugs.tickets, {
63
89
  id: parsedId,
64
90
  depth: 0,
@@ -81,13 +107,6 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
81
107
 
82
108
  // Track at message level
83
109
  if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
84
- const msg = await dbFindByID(payload, slugs.ticketMessages, {
85
- id: parsedMsgId,
86
- depth: 0,
87
- overrideAccess: true,
88
- select: { emailOpenedAt: true },
89
- }) as any
90
-
91
110
  if (msg && !msg.emailOpenedAt) {
92
111
  await dbUpdate(payload, slugs.ticketMessages, {
93
112
  id: parsedMsgId,
@@ -127,16 +146,20 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
127
146
  }
128
147
  }
129
148
 
130
- return new Response(TRANSPARENT_GIF, {
131
- status: 200,
132
- headers: {
133
- 'Content-Type': 'image/gif',
134
- 'Content-Length': String(TRANSPARENT_GIF.length),
135
- 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
136
- 'Pragma': 'no-cache',
137
- 'Expires': '0',
138
- },
139
- })
149
+ return transparentGifResponse()
140
150
  },
141
151
  }
142
152
  }
153
+
154
+ function transparentGifResponse(): Response {
155
+ return new Response(TRANSPARENT_GIF, {
156
+ status: 200,
157
+ headers: {
158
+ 'Content-Type': 'image/gif',
159
+ 'Content-Length': String(TRANSPARENT_GIF.length),
160
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
161
+ 'Pragma': 'no-cache',
162
+ 'Expires': '0',
163
+ },
164
+ })
165
+ }
@@ -3,7 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { handleAuthError, AuthError } from '../utils/auth'
4
4
  import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
- import { RateLimiter } from '../utils/rateLimiter'
6
+ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
7
7
  import { dbFindByID, dbFind, dbCreate, dbCount } from '../utils/db'
8
8
 
9
9
  // Simple RFC-5322 style sanity check — same level of strictness as the
@@ -16,7 +16,6 @@ const MAX_TRANSFERS_PER_DAY = 5
16
16
  // In-memory fail-closed rate limiter — enforced even when the persistent
17
17
  // emailLogs-based check is unavailable (collection disabled). Prevents the
18
18
  // endpoint from becoming an unbounded outbound mailer.
19
- const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY)
20
19
 
21
20
  /**
22
21
  * POST /api/support/tickets/:id/transfer
@@ -33,7 +32,8 @@ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_D
33
32
  * - Logs the action in `email-logs` (action='transfer') when that collection exists
34
33
  * - Rate-limited to 5 transfers per ticket per 24h
35
34
  */
36
- export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
35
+ export function createTransferTicketEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
36
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY, store)
37
37
  return {
38
38
  path: '/support/tickets/:id/transfer',
39
39
  method: 'post',
@@ -82,7 +82,7 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
82
82
 
83
83
  // Fail-closed in-memory rate limit (per user + ticket): enforced even
84
84
  // when the persistent emailLogs check below cannot run.
85
- if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
85
+ if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
86
86
  return Response.json(
87
87
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
88
88
  { status: 429 },
package/src/index.ts CHANGED
@@ -23,6 +23,13 @@ export { resolveSlugs, DEFAULT_SLUGS } from './utils/slugs'
23
23
  export type { CollectionSlugs } from './utils/slugs'
24
24
  export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
25
25
  export type { SupportSettings, UserPrefs } from './utils/readSettings'
26
+ export { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter } from './utils/rateLimiter'
27
+ export type { RateLimitEntry, RateLimitStore } from './utils/rateLimiter'
28
+ export { DEFAULT_INBOUND_EMAIL_LIMITS, validateInboundEmailPayload, verifySecret } from './utils/webhookSecurity'
29
+ export type { InboundEmailInput, InboundEmailLimits, InboundEmailValidationError } from './utils/webhookSecurity'
30
+
31
+ // Endpoint factories for framework adapters (for example Next route handlers).
32
+ export { createLoginEndpoint, createOAuthGoogleEndpoint, createTrackOpenEndpoint } from './endpoints'
26
33
  export { createAdminNotification } from './utils/adminNotification'
27
34
  export { dispatchWebhook } from './utils/webhookDispatcher'
28
35
  export { generateTicketSynthesis } from './utils/generateTicketSynthesis'
package/src/plugin.ts CHANGED
@@ -27,7 +27,10 @@ import {
27
27
  createAutomationRulesCollection,
28
28
  createSupportTeamCollection,
29
29
  createPushSubscriptionCollection,
30
+ createSupportRateLimitsCollection,
31
+ createSupportCountersCollection,
30
32
  } from './collections'
33
+ import { PayloadRateLimitStore } from './utils/rateLimiter'
31
34
 
32
35
  function viewConfig(component: string, path: string): AdminViewConfig {
33
36
  return { Component: component, path: path as `/${string}` }
@@ -68,6 +71,9 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
68
71
  ...config?.collectionSlugs,
69
72
  users: config?.userCollectionSlug || 'users',
70
73
  })
74
+ const rateLimitStore = config?.rateLimitStore === 'payload'
75
+ ? new PayloadRateLimitStore(slugs.rateLimits)
76
+ : config?.rateLimitStore
71
77
 
72
78
  return (incomingConfig: Config): Config => {
73
79
  const existingCollections = incomingConfig.collections || []
@@ -80,14 +86,17 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
80
86
  projectCollectionSlug: config?.projectCollectionSlug,
81
87
  documentsCollectionSlug: config?.documentsCollectionSlug,
82
88
  notificationSlug: config?.notificationSlug,
89
+ ticketNumber: config?.ticketNumber,
90
+ capabilities: config?.capabilities,
83
91
  }
84
92
  const messageOptions = {
85
93
  notificationSlug: config?.notificationSlug,
94
+ capabilities: config?.capabilities,
86
95
  }
87
96
  const supportCollections = [
88
97
  createTicketsCollection(slugs, ticketOptions),
89
98
  createTicketMessagesCollection(slugs, messageOptions),
90
- createSupportClientsCollection(slugs),
99
+ createSupportClientsCollection(slugs, { capabilities: config?.capabilities }),
91
100
  createCannedResponsesCollection(slugs),
92
101
  createTicketActivityLogCollection(slugs),
93
102
  createSatisfactionSurveysCollection(slugs),
@@ -98,7 +107,11 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
98
107
  createAutomationRulesCollection(slugs),
99
108
  createSupportTeamCollection(slugs),
100
109
  createPushSubscriptionCollection(slugs),
110
+ createSupportCountersCollection(slugs.counters),
101
111
  ]
112
+ if (config?.rateLimitStore === 'payload') {
113
+ supportCollections.push(createSupportRateLimitsCollection(slugs.rateLimits))
114
+ }
102
115
 
103
116
  // Auth logs (conditional)
104
117
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs))
@@ -150,6 +163,8 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
150
163
  const supportEndpoints = createSupportEndpoints(slugs, {
151
164
  oauth: { allowedEmailDomains: config?.allowedEmailDomains },
152
165
  features,
166
+ rateLimitStore,
167
+ capabilities: config?.capabilities,
153
168
  })
154
169
 
155
170
  return {
@@ -441,7 +441,7 @@ export function LiveChat() {
441
441
  width: 380,
442
442
  maxWidth: 'calc(100vw - 32px)',
443
443
  height: screen === 'identify' || screen === 'rating' ? 'auto' : 520,
444
- maxHeight: 'calc(100vh - 32px)',
444
+ maxHeight: 'calc(100dvh - 32px)',
445
445
  borderRadius: 16,
446
446
  border: '3px solid #000',
447
447
  backgroundColor: '#fff',
@@ -215,7 +215,7 @@ function ChatWidgetInner() {
215
215
  {!isOpen && (
216
216
  <button
217
217
  onClick={() => setIsOpen(true)}
218
- className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full border-3 border-black bg-[#00E5FF] shadow-[4px_4px_0px_#000] transition-all hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000] dark:border-gray-600 dark:shadow-[4px_4px_0px_#333]"
218
+ className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full border-3 border-black bg-[#00E5FF] shadow-[4px_4px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000] dark:border-gray-600 dark:shadow-[4px_4px_0px_#333]"
219
219
  title="Chat en direct"
220
220
  >
221
221
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
@@ -274,7 +274,7 @@ function ChatWidgetInner() {
274
274
  </p>
275
275
  <button
276
276
  onClick={startChat}
277
- className="rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-sm font-black text-black shadow-[4px_4px_0px_#000] transition-all hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000]"
277
+ className="rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-sm font-black text-black shadow-[4px_4px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000]"
278
278
  >
279
279
  Démarrer le chat
280
280
  </button>
@@ -320,7 +320,7 @@ function ChatWidgetInner() {
320
320
  <p className="mb-3 text-sm text-gray-500 dark:text-gray-400">Le chat est terminé.</p>
321
321
  <button
322
322
  onClick={resetChat}
323
- className="rounded-xl border-2 border-black bg-[#FFD600] px-4 py-2 text-xs font-bold text-black shadow-[2px_2px_0px_#000] transition-all hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-[1px_1px_0px_#000]"
323
+ className="rounded-xl border-2 border-black bg-[#FFD600] px-4 py-2 text-xs font-bold text-black shadow-[2px_2px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-[1px_1px_0px_#000]"
324
324
  >
325
325
  Nouveau chat
326
326
  </button>
@@ -344,7 +344,7 @@ function ChatWidgetInner() {
344
344
  <button
345
345
  type="submit"
346
346
  disabled={!input.trim() || sending}
347
- className="rounded-xl border-2 border-black bg-[#00E5FF] px-3 py-2 font-bold text-black transition-all hover:shadow-[2px_2px_0px_#000] disabled:opacity-50 dark:border-gray-600"
347
+ className="rounded-xl border-2 border-black bg-[#00E5FF] px-3 py-2 font-bold text-black transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-[2px_2px_0px_#000] disabled:opacity-50 dark:border-gray-600"
348
348
  >
349
349
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
350
350
  <line x1="22" y1="2" x2="11" y2="13" />