@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
@@ -8,13 +8,59 @@ export interface RateLimitStore {
8
8
  reset(key: string, context?: unknown): Promise<void>
9
9
  }
10
10
 
11
+ /**
12
+ * Hard ceiling on the number of distinct keys held at once, all endpoints
13
+ * combined. Sized for a busy install (thousands of client IPs inside one
14
+ * 15-minute window) while capping the map at a few hundred kilobytes.
15
+ */
16
+ export const MAX_MEMORY_RATE_LIMIT_KEYS = 10_000
17
+
11
18
  /**
12
19
  * Process-local fallback store. Applications running more than one process
13
20
  * should provide a persistent RateLimitStore through the plugin options.
21
+ *
22
+ * BOUNDED, because the keys come from anonymous HTTP traffic: `/support/login`
23
+ * and `/support/chatbot` key their limiter on the client IP, and the IP is read
24
+ * from `x-forwarded-for`. A caller rotating that header wrote one PERMANENT
25
+ * entry per value into a map that had no ceiling, no expiry sweep and no
26
+ * eviction — only `reset()` ever removed anything, and the login path never
27
+ * calls it. Worse, every fresh key opened a fresh window, so the limiter did
28
+ * not even slow down the flood that was exhausting it.
29
+ *
30
+ * Two independent bounds, the same pair `endpoints/typing.ts` already applies
31
+ * to its own module-level map: `clientIpRateKey` caps the SIZE of a key, the
32
+ * ceiling below caps their NUMBER.
14
33
  */
15
34
  export class MemoryRateLimitStore implements RateLimitStore {
16
35
  private readonly entries = new Map<string, RateLimitEntry>()
17
36
 
37
+ constructor(private readonly maxKeys: number = MAX_MEMORY_RATE_LIMIT_KEYS) {}
38
+
39
+ /** Distinct keys currently held. Exposed so the ceiling can be asserted. */
40
+ get size(): number {
41
+ return this.entries.size
42
+ }
43
+
44
+ /** Reclaims every window that has already closed. */
45
+ private sweepExpired(now: number): void {
46
+ for (const [key, entry] of this.entries) {
47
+ if (now > entry.resetAt) this.entries.delete(key)
48
+ }
49
+ }
50
+
51
+ /** Soonest-closing window first, so the ceiling drops the least useful entry. */
52
+ private evictOldest(): void {
53
+ let oldestKey: string | null = null
54
+ let oldestResetAt = Infinity
55
+ for (const [key, entry] of this.entries) {
56
+ if (entry.resetAt < oldestResetAt) {
57
+ oldestResetAt = entry.resetAt
58
+ oldestKey = key
59
+ }
60
+ }
61
+ if (oldestKey !== null) this.entries.delete(oldestKey)
62
+ }
63
+
18
64
  async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
19
65
  const now = Date.now()
20
66
  const current = this.entries.get(key)
@@ -22,6 +68,12 @@ export class MemoryRateLimitStore implements RateLimitStore {
22
68
  ? { count: 1, resetAt: now + windowMs }
23
69
  : { ...current, count: current.count + 1 }
24
70
 
71
+ // Only a key that is not already held can grow the map.
72
+ if (!current && this.entries.size >= this.maxKeys) {
73
+ this.sweepExpired(now)
74
+ if (this.entries.size >= this.maxKeys) this.evictOldest()
75
+ }
76
+
25
77
  this.entries.set(key, next)
26
78
  return next
27
79
  }
@@ -119,27 +171,107 @@ export class PayloadRateLimitStore implements RateLimitStore {
119
171
  }
120
172
  }
121
173
 
