@consilioweb/payload-support 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +36 -2
  2. package/dist/index.cjs +558 -94
  3. package/dist/index.d.cts +20 -5
  4. package/dist/index.d.ts +20 -5
  5. package/dist/index.js +558 -94
  6. package/package.json +1 -1
  7. package/src/collections/ChatMessages.ts +59 -2
  8. package/src/collections/ClientSummaries.ts +10 -4
  9. package/src/collections/TicketMessages.ts +4 -1
  10. package/src/collections/WebhookEndpoints.ts +44 -2
  11. package/src/endpoints/admin-chat.ts +3 -3
  12. package/src/endpoints/ai-agent.ts +3 -3
  13. package/src/endpoints/ai.ts +3 -3
  14. package/src/endpoints/auth-2fa.ts +16 -4
  15. package/src/endpoints/capabilities.ts +6 -6
  16. package/src/endpoints/chat.ts +7 -5
  17. package/src/endpoints/chatbot.ts +48 -2
  18. package/src/endpoints/client-intelligence.ts +4 -4
  19. package/src/endpoints/email-stats.ts +19 -3
  20. package/src/endpoints/import-conversation.ts +1 -1
  21. package/src/endpoints/index.ts +1 -1
  22. package/src/endpoints/invite-collaborator.ts +29 -3
  23. package/src/endpoints/login.ts +15 -2
  24. package/src/endpoints/oauth-google.ts +130 -8
  25. package/src/endpoints/resend-notification.ts +3 -3
  26. package/src/endpoints/send-reminder.ts +3 -3
  27. package/src/endpoints/signature.ts +9 -2
  28. package/src/endpoints/ticket-synthesis.ts +3 -3
  29. package/src/endpoints/transfer-ticket.ts +28 -3
  30. package/src/endpoints/typing.ts +117 -14
  31. package/src/endpoints/user-prefs.ts +5 -2
  32. package/src/plugin.ts +12 -0
  33. package/src/portal/auth/layout.tsx +19 -1
  34. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  35. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  36. package/src/portal/login/page.tsx +11 -3
  37. package/src/utils/fireWebhooks.ts +4 -1
  38. package/src/utils/rateLimiter.ts +39 -5
  39. package/src/utils/readSettings.ts +124 -14
  40. package/src/utils/ticketAccess.ts +16 -1
  41. package/src/utils/twoFactorChallenge.ts +80 -0
  42. package/src/utils/urlSafety.ts +230 -0
  43. package/src/utils/webhookDispatcher.ts +5 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -1,5 +1,56 @@
1
- import type { CollectionConfig } from 'payload'
1
+ import { APIError } from 'payload'
2
+ import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
2
3
  import type { CollectionSlugs } from '../utils/slugs'
4
+ import { dbFind } from '../utils/db'
5
+
6
+ /**
7
+ * Cross-tenant write guard for support-clients.
8
+ *
9
+ * `access.create` used to be `!!req.user`, which any principal of ANY auth
10
+ * collection satisfies — including a front-office member of the host app. With
11
+ * no hook validating the row, an attacker could POST a message attributed to
12
+ * someone else's `client` and flagged `senderType: 'agent'` with an arbitrary
13
+ * `agent` relation: the agent console and the targeted client both render it as
14
+ * a genuine conversation (phishing over a trusted channel).
15
+ *
16
+ * Mirrors `createRestrictClientTicketTarget` on ticket-messages: the client's
17
+ * own identity is FORCED onto the row instead of being trusted from the body.
18
+ * Internal writers (endpoints/chat.ts, endpoints/admin-chat.ts,
19
+ * collections/TicketMessages.ts) all write with `overrideAccess: true` and carry
20
+ * no support-client user, so they are untouched.
21
+ */
22
+ function createRestrictClientChatWrite(slugs: CollectionSlugs): CollectionBeforeChangeHook {
23
+ return async ({ data, req }) => {
24
+ if (req.user?.collection !== slugs.supportClients) return data
25
+ data.client = req.user.id
26
+ data.senderType = 'client'
27
+ delete data.agent
28
+
29
+ // …and the SESSION has to be theirs too. Forcing `client` alone still let a
30
+ // client drop a line INTO someone else's conversation: the agent console
31
+ // loads a thread by session id with `overrideAccess: true`
32
+ // (endpoints/admin-chat.ts), so the injected message surfaces in the target's
33
+ // thread even though `access.read` hides it from the target themselves.
34
+ // Session ids are 16 random bytes, so this is depth rather than a reachable
35
+ // hole — and it costs one indexed lookup on a path no internal writer takes
36
+ // (chat.ts / admin-chat.ts write without a support-client `req`).
37
+ const session = typeof data.session === 'string' ? data.session : null
38
+ if (!session) return data
39
+
40
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
41
+ where: { session: { equals: session } },
42
+ limit: 1,
43
+ depth: 0,
44
+ overrideAccess: true,
45
+ })
46
+ const owner = existing.docs[0]?.client
47
+ const ownerId = owner && typeof owner === 'object' ? (owner as { id?: unknown }).id : owner
48
+ if (ownerId !== undefined && ownerId !== null && String(ownerId) !== String(req.user.id)) {
49
+ throw new APIError('Session inaccessible.', 403)
50
+ }
51
+ return data
52
+ }
53
+ }
3
54
 
