@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.
Files changed (77) hide show
  1. package/README.md +58 -14
  2. package/dist/index.cjs +673 -107
  3. package/dist/index.d.cts +40 -5
  4. package/dist/index.d.ts +40 -5
  5. package/dist/index.js +673 -107
  6. package/dist/utils/db.d.ts +34 -0
  7. package/dist/utils/readSettings.d.ts +90 -0
  8. package/dist/views/BillingView/index.js +4 -4
  9. package/dist/views/ChatView/index.js +4 -4
  10. package/dist/views/CrmView/index.js +4 -4
  11. package/dist/views/EmailTrackingView/index.js +4 -4
  12. package/dist/views/ImportConversationView/index.js +4 -4
  13. package/dist/views/LogsView/index.js +4 -2
  14. package/dist/views/NewTicketView/index.js +4 -2
  15. package/dist/views/PendingEmailsView/index.js +4 -4
  16. package/dist/views/SupportDashboardView/index.js +4 -4
  17. package/dist/views/TicketDetailView/index.js +4 -4
  18. package/dist/views/TicketInboxView/index.js +4 -2
  19. package/dist/views/TicketingSettingsView/index.js +4 -4
  20. package/dist/views/TimeDashboardView/index.js +4 -4
  21. package/dist/views/shared/viewAccess.d.ts +29 -0
  22. package/dist/views/shared/viewAccess.js +24 -0
  23. package/package.json +26 -20
  24. package/src/collections/ChatMessages.ts +59 -2
  25. package/src/collections/ClientSummaries.ts +10 -4
  26. package/src/collections/TicketMessages.ts +4 -1
  27. package/src/collections/WebhookEndpoints.ts +44 -2
  28. package/src/endpoints/admin-chat.ts +3 -3
  29. package/src/endpoints/ai-agent.ts +3 -3
  30. package/src/endpoints/ai.ts +3 -3
  31. package/src/endpoints/auth-2fa.ts +68 -11
  32. package/src/endpoints/capabilities.ts +7 -9
  33. package/src/endpoints/chat.ts +7 -5
  34. package/src/endpoints/chatbot.ts +50 -4
  35. package/src/endpoints/client-intelligence.ts +4 -4
  36. package/src/endpoints/email-stats.ts +19 -3
  37. package/src/endpoints/import-conversation.ts +3 -3
  38. package/src/endpoints/index.ts +1 -1
  39. package/src/endpoints/invite-collaborator.ts +29 -3
  40. package/src/endpoints/login.ts +28 -5
  41. package/src/endpoints/oauth-google.ts +130 -8
  42. package/src/endpoints/push.ts +14 -1
  43. package/src/endpoints/resend-notification.ts +3 -3
  44. package/src/endpoints/send-reminder.ts +3 -3
  45. package/src/endpoints/signature.ts +9 -2
  46. package/src/endpoints/statuses.ts +17 -0
  47. package/src/endpoints/ticket-synthesis.ts +3 -3
  48. package/src/endpoints/transfer-ticket.ts +28 -3
  49. package/src/endpoints/typing.ts +117 -14
  50. package/src/endpoints/user-prefs.ts +5 -2
  51. package/src/plugin.ts +12 -0
  52. package/src/portal/auth/layout.tsx +19 -1
  53. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  54. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  55. package/src/portal/login/page.tsx +23 -5
  56. package/src/utils/fireWebhooks.ts +4 -1
  57. package/src/utils/push.ts +22 -0
  58. package/src/utils/rateLimiter.ts +136 -4
  59. package/src/utils/readSettings.ts +124 -14
  60. package/src/utils/ticketAccess.ts +16 -1
  61. package/src/utils/twoFactorChallenge.ts +85 -0
  62. package/src/utils/urlSafety.ts +265 -0
  63. package/src/utils/webhookDispatcher.ts +5 -1
  64. package/src/views/BillingView/index.tsx +4 -4
  65. package/src/views/ChatView/index.tsx +4 -4
  66. package/src/views/CrmView/index.tsx +4 -4
  67. package/src/views/EmailTrackingView/index.tsx +4 -4
  68. package/src/views/ImportConversationView/index.tsx +4 -4
  69. package/src/views/LogsView/index.tsx +4 -2
  70. package/src/views/NewTicketView/index.tsx +4 -2
  71. package/src/views/PendingEmailsView/index.tsx +4 -4
  72. package/src/views/SupportDashboardView/index.tsx +4 -4
  73. package/src/views/TicketDetailView/index.tsx +4 -4
  74. package/src/views/TicketInboxView/index.tsx +4 -2
  75. package/src/views/TicketingSettingsView/index.tsx +4 -4
  76. package/src/views/TimeDashboardView/index.tsx +4 -4
  77. package/src/views/shared/viewAccess.ts +73 -0
