@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.
- package/README.md +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
- package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
- package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
- package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
- package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
- package/dist/components/TicketConversation/components/ClientBar.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
- package/dist/components/TicketConversation/components/QuickActions.js +2 -2
- package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
- package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
- package/dist/components/TicketConversation/index.cjs +10 -10
- package/dist/components/TicketConversation/index.js +10 -10
- package/dist/index.cjs +710 -174
- package/dist/index.d.cts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +703 -176
- package/dist/styles/ChatView.module.scss +1 -1
- package/dist/styles/TicketDetail.module.scss +3 -3
- package/dist/styles/_tokens.scss +1 -1
- package/dist/styles/theme.css +6 -6
- package/dist/views/TicketingSettingsView/client.cjs +3 -3
- package/dist/views/TicketingSettingsView/client.js +3 -3
- package/package.json +9 -9
- package/src/__tests__/aiSummaryRendering.test.ts +21 -0
- package/src/__tests__/authResponses.test.ts +69 -0
- package/src/__tests__/capabilities.test.ts +71 -0
- package/src/__tests__/generateTrackingToken.test.ts +18 -4
- package/src/__tests__/integration/core-behaviors.test.ts +23 -0
- package/src/__tests__/rateLimiter.test.ts +60 -66
- package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
- package/src/__tests__/webhookSecurity.test.ts +58 -0
- package/src/collections/PendingEmails.ts +2 -1
- package/src/collections/SupportClients.ts +43 -1
- package/src/collections/SupportCounters.ts +14 -0
- package/src/collections/SupportRateLimits.ts +20 -0
- package/src/collections/TicketMessages.ts +76 -0
- package/src/collections/Tickets.ts +183 -40
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
- package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
- package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
- package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
- package/src/components/TicketConversation/index.tsx +10 -10
- package/src/endpoints/admin-chat.ts +4 -4
- package/src/endpoints/ai-agent.ts +6 -1
- package/src/endpoints/ai.ts +10 -5
- package/src/endpoints/auth-2fa.ts +6 -7
- package/src/endpoints/auto-close.ts +2 -1
- package/src/endpoints/capabilities.ts +144 -0
- package/src/endpoints/channels.ts +2 -1
- package/src/endpoints/chat.ts +6 -6
- package/src/endpoints/chatbot.ts +4 -4
- package/src/endpoints/client-intelligence.ts +13 -4
- package/src/endpoints/import-conversation.ts +6 -5
- package/src/endpoints/index.ts +35 -14
- package/src/endpoints/invite-collaborator.ts +4 -4
- package/src/endpoints/login.ts +4 -5
- package/src/endpoints/oauth-google.ts +1 -1
- package/src/endpoints/process-digests.ts +2 -1
- package/src/endpoints/process-scheduled.ts +2 -1
- package/src/endpoints/process-snooze.ts +2 -1
- package/src/endpoints/resend-notification.ts +4 -4
- package/src/endpoints/send-reminder.ts +4 -4
- package/src/endpoints/ticket-synthesis.ts +17 -2
- package/src/endpoints/track-open.ts +44 -21
- package/src/endpoints/transfer-ticket.ts +4 -4
- package/src/index.ts +7 -0
- package/src/plugin.ts +16 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/ChatWidget.tsx +4 -4
- package/src/portal/auth/ChatbotWidget.tsx +4 -4
- package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
- package/src/portal/auth/faq/FAQSearch.tsx +1 -1
- package/src/portal/auth/faq/page.tsx +2 -2
- package/src/portal/auth/profile/page.tsx +17 -17
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/page.tsx +5 -5
- package/src/portal/auth/tickets/new/page.tsx +8 -8
- package/src/portal/forgot-password/page.tsx +3 -3
- package/src/portal/layout.tsx +5 -2
- package/src/portal/login/page.tsx +7 -7
- package/src/portal/page.tsx +9 -9
- package/src/portal/register/page.tsx +4 -4
- package/src/portal/reset-password/page.tsx +3 -3
- package/src/styles/ChatView.module.scss +1 -1
- package/src/styles/TicketDetail.module.scss +3 -3
- package/src/styles/_tokens.scss +1 -1
- package/src/styles/theme.css +6 -6
- package/src/types.ts +71 -0
- package/src/utils/rateLimiter.ts +131 -38
- package/src/utils/slugs.ts +4 -0
- package/src/utils/webhookSecurity.ts +81 -0
- 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
|
|
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
|
|
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
|
|
package/src/endpoints/index.ts
CHANGED
|
@@ -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
|
|
package/src/endpoints/login.ts
CHANGED
|
@@ -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({
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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(
|
|
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 =
|
|
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')
|
|
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 &&
|
|
44
|
-
|
|
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
|
|
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'
|