4
55
  // ─── Collection factory ──────────────────────────────────
5
56
 
@@ -24,7 +75,10 @@ export function createChatMessagesCollection(slugs: CollectionSlugs): Collection
24
75
  }
25
76
  return false
26
77
  },
27
- create: ({ req }) => !!req.user,
78
+ // Staff and support-clients only — NOT "any authenticated principal":
79
+ // a user of any other auth collection of the host app satisfied `!!req.user`.
80
+ create: ({ req }) =>
81
+ req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
28
82
  update: ({ req }) => req.user?.collection === slugs.users,
29
83
  delete: ({ req }) => req.user?.collection === slugs.users,
30
84
  },
@@ -93,6 +147,9 @@ export function createChatMessagesCollection(slugs: CollectionSlugs): Collection
93
147
  },
94
148
  },
95
149
  ],
150
+ hooks: {
151
+ beforeChange: [createRestrictClientChatWrite(slugs)],
152
+ },
96
153
  timestamps: true,
97
154
  }
98
155
  }
@@ -122,11 +122,17 @@ export function createClientSummariesCollection(slugs: CollectionSlugs): Collect
122
122
  admin: { readOnly: true },
123
123
  },
124
124
  ],
125
+ // Staff-only, on the SAME source of truth as every other collection and as
126
+ // `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
127
+ // against is the DEFAULT slug, not the configured one — on a host app whose
128
+ // staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
129
+ // the front-office collection instead, opening read/create/update/delete on
130
+ // AI-generated client intelligence to it while locking the real agents out.
125
131
  access: {
126
- create: ({ req }) => req.user?.collection === 'users',
127
- read: ({ req }) => req.user?.collection === 'users',
128
- update: ({ req }) => req.user?.collection === 'users',
129
- delete: ({ req }) => req.user?.collection === 'users',
132
+ create: ({ req }) => req.user?.collection === slugs.users,
133
+ read: ({ req }) => req.user?.collection === slugs.users,
134
+ update: ({ req }) => req.user?.collection === slugs.users,
135
+ delete: ({ req }) => req.user?.collection === slugs.users,
130
136
  },
131
137
  timestamps: true,
132
138
  }
@@ -37,7 +37,10 @@ function createRestrictClientTicketTarget(slugs: CollectionSlugs): CollectionBef
37
37
  throw new APIError('Ticket cible requis.', 400)
38
38
  }
39
39
 
40
- const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id)
40
+ // 'write' scope: a collaborator invited as `viewer` may READ the thread but
41
+ // must not post into it — the invitation email promises exactly that, and a
42
+ // message from a viewer would fan out the whole notification chain.
43
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, 'write')
41
44
  if (!accessible.some((id) => String(id) === String(targetId))) {
42
45
  throw new APIError('Ticket inaccessible.', 403)
43
46
  }