@@ -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
  }
@@ -1,6 +1,6 @@
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 { readSupportSettings } from '../utils/readSettings'
5
5
  import { dbFind, dbCreate } from '../utils/db'
6
6
  import { verifySecret } from '../utils/webhookSecurity'
@@ -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',
@@ -154,7 +154,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs, store?:
154
154
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
155
155
  }
156
156
 
157
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
157
+ const ip = clientIpRateKey(req)
158
158
  if (await importLimiter.check(ip, req)) {
159
159
  return Response.json({ error: 'Rate limit exceeded. Maximum 10 imports per hour.' }, { status: 429 })
160
160
  }
@@ -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')
@@ -1,15 +1,19 @@
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 { dbCreate } from '../utils/db'
5
+ import { issueTwoFactorChallenge } from '../utils/twoFactorChallenge'
5
6
 
6
7
 
8
+ /** Enough to identify a browser, short enough that a flood cannot fill the disk. */
9
+ const MAX_LOGGED_USER_AGENT = 256
10
+
7
11
  /**
8
12
  * POST /api/support/login
9
13
  * Client login endpoint.
10
14
  */
11
15
  export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
12
- const loginLimiter = new RateLimiter(15 * 60_000, 10, store)
16
+ const loginLimiter = new RateLimiter(15 * 60_000, 10, store, 'login')
13
17
  return {
14
18
  path: '/support/login',
15
19
  method: 'post',
@@ -17,7 +21,12 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
17
21
  // NOTE: x-forwarded-for is spoofable unless this app sits strictly behind a
18
22
  // trusted proxy that rewrites it. This IP rate-limit is a SECONDARY defense;
19
23
  // the primary brute-force control is Payload's account lock (maxLoginAttempts).
20
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
24
+ //
25
+ // `clientIpRateKey` is what keeps a spoofed header from being more than a
26
+ // bucket choice: the raw header used to become the limiter key AND the
27
+ // `ipAddress` column below, so rotating it minted unbounded map entries in
28
+ // memory and unbounded rows on disk.
29
+ const ip = clientIpRateKey(req)
21
30
 
22
31
  if (await loginLimiter.check(ip, req)) {
23
32
  return Response.json(
@@ -34,7 +43,9 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
34
43
  return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
35
44
  }
36
45
  const { email, password } = body
37
- const userAgent = req.headers.get('user-agent') || ''
46
+ // Persisted on every failed attempt, from an anonymous request: bounded so
47
+ // a flood cannot write tens of kilobytes per row into `auth-logs`.
48
+ const userAgent = (req.headers.get('user-agent') || '').slice(0, MAX_LOGGED_USER_AGENT)
38
49
 
39
50
  if (!email || !password) {
40
51
  return Response.json({ error: 'Email et mot de passe requis.' }, { status: 400 })
@@ -75,8 +86,20 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
75
86
 
76
87
  // 2FA gate: password was correct but a fresh 2FA verification is required.
77
88
  // No session/cookie is issued (Payload rolled it back on the throw).
89
+ //
90
+ // The password check just succeeded, so this is where the short-lived
91
+ // proof for `POST /support/2fa {action:'send'}` is minted: without it,
92
+ // that endpoint would keep letting an anonymous caller burn a victim's
93
+ // send quota and lock them out of their own account.
78
94
  if (errorMessage.includes('2FA_REQUIRED')) {
79
- return Response.json({ requires2FA: true }, { status: 200 })
95
+ let challenge: string | undefined
96
+ try {
97
+ challenge = issueTwoFactorChallenge(email)
98
+ } catch {
99
+ // PAYLOAD_SECRET missing: 2FA cannot operate at all (codes are
100
+ // hashed with it). Answer without a challenge — fail closed.
101
+ }
102
+ return Response.json({ requires2FA: true, ...(challenge ? { challenge } : {}) }, { status: 200 })
80
103
  }
81
104
 
82
105
  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, cookieState: 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, cookieState } = body
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
- return Response.json({
50
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
51
- state: oauthState,
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
- if (!cookieState || !queryState || cookieState !== queryState) {
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,
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { dbFind, dbCreate, dbUpdate } from '../utils/db'
5
5
  import { getVapidPublicKey } from '../utils/push'
6
+ import { validatePushEndpoint, WEBHOOK_URL_MESSAGES } from '../utils/urlSafety'
6
7
 
7
8
  /** GET /api/support/push/vapid-public-key — public key the browser uses to subscribe. */
8
9
  export function createVapidKeyEndpoint(): Endpoint {
@@ -30,7 +31,19 @@ export function createPushSubscribeEndpoint(slugs: CollectionSlugs): Endpoint {
30
31
  if (!endpoint || !p256dh || !auth) {
31
32
  return Response.json({ error: 'subscription invalide (endpoint + keys requis).' }, { status: 400 })
32
33
  }
33
- const data = { user: req.user!.id, endpoint, p256dh, auth, userAgent: req.headers.get('user-agent') || '' }
34
+ // WRITE-time SSRF guard. `requireAdmin` above only proves the caller
35
+ // belongs to the staff collection — the very actor `utils/urlSafety`
36
+ // already models as hostile for webhook URLs — and `web-push` hands this
37
+ // string's host and port straight to `https.request`. Same guard, same
38
+ // reason, on the second field that steers an outbound request.
39
+ const endpointCheck = validatePushEndpoint(endpoint)
40
+ if (!endpointCheck.ok) {
41
+ return Response.json(
42
+ { error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || 'invalid_url'] },
43
+ { status: 400 },
44
+ )
45
+ }
46
+ const data = { user: req.user!.id, endpoint, p256dh, auth, userAgent: (req.headers.get('user-agent') || '').slice(0, 256) }
34
47
  const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true })
35
48
  if (existing.docs.length > 0) {
36
49
  await dbUpdate(req.payload, slugs.pushSubscriptions, { id: (existing.docs[0] as { id: number | string }).id, data, overrideAccess: true })
@@ -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(String(req.user.id), req)) {
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(String(req.user.id), req)) {
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
- where: { key: { equals: `${PREF_KEY}-${req.user.id}` } },
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,
@@ -5,6 +5,18 @@ import { dbFind } from '../utils/db'
5
5
  /**
6
6
  * GET /api/support/statuses
7
7
  * Returns all ticket statuses sorted by sortOrder.
8
+ *
9
+ * The guard used to be `!!req.user`, which ANY authenticated principal
10
+ * satisfies — including a member of an unrelated auth collection of the host
11
+ * app (customers, members, subscribers created by public sign-up). Because the
12
+ * rows below are read with `overrideAccess: true`, that handed them the whole
13
+ * support workflow taxonomy — internal status names, private states, pipeline
14
+ * order — which `ticket-statuses.access.read` explicitly refuses them.
15
+ *
16
+ * The check below MIRRORS that ACL rather than replacing it: staff and
17
+ * support-clients, nobody else. `overrideAccess` stays, because the endpoint
18
+ * legitimately serves the full list to both, and the projection it returns is
19
+ * narrower than the documents themselves.
8
20
  */
9
21
  export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
10
22
  return {
@@ -18,6 +30,11 @@ export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
18
30
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
19
31
  }
20
32
 
33
+ const collection = (req.user as { collection?: string }).collection
34
+ if (collection !== slugs.users && collection !== slugs.supportClients) {
35
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
36
+ }
37
+
21
38
  const { docs } = await dbFind(payload, slugs.ticketStatuses, {
22
39
  sort: 'sortOrder',
23
40
  limit: 100,
@@ -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(String(req.user!.id), req)) {
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.id}:${ticketId}`, req)) {
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()