@consilioweb/payload-support 3.0.0 → 5.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 +58 -14
- package/dist/index.cjs +673 -107
- package/dist/index.d.cts +40 -5
- package/dist/index.d.ts +40 -5
- package/dist/index.js +673 -107
- package/dist/utils/db.d.ts +34 -0
- package/dist/utils/readSettings.d.ts +90 -0
- package/dist/views/BillingView/index.js +4 -4
- package/dist/views/ChatView/index.js +4 -4
- package/dist/views/CrmView/index.js +4 -4
- package/dist/views/EmailTrackingView/index.js +4 -4
- package/dist/views/ImportConversationView/index.js +4 -4
- package/dist/views/LogsView/index.js +4 -2
- package/dist/views/NewTicketView/index.js +4 -2
- package/dist/views/PendingEmailsView/index.js +4 -4
- package/dist/views/SupportDashboardView/index.js +4 -4
- package/dist/views/TicketDetailView/index.js +4 -4
- package/dist/views/TicketInboxView/index.js +4 -2
- package/dist/views/TicketingSettingsView/index.js +4 -4
- package/dist/views/TimeDashboardView/index.js +4 -4
- package/dist/views/shared/viewAccess.d.ts +29 -0
- package/dist/views/shared/viewAccess.js +24 -0
- package/package.json +26 -20
- package/src/collections/ChatMessages.ts +59 -2
- package/src/collections/ClientSummaries.ts +10 -4
- package/src/collections/TicketMessages.ts +4 -1
- package/src/collections/WebhookEndpoints.ts +44 -2
- package/src/endpoints/admin-chat.ts +3 -3
- package/src/endpoints/ai-agent.ts +3 -3
- package/src/endpoints/ai.ts +3 -3
- package/src/endpoints/auth-2fa.ts +68 -11
- package/src/endpoints/capabilities.ts +7 -9
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +50 -4
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +3 -3
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +28 -5
- package/src/endpoints/oauth-google.ts +130 -8
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/resend-notification.ts +3 -3
- package/src/endpoints/send-reminder.ts +3 -3
- package/src/endpoints/signature.ts +9 -2
- package/src/endpoints/statuses.ts +17 -0
- package/src/endpoints/ticket-synthesis.ts +3 -3
- package/src/endpoints/transfer-ticket.ts +28 -3
- package/src/endpoints/typing.ts +117 -14
- package/src/endpoints/user-prefs.ts +5 -2
- package/src/plugin.ts +12 -0
- package/src/portal/auth/layout.tsx +19 -1
- package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
- package/src/portal/auth/tickets/detail/page.tsx +2 -6
- package/src/portal/login/page.tsx +23 -5
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +136 -4
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +85 -0
- package/src/utils/urlSafety.ts +265 -0
- package/src/utils/webhookDispatcher.ts +5 -1
- package/src/views/BillingView/index.tsx +4 -4
- package/src/views/ChatView/index.tsx +4 -4
- package/src/views/CrmView/index.tsx +4 -4
- package/src/views/EmailTrackingView/index.tsx +4 -4
- package/src/views/ImportConversationView/index.tsx +4 -4
- package/src/views/LogsView/index.tsx +4 -2
- package/src/views/NewTicketView/index.tsx +4 -2
- package/src/views/PendingEmailsView/index.tsx +4 -4
- package/src/views/SupportDashboardView/index.tsx +4 -4
- package/src/views/TicketDetailView/index.tsx +4 -4
- package/src/views/TicketInboxView/index.tsx +4 -2
- package/src/views/TicketingSettingsView/index.tsx +4 -4
- package/src/views/TimeDashboardView/index.tsx +4 -4
- package/src/views/shared/viewAccess.ts +73 -0
|
@@ -1,5 +1,56 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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 ===
|
|
127
|
-
read: ({ req }) => req.user?.collection ===
|
|
128
|
-
update: ({ req }) => req.user?.collection ===
|
|
129
|
-
delete: ({ req }) => req.user?.collection ===
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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 } = {}
|
package/src/endpoints/ai.ts
CHANGED
|
@@ -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(
|
|
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 { issueTwoFactorChallenge, normalizeEmail, verifyTwoFactorChallenge } from '../utils/twoFactorChallenge'
|
|
7
8
|
|
|
8
9
|
function generateSecureCode(): string {
|
|
9
10
|
const buf = crypto.randomBytes(4)
|
|
@@ -25,31 +26,70 @@ 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 })
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
// Both limiters are keyed on the SAME normalized address the challenge is
|
|
50
|
+
// signed over. Keyed on the raw input, one challenge bought one budget per
|
|
51
|
+
// casing variant of the address it was minted for.
|
|
52
|
+
const limiterKey = normalizeEmail(email)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A challenge is a 10-minute proof that the password step succeeded. It
|
|
56
|
+
* is refreshed on every accepted `send` so it outlives exactly as long as
|
|
57
|
+
* the code that send just minted: without this, a resend late in the
|
|
58
|
+
* window produced a code whose challenge expired before the user could
|
|
59
|
+
* type it, and `verify` answered 401 on a perfectly valid code.
|
|
60
|
+
*
|
|
61
|
+
* Returned on EVERY `send` reply, including the throttled and the
|
|
62
|
+
* unknown-address ones, so the response shape never tells the caller
|
|
63
|
+
* whether the account exists.
|
|
64
|
+
*/
|
|
65
|
+
const sendResponse = (): Response => {
|
|
66
|
+
let refreshed: string | undefined
|
|
67
|
+
try {
|
|
68
|
+
refreshed = issueTwoFactorChallenge(email)
|
|
69
|
+
} catch {
|
|
70
|
+
// PAYLOAD_SECRET vanished mid-flight: answer without a challenge.
|
|
71
|
+
}
|
|
72
|
+
return Response.json({
|
|
73
|
+
success: true,
|
|
74
|
+
message: 'Si un compte existe, un code a été envoyé.',
|
|
75
|
+
...(refreshed ? { challenge: refreshed } : {}),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
49
78
|
|
|
50
79
|
if (action === 'send') {
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
// The challenge is checked BEFORE the limiter on purpose: the limiter
|
|
81
|
+
// is keyed on the VICTIM's email, so letting an unauthenticated caller
|
|
82
|
+
// reach it is what made the lockout possible. No challenge, no budget
|
|
83
|
+
// consumed, no mail sent, no code overwritten.
|
|
84
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
85
|
+
return Response.json(
|
|
86
|
+
{ error: 'Authentification requise avant l\'envoi d\'un code.' },
|
|
87
|
+
{ status: 401 },
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (await sendLimiter.check(limiterKey, req)) {
|
|
92
|
+
return sendResponse()
|
|
53
93
|
}
|
|
54
94
|
|
|
55
95
|
const clients = await dbFind(payload, slugs.supportClients, {
|
|
@@ -60,7 +100,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
|
|
|
60
100
|
})
|
|
61
101
|
|
|
62
102
|
if (clients.docs.length === 0) {
|
|
63
|
-
return
|
|
103
|
+
return sendResponse()
|
|
64
104
|
}
|
|
65
105
|
|
|
66
106
|
const client = clients.docs[0] as any
|
|
@@ -89,15 +129,29 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
|
|
|
89
129
|
</div>`,
|
|
90
130
|
})
|
|
91
131
|
|
|
92
|
-
return
|
|
132
|
+
return sendResponse()
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
if (action === 'verify') {
|
|
136
|
+
// Same proof, same reason, same position as `send` above: the limiter
|
|
137
|
+
// below is keyed on the VICTIM's email, so an unauthenticated caller
|
|
138
|
+
// able to reach it could burn the 5 attempts and leave the victim —
|
|
139
|
+
// holding the right password AND the right code — locked out of the
|
|
140
|
+
// only route that clears the 2FA gate, renewably, every 15 minutes.
|
|
141
|
+
// Only `send` got this guard in the previous pass; `verify` is the
|
|
142
|
+
// other half of the same door.
|
|
143
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
144
|
+
return Response.json(
|
|
145
|
+
{ error: 'Authentification requise avant la vérification d\'un code.' },
|
|
146
|
+
{ status: 401 },
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
96
150
|
if (!code) {
|
|
97
151
|
return Response.json({ error: 'Code manquant' }, { status: 400 })
|
|
98
152
|
}
|
|
99
153
|
|
|
100
|
-
if (await verifyLimiter.check(
|
|
154
|
+
if (await verifyLimiter.check(limiterKey, req)) {
|
|
101
155
|
return Response.json(
|
|
102
156
|
{ error: 'Trop de tentatives. Réessayez dans 15 minutes.' },
|
|
103
157
|
{ status: 429 },
|
|
@@ -147,7 +201,10 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
|
|
|
147
201
|
overrideAccess: true,
|
|
148
202
|
})
|
|
149
203
|
|
|
150
|
-
|
|
204
|
+
// `req` is REQUIRED here: PayloadRateLimitStore throws without it, and
|
|
205
|
+
// this reset runs after the marker was written — a throw turned a
|
|
206
|
+
// successful verification into a 500 for the caller.
|
|
207
|
+
await verifyLimiter.reset(limiterKey, req)
|
|
151
208
|
|
|
152
209
|
return Response.json({ success: true, verified: true })
|
|
153
210
|
}
|
|
@@ -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 { clientIpRateKey, 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',
|
|
@@ -21,9 +21,7 @@ export function createInboundEmailEndpoint(
|
|
|
21
21
|
if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
|
|
22
22
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
23
23
|
}
|
|
24
|
-
const ip = req
|
|
25
|
-
|| req.headers.get('x-real-ip')
|
|
26
|
-
|| 'unknown'
|
|
24
|
+
const ip = clientIpRateKey(req)
|
|
27
25
|
if (await limiter.check(ip, req)) {
|
|
28
26
|
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
29
27
|
}
|
|
@@ -51,7 +49,7 @@ export function createProjectSuggestionsEndpoint(
|
|
|
51
49
|
capability: NonNullable<SupportCapabilities['projectSuggestions']>,
|
|
52
50
|
store?: RateLimitStore,
|
|
53
51
|
): Endpoint {
|
|
54
|
-
const limiter = new RateLimiter(60_000, 20, store)
|
|
52
|
+
const limiter = new RateLimiter(60_000, 20, store, 'suggest-projects')
|
|
55
53
|
return {
|
|
56
54
|
path: '/support/suggest-projects',
|
|
57
55
|
method: 'post',
|
|
@@ -59,7 +57,7 @@ export function createProjectSuggestionsEndpoint(
|
|
|
59
57
|
if (!req.user || req.user.collection !== slugs.users) {
|
|
60
58
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
61
59
|
}
|
|
62
|
-
const key =
|
|
60
|
+
const key = principalRateKey(req.user)
|
|
63
61
|
if (await limiter.check(key, req)) {
|
|
64
62
|
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
65
63
|
}
|
|
@@ -73,7 +71,7 @@ export function createTicketTitleEndpoint(
|
|
|
73
71
|
capability: NonNullable<SupportCapabilities['aiTitles']>,
|
|
74
72
|
store?: RateLimitStore,
|
|
75
73
|
): Endpoint {
|
|
76
|
-
const limiter = new RateLimiter(60_000, 20, store)
|
|
74
|
+
const limiter = new RateLimiter(60_000, 20, store, 'ticket-title')
|
|
77
75
|
return {
|
|
78
76
|
path: '/support/ticket-title',
|
|
79
77
|
method: 'post',
|
|
@@ -100,7 +98,7 @@ export function createGenerateMissingTitlesEndpoint(
|
|
|
100
98
|
capability: NonNullable<SupportCapabilities['aiTitles']>,
|
|
101
99
|
store?: RateLimitStore,
|
|
102
100
|
): Endpoint {
|
|
103
|
-
const limiter = new RateLimiter(60_000, 5, store)
|
|
101
|
+
const limiter = new RateLimiter(60_000, 5, store, 'generate-missing-titles')
|
|
104
102
|
return {
|
|
105
103
|
path: '/support/generate-missing-titles',
|
|
106
104
|
method: 'post',
|
package/src/endpoints/chat.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
package/src/endpoints/chatbot.ts
CHANGED
|
@@ -1,22 +1,47 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
-
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
3
|
+
import { clientIpRateKey, 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(
|
|
13
|
-
|
|
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',
|
|
17
42
|
handler: async (req) => {
|
|
18
43
|
try {
|
|
19
|
-
const ip =
|
|
44
|
+
const ip = clientIpRateKey(req)
|
|
20
45
|
if (await chatbotLimiter.check(ip, req)) {
|
|
21
46
|
return Response.json({ error: 'Too many requests. Please wait a moment.' }, { status: 429 })
|
|
22
47
|
}
|
|
@@ -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(
|
|
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(
|
|
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
|