@@ -1,5 +1,41 @@
1
- import type { CollectionConfig } from 'payload'
1
+ import { APIError } from 'payload'
2
+ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
2
3
  import type { CollectionSlugs } from '../utils/slugs'
4
+ import { WEBHOOK_URL_MESSAGES, validateWebhookUrl } from '../utils/urlSafety'
5
+
6
+ /**
7
+ * First SSRF layer: the value is fetched by the SERVER on every ticket event, so
8
+ * a loopback / private / link-local target (or a non-https scheme) must never be
9
+ * SAVED. The send path re-checks the resolved address and every redirect hop —
10
+ * see `utils/urlSafety.ts`.
11
+ *
12
+ * Why a collection hook rather than a field `validate`: Payload re-validates the
13
+ * MERGED document on every write, so a field-level guard also fires on partial
14
+ * updates that never mention `url`. A row saved before this guard existed (or
15
+ * seeded through `payload.db`, which runs no hooks) then became totally
16
+ * immutable — impossible to rename, to re-scope its events, even to set
17
+ * `active: false` — and the dispatcher's own bookkeeping write (`lastStatus: 0`,
18
+ * issued inside a `catch` that swallows errors) failed silently, leaving a dead
19
+ * endpoint looking healthy in the admin.
20
+ *
21
+ * So only a URL actually being SET or CHANGED is checked. Leaving an existing
22
+ * bad value alone costs nothing: the send path refuses to call it anyway.
23
+ */
24
+ function createValidateWebhookUrl(): CollectionBeforeValidateHook {
25
+ return ({ data, operation, originalDoc }) => {
26
+ const incoming = (data as { url?: unknown } | undefined)?.url
27
+ if (incoming === undefined || incoming === null) return data
28
+
29
+ const previous = (originalDoc as { url?: unknown } | undefined)?.url
30
+ if (operation === 'update' && incoming === previous) return data
31
+
32
+ const result = validateWebhookUrl(incoming)
33
+ if (!result.ok) {
34
+ throw new APIError(WEBHOOK_URL_MESSAGES[result.reason || 'invalid_url'], 400)
35
+ }
36
+ return data
37
+ }
38
+ }
3
39
 
4
40
  // ─── Collection factory ──────────────────────────────────
5
41
 
@@ -36,8 +72,11 @@ export function createWebhookEndpointsCollection(slugs: CollectionSlugs): Collec
36
72
  type: 'text',
37
73
  required: true,
38
74
  label: 'URL',
75
+ // The SSRF check lives in the collection `beforeValidate` above, NOT in a
76
+ // field `validate`: the latter re-runs on the merged document and would
77
+ // freeze every pre-existing row on any unrelated edit.
39
78
  admin: {
40
- description: 'URL du webhook à appeler (POST)',
79
+ description: 'URL https:// du webhook à appeler (POST). Les adresses privées et loopback sont refusées.',
41
80
  },
42
81
  },
43
82
  {
@@ -91,6 +130,9 @@ export function createWebhookEndpointsCollection(slugs: CollectionSlugs): Collec
91
130
  },
92
131
  },
93
132
  ],
133
+ hooks: {
134
+ beforeValidate: [createValidateWebhookUrl()],
135
+ },
94
136
  timestamps: true,
95
137
  }
96
138
  }
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { Where } from 'payload'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { requireAdmin, handleAuthError } from '../utils/auth'
6
6
  import { dbFind, dbCreate, dbUpdate } from '../utils/db'
7
7
 
@@ -111,7 +111,7 @@ export function createAdminChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
111
111
  * Admin sends a message or closes a session.
112
112
  */
113
113
  export function createAdminChatPostEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