174
+ /**
175
+ * Identity part of a rate-limit key for an authenticated caller.
176
+ *
177
+ * Ids are per-collection sequences: support-client #7 and agent #7 are different
178
+ * people with the same `id`. Keying a limiter on `String(user.id)` alone let one
179
+ * consume the other's budget, so the auth collection travels with the id.
180
+ */
181
+ export function principalRateKey(
182
+ user: { id?: unknown; collection?: unknown } | null | undefined,
183
+ ): string {
184
+ if (!user || user.id === undefined || user.id === null) return 'anonymous'
185
+ const collection = typeof user.collection === 'string' && user.collection ? user.collection : 'unknown'
186
+ return `${collection}:${String(user.id)}`
187
+ }
188
+
189
+ /**
190
+ * Longest textual IPv6 address, `xxxx:` * 7 + an embedded IPv4 literal.
191
+ * Nothing legitimate is longer; the cap is what makes the key bounded.
192
+ */
193
+ const MAX_IP_KEY_LENGTH = 45
194
+ /** Dotted-quad, or the hex/`::`/zone alphabet of an IPv6 literal. Shape only. */
195
+ const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
196
+ const IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/
197
+
198
+ /**
199
+ * Identity part of a rate-limit key for an ANONYMOUS caller.
200
+ *
201
+ * `x-forwarded-for` is attacker-controlled — the note in `endpoints/login.ts`
202
+ * has always said so, and Payload's account lock is what actually stops a
203
+ * brute-force. But the header was also used RAW as the limiter key, and that is
204
+ * a second, distinct problem: a key is a stored object. A caller rotating the
205
+ * header minted one unbounded, never-reclaimed entry per value; a value can be
206
+ * most of Node's 16 KB header budget. This validates the SHAPE and caps the
207
+ * LENGTH, so a forged header can still pick a bucket but can no longer invent
208
+ * an unbounded number of them, nor make any single one large.
209
+ *
210
+ * Anything that is not an IP literal falls back to the shared `unknown` bucket.
211
+ * That is the fail-closed direction: an install with no proxy already puts
212
+ * every caller there, and one behind a proxy never lands there legitimately.
213
+ */
214
+ export function clientIpRateKey(req: { headers: { get(name: string): string | null } }): string {
215
+ const candidate = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
216
+ || req.headers.get('x-real-ip')?.trim()
217
+ || ''
218
+ return normalizeIpKey(candidate)
219
+ }
220
+
221
+ /** Exposed separately so a caller holding an already-extracted address can reuse it. */
222
+ export function normalizeIpKey(candidate: string): string {
223
+ if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return 'unknown'
224
+ const host = candidate.startsWith('[') && candidate.endsWith(']')
225
+ ? candidate.slice(1, -1)
226
+ : candidate
227
+ if (!host) return 'unknown'
228
+ if (IPV4_PATTERN.test(host)) {
229
+ return host.split('.').every((octet) => Number(octet) <= 255) ? host : 'unknown'
230
+ }
231
+ if (host.includes(':') && IPV6_PATTERN.test(host)) return host.toLowerCase()
232
+ return 'unknown'
233
+ }
234
+
122
235
  export class RateLimiter {
123
236
  private readonly store: RateLimitStore
237
+ private readonly prefix: string
124
238
 
239
+ /**
240
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
241
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
242
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
243
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
244
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
245
+ * by five different endpoints. Always pass one; it is optional only because
246
+ * `RateLimiter` is part of the published API surface.
247
+ */
125
248
  constructor(
126
249
  private readonly windowMs: number,
127
250
  private readonly maxRequests: number,
128
251
  store?: RateLimitStore,
252
+ namespace?: string,
129
253
  ) {
130
254
  this.store = store ?? new MemoryRateLimitStore()
255
+ this.prefix = namespace ? `${namespace}:` : ''
256
+ }
257
+
258
+ /** The key actually written to the store. Exposed for assertions in tests. */
259
+ scopedKey(key: string): string {
260
+ return `${this.prefix}${key}`
131
261
  }
132
262
 
133
263
  async check(key: string, context?: unknown): Promise<boolean> {
264
+ const scoped = this.scopedKey(key)
134
265
  const entry = context === undefined
135
- ? await this.store.increment(key, this.windowMs)
136
- : await this.store.increment(key, this.windowMs, context)
266
+ ? await this.store.increment(scoped, this.windowMs)
267
+ : await this.store.increment(scoped, this.windowMs, context)
137
268
  return entry.count > this.maxRequests
138
269
  }
139
270
 
140
271
  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)
272
+ const scoped = this.scopedKey(key)
273
+ if (context === undefined) await this.store.reset(scoped)
274
+ else await this.store.reset(scoped, context)
143
275
  }
144
276
  }
145
277
  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,85 @@
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
+ /**
40
+ * The address the challenge is signed over. Also the rate-limit key of both 2FA
41
+ * branches: signing over the normalized form while keying the limiter on the raw
42
+ * one would give a single challenge one budget PER CASING VARIANT.
43
+ */
44
+ export function normalizeEmail(email: string): string {
45
+ return String(email).trim().toLowerCase()
46
+ }
47
+
48
+ function sign(email: string, expiresAt: number): string {
49
+ return createHmac('sha256', challengeSecret())
50
+ .update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`)
51
+ .digest('hex')
52
+ }
53
+
54
+ /** Issued by the login endpoints once credentials have been verified. */
55
+ export function issueTwoFactorChallenge(email: string, now: number = Date.now()): string {
56
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS
57
+ return `${expiresAt}.${sign(email, expiresAt)}`
58
+ }
59
+
60
+ /** Constant-time; false on any malformed, expired or foreign token. */
61
+ export function verifyTwoFactorChallenge(
62
+ email: unknown,
63
+ token: unknown,
64
+ now: number = Date.now(),
65
+ ): boolean {
66
+ if (typeof email !== 'string' || !email || typeof token !== 'string') return false
67
+ const separator = token.indexOf('.')
68
+ if (separator <= 0) return false
69
+
70
+ const expiresAt = Number(token.slice(0, separator))
71
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false
72
+
73
+ const received = token.slice(separator + 1)
74
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false
75
+
76
+ let expected: string
77
+ try {
78
+ expected = sign(email, expiresAt)
79
+ } catch {
80
+ return false // no secret configured — fail closed
81
+ }
82
+ const a = Buffer.from(expected, 'hex')
83
+ const b = Buffer.from(received.toLowerCase(), 'hex')
84
+ return a.length === b.length && timingSafeEqual(a, b)
85
+ }