@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.
- package/README.md +36 -2
- package/dist/index.cjs +558 -94
- package/dist/index.d.cts +20 -5
- package/dist/index.d.ts +20 -5
- package/dist/index.js +558 -94
- package/package.json +1 -1
- 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 +16 -4
- package/src/endpoints/capabilities.ts +6 -6
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +48 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +1 -1
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +15 -2
- package/src/endpoints/oauth-google.ts +130 -8
- 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/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 +11 -3
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/rateLimiter.ts +39 -5
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +80 -0
- package/src/utils/urlSafety.ts +230 -0
- package/src/utils/webhookDispatcher.ts +5 -1
package/src/endpoints/login.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Endpoint } from 'payload'
|
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
4
4
|
import { dbCreate } from '../utils/db'
|
|
5
|
+
import { issueTwoFactorChallenge } from '../utils/twoFactorChallenge'
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -9,7 +10,7 @@ import { dbCreate } from '../utils/db'
|
|
|
9
10
|
* Client login endpoint.
|
|
10
11
|
*/
|
|
11
12
|
export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
12
|
-
const loginLimiter = new RateLimiter(15 * 60_000, 10, store)
|
|
13
|
+
const loginLimiter = new RateLimiter(15 * 60_000, 10, store, 'login')
|
|
13
14
|
return {
|
|
14
15
|
path: '/support/login',
|
|
15
16
|
method: 'post',
|
|
@@ -75,8 +76,20 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
|
|
|
75
76
|
|
|
76
77
|
// 2FA gate: password was correct but a fresh 2FA verification is required.
|
|
77
78
|
// No session/cookie is issued (Payload rolled it back on the throw).
|
|
79
|
+
//
|
|
80
|
+
// The password check just succeeded, so this is where the short-lived
|
|
81
|
+
// proof for `POST /support/2fa {action:'send'}` is minted: without it,
|
|
82
|
+
// that endpoint would keep letting an anonymous caller burn a victim's
|
|
83
|
+
// send quota and lock them out of their own account.
|
|
78
84
|
if (errorMessage.includes('2FA_REQUIRED')) {
|
|
79
|
-
|
|
85
|
+
let challenge: string | undefined
|
|
86
|
+
try {
|
|
87
|
+
challenge = issueTwoFactorChallenge(email)
|
|
88
|
+
} catch {
|
|
89
|
+
// PAYLOAD_SECRET missing: 2FA cannot operate at all (codes are
|
|
90
|
+
// hashed with it). Answer without a challenge — fail closed.
|
|
91
|
+
}
|
|
92
|
+
return Response.json({ requires2FA: true, ...(challenge ? { challenge } : {}) }, { status: 200 })
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
let errorReason = 'Identifiants incorrects'
|
|
@@ -3,16 +3,74 @@ import { getFieldsToSign, jwtSign } from 'payload'
|
|
|
3
3
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
4
|
import crypto from 'crypto'
|
|
5
5
|
import { dbFind, dbUpdate, dbCreate, dbFindByID } from '../utils/db'
|
|
6
|
+
import { issueTwoFactorChallenge } from '../utils/twoFactorChallenge'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* POST /api/support/oauth/google
|
|
9
10
|
* Google OAuth — handles both login redirect and callback.
|
|
10
|
-
* Body: { action: 'login' } or { code: string, state: string
|
|
11
|
+
* Body: { action: 'login' } or { code: string, state: string }
|
|
12
|
+
*
|
|
13
|
+
* The CSRF state is NOT taken from the body: `action: 'login'` issues it as an
|
|
14
|
+
* HttpOnly cookie and the callback reads it back from the `Cookie` header.
|
|
11
15
|
*/
|
|
12
16
|
export interface OAuthGoogleOptions {
|
|
13
17
|
allowedEmailDomains?: string[]
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/** Name of the HttpOnly cookie carrying the CSRF state between the two steps. */
|
|
21
|
+
export const OAUTH_STATE_COOKIE = 'support-oauth-state'
|
|
22
|
+
|
|
23
|
+
/** Same window as the Google authorization code: 10 minutes is plenty. */
|
|
24
|
+
const OAUTH_STATE_MAX_AGE = 600
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A successful `/support/2fa` verify stamps `twoFactorVerifiedAt`; the marker is
|
|
28
|
+
* valid for this window then consumed. Mirrors `TWO_FA_WINDOW_MS` in
|
|
29
|
+
* `collections/SupportClients.ts` — the two MUST stay in sync.
|
|
30
|
+
*/
|
|
31
|
+
const TWO_FA_WINDOW_MS = 5 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read one cookie from the request's own `Cookie` header.
|
|
35
|
+
*
|
|
36
|
+
* The callback used to compare `state` against a `cookieState` ALSO taken from
|
|
37
|
+
* the JSON body: two values from the same attacker-controlled place, so the CSRF
|
|
38
|
+
* check was a no-op for any non-browser caller (send the same string twice). The
|
|
39
|
+
* state is now issued as an HttpOnly cookie at the `login` step and read back
|
|
40
|
+
* here, server-side — the only place a browser cannot forge it from script.
|
|
41
|
+
*/
|
|
42
|
+
export function readCookie(header: string | null | undefined, name: string): string | null {
|
|
43
|
+
if (!header) return null
|
|
44
|
+
for (const part of header.split(';')) {
|
|
45
|
+
const eq = part.indexOf('=')
|
|
46
|
+
if (eq === -1) continue
|
|
47
|
+
if (part.slice(0, eq).trim() !== name) continue
|
|
48
|
+
try {
|
|
49
|
+
return decodeURIComponent(part.slice(eq + 1).trim())
|
|
50
|
+
} catch {
|
|
51
|
+
return part.slice(eq + 1).trim()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Expire the state cookie: it is single-use, and leaving it live for the rest of
|
|
59
|
+
* its 10 minutes keeps a consumed CSRF token replayable for no benefit.
|
|
60
|
+
*/
|
|
61
|
+
export function clearedStateCookie(): string {
|
|
62
|
+
const secure = process.env.NODE_ENV === 'production'
|
|
63
|
+
return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Constant-time comparison — the state is a secret for the length of the flow. */
|
|
67
|
+
function statesMatch(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
68
|
+
if (!a || !b) return false
|
|
69
|
+
const left = crypto.createHash('sha256').update(a).digest()
|
|
70
|
+
const right = crypto.createHash('sha256').update(b).digest()
|
|
71
|
+
return crypto.timingSafeEqual(left, right)
|
|
72
|
+
}
|
|
73
|
+
|
|
16
74
|
export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAuthGoogleOptions): Endpoint {
|
|
17
75
|
return {
|
|
18
76
|
path: '/support/oauth/google',
|
|
@@ -31,7 +89,7 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
|
|
|
31
89
|
|
|
32
90
|
try {
|
|
33
91
|
const body = await req.json!()
|
|
34
|
-
const { action, code, state: queryState
|
|
92
|
+
const { action, code, state: queryState } = body
|
|
35
93
|
|
|
36
94
|
// Step 1: Generate OAuth URL
|
|
37
95
|
if (action === 'login') {
|
|
@@ -46,16 +104,30 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
|
|
|
46
104
|
prompt: 'select_account',
|
|
47
105
|
})
|
|
48
106
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
107
|
+
const secure = process.env.NODE_ENV === 'production'
|
|
108
|
+
const loginHeaders = new Headers({ 'Content-Type': 'application/json' })
|
|
109
|
+
loginHeaders.append(
|
|
110
|
+
'Set-Cookie',
|
|
111
|
+
`${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return new Response(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
117
|
+
// Kept for callers that echo it back in the redirect URL; the
|
|
118
|
+
// server no longer trusts anything the caller returns.
|
|
119
|
+
state: oauthState,
|
|
120
|
+
}),
|
|
121
|
+
{ status: 200, headers: loginHeaders },
|
|
122
|
+
)
|
|
53
123
|
}
|
|
54
124
|
|
|
55
125
|
// Step 2: Handle callback (code exchange)
|
|
56
126
|
if (code) {
|
|
57
|
-
// Validate state
|
|
58
|
-
|
|
127
|
+
// Validate state against the HttpOnly cookie set at the `login` step —
|
|
128
|
+
// NOT against a second copy taken from the request body.
|
|
129
|
+
const issuedState = readCookie(req.headers.get('cookie'), OAUTH_STATE_COOKIE)
|
|
130
|
+
if (!statesMatch(issuedState, queryState)) {
|
|
59
131
|
return Response.json({ error: 'state_mismatch' }, { status: 400 })
|
|
60
132
|
}
|
|
61
133
|
|
|
@@ -158,6 +230,54 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
|
|
|
158
230
|
}) as { id: number | string; email: string }
|
|
159
231
|
}
|
|
160
232
|
|
|
233
|
+
// Replay the 2FA rule BEFORE minting anything.
|
|
234
|
+
//
|
|
235
|
+
// This path never calls `payload.login`, so the `beforeLogin` hook
|
|
236
|
+
// `createEnforce2FA` (collections/SupportClients.ts) never runs: a
|
|
237
|
+
// client who turned 2FA on from the portal profile page could skip it
|
|
238
|
+
// entirely by clicking "Sign in with Google". Same contract as
|
|
239
|
+
// `endpoints/login.ts`: no token, `{ requires2FA: true }`, and the
|
|
240
|
+
// verified marker is single-use.
|
|
241
|
+
const twoFactorDoc = (await dbFindByID(payload, slugs.supportClients, {
|
|
242
|
+
id: clientDoc.id,
|
|
243
|
+
depth: 0,
|
|
244
|
+
overrideAccess: true,
|
|
245
|
+
showHiddenFields: true,
|
|
246
|
+
})) as { twoFactorEnabled?: boolean; twoFactorVerifiedAt?: string | null }
|
|
247
|
+
|
|
248
|
+
if (twoFactorDoc?.twoFactorEnabled) {
|
|
249
|
+
const raw = twoFactorDoc.twoFactorVerifiedAt
|
|
250
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0
|
|
251
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
252
|
+
// The authorization code is spent: the client has to restart the
|
|
253
|
+
// whole flow after verifying, so the state goes with it.
|
|
254
|
+
//
|
|
255
|
+
// Google just proved this identity, so the client is entitled to
|
|
256
|
+
// request a code: hand out the same short-lived challenge
|
|
257
|
+
// `endpoints/login.ts` mints, or `POST /support/2fa` would refuse
|
|
258
|
+
// to send one.
|
|
259
|
+
let challenge: string | undefined
|
|
260
|
+
try {
|
|
261
|
+
challenge = issueTwoFactorChallenge(clientDoc.email)
|
|
262
|
+
} catch {
|
|
263
|
+
// PAYLOAD_SECRET missing — 2FA is inoperable anyway; fail closed.
|
|
264
|
+
}
|
|
265
|
+
return new Response(JSON.stringify({ requires2FA: true, ...(challenge ? { challenge } : {}) }), {
|
|
266
|
+
status: 200,
|
|
267
|
+
headers: new Headers({
|
|
268
|
+
'Content-Type': 'application/json',
|
|
269
|
+
'Set-Cookie': clearedStateCookie(),
|
|
270
|
+
}),
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
// Consume the marker so the verified window cannot be replayed.
|
|
274
|
+
await dbUpdate(payload, slugs.supportClients, {
|
|
275
|
+
id: clientDoc.id,
|
|
276
|
+
data: { twoFactorVerifiedAt: null },
|
|
277
|
+
overrideAccess: true,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
161
281
|
// Mint a Payload session WITHOUT touching the user's password.
|
|
162
282
|
// Uses Payload's own jwtSign/getFieldsToSign so the token format matches
|
|
163
283
|
// exactly, and replicates addSessionToUser for the sessions array.
|
|
@@ -209,6 +329,8 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
|
|
|
209
329
|
'Set-Cookie',
|
|
210
330
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`,
|
|
211
331
|
)
|
|
332
|
+
// The state has done its job — do not leave it replayable.
|
|
333
|
+
headers.append('Set-Cookie', clearedStateCookie())
|
|
212
334
|
|
|
213
335
|
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
214
336
|
status: 200,
|
|
@@ -1,7 +1,7 @@
|
|
|
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, type RateLimitStore } from '../utils/rateLimiter'
|
|
4
|
+
import { principalRateKey, 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'
|
|
@@ -12,7 +12,7 @@ import { dbFindByID } from '../utils/db'
|
|
|
12
12
|
* Resend the email notification for a specific ticket message. Admin-only.
|
|
13
13
|
*/
|
|
14
14
|
export function createResendNotificationEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
15
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1000, 10, store)
|
|
15
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1000, 10, store, 'resend-notification')
|
|
16
16
|
return {
|
|
17
17
|
path: '/support/resend-notification',
|
|
18
18
|
method: 'post',
|
|
@@ -22,7 +22,7 @@ export function createResendNotificationEndpoint(slugs: CollectionSlugs, store?:
|
|
|
22
22
|
|
|
23
23
|
requireAdmin(req, slugs)
|
|
24
24
|
|
|
25
|
-
if (await resendLimiter.check(
|
|
25
|
+
if (await resendLimiter.check(principalRateKey(req.user), req)) {
|
|
26
26
|
return Response.json(
|
|
27
27
|
{ error: 'Trop de renvois. Réessayez dans une heure.' },
|
|
28
28
|
{ status: 429 },
|
|
@@ -1,7 +1,7 @@
|
|
|
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, type RateLimitStore } from '../utils/rateLimiter'
|
|
4
|
+
import { principalRateKey, 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'
|
|
@@ -38,7 +38,7 @@ function formatFr(date: Date, withTime: boolean): string {
|
|
|
38
38
|
* Admin-only, rate-limited.
|
|
39
39
|
*/
|
|
40
40
|
export function createSendReminderEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
41
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30, store)
|
|
41
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30, store, 'send-reminder')
|
|
42
42
|
return {
|
|
43
43
|
path: '/support/send-reminder',
|
|
44
44
|
method: 'post',
|
|
@@ -48,7 +48,7 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs, store?: RateL
|
|
|
48
48
|
|
|
49
49
|
requireAdmin(req, slugs)
|
|
50
50
|
|
|
51
|
-
if (await reminderLimiter.check(
|
|
51
|
+
if (await reminderLimiter.check(principalRateKey(req.user), req)) {
|
|
52
52
|
return Response.json(
|
|
53
53
|
{ error: 'Trop de relances. Réessayez dans une heure.' },
|
|
54
54
|
{ status: 429 },
|
|
@@ -19,7 +19,14 @@ export function createSignatureGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
19
19
|
requireAdmin(req, slugs)
|
|
20
20
|
|
|
21
21
|
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
22
|
-
|
|
22
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
23
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
24
|
+
// collections — a support-client with the same id would otherwise own
|
|
25
|
+
// the `email-signature-<id>` row read back for the agent.
|
|
26
|
+
where: {
|
|
27
|
+
key: { equals: `${PREF_KEY}-${req.user.id}` },
|
|
28
|
+
'user.relationTo': { equals: slugs.users },
|
|
29
|
+
},
|
|
23
30
|
limit: 1,
|
|
24
31
|
depth: 0,
|
|
25
32
|
overrideAccess: true,
|
|
@@ -57,7 +64,7 @@ export function createSignaturePostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
57
64
|
const key = `${PREF_KEY}-${req.user.id}`
|
|
58
65
|
|
|
59
66
|
const existing = await dbFind(payload, 'payload-preferences', {
|
|
60
|
-
where: { key: { equals: key } },
|
|
67
|
+
where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
|
|
61
68
|
limit: 1,
|
|
62
69
|
depth: 0,
|
|
63
70
|
overrideAccess: true,
|
|
@@ -4,7 +4,7 @@ import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
|
4
4
|
import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
|
|
5
5
|
import { dbFindByID } from '../utils/db'
|
|
6
6
|
import type { SupportCapabilities } from '../types'
|
|
7
|
-
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
7
|
+
import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* POST /api/support/ticket-synthesis?ticketId=X[&force=true]
|
|
@@ -23,14 +23,14 @@ export function createTicketSynthesisEndpoint(
|
|
|
23
23
|
generator?: SupportCapabilities['aiSummaries'],
|
|
24
24
|
store?: RateLimitStore,
|
|
25
25
|
): Endpoint {
|
|
26
|
-
const limiter = new RateLimiter(60_000, 20, store)
|
|
26
|
+
const limiter = new RateLimiter(60_000, 20, store, 'ticket-synthesis')
|
|
27
27
|
return {
|
|
28
28
|
path: '/support/ticket-synthesis',
|
|
29
29
|
method: 'post',
|
|
30
30
|
handler: async (req) => {
|
|
31
31
|
try {
|
|
32
32
|
requireAdmin(req, slugs)
|
|
33
|
-
if (await limiter.check(
|
|
33
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
34
34
|
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
35
35
|
}
|
|
36
36
|
const payload = req.payload
|
|
@@ -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, type RateLimitStore } from '../utils/rateLimiter'
|
|
6
|
+
import { principalRateKey, 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
|
|
@@ -13,6 +13,21 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
13
13
|
|
|
14
14
|
const MAX_TRANSFERS_PER_DAY = 5
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Per-USER ceiling, ticket-independent.
|
|
18
|
+
*
|
|
19
|
+
* Both existing quotas (the in-memory limiter keyed `<user>:<ticket>` and the
|
|
20
|
+
* `email-logs` count) are per TICKET, and a support-client can open as many
|
|
21
|
+
* tickets as they want — so "5 transfers / 24 h" resets on every new ticket and
|
|
22
|
+
* the endpoint is an unbounded outbound mailer: the operator's From address, a
|
|
23
|
+
* "Récapitulatif support" subject, and 1 000 characters chosen by the caller,
|
|
24
|
+
* to any mailbox. This second quota is what actually bounds the volume.
|
|
25
|
+
*
|
|
26
|
+
* Applied to support-clients only: staff already hold the whole mailing surface
|
|
27
|
+
* of the admin panel, and capping them here would break legitimate bulk recaps.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_TRANSFERS_PER_USER_PER_DAY = 15
|
|
30
|
+
|
|
16
31
|
// In-memory fail-closed rate limiter — enforced even when the persistent
|
|
17
32
|
// emailLogs-based check is unavailable (collection disabled). Prevents the
|
|
18
33
|
// endpoint from becoming an unbounded outbound mailer.
|
|
@@ -33,7 +48,8 @@ const MAX_TRANSFERS_PER_DAY = 5
|
|
|
33
48
|
* - Rate-limited to 5 transfers per ticket per 24h
|
|
34
49
|
*/
|
|
35
50
|
export function createTransferTicketEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
36
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY, store)
|
|
51
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY, store, 'transfer:ticket')
|
|
52
|
+
const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_USER_PER_DAY, store, 'transfer:user')
|
|
37
53
|
return {
|
|
38
54
|
path: '/support/tickets/:id/transfer',
|
|
39
55
|
method: 'post',
|
|
@@ -82,13 +98,22 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs, store?: Rat
|
|
|
82
98
|
|
|
83
99
|
// Fail-closed in-memory rate limit (per user + ticket): enforced even
|
|
84
100
|
// when the persistent emailLogs check below cannot run.
|
|
85
|
-
if (await transferLimiter.check(`${req.user
|
|
101
|
+
if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
|
|
86
102
|
return Response.json(
|
|
87
103
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
88
104
|
{ status: 429 },
|
|
89
105
|
)
|
|
90
106
|
}
|
|
91
107
|
|
|
108
|
+
// Ticket-independent quota — the per-ticket ones above reset on every
|
|
109
|
+
// newly created ticket, which a client can do at will.
|
|
110
|
+
if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
|
|
111
|
+
return Response.json(
|
|
112
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
|
|
113
|
+
{ status: 429 },
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
92
117
|
// Rate limit: count transfers in last 24h for this ticket
|
|
93
118
|
try {
|
|
94
119
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
package/src/endpoints/typing.ts
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
1
|
-
import type { Endpoint } from 'payload'
|
|
1
|
+
import type { Endpoint, PayloadRequest } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { dbFindByID } from '../utils/db'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Ephemeral "someone is typing" state, process-local.
|
|
7
|
+
*
|
|
8
|
+
* Three properties this map MUST keep, because it is fed by an HTTP endpoint:
|
|
9
|
+
* - keys are shaped like ticket ids, never a caller-supplied blob;
|
|
10
|
+
* - a key is only created by a principal that can actually READ that ticket;
|
|
11
|
+
* - the map is bounded, and expired entries are reclaimed without depending on
|
|
12
|
+
* a matching GET (`cleanExpired` used to run only on the read path, for the
|
|
13
|
+
* single id being read — nothing ever swept the rest).
|
|
14
|
+
*/
|
|
5
15
|
const typingState = new Map<string, { admin?: number; client?: number; adminName?: string; clientName?: string }>()
|
|
6
16
|
|
|
7
17
|
const TYPING_TTL = 5000 // 5 seconds
|
|
18
|
+
/** Hard ceiling on distinct ticket ids held at once — the state lives 5s. */
|
|
19
|
+
const MAX_TYPING_KEYS = 500
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ids are integers on SQL adapters and hex ObjectIds on Mongo, so the shape is
|
|
23
|
+
* validated rather than the type: printable id characters, bounded length. This
|
|
24
|
+
* caps the key SIZE; the access check below caps their NUMBER.
|
|
25
|
+
*/
|
|
26
|
+
const TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
|
27
|
+
|
|
28
|
+
function normalizeTicketId(raw: unknown): string | null {
|
|
29
|
+
if (typeof raw === 'number') return Number.isInteger(raw) && raw > 0 ? String(raw) : null
|
|
30
|
+
if (typeof raw !== 'string') return null
|
|
31
|
+
const value = raw.trim()
|
|
32
|
+
return TICKET_ID_PATTERN.test(value) ? value : null
|
|
33
|
+
}
|
|
8
34
|
|
|
9
35
|
function cleanExpired(ticketId: string) {
|
|
10
36
|
const state = typingState.get(ticketId)
|
|
@@ -21,6 +47,53 @@ function cleanExpired(ticketId: string) {
|
|
|
21
47
|
if (!state.admin && !state.client) typingState.delete(ticketId)
|
|
22
48
|
}
|
|
23
49
|
|
|
50
|
+
/** Reclaims every expired entry — the map is at most MAX_TYPING_KEYS long. */
|
|
51
|
+
function sweepExpired() {
|
|
52
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Oldest signal first, so the ceiling evicts the least recently active ticket. */
|
|
56
|
+
function evictOldest() {
|
|
57
|
+
let oldestKey: string | null = null
|
|
58
|
+
let oldestTs = Infinity
|
|
59
|
+
for (const [key, state] of typingState) {
|
|
60
|
+
const ts = Math.max(state.admin || 0, state.client || 0)
|
|
61
|
+
if (ts < oldestTs) {
|
|
62
|
+
oldestTs = ts
|
|
63
|
+
oldestKey = key
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (oldestKey !== null) typingState.delete(oldestKey)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Can this principal see this ticket? Delegates to the `tickets` collection
|
|
71
|
+
* access rules — the SAME source of truth the rest of the plugin reads through
|
|
72
|
+
* (owner, collaborator, team scoping), instead of a second implementation that
|
|
73
|
+
* would drift from it. Any other auth collection of the host app is refused
|
|
74
|
+
* outright: the endpoint only ever spoke for staff and support-clients.
|
|
75
|
+
*/
|
|
76
|
+
async function mayAccessTicket(
|
|
77
|
+
req: PayloadRequest,
|
|
78
|
+
slugs: CollectionSlugs,
|
|
79
|
+
ticketId: string,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
const collection = (req.user as { collection?: string } | null)?.collection
|
|
82
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false
|
|
83
|
+
try {
|
|
84
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
85
|
+
id: ticketId,
|
|
86
|
+
depth: 0,
|
|
87
|
+
overrideAccess: false,
|
|
88
|
+
user: req.user,
|
|
89
|
+
})
|
|
90
|
+
return !!doc
|
|
91
|
+
} catch {
|
|
92
|
+
// Forbidden or NotFound — same answer either way, so no existence oracle.
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
24
97
|
/**
|
|
25
98
|
* POST /api/support/typing — Signal that user is typing
|
|
26
99
|
*/
|
|
@@ -34,14 +107,23 @@ export function createTypingPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
34
107
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
35
108
|
}
|
|
36
109
|
|
|
37
|
-
const { ticketId } = (await req.json!()) as { ticketId
|
|
38
|
-
|
|
110
|
+
const { ticketId } = (await req.json!()) as { ticketId?: unknown }
|
|
111
|
+
const key = normalizeTicketId(ticketId)
|
|
112
|
+
if (!key) {
|
|
39
113
|
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
40
114
|
}
|
|
41
115
|
|
|
42
|
-
|
|
116
|
+
if (!(await mayAccessTicket(req, slugs, key))) {
|
|
117
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
118
|
+
}
|
|
119
|
+
|
|
43
120
|
const state = typingState.get(key) || {}
|
|
44
121
|
|
|
122
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
123
|
+
sweepExpired()
|
|
124
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest()
|
|
125
|
+
}
|
|
126
|
+
|
|
45
127
|
if (req.user.collection === slugs.users) {
|
|
46
128
|
state.admin = Date.now()
|
|
47
129
|
state.adminName = (req.user as any).firstName || 'Support'
|
|
@@ -67,35 +149,56 @@ export function createTypingGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
67
149
|
path: '/support/typing',
|
|
68
150
|
method: 'get',
|
|
69
151
|
handler: async (req) => {
|
|
152
|
+
const idle = { typing: false, name: null }
|
|
70
153
|
try {
|
|
71
154
|
if (!req.user) {
|
|
72
155
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
73
156
|
}
|
|
74
157
|
|
|
75
158
|
const url = new URL(req.url!)
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
159
|
+
const key = normalizeTicketId(url.searchParams.get('ticketId'))
|
|
160
|
+
if (!key) {
|
|
78
161
|
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
79
162
|
}
|
|
80
163
|
|
|
81
|
-
cleanExpired(
|
|
82
|
-
const state = typingState.get(
|
|
164
|
+
cleanExpired(key)
|
|
165
|
+
const state = typingState.get(key)
|
|
166
|
+
|
|
167
|
+
// Nothing to disclose: answer without touching the database. A caller
|
|
168
|
+
// with no right to the ticket gets this exact body too, so "idle" and
|
|
169
|
+
// "not yours" stay indistinguishable — and the 2s poll of every open
|
|
170
|
+
// ticket page costs no query in the common case.
|
|
171
|
+
if (!state) return Response.json(idle)
|
|
172
|
+
|
|
173
|
+
// There IS a name to hand out (the agent's first name, historically
|
|
174
|
+
// served to any authenticated caller for any ticket id) — check first.
|
|
175
|
+
if (!(await mayAccessTicket(req, slugs, key))) return Response.json(idle)
|
|
83
176
|
|
|
84
177
|
// Admin sees client typing, client sees admin typing
|
|
85
178
|
if (req.user.collection === slugs.users) {
|
|
86
179
|
return Response.json({
|
|
87
|
-
typing: !!state
|
|
88
|
-
name: state
|
|
180
|
+
typing: !!state.client,
|
|
181
|
+
name: state.clientName || null,
|
|
89
182
|
})
|
|
90
183
|
} else {
|
|
91
184
|
return Response.json({
|
|
92
|
-
typing: !!state
|
|
93
|
-
name: state
|
|
185
|
+
typing: !!state.admin,
|
|
186
|
+
name: state.adminName || null,
|
|
94
187
|
})
|
|
95
188
|
}
|
|
96
189
|
} catch {
|
|
97
|
-
return Response.json(
|
|
190
|
+
return Response.json(idle)
|
|
98
191
|
}
|
|
99
192
|
},
|
|
100
193
|
}
|
|
101
194
|
}
|
|
195
|
+
|
|
196
|
+
/** Test seam — the module-level map must not leak state across test cases. */
|
|
197
|
+
export function __resetTypingStateForTests(): void {
|
|
198
|
+
typingState.clear()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Test seam — the ceiling is the point of the fix, so it must be observable. */
|
|
202
|
+
export function __typingStateSizeForTests(): number {
|
|
203
|
+
return typingState.size
|
|
204
|
+
}
|
|
@@ -30,8 +30,11 @@ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
30
30
|
|
|
31
31
|
const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
|
|
32
32
|
|
|
33
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
34
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
35
|
+
// collections.
|
|
33
36
|
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
34
|
-
where: { key: { equals: key } },
|
|
37
|
+
where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
|
|
35
38
|
limit: 1,
|
|
36
39
|
depth: 0,
|
|
37
40
|
overrideAccess: true,
|
|
@@ -75,7 +78,7 @@ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
75
78
|
|
|
76
79
|
// Read existing prefs to merge
|
|
77
80
|
const existing = await dbFind(payload, 'payload-preferences', {
|
|
78
|
-
where: { key: { equals: key } },
|
|
81
|
+
where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
|
|
79
82
|
limit: 1,
|
|
80
83
|
depth: 0,
|
|
81
84
|
overrideAccess: true,
|
package/src/plugin.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
createSupportCountersCollection,
|
|
32
32
|
} from './collections'
|
|
33
33
|
import { PayloadRateLimitStore } from './utils/rateLimiter'
|
|
34
|
+
import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from './utils/readSettings'
|
|
34
35
|
|
|
35
36
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
36
37
|
return { Component: component, path: path as `/${string}` }
|
|
@@ -169,6 +170,17 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
169
170
|
|
|
170
171
|
return {
|
|
171
172
|
...incomingConfig,
|
|
173
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
174
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
175
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
176
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
177
|
+
// auth collection of the host app — a different collection on any app
|
|
178
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
179
|
+
// reopened right there.
|
|
180
|
+
custom: {
|
|
181
|
+
...incomingConfig.custom,
|
|
182
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users,
|
|
183
|
+
},
|
|
172
184
|
collections: config?.skipCollections
|
|
173
185
|
? existingCollections
|
|
174
186
|
: [...existingCollections, ...supportCollections],
|
|
@@ -18,8 +18,26 @@ export type SupportUser = {
|
|
|
18
18
|
collection: 'support-clients'
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The ONLY reliable discriminant is the auth collection the session belongs to.
|
|
23
|
+
*
|
|
24
|
+
* This used to be a duck test on the presence of a `company` field, which any
|
|
25
|
+
* user of another auth collection of the host app (front-office members,
|
|
26
|
+
* subscribers — `company` is a very common field on such a document) satisfied:
|
|
27
|
+
* they entered the portal shell instead of being redirected to the login page.
|
|
28
|
+
*
|
|
29
|
+
* `'support-clients'` is hard-coded on purpose — every other portal file already
|
|
30
|
+
* hard-codes it (`/api/support-clients/login`, `/api/support-clients/me`, the
|
|
31
|
+
* `user.collection` tests in `tickets/detail/page.tsx`), because the portal is a
|
|
32
|
+
* copied template with no access to the plugin's resolved `collectionSlugs`. An
|
|
33
|
+
* integrator who renames the collection must adjust the whole template.
|
|
34
|
+
*/
|
|
21
35
|
function isSupportUser(user: unknown): user is SupportUser {
|
|
22
|
-
return
|
|
36
|
+
return (
|
|
37
|
+
typeof user === 'object' &&
|
|
38
|
+
user !== null &&
|
|
39
|
+
(user as { collection?: unknown }).collection === 'support-clients'
|
|
40
|
+
)
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
async function getSupportUser(): Promise<SupportUser | null> {
|