@consilioweb/payload-support 1.1.0 → 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 +710 -174
  15. package/dist/index.d.cts +165 -3
  16. package/dist/index.d.ts +165 -3
  17. package/dist/index.js +703 -176
  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 +183 -40
  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
@@ -2,9 +2,11 @@ import type { Endpoint, PayloadRequest } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
5
+ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
6
 
6
- function getClient(aiSettings: SupportSettings['ai']) {
7
- const Anthropic = require('@anthropic-ai/sdk').default
7
+ async function getClient(aiSettings: SupportSettings['ai']) {
8
+ const moduleName = '@anthropic-ai/sdk'
9
+ const { default: Anthropic } = await import(moduleName)
8
10
  if (aiSettings.provider === 'ollama') {
9
11
  const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
10
12
  return new Anthropic({ apiKey: 'ollama', baseURL })
@@ -25,10 +27,14 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
25
27
  * POST /api/support/client-intelligence?clientId=X
26
28
  * Force-refreshes the summary.
27
29
  */
28
- export function createClientIntelligenceEndpoint(slugs: CollectionSlugs): Endpoint[] {
30
+ export function createClientIntelligenceEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint[] {
31
+ const limiter = new RateLimiter(60_000, 20, store)
29
32
  const getHandler = async (req: PayloadRequest) => {
30
33
  try {
31
34
  requireAdmin(req, slugs)
35
+ if (await limiter.check(String(req.user!.id), req)) {
36
+ return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
37
+ }
32
38
  const payload = req.payload
33
39
  const url = new URL(req.url || '', 'http://localhost')
34
40
  const clientId = url.searchParams.get('clientId')
@@ -64,6 +70,9 @@ export function createClientIntelligenceEndpoint(slugs: CollectionSlugs): Endpoi
64
70
  const postHandler = async (req: PayloadRequest) => {
65
71
  try {
66
72
  requireAdmin(req, slugs)
73
+ if (await limiter.check(String(req.user!.id), req)) {
74
+ return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
75
+ }
67
76
  const payload = req.payload
68
77
  const body = await req.json?.() || {}
69
78
  const clientId = body.clientId
@@ -197,7 +206,7 @@ Réponds en JSON strict (pas de markdown, pas de commentaires) avec cette struct
197
206
  Sois factuel. Ne dépasse pas 5 items par tableau. Réponds UNIQUEMENT avec le JSON.`
198
207
 
199
208
  // 6. Call AI
200
- const anthropic = getClient(aiSettings)
209
+ const anthropic = await getClient(aiSettings)
201
210
  const model = getModel(aiSettings)
202
211
 
203
212
  const res = await anthropic.messages.create({
@@ -1,8 +1,9 @@
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 { readSupportSettings } from '../utils/readSettings'
5
5
  import { dbFind, dbCreate } from '../utils/db'
6
+ import { verifySecret } from '../utils/webhookSecurity'
6
7
  import crypto from 'crypto'
7
8
 
8
9
  interface ParsedConversation {
@@ -11,7 +12,6 @@ interface ParsedConversation {
11
12
  messages: { from: 'client' | 'admin'; name: string; date: string; content: string }[]
12
13
  }
13
14
 
14
- const importLimiter = new RateLimiter(3_600_000, 10) // 10 per hour
15
15
 
16
16
  function parseStructuredMarkdown(markdown: string): ParsedConversation | null {
17
17
  const clientMatch = markdown.match(
@@ -131,7 +131,8 @@ ONLY JSON, nothing else.`,
131
131
  * POST /api/support/import-conversation
132
132
  * Import a conversation from markdown into the ticket system.
133
133
  */
134
- export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoint {
134
+ export function createImportConversationEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
135
+ const importLimiter = new RateLimiter(3_600_000, 10, store)
135
136
  return {
136
137
  path: '/support/import-conversation',
137
138
  method: 'post',
@@ -143,7 +144,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
143
144
  const webhookSecret = req.headers.get('x-webhook-secret')
144
145
  let isAuthed = false
145
146
 
146
- if (webhookSecret && process.env.SUPPORT_WEBHOOK_SECRET && webhookSecret === process.env.SUPPORT_WEBHOOK_SECRET) {
147
+ if (verifySecret(webhookSecret, process.env.SUPPORT_WEBHOOK_SECRET)) {
147
148
  isAuthed = true
148
149
  } else if (req.user && req.user.collection === slugs.users) {
149
150
  isAuthed = true
@@ -154,7 +155,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
154
155
  }
155
156
 
156
157
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
157
- if (importLimiter.check(ip)) {
158
+ if (await importLimiter.check(ip, req)) {
158
159
  return Response.json({ error: 'Rate limit exceeded. Maximum 10 imports per hour.' }, { status: 429 })
159
160
  }
160
161
 
@@ -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'