114
- const adminChatLimiter = new RateLimiter(60_000, 30, store)
114
+ const adminChatLimiter = new RateLimiter(60_000, 30, store, 'admin-chat')
115
115
  return {
116
116
  path: '/support/admin-chat',
117
117
  method: 'post',
@@ -150,7 +150,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs, store?: Rate
150
150
 
151
151
  // Agent sends a message
152
152
  if (action === 'send' && message) {
153
- if (await adminChatLimiter.check(String(req.user.id), req)) {
153
+ if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
154
154
  return Response.json({ error: 'Rate limit atteint.' }, { status: 429 })
155
155
  }
156
156
 
@@ -2,7 +2,7 @@ import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { runAiAgent } from '../utils/aiAgent'
5
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
6
 
7
7
  /**
8
8
  * POST /api/support/ai-agent body: { ticketId, confidenceThreshold? }
@@ -11,14 +11,14 @@ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
11
11
  * (when confident) or escalates to a human with an internal note. Admin-only.
12
12
  */
13
13
  export function createAiAgentEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
14
- const limiter = new RateLimiter(60_000, 10, store)
14
+ const limiter = new RateLimiter(60_000, 10, store, 'ai-agent')
15
15
  return {
16
16
  path: '/support/ai-agent',
17
17
  method: 'post',
18
18
  handler: async (req) => {
19
19
  try {
20
20
  requireAdmin(req, slugs)
21
- if (await limiter.check(String(req.user!.id), req)) {
21
+ if (await limiter.check(principalRateKey(req.user), req)) {
22
22
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
23
23
  }
24
24
  let body: { ticketId?: number | string; confidenceThreshold?: number } = {}
@@ -2,7 +2,7 @@ import type { Endpoint } 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
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
6
  import { resolveOllamaBaseUrl } from '../utils/aiProvider'
7
7
 
8
8
  async function getClient(aiSettings: SupportSettings['ai']) {
@@ -26,7 +26,7 @@ type AiAction = 'sentiment' | 'synthesis' | 'suggest_reply' | 'rewrite'
26
26
  * Admin-only endpoint for AI features in support.
27
27
  */
28
28
  export function createAiEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
29
- const limiter = new RateLimiter(60_000, 30, store)
29
+ const limiter = new RateLimiter(60_000, 30, store, 'ai')
30
30
  return {
31
31
  path: '/support/ai',
32
32
  method: 'post',
@@ -35,7 +35,7 @@ export function createAiEndpoint(slugs: CollectionSlugs, store?: RateLimitStore)
35
35
  const payload = req.payload
36
36
 
37
37
  requireAdmin(req, slugs)
38
- if (await limiter.check(String(req.user!.id), req)) {
38
+ if (await limiter.check(principalRateKey(req.user), req)) {
39
39
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
40
40
  }
41
41
 
@@ -4,6 +4,7 @@ import crypto, { createHmac } from 'crypto'
4
4
  import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { escapeHtml } from '../utils/emailTemplate'
6
6
  import { dbFind, dbUpdate } from '../utils/db'
7
+ import { verifyTwoFactorChallenge } from '../utils/twoFactorChallenge'
7
8
 
8
9
  function generateSecureCode(): string {
9
10
  const buf = crypto.randomBytes(4)
@@ -25,21 +26,21 @@ function hashCode(code: string): string {
25
26
  * Send or verify a 2FA code.
26
27
  */
27
28
  export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
28
- const sendLimiter = new RateLimiter(60 * 60 * 1000, 3, store)
29
- const verifyLimiter = new RateLimiter(15 * 60 * 1000, 5, store)
29
+ const sendLimiter = new RateLimiter(60 * 60 * 1000, 3, store, '2fa:send')
30
+ const verifyLimiter = new RateLimiter(15 * 60 * 1000, 5, store, '2fa:verify')
30
31
  return {
31
32
  path: '/support/2fa',
32
33
  method: 'post',
33
34
  handler: async (req) => {
34
35
  try {
35
36
  const payload = req.payload
36
- let body: { action?: string; email?: string; code?: string }
37
+ let body: { action?: string; email?: string; code?: string; challenge?: string }
37
38
  try {
38
39
  body = await req.json!()
39
40
  } catch {
40
41
  return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
41
42
  }
42
- const { action, email, code } = body
43
+ const { action, email, code, challenge } = body
43
44
 
44
45
  if (!action || !email) {
45
46
  return Response.json({ error: 'Paramètres manquants' }, { status: 400 })
@@ -48,6 +49,17 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
48
49
  const genericSendResponse = { success: true, message: 'Si un compte existe, un code a été envoyé.' }
49
50
 
50
51
  if (action === 'send') {
52
+ // The challenge is checked BEFORE the limiter on purpose: the limiter
53
+ // is keyed on the VICTIM's email, so letting an unauthenticated caller
54
+ // reach it is what made the lockout possible. No challenge, no budget
55
+ // consumed, no mail sent, no code overwritten.
56
+ if (!verifyTwoFactorChallenge(email, challenge)) {
57
+ return Response.json(
58
+ { error: 'Authentification requise avant l\'envoi d\'un code.' },
59
+ { status: 401 },
60
+ )
61
+ }
62
+
51
63
  if (await sendLimiter.check(email, req)) {
52
64
  return Response.json(genericSendResponse)
53
65
  }
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint, Where } from 'payload'
2
2
  import type { SupportCapabilities } from '../types'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import {
6
6
  validateInboundEmailPayload,
7
7
  verifySecret,
@@ -12,7 +12,7 @@ export function createInboundEmailEndpoint(
12
12
  capability: NonNullable<SupportCapabilities['inboundEmail']>,
13
13
  store?: RateLimitStore,
14
14
  ): Endpoint {
15
- const limiter = new RateLimiter(60_000, 60, store)
15
+ const limiter = new RateLimiter(60_000, 60, store, 'inbound-email')
16
16
  return {
17
17
  path: '/support-webhook/inbound-email',
18
18
  method: 'post',
@@ -51,7 +51,7 @@ export function createProjectSuggestionsEndpoint(
51
51
  capability: NonNullable<SupportCapabilities['projectSuggestions']>,
52
52
  store?: RateLimitStore,
53
53
  ): Endpoint {
54
- const limiter = new RateLimiter(60_000, 20, store)
54
+ const limiter = new RateLimiter(60_000, 20, store, 'suggest-projects')
55
55
  return {
56
56
  path: '/support/suggest-projects',
57
57
  method: 'post',
@@ -59,7 +59,7 @@ export function createProjectSuggestionsEndpoint(
59
59
  if (!req.user || req.user.collection !== slugs.users) {
60
60
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
61
61
  }
62
- const key = req.user?.id ? String(req.user.id) : 'anonymous'
62
+ const key = principalRateKey(req.user)
63
63
  if (await limiter.check(key, req)) {
64
64
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
65
65
  }
@@ -73,7 +73,7 @@ export function createTicketTitleEndpoint(
73
73
  capability: NonNullable<SupportCapabilities['aiTitles']>,
74
74
  store?: RateLimitStore,
75
75
  ): Endpoint {
76
- const limiter = new RateLimiter(60_000, 20, store)
76
+ const limiter = new RateLimiter(60_000, 20, store, 'ticket-title')
77
77
  return {
78
78
  path: '/support/ticket-title',
79
79
  method: 'post',
@@ -100,7 +100,7 @@ export function createGenerateMissingTitlesEndpoint(
100
100
  capability: NonNullable<SupportCapabilities['aiTitles']>,
101
101
  store?: RateLimitStore,
102
102
  ): Endpoint {
103
- const limiter = new RateLimiter(60_000, 5, store)
103
+ const limiter = new RateLimiter(60_000, 5, store, 'generate-missing-titles')
104
104
  return {
105
105
  path: '/support/generate-missing-titles',
106
106
  method: 'post',
@@ -2,7 +2,7 @@ import type { Endpoint } from 'payload'
2
2
  import type { Where } from 'payload'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
4
  import crypto from 'crypto'
5
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
6
  import { requireClient, handleAuthError } from '../utils/auth'
7
7
  import { dbFind, dbCreate } from '../utils/db'
8
8
 
@@ -67,8 +67,8 @@ export function createChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
67
67
  * Send a message or start a new chat session. Client-only.
68
68
  */
69
69
  export function createChatPostEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
70
- const chatSessionLimiter = new RateLimiter(3_600_000, 5, store)
71
- const chatMessageLimiter = new RateLimiter(60_000, 15, store)
70
+ const chatSessionLimiter = new RateLimiter(3_600_000, 5, store, 'chat:session')
71
+ const chatMessageLimiter = new RateLimiter(60_000, 15, store, 'chat:message')
72
72
  return {
73
73
  path: '/support/chat',
74
74
  method: 'post',
@@ -86,10 +86,12 @@ export function createChatPostEndpoint(slugs: CollectionSlugs, store?: RateLimit
86
86
  }
87
87
  const { action, session, message } = body
88
88
  const userId = String(req.user.id)
89
+ // Collection-qualified: agent #7 and support-client #7 must not share a budget.
90
+ const rateKey = principalRateKey(req.user)
89
91
 
90
92
  // Start a new session
91
93
  if (action === 'start') {
92
- if (await chatSessionLimiter.check(userId, req)) {
94
+ if (await chatSessionLimiter.check(rateKey, req)) {
93
95
  return Response.json({ error: 'Trop de sessions créées. Réessayez plus tard.' }, { status: 429 })
94
96
  }
95
97
 
@@ -111,7 +113,7 @@ export function createChatPostEndpoint(slugs: CollectionSlugs, store?: RateLimit
111
113
 
112
114
  // Send a message
113
115
  if (action === 'send' && session && message) {
114
- if (await chatMessageLimiter.check(userId, req)) {
116
+ if (await chatMessageLimiter.check(rateKey, req)) {
115
117
  return Response.json({ error: 'Trop de messages. Attendez un moment.' }, { status: 429 })
116
118
  }
117
119
 
@@ -4,13 +4,38 @@ import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
4
  import { dbFind } from '../utils/db'
5
5
 
6
6
 
7
+ /**
8
+ * Global hourly ceiling on chatbot calls, all callers combined.
9
+ *
10
+ * The per-IP window below is keyed on the FIRST hop of `X-Forwarded-For`, a
11
+ * value the caller sends itself: an anonymous client rotating that header gets a
12
+ * fresh window on every request. Unlike `login.ts` — where the account lock is
13
+ * the primary control — this endpoint has no second line of defence, and every
14
+ * accepted call ships ~50 KB of knowledge base to the Anthropic API on the
15
+ * operator's key. This unkeyed ceiling makes the bill bounded even when the
16
+ * per-IP key is bypassed. Raise it with `SUPPORT_CHATBOT_MAX_PER_HOUR` on a
17
+ * high-traffic portal, or pass `maxPerHour` when building the endpoint.
18
+ */
19
+ export const DEFAULT_CHATBOT_MAX_PER_HOUR = 200
20
+
21
+ function resolveMaxPerHour(explicit?: number): number {
22
+ if (typeof explicit === 'number' && explicit > 0) return explicit
23
+ const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR)
24
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR
25
+ }
26
+
7
27
  /**
8
28
  * POST /api/support/chatbot
9
29
  * AI chatbot that answers from the knowledge base before creating a ticket.
10
30
  * Public endpoint (accessible from the support portal).
11
31
  */
12
- export function createChatbotEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
13
- const chatbotLimiter = new RateLimiter(60_000, 10, store)
32
+ export function createChatbotEndpoint(
33
+ slugs: CollectionSlugs,
34
+ store?: RateLimitStore,
35
+ maxPerHour?: number,
36
+ ): Endpoint {
37
+ const chatbotLimiter = new RateLimiter(60_000, 10, store, 'chatbot:ip')
38
+ const globalLimiter = new RateLimiter(60 * 60_000, resolveMaxPerHour(maxPerHour), store, 'chatbot:global')
14
39
  return {
15
40
  path: '/support/chatbot',
16
41
  method: 'post',
@@ -33,6 +58,27 @@ export function createChatbotEndpoint(slugs: CollectionSlugs, store?: RateLimitS
33
58
  return Response.json({ error: 'Question too short' }, { status: 400 })
34
59
  }
35
60
 
61
+ // Not keyed on anything the caller controls — see the note above.
62
+ // Placed after validation so malformed requests do not eat the budget.
63
+ //
64
+ // Over budget, the answer DEGRADES, it does not fail: a shared counter
65
+ // that returns 429 hands an anonymous visitor a switch to turn the
66
+ // chatbot off for everybody (the per-IP key above is spoofable, so
67
+ // exhausting the ceiling costs nothing). The deflection path — "no
68
+ // answer here, open a ticket" — is the exact response this endpoint
69
+ // already returns when the knowledge base is empty or the API key is
70
+ // missing, so no caller learns a new shape, and the bill stays capped
71
+ // because the AI call below is never reached.
72
+ if (await globalLimiter.check('all', req)) {
73
+ return Response.json({
74
+ answer: null,
75
+ confidence: 0,
76
+ suggestion: 'create_ticket',
77
+ aiUnavailable: true,
78
+ message: 'L\'assistant est momentanément indisponible. Créez un ticket, un agent vous répondra.',
79
+ })
80
+ }
81
+
36
82
  const payload = req.payload
37
83
 
38
84
  const articles = await dbFind(payload, slugs.knowledgeBase, {
@@ -2,7 +2,7 @@ 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
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
6
  import { resolveOllamaBaseUrl } from '../utils/aiProvider'
7
7
 
8
8
  async function getClient(aiSettings: SupportSettings['ai']) {
@@ -29,11 +29,11 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
29
29
  * Force-refreshes the summary.
30
30
  */
31
31
  export function createClientIntelligenceEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint[] {
32
- const limiter = new RateLimiter(60_000, 20, store)
32
+ const limiter = new RateLimiter(60_000, 20, store, 'client-intelligence')
33
33
  const getHandler = async (req: PayloadRequest) => {
34
34
  try {
35
35
  requireAdmin(req, slugs)
36
- if (await limiter.check(String(req.user!.id), req)) {
36
+ if (await limiter.check(principalRateKey(req.user), req)) {
37
37
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
38
38
  }
39
39
  const payload = req.payload
@@ -71,7 +71,7 @@ export function createClientIntelligenceEndpoint(slugs: CollectionSlugs, store?:
71
71
  const postHandler = async (req: PayloadRequest) => {
72
72
  try {
73
73
  requireAdmin(req, slugs)
74
- if (await limiter.check(String(req.user!.id), req)) {
74
+ if (await limiter.check(principalRateKey(req.user), req)) {
75
75
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
76
76
  }
77
77
  const payload = req.payload
@@ -1,12 +1,23 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import { handleAuthError, requireAdmin } from '../utils/auth'
4
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
3
5
  import { dbFind } from '../utils/db'
4
6
 
5
7
  /**
6
8
  * GET /api/support/email-stats?days=7
7
9
  * Aggregates EmailLogs data for the tracking dashboard. Admin-only.
10
+ *
11
+ * The guard used to be `!!req.user`, which ANY authenticated principal
12
+ * satisfies — a support-client, or a member of any other auth collection of the
13
+ * host app. That leaked the operational aggregate of `email-logs` (volume,
14
+ * failure rate, per-day and per-action breakdown) even though the collection's
15
+ * own `read` is staff-only, because the pages below are fetched with
16
+ * `overrideAccess: true`. It also handed any account an amplification primitive:
17
+ * up to 25 000 rows read and materialised per call, with no rate limit.
8
18
  */
9
- export function createEmailStatsEndpoint(slugs: CollectionSlugs): Endpoint {
19
+ export function createEmailStatsEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
20
+ const statsLimiter = new RateLimiter(60_000, 20, store, 'email-stats')
10
21
  return {
11
22
  path: '/support/email-stats',
12
23
  method: 'get',
@@ -14,8 +25,11 @@ export function createEmailStatsEndpoint(slugs: CollectionSlugs): Endpoint {
14
25
  try {
15
26
  const payload = req.payload
16
27
 
17
- if (!req.user) {
18
- return Response.json({ error: 'Unauthorized' }, { status: 401 })
28
+ requireAdmin(req, slugs)
29
+
30
+ // Keyed on the authenticated user id, not on a spoofable header.
31
+ if (await statsLimiter.check(principalRateKey(req.user), req)) {
32
+ return Response.json({ error: 'Too many requests.' }, { status: 429 })
19
33
  }
20
34
 
21
35
  const url = new URL(req.url!)
@@ -101,6 +115,8 @@ export function createEmailStatsEndpoint(slugs: CollectionSlugs): Endpoint {
101
115
  actions: Object.fromEntries(actionMap),
102
116
  })
103
117
  } catch (err) {
118
+ const authResponse = handleAuthError(err)
119
+ if (authResponse) return authResponse
104
120
  console.error('[email-stats] Error:', err)
105
121
  return Response.json({ error: 'Internal server error' }, { status: 500 })
106
122
  }
@@ -132,7 +132,7 @@ ONLY JSON, nothing else.`,
132
132
  * Import a conversation from markdown into the ticket system.
133
133
  */
134
134
  export function createImportConversationEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
135
- const importLimiter = new RateLimiter(3_600_000, 10, store)
135
+ const importLimiter = new RateLimiter(3_600_000, 10, store, 'import-conversation')
136
136
  return {
137
137
  path: '/support/import-conversation',
138
138
  method: 'post',
@@ -199,7 +199,7 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
199
199
  }
200
200
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs))
201
201
  if (!f || f.emailTracking !== false) {
202
- endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs))
202
+ endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs))
203
203
  }
204
204
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs))
205
205
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs))
@@ -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, type RateLimitStore } from '../utils/rateLimiter'
7
+ import { principalRateKey, 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@]+$/
@@ -13,6 +13,21 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
13
13
  // email, so cap both the volume per user and the collaborators per ticket.
14
14
  const MAX_COLLABORATORS_PER_TICKET = 20
15
15
 
16
+ /**
17
+ * Long-window ceiling on ACCOUNT CREATIONS per inviter.
18
+ *
19
+ * The two existing quotas are per hour (10 invites) and per ticket (20
20
+ * collaborators); neither bounds the total number of `support-clients` rows a
21
+ * single client can conjure, nor the total number of third parties it can mail,
22
+ * since a client can open as many tickets as it wants. This one is only consumed
23
+ * when the invited address has NO account yet — inviting an existing client
24
+ * (the normal case) never touches it.
25
+ *
26
+ * Applied to support-clients only: staff onboarding is a legitimate bulk flow.
27
+ */
28
+ const MAX_NEW_ACCOUNTS_PER_INVITER = 30
29
+ const NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
30
+
16
31
  /**
17
32
  * POST /api/support/tickets/:id/invite
18
33
  *
@@ -31,7 +46,8 @@ const MAX_COLLABORATORS_PER_TICKET = 20
31
46
  * - Returns { ok, invitedTo, role }.
32
47
  */
33
48
  export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
34
- const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10, store)
49
+ const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10, store, 'invite-collaborator')
50
+ const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, 'invite-collaborator:new-account')
35
51
  return {
36
52
  path: '/support/tickets/:id/invite',
37
53
  method: 'post',
@@ -75,7 +91,7 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs, store?:
75
91
  }
76
92
 
77
93
  // Anti-abuse: cap invite volume per user (in-memory, fail-closed).
78
- if (await inviteLimiter.check(String(req.user.id), req)) {
94
+ if (await inviteLimiter.check(principalRateKey(req.user), req)) {
79
95
  return Response.json({ error: 'Trop d\'invitations. Réessayez plus tard.' }, { status: 429 })
80
96
  }
81
97
 
@@ -107,6 +123,16 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs, store?:
107
123
  if (existing.docs.length > 0) {
108
124
  inviteeId = (existing.docs[0] as any).id
109
125
  } else {
126
+ // Creating an account for an address nobody vouched for — bounded over a
127
+ // long window so the endpoint cannot be used to mass-create ghost
128
+ // accounts and mail arbitrary third parties.
129
+ if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
130
+ return Response.json(
131
+ { error: 'Trop de nouveaux comptes invités. Réessayez plus tard.' },
132
+ { status: 429 },
133
+ )
134
+ }
135
+
110
136
  // Create a placeholder client; password is random — the invitee will reset
111
137
  // via the forgotPassword flow triggered by the SupportClients afterChange hook.
112
138
  const tempPassword = randomBytes(16).toString('hex')