@consilioweb/payload-support 3.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -14
- package/dist/index.cjs +673 -107
- package/dist/index.d.cts +40 -5
- package/dist/index.d.ts +40 -5
- package/dist/index.js +673 -107
- package/dist/utils/db.d.ts +34 -0
- package/dist/utils/readSettings.d.ts +90 -0
- package/dist/views/BillingView/index.js +4 -4
- package/dist/views/ChatView/index.js +4 -4
- package/dist/views/CrmView/index.js +4 -4
- package/dist/views/EmailTrackingView/index.js +4 -4
- package/dist/views/ImportConversationView/index.js +4 -4
- package/dist/views/LogsView/index.js +4 -2
- package/dist/views/NewTicketView/index.js +4 -2
- package/dist/views/PendingEmailsView/index.js +4 -4
- package/dist/views/SupportDashboardView/index.js +4 -4
- package/dist/views/TicketDetailView/index.js +4 -4
- package/dist/views/TicketInboxView/index.js +4 -2
- package/dist/views/TicketingSettingsView/index.js +4 -4
- package/dist/views/TimeDashboardView/index.js +4 -4
- package/dist/views/shared/viewAccess.d.ts +29 -0
- package/dist/views/shared/viewAccess.js +24 -0
- package/package.json +26 -20
- package/src/collections/ChatMessages.ts +59 -2
- package/src/collections/ClientSummaries.ts +10 -4
- package/src/collections/TicketMessages.ts +4 -1
- package/src/collections/WebhookEndpoints.ts +44 -2
- package/src/endpoints/admin-chat.ts +3 -3
- package/src/endpoints/ai-agent.ts +3 -3
- package/src/endpoints/ai.ts +3 -3
- package/src/endpoints/auth-2fa.ts +68 -11
- package/src/endpoints/capabilities.ts +7 -9
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +50 -4
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +3 -3
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +28 -5
- package/src/endpoints/oauth-google.ts +130 -8
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/resend-notification.ts +3 -3
- package/src/endpoints/send-reminder.ts +3 -3
- package/src/endpoints/signature.ts +9 -2
- package/src/endpoints/statuses.ts +17 -0
- package/src/endpoints/ticket-synthesis.ts +3 -3
- package/src/endpoints/transfer-ticket.ts +28 -3
- package/src/endpoints/typing.ts +117 -14
- package/src/endpoints/user-prefs.ts +5 -2
- package/src/plugin.ts +12 -0
- package/src/portal/auth/layout.tsx +19 -1
- package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
- package/src/portal/auth/tickets/detail/page.tsx +2 -6
- package/src/portal/login/page.tsx +23 -5
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +136 -4
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +85 -0
- package/src/utils/urlSafety.ts +265 -0
- package/src/utils/webhookDispatcher.ts +5 -1
- package/src/views/BillingView/index.tsx +4 -4
- package/src/views/ChatView/index.tsx +4 -4
- package/src/views/CrmView/index.tsx +4 -4
- package/src/views/EmailTrackingView/index.tsx +4 -4
- package/src/views/ImportConversationView/index.tsx +4 -4
- package/src/views/LogsView/index.tsx +4 -2
- package/src/views/NewTicketView/index.tsx +4 -2
- package/src/views/PendingEmailsView/index.tsx +4 -4
- package/src/views/SupportDashboardView/index.tsx +4 -4
- package/src/views/TicketDetailView/index.tsx +4 -4
- package/src/views/TicketInboxView/index.tsx +4 -2
- package/src/views/TicketingSettingsView/index.tsx +4 -4
- package/src/views/TimeDashboardView/index.tsx +4 -4
- package/src/views/shared/viewAccess.ts +73 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { initTransaction, commitTransaction, killTransaction, getFieldsToSign, jwtSign, APIError } from 'payload';
|
|
2
2
|
import crypto3, { createHash, timingSafeEqual, createHmac, randomBytes } from 'crypto';
|
|
3
3
|
import PDFDocument from 'pdfkit';
|
|
4
|
+
import { lookup } from 'dns/promises';
|
|
4
5
|
import webpush from 'web-push';
|
|
5
6
|
import sanitizeHtml from 'sanitize-html';
|
|
6
7
|
|
|
@@ -74,12 +75,43 @@ var DEFAULT_SLUGS = {
|
|
|
74
75
|
function resolveSlugs(overrides) {
|
|
75
76
|
return { ...DEFAULT_SLUGS, ...overrides };
|
|
76
77
|
}
|
|
78
|
+
var MAX_MEMORY_RATE_LIMIT_KEYS = 1e4;
|
|
77
79
|
var MemoryRateLimitStore = class {
|
|
80
|
+
constructor(maxKeys = MAX_MEMORY_RATE_LIMIT_KEYS) {
|
|
81
|
+
this.maxKeys = maxKeys;
|
|
82
|
+
}
|
|
83
|
+
maxKeys;
|
|
78
84
|
entries = /* @__PURE__ */ new Map();
|
|
85
|
+
/** Distinct keys currently held. Exposed so the ceiling can be asserted. */
|
|
86
|
+
get size() {
|
|
87
|
+
return this.entries.size;
|
|
88
|
+
}
|
|
89
|
+
/** Reclaims every window that has already closed. */
|
|
90
|
+
sweepExpired(now) {
|
|
91
|
+
for (const [key, entry] of this.entries) {
|
|
92
|
+
if (now > entry.resetAt) this.entries.delete(key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Soonest-closing window first, so the ceiling drops the least useful entry. */
|
|
96
|
+
evictOldest() {
|
|
97
|
+
let oldestKey = null;
|
|
98
|
+
let oldestResetAt = Infinity;
|
|
99
|
+
for (const [key, entry] of this.entries) {
|
|
100
|
+
if (entry.resetAt < oldestResetAt) {
|
|
101
|
+
oldestResetAt = entry.resetAt;
|
|
102
|
+
oldestKey = key;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (oldestKey !== null) this.entries.delete(oldestKey);
|
|
106
|
+
}
|
|
79
107
|
async increment(key, windowMs) {
|
|
80
108
|
const now = Date.now();
|
|
81
109
|
const current = this.entries.get(key);
|
|
82
110
|
const next = !current || now > current.resetAt ? { count: 1, resetAt: now + windowMs } : { ...current, count: current.count + 1 };
|
|
111
|
+
if (!current && this.entries.size >= this.maxKeys) {
|
|
112
|
+
this.sweepExpired(now);
|
|
113
|
+
if (this.entries.size >= this.maxKeys) this.evictOldest();
|
|
114
|
+
}
|
|
83
115
|
this.entries.set(key, next);
|
|
84
116
|
return next;
|
|
85
117
|
}
|
|
@@ -158,22 +190,61 @@ var PayloadRateLimitStore = class {
|
|
|
158
190
|
return { payload: context };
|
|
159
191
|
}
|
|
160
192
|
};
|
|
193
|
+
function principalRateKey(user) {
|
|
194
|
+
if (!user || user.id === void 0 || user.id === null) return "anonymous";
|
|
195
|
+
const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
|
|
196
|
+
return `${collection}:${String(user.id)}`;
|
|
197
|
+
}
|
|
198
|
+
var MAX_IP_KEY_LENGTH = 45;
|
|
199
|
+
var IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
|
200
|
+
var IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/;
|
|
201
|
+
function clientIpRateKey(req) {
|
|
202
|
+
const candidate = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim() || "";
|
|
203
|
+
return normalizeIpKey(candidate);
|
|
204
|
+
}
|
|
205
|
+
function normalizeIpKey(candidate) {
|
|
206
|
+
if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return "unknown";
|
|
207
|
+
const host = candidate.startsWith("[") && candidate.endsWith("]") ? candidate.slice(1, -1) : candidate;
|
|
208
|
+
if (!host) return "unknown";
|
|
209
|
+
if (IPV4_PATTERN.test(host)) {
|
|
210
|
+
return host.split(".").every((octet) => Number(octet) <= 255) ? host : "unknown";
|
|
211
|
+
}
|
|
212
|
+
if (host.includes(":") && IPV6_PATTERN.test(host)) return host.toLowerCase();
|
|
213
|
+
return "unknown";
|
|
214
|
+
}
|
|
161
215
|
var RateLimiter = class {
|
|
162
|
-
|
|
216
|
+
/**
|
|
217
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
218
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
219
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
220
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
221
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
222
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
223
|
+
* `RateLimiter` is part of the published API surface.
|
|
224
|
+
*/
|
|
225
|
+
constructor(windowMs, maxRequests, store, namespace) {
|
|
163
226
|
this.windowMs = windowMs;
|
|
164
227
|
this.maxRequests = maxRequests;
|
|
165
228
|
this.store = store ?? new MemoryRateLimitStore();
|
|
229
|
+
this.prefix = namespace ? `${namespace}:` : "";
|
|
166
230
|
}
|
|
167
231
|
windowMs;
|
|
168
232
|
maxRequests;
|
|
169
233
|
store;
|
|
234
|
+
prefix;
|
|
235
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
236
|
+
scopedKey(key) {
|
|
237
|
+
return `${this.prefix}${key}`;
|
|
238
|
+
}
|
|
170
239
|
async check(key, context) {
|
|
171
|
-
const
|
|
240
|
+
const scoped = this.scopedKey(key);
|
|
241
|
+
const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
|
|
172
242
|
return entry.count > this.maxRequests;
|
|
173
243
|
}
|
|
174
244
|
async reset(key, context) {
|
|
175
|
-
|
|
176
|
-
|
|
245
|
+
const scoped = this.scopedKey(key);
|
|
246
|
+
if (context === void 0) await this.store.reset(scoped);
|
|
247
|
+
else await this.store.reset(scoped, context);
|
|
177
248
|
}
|
|
178
249
|
};
|
|
179
250
|
var DEFAULT_INBOUND_EMAIL_LIMITS = {
|
|
@@ -211,7 +282,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
|
|
|
211
282
|
|
|
212
283
|
// src/endpoints/capabilities.ts
|
|
213
284
|
function createInboundEmailEndpoint(capability, store) {
|
|
214
|
-
const limiter = new RateLimiter(6e4, 60, store);
|
|
285
|
+
const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
|
|
215
286
|
return {
|
|
216
287
|
path: "/support-webhook/inbound-email",
|
|
217
288
|
method: "post",
|
|
@@ -220,7 +291,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
220
291
|
if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
|
|
221
292
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
222
293
|
}
|
|
223
|
-
const ip =
|
|
294
|
+
const ip = clientIpRateKey(req);
|
|
224
295
|
if (await limiter.check(ip, req)) {
|
|
225
296
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
226
297
|
}
|
|
@@ -242,7 +313,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
242
313
|
};
|
|
243
314
|
}
|
|
244
315
|
function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
245
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
316
|
+
const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
|
|
246
317
|
return {
|
|
247
318
|
path: "/support/suggest-projects",
|
|
248
319
|
method: "post",
|
|
@@ -250,7 +321,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
250
321
|
if (!req.user || req.user.collection !== slugs.users) {
|
|
251
322
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
252
323
|
}
|
|
253
|
-
const key =
|
|
324
|
+
const key = principalRateKey(req.user);
|
|
254
325
|
if (await limiter.check(key, req)) {
|
|
255
326
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
256
327
|
}
|
|
@@ -259,7 +330,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
259
330
|
};
|
|
260
331
|
}
|
|
261
332
|
function createTicketTitleEndpoint(slugs, capability, store) {
|
|
262
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
333
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
|
|
263
334
|
return {
|
|
264
335
|
path: "/support/ticket-title",
|
|
265
336
|
method: "post",
|
|
@@ -281,7 +352,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
|
|
|
281
352
|
};
|
|
282
353
|
}
|
|
283
354
|
function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
|
|
284
|
-
const limiter = new RateLimiter(6e4, 5, store);
|
|
355
|
+
const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
|
|
285
356
|
return {
|
|
286
357
|
path: "/support/generate-missing-titles",
|
|
287
358
|
method: "post",
|
|
@@ -437,6 +508,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
|
|
|
437
508
|
var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
|
|
438
509
|
var USER_PREFS_KEY_PREFIX = "support-user-prefs";
|
|
439
510
|
var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
|
|
511
|
+
var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
|
|
512
|
+
function resolveStaffPrefSlug(payload, staffSlug) {
|
|
513
|
+
if (staffSlug) return staffSlug;
|
|
514
|
+
const config = payload.config;
|
|
515
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
|
|
516
|
+
if (typeof registered === "string" && registered) return registered;
|
|
517
|
+
return config?.admin?.user || "users";
|
|
518
|
+
}
|
|
440
519
|
var DEFAULT_SETTINGS = {
|
|
441
520
|
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
442
521
|
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
|
|
@@ -448,10 +527,13 @@ var DEFAULT_USER_PREFS = {
|
|
|
448
527
|
locale: "fr",
|
|
449
528
|
signature: ""
|
|
450
529
|
};
|
|
451
|
-
var settingsCache =
|
|
530
|
+
var settingsCache = /* @__PURE__ */ new Map();
|
|
452
531
|
var SETTINGS_TTL_MS = 6e4;
|
|
532
|
+
var SETTINGS_CACHE_MAX = 8;
|
|
533
|
+
var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
|
|
453
534
|
function invalidateSupportSettingsCache() {
|
|
454
|
-
settingsCache
|
|
535
|
+
settingsCache.clear();
|
|
536
|
+
warnedForeignSettingsRow.clear();
|
|
455
537
|
}
|
|
456
538
|
function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
457
539
|
const autoClose = { ...base.autoClose, ...stored?.autoClose };
|
|
@@ -468,9 +550,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
|
468
550
|
)
|
|
469
551
|
};
|
|
470
552
|
}
|
|
471
|
-
async function readSupportSettingsState(payload) {
|
|
472
|
-
|
|
473
|
-
|
|
553
|
+
async function readSupportSettingsState(payload, staffSlug) {
|
|
554
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug);
|
|
555
|
+
const cached = settingsCache.get(staff);
|
|
556
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
557
|
+
return cached.value;
|
|
474
558
|
}
|
|
475
559
|
let value = {
|
|
476
560
|
settings: mergeSupportSettings(null),
|
|
@@ -478,7 +562,10 @@ async function readSupportSettingsState(payload) {
|
|
|
478
562
|
};
|
|
479
563
|
try {
|
|
480
564
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
481
|
-
|
|
565
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
566
|
+
// security boundary: without it any authenticated principal can plant a
|
|
567
|
+
// `support-settings` row and own the plugin's server settings.
|
|
568
|
+
where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
|
|
482
569
|
// The upsert is scoped per admin user, so several rows can share the key.
|
|
483
570
|
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
484
571
|
sort: "-updatedAt",
|
|
@@ -491,22 +578,43 @@ async function readSupportSettingsState(payload) {
|
|
|
491
578
|
const featuresConfigured = !!stored.features && typeof stored.features === "object";
|
|
492
579
|
const settings = mergeSupportSettings(stored);
|
|
493
580
|
if (!featuresConfigured) {
|
|
494
|
-
settings.features.roundRobin = await readLegacyRoundRobin(payload);
|
|
581
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
|
|
495
582
|
}
|
|
496
583
|
value = { settings, featuresConfigured };
|
|
584
|
+
} else {
|
|
585
|
+
await warnOnForeignSettingsRow(payload, staff);
|
|
497
586
|
}
|
|
498
587
|
} catch {
|
|
499
588
|
}
|
|
500
|
-
settingsCache
|
|
589
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
|
|
590
|
+
settingsCache.set(staff, { value, ts: Date.now() });
|
|
501
591
|
return value;
|
|
502
592
|
}
|
|
503
|
-
async function
|
|
504
|
-
|
|
593
|
+
async function warnOnForeignSettingsRow(payload, staff) {
|
|
594
|
+
if (warnedForeignSettingsRow.has(staff)) return;
|
|
595
|
+
try {
|
|
596
|
+
const any = await dbFind(payload, "payload-preferences", {
|
|
597
|
+
where: { key: { equals: PREF_KEY } },
|
|
598
|
+
limit: 1,
|
|
599
|
+
depth: 0,
|
|
600
|
+
overrideAccess: true
|
|
601
|
+
});
|
|
602
|
+
if (any.docs.length === 0) return;
|
|
603
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
|
|
604
|
+
warnedForeignSettingsRow.add(staff);
|
|
605
|
+
console.warn(
|
|
606
|
+
`[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: the plugin is running on its DEFAULT settings. Either the staff auth collection differs from \`admin.user\`, or the row was written by a principal that is not staff \u2014 in which case it is ignored on purpose.`
|
|
607
|
+
);
|
|
608
|
+
} catch {
|
|
609
|
+
}
|
|
505
610
|
}
|
|
506
|
-
async function
|
|
611
|
+
async function readSupportSettings(payload, staffSlug) {
|
|
612
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings;
|
|
613
|
+
}
|
|
614
|
+
async function readLegacyRoundRobin(payload, staff) {
|
|
507
615
|
try {
|
|
508
616
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
509
|
-
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
|
|
617
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
|
|
510
618
|
limit: 1,
|
|
511
619
|
depth: 0,
|
|
512
620
|
overrideAccess: true
|
|
@@ -518,11 +626,14 @@ async function readLegacyRoundRobin(payload) {
|
|
|
518
626
|
}
|
|
519
627
|
return DEFAULT_TICKETING_FEATURES.roundRobin;
|
|
520
628
|
}
|
|
521
|
-
async function readUserPrefs(payload, userId) {
|
|
629
|
+
async function readUserPrefs(payload, userId, staffSlug) {
|
|
522
630
|
try {
|
|
523
631
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
|
|
524
632
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
525
|
-
where: {
|
|
633
|
+
where: {
|
|
634
|
+
key: { equals: key },
|
|
635
|
+
"user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
|
|
636
|
+
},
|
|
526
637
|
limit: 1,
|
|
527
638
|
depth: 0,
|
|
528
639
|
overrideAccess: true
|
|
@@ -560,7 +671,7 @@ function getModel(aiSettings) {
|
|
|
560
671
|
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
561
672
|
}
|
|
562
673
|
function createAiEndpoint(slugs, store) {
|
|
563
|
-
const limiter = new RateLimiter(6e4, 30, store);
|
|
674
|
+
const limiter = new RateLimiter(6e4, 30, store, "ai");
|
|
564
675
|
return {
|
|
565
676
|
path: "/support/ai",
|
|
566
677
|
method: "post",
|
|
@@ -568,7 +679,7 @@ function createAiEndpoint(slugs, store) {
|
|
|
568
679
|
try {
|
|
569
680
|
const payload = req.payload;
|
|
570
681
|
requireAdmin(req, slugs);
|
|
571
|
-
if (await limiter.check(
|
|
682
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
572
683
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
573
684
|
}
|
|
574
685
|
const settings = await readSupportSettings(payload);
|
|
@@ -813,14 +924,14 @@ ${kbText || "(vide)"}`;
|
|
|
813
924
|
|
|
814
925
|
// src/endpoints/ai-agent.ts
|
|
815
926
|
function createAiAgentEndpoint(slugs, store) {
|
|
816
|
-
const limiter = new RateLimiter(6e4, 10, store);
|
|
927
|
+
const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
|
|
817
928
|
return {
|
|
818
929
|
path: "/support/ai-agent",
|
|
819
930
|
method: "post",
|
|
820
931
|
handler: async (req) => {
|
|
821
932
|
try {
|
|
822
933
|
requireAdmin(req, slugs);
|
|
823
|
-
if (await limiter.check(
|
|
934
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
824
935
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
825
936
|
}
|
|
826
937
|
let body = {};
|
|
@@ -860,11 +971,11 @@ function getModel2(aiSettings) {
|
|
|
860
971
|
}
|
|
861
972
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
862
973
|
function createClientIntelligenceEndpoint(slugs, store) {
|
|
863
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
974
|
+
const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
|
|
864
975
|
const getHandler = async (req) => {
|
|
865
976
|
try {
|
|
866
977
|
requireAdmin(req, slugs);
|
|
867
|
-
if (await limiter.check(
|
|
978
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
868
979
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
869
980
|
}
|
|
870
981
|
const payload = req.payload;
|
|
@@ -896,7 +1007,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
|
|
|
896
1007
|
const postHandler = async (req) => {
|
|
897
1008
|
try {
|
|
898
1009
|
requireAdmin(req, slugs);
|
|
899
|
-
if (await limiter.check(
|
|
1010
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
900
1011
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
901
1012
|
}
|
|
902
1013
|
const payload = req.payload;
|
|
@@ -1525,6 +1636,14 @@ function createSplitTicketEndpoint(slugs) {
|
|
|
1525
1636
|
// src/endpoints/typing.ts
|
|
1526
1637
|
var typingState = /* @__PURE__ */ new Map();
|
|
1527
1638
|
var TYPING_TTL = 5e3;
|
|
1639
|
+
var MAX_TYPING_KEYS = 500;
|
|
1640
|
+
var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1641
|
+
function normalizeTicketId(raw) {
|
|
1642
|
+
if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
|
|
1643
|
+
if (typeof raw !== "string") return null;
|
|
1644
|
+
const value = raw.trim();
|
|
1645
|
+
return TICKET_ID_PATTERN.test(value) ? value : null;
|
|
1646
|
+
}
|
|
1528
1647
|
function cleanExpired(ticketId) {
|
|
1529
1648
|
const state = typingState.get(ticketId);
|
|
1530
1649
|
if (!state) return;
|
|
@@ -1539,6 +1658,36 @@ function cleanExpired(ticketId) {
|
|
|
1539
1658
|
}
|
|
1540
1659
|
if (!state.admin && !state.client) typingState.delete(ticketId);
|
|
1541
1660
|
}
|
|
1661
|
+
function sweepExpired() {
|
|
1662
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key);
|
|
1663
|
+
}
|
|
1664
|
+
function evictOldest() {
|
|
1665
|
+
let oldestKey = null;
|
|
1666
|
+
let oldestTs = Infinity;
|
|
1667
|
+
for (const [key, state] of typingState) {
|
|
1668
|
+
const ts = Math.max(state.admin || 0, state.client || 0);
|
|
1669
|
+
if (ts < oldestTs) {
|
|
1670
|
+
oldestTs = ts;
|
|
1671
|
+
oldestKey = key;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (oldestKey !== null) typingState.delete(oldestKey);
|
|
1675
|
+
}
|
|
1676
|
+
async function mayAccessTicket(req, slugs, ticketId) {
|
|
1677
|
+
const collection = req.user?.collection;
|
|
1678
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false;
|
|
1679
|
+
try {
|
|
1680
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
1681
|
+
id: ticketId,
|
|
1682
|
+
depth: 0,
|
|
1683
|
+
overrideAccess: false,
|
|
1684
|
+
user: req.user
|
|
1685
|
+
});
|
|
1686
|
+
return !!doc;
|
|
1687
|
+
} catch {
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1542
1691
|
function createTypingPostEndpoint(slugs) {
|
|
1543
1692
|
return {
|
|
1544
1693
|
path: "/support/typing",
|
|
@@ -1549,11 +1698,18 @@ function createTypingPostEndpoint(slugs) {
|
|
|
1549
1698
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1550
1699
|
}
|
|
1551
1700
|
const { ticketId } = await req.json();
|
|
1552
|
-
|
|
1701
|
+
const key = normalizeTicketId(ticketId);
|
|
1702
|
+
if (!key) {
|
|
1553
1703
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1554
1704
|
}
|
|
1555
|
-
|
|
1705
|
+
if (!await mayAccessTicket(req, slugs, key)) {
|
|
1706
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
1707
|
+
}
|
|
1556
1708
|
const state = typingState.get(key) || {};
|
|
1709
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
1710
|
+
sweepExpired();
|
|
1711
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
|
|
1712
|
+
}
|
|
1557
1713
|
if (req.user.collection === slugs.users) {
|
|
1558
1714
|
state.admin = Date.now();
|
|
1559
1715
|
state.adminName = req.user.firstName || "Support";
|
|
@@ -1574,30 +1730,33 @@ function createTypingGetEndpoint(slugs) {
|
|
|
1574
1730
|
path: "/support/typing",
|
|
1575
1731
|
method: "get",
|
|
1576
1732
|
handler: async (req) => {
|
|
1733
|
+
const idle = { typing: false, name: null };
|
|
1577
1734
|
try {
|
|
1578
1735
|
if (!req.user) {
|
|
1579
1736
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1580
1737
|
}
|
|
1581
1738
|
const url = new URL(req.url);
|
|
1582
|
-
const
|
|
1583
|
-
if (!
|
|
1739
|
+
const key = normalizeTicketId(url.searchParams.get("ticketId"));
|
|
1740
|
+
if (!key) {
|
|
1584
1741
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1585
1742
|
}
|
|
1586
|
-
cleanExpired(
|
|
1587
|
-
const state = typingState.get(
|
|
1743
|
+
cleanExpired(key);
|
|
1744
|
+
const state = typingState.get(key);
|
|
1745
|
+
if (!state) return Response.json(idle);
|
|
1746
|
+
if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
|
|
1588
1747
|
if (req.user.collection === slugs.users) {
|
|
1589
1748
|
return Response.json({
|
|
1590
|
-
typing: !!state
|
|
1591
|
-
name: state
|
|
1749
|
+
typing: !!state.client,
|
|
1750
|
+
name: state.clientName || null
|
|
1592
1751
|
});
|
|
1593
1752
|
} else {
|
|
1594
1753
|
return Response.json({
|
|
1595
|
-
typing: !!state
|
|
1596
|
-
name: state
|
|
1754
|
+
typing: !!state.admin,
|
|
1755
|
+
name: state.adminName || null
|
|
1597
1756
|
});
|
|
1598
1757
|
}
|
|
1599
1758
|
} catch {
|
|
1600
|
-
return Response.json(
|
|
1759
|
+
return Response.json(idle);
|
|
1601
1760
|
}
|
|
1602
1761
|
}
|
|
1603
1762
|
};
|
|
@@ -1760,7 +1919,14 @@ function createSignatureGetEndpoint(slugs) {
|
|
|
1760
1919
|
const payload = req.payload;
|
|
1761
1920
|
requireAdmin(req, slugs);
|
|
1762
1921
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
1763
|
-
|
|
1922
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
1923
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
1924
|
+
// collections — a support-client with the same id would otherwise own
|
|
1925
|
+
// the `email-signature-<id>` row read back for the agent.
|
|
1926
|
+
where: {
|
|
1927
|
+
key: { equals: `${PREF_KEY2}-${req.user.id}` },
|
|
1928
|
+
"user.relationTo": { equals: slugs.users }
|
|
1929
|
+
},
|
|
1764
1930
|
limit: 1,
|
|
1765
1931
|
depth: 0,
|
|
1766
1932
|
overrideAccess: true
|
|
@@ -1787,7 +1953,7 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1787
1953
|
const { signature } = await req.json();
|
|
1788
1954
|
const key = `${PREF_KEY2}-${req.user.id}`;
|
|
1789
1955
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
1790
|
-
where: { key: { equals: key } },
|
|
1956
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
1791
1957
|
limit: 1,
|
|
1792
1958
|
depth: 0,
|
|
1793
1959
|
overrideAccess: true
|
|
@@ -2548,7 +2714,7 @@ function formatFr(date, withTime) {
|
|
|
2548
2714
|
});
|
|
2549
2715
|
}
|
|
2550
2716
|
function createSendReminderEndpoint(slugs, store) {
|
|
2551
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
|
|
2717
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
|
|
2552
2718
|
return {
|
|
2553
2719
|
path: "/support/send-reminder",
|
|
2554
2720
|
method: "post",
|
|
@@ -2556,7 +2722,7 @@ function createSendReminderEndpoint(slugs, store) {
|
|
|
2556
2722
|
try {
|
|
2557
2723
|
const payload = req.payload;
|
|
2558
2724
|
requireAdmin(req, slugs);
|
|
2559
|
-
if (await reminderLimiter.check(
|
|
2725
|
+
if (await reminderLimiter.check(principalRateKey(req.user), req)) {
|
|
2560
2726
|
return Response.json(
|
|
2561
2727
|
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2562
2728
|
{ status: 429 }
|
|
@@ -2682,6 +2848,10 @@ function createStatusesEndpoint(slugs) {
|
|
|
2682
2848
|
if (!req.user) {
|
|
2683
2849
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
2684
2850
|
}
|
|
2851
|
+
const collection = req.user.collection;
|
|
2852
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) {
|
|
2853
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
2854
|
+
}
|
|
2685
2855
|
const { docs } = await dbFind(payload, slugs.ticketStatuses, {
|
|
2686
2856
|
sort: "sortOrder",
|
|
2687
2857
|
limit: 100,
|
|
@@ -2859,14 +3029,20 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2859
3029
|
}
|
|
2860
3030
|
|
|
2861
3031
|
// src/endpoints/chatbot.ts
|
|
2862
|
-
|
|
2863
|
-
|
|
3032
|
+
var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
|
|
3033
|
+
function resolveMaxPerHour(explicit) {
|
|
3034
|
+
const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
|
|
3035
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
|
|
3036
|
+
}
|
|
3037
|
+
function createChatbotEndpoint(slugs, store, maxPerHour) {
|
|
3038
|
+
const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
|
|
3039
|
+
const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
|
|
2864
3040
|
return {
|
|
2865
3041
|
path: "/support/chatbot",
|
|
2866
3042
|
method: "post",
|
|
2867
3043
|
handler: async (req) => {
|
|
2868
3044
|
try {
|
|
2869
|
-
const ip =
|
|
3045
|
+
const ip = clientIpRateKey(req);
|
|
2870
3046
|
if (await chatbotLimiter.check(ip, req)) {
|
|
2871
3047
|
return Response.json({ error: "Too many requests. Please wait a moment." }, { status: 429 });
|
|
2872
3048
|
}
|
|
@@ -2880,6 +3056,15 @@ function createChatbotEndpoint(slugs, store) {
|
|
|
2880
3056
|
if (!question?.trim() || question.trim().length < 5) {
|
|
2881
3057
|
return Response.json({ error: "Question too short" }, { status: 400 });
|
|
2882
3058
|
}
|
|
3059
|
+
if (await globalLimiter.check("all", req)) {
|
|
3060
|
+
return Response.json({
|
|
3061
|
+
answer: null,
|
|
3062
|
+
confidence: 0,
|
|
3063
|
+
suggestion: "create_ticket",
|
|
3064
|
+
aiUnavailable: true,
|
|
3065
|
+
message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
|
|
3066
|
+
});
|
|
3067
|
+
}
|
|
2883
3068
|
const payload = req.payload;
|
|
2884
3069
|
const articles = await dbFind(payload, slugs.knowledgeBase, {
|
|
2885
3070
|
where: { published: { equals: true } },
|
|
@@ -2992,8 +3177,8 @@ function createChatGetEndpoint(slugs) {
|
|
|
2992
3177
|
};
|
|
2993
3178
|
}
|
|
2994
3179
|
function createChatPostEndpoint(slugs, store) {
|
|
2995
|
-
const chatSessionLimiter = new RateLimiter(36e5, 5, store);
|
|
2996
|
-
const chatMessageLimiter = new RateLimiter(6e4, 15, store);
|
|
3180
|
+
const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
|
|
3181
|
+
const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
|
|
2997
3182
|
return {
|
|
2998
3183
|
path: "/support/chat",
|
|
2999
3184
|
method: "post",
|
|
@@ -3009,8 +3194,9 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3009
3194
|
}
|
|
3010
3195
|
const { action, session, message } = body;
|
|
3011
3196
|
const userId = String(req.user.id);
|
|
3197
|
+
const rateKey = principalRateKey(req.user);
|
|
3012
3198
|
if (action === "start") {
|
|
3013
|
-
if (await chatSessionLimiter.check(
|
|
3199
|
+
if (await chatSessionLimiter.check(rateKey, req)) {
|
|
3014
3200
|
return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
|
|
3015
3201
|
}
|
|
3016
3202
|
const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
|
|
@@ -3027,7 +3213,7 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3027
3213
|
return Response.json({ session: sessionId, messages: [systemMsg] });
|
|
3028
3214
|
}
|
|
3029
3215
|
if (action === "send" && session && message) {
|
|
3030
|
-
if (await chatMessageLimiter.check(
|
|
3216
|
+
if (await chatMessageLimiter.check(rateKey, req)) {
|
|
3031
3217
|
return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
|
|
3032
3218
|
}
|
|
3033
3219
|
const trimmedMessage = String(message).trim();
|
|
@@ -3267,7 +3453,7 @@ function createAdminChatGetEndpoint(slugs) {
|
|
|
3267
3453
|
};
|
|
3268
3454
|
}
|
|
3269
3455
|
function createAdminChatPostEndpoint(slugs, store) {
|
|
3270
|
-
const adminChatLimiter = new RateLimiter(6e4, 30, store);
|
|
3456
|
+
const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
|
|
3271
3457
|
return {
|
|
3272
3458
|
path: "/support/admin-chat",
|
|
3273
3459
|
method: "post",
|
|
@@ -3296,7 +3482,7 @@ function createAdminChatPostEndpoint(slugs, store) {
|
|
|
3296
3482
|
}
|
|
3297
3483
|
const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
|
|
3298
3484
|
if (action === "send" && message) {
|
|
3299
|
-
if (await adminChatLimiter.check(
|
|
3485
|
+
if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
|
|
3300
3486
|
return Response.json({ error: "Rate limit atteint." }, { status: 429 });
|
|
3301
3487
|
}
|
|
3302
3488
|
const trimmedMessage = String(message).trim();
|
|
@@ -4251,14 +4437,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
|
|
|
4251
4437
|
|
|
4252
4438
|
// src/endpoints/ticket-synthesis.ts
|
|
4253
4439
|
function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
4254
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
4440
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
|
|
4255
4441
|
return {
|
|
4256
4442
|
path: "/support/ticket-synthesis",
|
|
4257
4443
|
method: "post",
|
|
4258
4444
|
handler: async (req) => {
|
|
4259
4445
|
try {
|
|
4260
4446
|
requireAdmin(req, slugs);
|
|
4261
|
-
if (await limiter.check(
|
|
4447
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
4262
4448
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
4263
4449
|
}
|
|
4264
4450
|
const payload = req.payload;
|
|
@@ -4302,15 +4488,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
|
4302
4488
|
}
|
|
4303
4489
|
|
|
4304
4490
|
// src/endpoints/email-stats.ts
|
|
4305
|
-
function createEmailStatsEndpoint(slugs) {
|
|
4491
|
+
function createEmailStatsEndpoint(slugs, store) {
|
|
4492
|
+
const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
|
|
4306
4493
|
return {
|
|
4307
4494
|
path: "/support/email-stats",
|
|
4308
4495
|
method: "get",
|
|
4309
4496
|
handler: async (req) => {
|
|
4310
4497
|
try {
|
|
4311
4498
|
const payload = req.payload;
|
|
4312
|
-
|
|
4313
|
-
|
|
4499
|
+
requireAdmin(req, slugs);
|
|
4500
|
+
if (await statsLimiter.check(principalRateKey(req.user), req)) {
|
|
4501
|
+
return Response.json({ error: "Too many requests." }, { status: 429 });
|
|
4314
4502
|
}
|
|
4315
4503
|
const url = new URL(req.url);
|
|
4316
4504
|
const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
|
|
@@ -4377,6 +4565,8 @@ function createEmailStatsEndpoint(slugs) {
|
|
|
4377
4565
|
actions: Object.fromEntries(actionMap)
|
|
4378
4566
|
});
|
|
4379
4567
|
} catch (err) {
|
|
4568
|
+
const authResponse = handleAuthError(err);
|
|
4569
|
+
if (authResponse) return authResponse;
|
|
4380
4570
|
console.error("[email-stats] Error:", err);
|
|
4381
4571
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
4382
4572
|
}
|
|
@@ -4809,7 +4999,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
|
|
|
4809
4999
|
|
|
4810
5000
|
// src/endpoints/resend-notification.ts
|
|
4811
5001
|
function createResendNotificationEndpoint(slugs, store) {
|
|
4812
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
5002
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
|
|
4813
5003
|
return {
|
|
4814
5004
|
path: "/support/resend-notification",
|
|
4815
5005
|
method: "post",
|
|
@@ -4817,7 +5007,7 @@ function createResendNotificationEndpoint(slugs, store) {
|
|
|
4817
5007
|
try {
|
|
4818
5008
|
const payload = req.payload;
|
|
4819
5009
|
requireAdmin(req, slugs);
|
|
4820
|
-
if (await resendLimiter.check(
|
|
5010
|
+
if (await resendLimiter.check(principalRateKey(req.user), req)) {
|
|
4821
5011
|
return Response.json(
|
|
4822
5012
|
{ error: "Trop de renvois. R\xE9essayez dans une heure." },
|
|
4823
5013
|
{ status: 429 }
|
|
@@ -5018,15 +5208,54 @@ function createSeedKbEndpoint(slugs) {
|
|
|
5018
5208
|
}
|
|
5019
5209
|
};
|
|
5020
5210
|
}
|
|
5211
|
+
var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
|
|
5212
|
+
function challengeSecret() {
|
|
5213
|
+
const secret = process.env.PAYLOAD_SECRET;
|
|
5214
|
+
if (!secret) {
|
|
5215
|
+
throw new Error(
|
|
5216
|
+
"[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
|
|
5217
|
+
);
|
|
5218
|
+
}
|
|
5219
|
+
return secret;
|
|
5220
|
+
}
|
|
5221
|
+
function normalizeEmail(email) {
|
|
5222
|
+
return String(email).trim().toLowerCase();
|
|
5223
|
+
}
|
|
5224
|
+
function sign(email, expiresAt) {
|
|
5225
|
+
return createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
|
|
5226
|
+
}
|
|
5227
|
+
function issueTwoFactorChallenge(email, now = Date.now()) {
|
|
5228
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
|
|
5229
|
+
return `${expiresAt}.${sign(email, expiresAt)}`;
|
|
5230
|
+
}
|
|
5231
|
+
function verifyTwoFactorChallenge(email, token, now = Date.now()) {
|
|
5232
|
+
if (typeof email !== "string" || !email || typeof token !== "string") return false;
|
|
5233
|
+
const separator = token.indexOf(".");
|
|
5234
|
+
if (separator <= 0) return false;
|
|
5235
|
+
const expiresAt = Number(token.slice(0, separator));
|
|
5236
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
|
|
5237
|
+
const received = token.slice(separator + 1);
|
|
5238
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
|
|
5239
|
+
let expected;
|
|
5240
|
+
try {
|
|
5241
|
+
expected = sign(email, expiresAt);
|
|
5242
|
+
} catch {
|
|
5243
|
+
return false;
|
|
5244
|
+
}
|
|
5245
|
+
const a = Buffer.from(expected, "hex");
|
|
5246
|
+
const b = Buffer.from(received.toLowerCase(), "hex");
|
|
5247
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
5248
|
+
}
|
|
5021
5249
|
|
|
5022
5250
|
// src/endpoints/login.ts
|
|
5251
|
+
var MAX_LOGGED_USER_AGENT = 256;
|
|
5023
5252
|
function createLoginEndpoint(slugs, store) {
|
|
5024
|
-
const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
|
|
5253
|
+
const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
|
|
5025
5254
|
return {
|
|
5026
5255
|
path: "/support/login",
|
|
5027
5256
|
method: "post",
|
|
5028
5257
|
handler: async (req) => {
|
|
5029
|
-
const ip =
|
|
5258
|
+
const ip = clientIpRateKey(req);
|
|
5030
5259
|
if (await loginLimiter.check(ip, req)) {
|
|
5031
5260
|
return Response.json(
|
|
5032
5261
|
{ error: "Trop de tentatives. R\xE9essayez dans quelques minutes." },
|
|
@@ -5041,7 +5270,7 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5041
5270
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5042
5271
|
}
|
|
5043
5272
|
const { email, password } = body;
|
|
5044
|
-
const userAgent = req.headers.get("user-agent") || "";
|
|
5273
|
+
const userAgent = (req.headers.get("user-agent") || "").slice(0, MAX_LOGGED_USER_AGENT);
|
|
5045
5274
|
if (!email || !password) {
|
|
5046
5275
|
return Response.json({ error: "Email et mot de passe requis." }, { status: 400 });
|
|
5047
5276
|
}
|
|
@@ -5074,7 +5303,12 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5074
5303
|
} catch (err) {
|
|
5075
5304
|
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
|
|
5076
5305
|
if (errorMessage.includes("2FA_REQUIRED")) {
|
|
5077
|
-
|
|
5306
|
+
let challenge;
|
|
5307
|
+
try {
|
|
5308
|
+
challenge = issueTwoFactorChallenge(email);
|
|
5309
|
+
} catch {
|
|
5310
|
+
}
|
|
5311
|
+
return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
|
|
5078
5312
|
}
|
|
5079
5313
|
let errorReason = "Identifiants incorrects";
|
|
5080
5314
|
if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
|
|
@@ -5106,8 +5340,8 @@ function hashCode(code) {
|
|
|
5106
5340
|
return createHmac("sha256", secret).update(code).digest("hex");
|
|
5107
5341
|
}
|
|
5108
5342
|
function createAuth2faEndpoint(slugs, store) {
|
|
5109
|
-
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
|
|
5110
|
-
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
|
|
5343
|
+
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
|
|
5344
|
+
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
|
|
5111
5345
|
return {
|
|
5112
5346
|
path: "/support/2fa",
|
|
5113
5347
|
method: "post",
|
|
@@ -5120,14 +5354,32 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5120
5354
|
} catch {
|
|
5121
5355
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5122
5356
|
}
|
|
5123
|
-
const { action, email, code } = body;
|
|
5357
|
+
const { action, email, code, challenge } = body;
|
|
5124
5358
|
if (!action || !email) {
|
|
5125
5359
|
return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
|
|
5126
5360
|
}
|
|
5127
|
-
const
|
|
5361
|
+
const limiterKey = normalizeEmail(email);
|
|
5362
|
+
const sendResponse = () => {
|
|
5363
|
+
let refreshed;
|
|
5364
|
+
try {
|
|
5365
|
+
refreshed = issueTwoFactorChallenge(email);
|
|
5366
|
+
} catch {
|
|
5367
|
+
}
|
|
5368
|
+
return Response.json({
|
|
5369
|
+
success: true,
|
|
5370
|
+
message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9.",
|
|
5371
|
+
...refreshed ? { challenge: refreshed } : {}
|
|
5372
|
+
});
|
|
5373
|
+
};
|
|
5128
5374
|
if (action === "send") {
|
|
5129
|
-
if (
|
|
5130
|
-
return Response.json(
|
|
5375
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5376
|
+
return Response.json(
|
|
5377
|
+
{ error: "Authentification requise avant l'envoi d'un code." },
|
|
5378
|
+
{ status: 401 }
|
|
5379
|
+
);
|
|
5380
|
+
}
|
|
5381
|
+
if (await sendLimiter.check(limiterKey, req)) {
|
|
5382
|
+
return sendResponse();
|
|
5131
5383
|
}
|
|
5132
5384
|
const clients = await dbFind(payload, slugs.supportClients, {
|
|
5133
5385
|
where: { email: { equals: email } },
|
|
@@ -5136,7 +5388,7 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5136
5388
|
overrideAccess: true
|
|
5137
5389
|
});
|
|
5138
5390
|
if (clients.docs.length === 0) {
|
|
5139
|
-
return
|
|
5391
|
+
return sendResponse();
|
|
5140
5392
|
}
|
|
5141
5393
|
const client = clients.docs[0];
|
|
5142
5394
|
const plainCode = generateSecureCode();
|
|
@@ -5161,13 +5413,19 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5161
5413
|
<p style="font-size: 13px; color: #6b7280;">Ce code est valable 10 minutes.</p>
|
|
5162
5414
|
</div>`
|
|
5163
5415
|
});
|
|
5164
|
-
return
|
|
5416
|
+
return sendResponse();
|
|
5165
5417
|
}
|
|
5166
5418
|
if (action === "verify") {
|
|
5419
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5420
|
+
return Response.json(
|
|
5421
|
+
{ error: "Authentification requise avant la v\xE9rification d'un code." },
|
|
5422
|
+
{ status: 401 }
|
|
5423
|
+
);
|
|
5424
|
+
}
|
|
5167
5425
|
if (!code) {
|
|
5168
5426
|
return Response.json({ error: "Code manquant" }, { status: 400 });
|
|
5169
5427
|
}
|
|
5170
|
-
if (await verifyLimiter.check(
|
|
5428
|
+
if (await verifyLimiter.check(limiterKey, req)) {
|
|
5171
5429
|
return Response.json(
|
|
5172
5430
|
{ error: "Trop de tentatives. R\xE9essayez dans 15 minutes." },
|
|
5173
5431
|
{ status: 429 }
|
|
@@ -5208,7 +5466,7 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5208
5466
|
data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
5209
5467
|
overrideAccess: true
|
|
5210
5468
|
});
|
|
5211
|
-
verifyLimiter.reset(
|
|
5469
|
+
await verifyLimiter.reset(limiterKey, req);
|
|
5212
5470
|
return Response.json({ success: true, verified: true });
|
|
5213
5471
|
}
|
|
5214
5472
|
return Response.json({ error: "Action invalide" }, { status: 400 });
|
|
@@ -5219,6 +5477,33 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5219
5477
|
}
|
|
5220
5478
|
};
|
|
5221
5479
|
}
|
|
5480
|
+
var OAUTH_STATE_COOKIE = "support-oauth-state";
|
|
5481
|
+
var OAUTH_STATE_MAX_AGE = 600;
|
|
5482
|
+
var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
|
|
5483
|
+
function readCookie(header, name) {
|
|
5484
|
+
if (!header) return null;
|
|
5485
|
+
for (const part of header.split(";")) {
|
|
5486
|
+
const eq = part.indexOf("=");
|
|
5487
|
+
if (eq === -1) continue;
|
|
5488
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
5489
|
+
try {
|
|
5490
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
5491
|
+
} catch {
|
|
5492
|
+
return part.slice(eq + 1).trim();
|
|
5493
|
+
}
|
|
5494
|
+
}
|
|
5495
|
+
return null;
|
|
5496
|
+
}
|
|
5497
|
+
function clearedStateCookie() {
|
|
5498
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5499
|
+
return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
|
|
5500
|
+
}
|
|
5501
|
+
function statesMatch(a, b) {
|
|
5502
|
+
if (!a || !b) return false;
|
|
5503
|
+
const left = crypto3.createHash("sha256").update(a).digest();
|
|
5504
|
+
const right = crypto3.createHash("sha256").update(b).digest();
|
|
5505
|
+
return crypto3.timingSafeEqual(left, right);
|
|
5506
|
+
}
|
|
5222
5507
|
function createOAuthGoogleEndpoint(slugs, options) {
|
|
5223
5508
|
return {
|
|
5224
5509
|
path: "/support/oauth/google",
|
|
@@ -5235,7 +5520,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5235
5520
|
}
|
|
5236
5521
|
try {
|
|
5237
5522
|
const body = await req.json();
|
|
5238
|
-
const { action, code, state: queryState
|
|
5523
|
+
const { action, code, state: queryState } = body;
|
|
5239
5524
|
if (action === "login") {
|
|
5240
5525
|
const oauthState = crypto3.randomBytes(32).toString("hex");
|
|
5241
5526
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5247,13 +5532,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5247
5532
|
state: oauthState,
|
|
5248
5533
|
prompt: "select_account"
|
|
5249
5534
|
});
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5535
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5536
|
+
const loginHeaders = new Headers({ "Content-Type": "application/json" });
|
|
5537
|
+
loginHeaders.append(
|
|
5538
|
+
"Set-Cookie",
|
|
5539
|
+
`${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
|
|
5540
|
+
);
|
|
5541
|
+
return new Response(
|
|
5542
|
+
JSON.stringify({
|
|
5543
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
5544
|
+
// Kept for callers that echo it back in the redirect URL; the
|
|
5545
|
+
// server no longer trusts anything the caller returns.
|
|
5546
|
+
state: oauthState
|
|
5547
|
+
}),
|
|
5548
|
+
{ status: 200, headers: loginHeaders }
|
|
5549
|
+
);
|
|
5254
5550
|
}
|
|
5255
5551
|
if (code) {
|
|
5256
|
-
|
|
5552
|
+
const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
|
|
5553
|
+
if (!statesMatch(issuedState, queryState)) {
|
|
5257
5554
|
return Response.json({ error: "state_mismatch" }, { status: 400 });
|
|
5258
5555
|
}
|
|
5259
5556
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5335,6 +5632,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5335
5632
|
overrideAccess: true
|
|
5336
5633
|
});
|
|
5337
5634
|
}
|
|
5635
|
+
const twoFactorDoc = await dbFindByID(payload, slugs.supportClients, {
|
|
5636
|
+
id: clientDoc.id,
|
|
5637
|
+
depth: 0,
|
|
5638
|
+
overrideAccess: true,
|
|
5639
|
+
showHiddenFields: true
|
|
5640
|
+
});
|
|
5641
|
+
if (twoFactorDoc?.twoFactorEnabled) {
|
|
5642
|
+
const raw = twoFactorDoc.twoFactorVerifiedAt;
|
|
5643
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
5644
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
5645
|
+
let challenge;
|
|
5646
|
+
try {
|
|
5647
|
+
challenge = issueTwoFactorChallenge(clientDoc.email);
|
|
5648
|
+
} catch {
|
|
5649
|
+
}
|
|
5650
|
+
return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
|
|
5651
|
+
status: 200,
|
|
5652
|
+
headers: new Headers({
|
|
5653
|
+
"Content-Type": "application/json",
|
|
5654
|
+
"Set-Cookie": clearedStateCookie()
|
|
5655
|
+
})
|
|
5656
|
+
});
|
|
5657
|
+
}
|
|
5658
|
+
await dbUpdate(payload, slugs.supportClients, {
|
|
5659
|
+
id: clientDoc.id,
|
|
5660
|
+
data: { twoFactorVerifiedAt: null },
|
|
5661
|
+
overrideAccess: true
|
|
5662
|
+
});
|
|
5663
|
+
}
|
|
5338
5664
|
const secret = process.env.PAYLOAD_SECRET;
|
|
5339
5665
|
if (!secret) {
|
|
5340
5666
|
return Response.json({ error: "server_misconfigured" }, { status: 500 });
|
|
@@ -5378,6 +5704,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5378
5704
|
"Set-Cookie",
|
|
5379
5705
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
|
|
5380
5706
|
);
|
|
5707
|
+
headers.append("Set-Cookie", clearedStateCookie());
|
|
5381
5708
|
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
5382
5709
|
status: 200,
|
|
5383
5710
|
headers
|
|
@@ -5684,7 +6011,7 @@ ONLY JSON, nothing else.`
|
|
|
5684
6011
|
}
|
|
5685
6012
|
}
|
|
5686
6013
|
function createImportConversationEndpoint(slugs, store) {
|
|
5687
|
-
const importLimiter = new RateLimiter(36e5, 10, store);
|
|
6014
|
+
const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
|
|
5688
6015
|
return {
|
|
5689
6016
|
path: "/support/import-conversation",
|
|
5690
6017
|
method: "post",
|
|
@@ -5701,7 +6028,7 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5701
6028
|
if (!isAuthed) {
|
|
5702
6029
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
5703
6030
|
}
|
|
5704
|
-
const ip = req
|
|
6031
|
+
const ip = clientIpRateKey(req);
|
|
5705
6032
|
if (await importLimiter.check(ip, req)) {
|
|
5706
6033
|
return Response.json({ error: "Rate limit exceeded. Maximum 10 imports per hour." }, { status: 429 });
|
|
5707
6034
|
}
|
|
@@ -5827,6 +6154,152 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5827
6154
|
}
|
|
5828
6155
|
};
|
|
5829
6156
|
}
|
|
6157
|
+
function httpAllowed() {
|
|
6158
|
+
return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
|
|
6159
|
+
}
|
|
6160
|
+
var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
|
|
6161
|
+
function parseIPv4(host) {
|
|
6162
|
+
const parts = host.split(".");
|
|
6163
|
+
if (parts.length !== 4) return null;
|
|
6164
|
+
const octets = [];
|
|
6165
|
+
for (const part of parts) {
|
|
6166
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
6167
|
+
const n = Number(part);
|
|
6168
|
+
if (n > 255) return null;
|
|
6169
|
+
octets.push(n);
|
|
6170
|
+
}
|
|
6171
|
+
return octets;
|
|
6172
|
+
}
|
|
6173
|
+
function isPrivateIPv4(octets) {
|
|
6174
|
+
const [a, b] = octets;
|
|
6175
|
+
if (a === 0) return true;
|
|
6176
|
+
if (a === 10) return true;
|
|
6177
|
+
if (a === 127) return true;
|
|
6178
|
+
if (a === 169 && b === 254) return true;
|
|
6179
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
6180
|
+
if (a === 192 && b === 168) return true;
|
|
6181
|
+
if (a === 192 && b === 0) return true;
|
|
6182
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
6183
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
6184
|
+
if (a >= 224) return true;
|
|
6185
|
+
return false;
|
|
6186
|
+
}
|
|
6187
|
+
function isPrivateIPv6(host) {
|
|
6188
|
+
const lower = host.toLowerCase();
|
|
6189
|
+
if (lower === "::" || lower === "::1") return true;
|
|
6190
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
|
|
6191
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
6192
|
+
if (lower.startsWith("ff")) return true;
|
|
6193
|
+
const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
6194
|
+
if (dotted) {
|
|
6195
|
+
const octets = parseIPv4(dotted[1]);
|
|
6196
|
+
return octets ? isPrivateIPv4(octets) : true;
|
|
6197
|
+
}
|
|
6198
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
6199
|
+
if (hex) {
|
|
6200
|
+
const high = parseInt(hex[1], 16);
|
|
6201
|
+
const low = parseInt(hex[2], 16);
|
|
6202
|
+
return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
|
|
6203
|
+
}
|
|
6204
|
+
return false;
|
|
6205
|
+
}
|
|
6206
|
+
function normalizeHost(hostname) {
|
|
6207
|
+
const host = hostname.trim().toLowerCase();
|
|
6208
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
6209
|
+
}
|
|
6210
|
+
function isBlockedHost(hostname) {
|
|
6211
|
+
const host = normalizeHost(hostname);
|
|
6212
|
+
if (!host) return true;
|
|
6213
|
+
if (host === "localhost") return true;
|
|
6214
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
6215
|
+
const v4 = parseIPv4(host);
|
|
6216
|
+
if (v4) return isPrivateIPv4(v4);
|
|
6217
|
+
if (host.includes(":")) return isPrivateIPv6(host);
|
|
6218
|
+
return false;
|
|
6219
|
+
}
|
|
6220
|
+
function validateWebhookUrl(raw) {
|
|
6221
|
+
if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
|
|
6222
|
+
let url;
|
|
6223
|
+
try {
|
|
6224
|
+
url = new URL(raw.trim());
|
|
6225
|
+
} catch {
|
|
6226
|
+
return { ok: false, reason: "invalid_url" };
|
|
6227
|
+
}
|
|
6228
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
|
|
6229
|
+
return { ok: false, reason: "scheme_not_allowed" };
|
|
6230
|
+
}
|
|
6231
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6232
|
+
return { ok: true, url };
|
|
6233
|
+
}
|
|
6234
|
+
var WEBHOOK_URL_MESSAGES = {
|
|
6235
|
+
invalid_url: "URL invalide.",
|
|
6236
|
+
scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
|
|
6237
|
+
private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
|
|
6238
|
+
};
|
|
6239
|
+
var MAX_PUSH_ENDPOINT_LENGTH = 2048;
|
|
6240
|
+
function validatePushEndpoint(raw) {
|
|
6241
|
+
if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_PUSH_ENDPOINT_LENGTH) {
|
|
6242
|
+
return { ok: false, reason: "invalid_url" };
|
|
6243
|
+
}
|
|
6244
|
+
let url;
|
|
6245
|
+
try {
|
|
6246
|
+
url = new URL(raw.trim());
|
|
6247
|
+
} catch {
|
|
6248
|
+
return { ok: false, reason: "invalid_url" };
|
|
6249
|
+
}
|
|
6250
|
+
if (url.protocol !== "https:") return { ok: false, reason: "scheme_not_allowed" };
|
|
6251
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6252
|
+
return { ok: true, url };
|
|
6253
|
+
}
|
|
6254
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
6255
|
+
var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
|
|
6256
|
+
var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
|
|
6257
|
+
async function assertPublicHost(hostname) {
|
|
6258
|
+
const host = normalizeHost(hostname);
|
|
6259
|
+
if (isBlockedHost(host)) return false;
|
|
6260
|
+
if (parseIPv4(host) || host.includes(":")) return true;
|
|
6261
|
+
try {
|
|
6262
|
+
const addresses = await Promise.race([
|
|
6263
|
+
lookup(host, { all: true }),
|
|
6264
|
+
new Promise(
|
|
6265
|
+
(resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
|
|
6266
|
+
)
|
|
6267
|
+
]);
|
|
6268
|
+
if (addresses === LOOKUP_TIMED_OUT) return false;
|
|
6269
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return false;
|
|
6270
|
+
return addresses.every((entry) => !isBlockedHost(entry.address));
|
|
6271
|
+
} catch (error) {
|
|
6272
|
+
const code = error?.code;
|
|
6273
|
+
return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
|
|
6274
|
+
}
|
|
6275
|
+
}
|
|
6276
|
+
var BlockedRequestError = class extends Error {
|
|
6277
|
+
constructor(reason) {
|
|
6278
|
+
super(`Blocked outbound request: ${reason}`);
|
|
6279
|
+
this.reason = reason;
|
|
6280
|
+
this.name = "BlockedRequestError";
|
|
6281
|
+
}
|
|
6282
|
+
reason;
|
|
6283
|
+
};
|
|
6284
|
+
var MAX_REDIRECTS = 3;
|
|
6285
|
+
async function safeFetch(rawUrl, init) {
|
|
6286
|
+
let current = rawUrl;
|
|
6287
|
+
let body = init.body;
|
|
6288
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
6289
|
+
const validation = validateWebhookUrl(current);
|
|
6290
|
+
if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
|
|
6291
|
+
if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
|
|
6292
|
+
const response = await fetch(current, { ...init, body, redirect: "manual" });
|
|
6293
|
+
if (response.status < 300 || response.status > 399) return response;
|
|
6294
|
+
const location = response.headers.get("location");
|
|
6295
|
+
if (!location) return response;
|
|
6296
|
+
current = new URL(location, current).toString();
|
|
6297
|
+
if (response.status === 303) body = void 0;
|
|
6298
|
+
}
|
|
6299
|
+
throw new BlockedRequestError("too_many_redirects");
|
|
6300
|
+
}
|
|
6301
|
+
|
|
6302
|
+
// src/utils/webhookDispatcher.ts
|
|
5830
6303
|
function dispatchWebhook(data, event, payload, slugs) {
|
|
5831
6304
|
const done = _dispatch(data, event, payload, slugs);
|
|
5832
6305
|
return done;
|
|
@@ -5863,7 +6336,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
|
5863
6336
|
const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
5864
6337
|
headers["X-Webhook-Signature"] = signature;
|
|
5865
6338
|
}
|
|
5866
|
-
const response = await
|
|
6339
|
+
const response = await safeFetch(endpoint.url, {
|
|
5867
6340
|
method: "POST",
|
|
5868
6341
|
headers,
|
|
5869
6342
|
body,
|
|
@@ -6233,6 +6706,15 @@ async function sendPushToUser(payload, slugs, userId, notification) {
|
|
|
6233
6706
|
for (const s of subs.docs) {
|
|
6234
6707
|
const row = s;
|
|
6235
6708
|
if (!row.endpoint || !row.p256dh || !row.auth) continue;
|
|
6709
|
+
const check = validatePushEndpoint(row.endpoint);
|
|
6710
|
+
if (!check.ok || !check.url) {
|
|
6711
|
+
console.warn("[support] Push endpoint refused (unsafe URL):", check.reason);
|
|
6712
|
+
continue;
|
|
6713
|
+
}
|
|
6714
|
+
if (!await assertPublicHost(check.url.hostname)) {
|
|
6715
|
+
console.warn("[support] Push endpoint refused (host resolves to a private address)");
|
|
6716
|
+
continue;
|
|
6717
|
+
}
|
|
6236
6718
|
try {
|
|
6237
6719
|
await webpush.sendNotification(
|
|
6238
6720
|
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|
|
@@ -6283,7 +6765,14 @@ function createPushSubscribeEndpoint(slugs) {
|
|
|
6283
6765
|
if (!endpoint || !p256dh || !auth) {
|
|
6284
6766
|
return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
|
|
6285
6767
|
}
|
|
6286
|
-
const
|
|
6768
|
+
const endpointCheck = validatePushEndpoint(endpoint);
|
|
6769
|
+
if (!endpointCheck.ok) {
|
|
6770
|
+
return Response.json(
|
|
6771
|
+
{ error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || "invalid_url"] },
|
|
6772
|
+
{ status: 400 }
|
|
6773
|
+
);
|
|
6774
|
+
}
|
|
6775
|
+
const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: (req.headers.get("user-agent") || "").slice(0, 256) };
|
|
6287
6776
|
const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
|
|
6288
6777
|
if (existing.docs.length > 0) {
|
|
6289
6778
|
await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
|
|
@@ -6317,7 +6806,7 @@ function createUserPrefsGetEndpoint(slugs) {
|
|
|
6317
6806
|
requireAdmin(req, slugs);
|
|
6318
6807
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6319
6808
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
6320
|
-
where: { key: { equals: key } },
|
|
6809
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6321
6810
|
limit: 1,
|
|
6322
6811
|
depth: 0,
|
|
6323
6812
|
overrideAccess: true
|
|
@@ -6351,7 +6840,7 @@ function createUserPrefsPostEndpoint(slugs) {
|
|
|
6351
6840
|
const body = await req.json();
|
|
6352
6841
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6353
6842
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
6354
|
-
where: { key: { equals: key } },
|
|
6843
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6355
6844
|
limit: 1,
|
|
6356
6845
|
depth: 0,
|
|
6357
6846
|
overrideAccess: true
|
|
@@ -6507,8 +6996,10 @@ function createTicketFeedbackEndpoint(slugs) {
|
|
|
6507
6996
|
// src/endpoints/transfer-ticket.ts
|
|
6508
6997
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6509
6998
|
var MAX_TRANSFERS_PER_DAY = 5;
|
|
6999
|
+
var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
|
|
6510
7000
|
function createTransferTicketEndpoint(slugs, store) {
|
|
6511
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
|
|
7001
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
|
|
7002
|
+
const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
|
|
6512
7003
|
return {
|
|
6513
7004
|
path: "/support/tickets/:id/transfer",
|
|
6514
7005
|
method: "post",
|
|
@@ -6543,12 +7034,18 @@ function createTransferTicketEndpoint(slugs, store) {
|
|
|
6543
7034
|
if (!isAdmin && !isOwner) {
|
|
6544
7035
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6545
7036
|
}
|
|
6546
|
-
if (await transferLimiter.check(`${req.user
|
|
7037
|
+
if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
|
|
6547
7038
|
return Response.json(
|
|
6548
7039
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
6549
7040
|
{ status: 429 }
|
|
6550
7041
|
);
|
|
6551
7042
|
}
|
|
7043
|
+
if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
|
|
7044
|
+
return Response.json(
|
|
7045
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
|
|
7046
|
+
{ status: 429 }
|
|
7047
|
+
);
|
|
7048
|
+
}
|
|
6552
7049
|
try {
|
|
6553
7050
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
|
|
6554
7051
|
const existing = await dbCount(payload, slugs.emailLogs, {
|
|
@@ -6690,8 +7187,11 @@ function statusToLabel(status) {
|
|
|
6690
7187
|
}
|
|
6691
7188
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6692
7189
|
var MAX_COLLABORATORS_PER_TICKET = 20;
|
|
7190
|
+
var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
|
|
7191
|
+
var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
6693
7192
|
function createInviteCollaboratorEndpoint(slugs, store) {
|
|
6694
|
-
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
7193
|
+
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
|
|
7194
|
+
const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
|
|
6695
7195
|
return {
|
|
6696
7196
|
path: "/support/tickets/:id/invite",
|
|
6697
7197
|
method: "post",
|
|
@@ -6726,7 +7226,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6726
7226
|
if (!isAdmin && !isOwner) {
|
|
6727
7227
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6728
7228
|
}
|
|
6729
|
-
if (await inviteLimiter.check(
|
|
7229
|
+
if (await inviteLimiter.check(principalRateKey(req.user), req)) {
|
|
6730
7230
|
return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
|
|
6731
7231
|
}
|
|
6732
7232
|
const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
|
|
@@ -6750,6 +7250,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6750
7250
|
if (existing.docs.length > 0) {
|
|
6751
7251
|
inviteeId = existing.docs[0].id;
|
|
6752
7252
|
} else {
|
|
7253
|
+
if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
|
|
7254
|
+
return Response.json(
|
|
7255
|
+
{ error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
|
|
7256
|
+
{ status: 429 }
|
|
7257
|
+
);
|
|
7258
|
+
}
|
|
6753
7259
|
const tempPassword = randomBytes(16).toString("hex");
|
|
6754
7260
|
const created = await dbCreate(payload, slugs.supportClients, {
|
|
6755
7261
|
data: {
|
|
@@ -6921,7 +7427,7 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6921
7427
|
}
|
|
6922
7428
|
if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
|
|
6923
7429
|
if (!f || f.emailTracking !== false) {
|
|
6924
|
-
endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
|
|
7430
|
+
endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
|
|
6925
7431
|
}
|
|
6926
7432
|
if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
|
|
6927
7433
|
if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
|
|
@@ -8406,7 +8912,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8406
8912
|
}
|
|
8407
8913
|
|
|
8408
8914
|
// src/utils/ticketAccess.ts
|
|
8409
|
-
async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
8915
|
+
async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
|
|
8410
8916
|
const ids = /* @__PURE__ */ new Set();
|
|
8411
8917
|
try {
|
|
8412
8918
|
const owned = await dbFind(payload, slugs.tickets, {
|
|
@@ -8427,6 +8933,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
|
8427
8933
|
});
|
|
8428
8934
|
for (const r of collab.docs) {
|
|
8429
8935
|
const row = r;
|
|
8936
|
+
if (mode === "write" && row.role !== "collaborator") continue;
|
|
8430
8937
|
const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
|
|
8431
8938
|
if (tid !== void 0 && tid !== null) ids.add(tid);
|
|
8432
8939
|
}
|
|
@@ -8533,7 +9040,7 @@ function createRestrictClientTicketTarget(slugs) {
|
|
|
8533
9040
|
if (targetId === void 0 || targetId === null || targetId === "") {
|
|
8534
9041
|
throw new APIError("Ticket cible requis.", 400);
|
|
8535
9042
|
}
|
|
8536
|
-
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
|
|
9043
|
+
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
|
|
8537
9044
|
if (!accessible.some((id) => String(id) === String(targetId))) {
|
|
8538
9045
|
throw new APIError("Ticket inaccessible.", 403);
|
|
8539
9046
|
}
|
|
@@ -9050,13 +9557,13 @@ function createSendInvitationOnCreate(slugs) {
|
|
|
9050
9557
|
return doc;
|
|
9051
9558
|
};
|
|
9052
9559
|
}
|
|
9053
|
-
var
|
|
9560
|
+
var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
9054
9561
|
function createEnforce2FA(slugs) {
|
|
9055
9562
|
return async ({ req, user }) => {
|
|
9056
9563
|
if (!user?.twoFactorEnabled) return user;
|
|
9057
9564
|
const raw = user.twoFactorVerifiedAt;
|
|
9058
9565
|
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
9059
|
-
if (!(verifiedAt > Date.now() -
|
|
9566
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
|
|
9060
9567
|
throw new APIError("2FA_REQUIRED", 401);
|
|
9061
9568
|
}
|
|
9062
9569
|
await req.payload.update({
|
|
@@ -9897,8 +10404,28 @@ function createKnowledgeBaseCollection(slugs) {
|
|
|
9897
10404
|
timestamps: true
|
|
9898
10405
|
};
|
|
9899
10406
|
}
|
|
9900
|
-
|
|
9901
|
-
|
|
10407
|
+
function createRestrictClientChatWrite(slugs) {
|
|
10408
|
+
return async ({ data, req }) => {
|
|
10409
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
10410
|
+
data.client = req.user.id;
|
|
10411
|
+
data.senderType = "client";
|
|
10412
|
+
delete data.agent;
|
|
10413
|
+
const session = typeof data.session === "string" ? data.session : null;
|
|
10414
|
+
if (!session) return data;
|
|
10415
|
+
const existing = await dbFind(req.payload, slugs.chatMessages, {
|
|
10416
|
+
where: { session: { equals: session } },
|
|
10417
|
+
limit: 1,
|
|
10418
|
+
depth: 0,
|
|
10419
|
+
overrideAccess: true
|
|
10420
|
+
});
|
|
10421
|
+
const owner = existing.docs[0]?.client;
|
|
10422
|
+
const ownerId = owner && typeof owner === "object" ? owner.id : owner;
|
|
10423
|
+
if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
|
|
10424
|
+
throw new APIError("Session inaccessible.", 403);
|
|
10425
|
+
}
|
|
10426
|
+
return data;
|
|
10427
|
+
};
|
|
10428
|
+
}
|
|
9902
10429
|
function createChatMessagesCollection(slugs) {
|
|
9903
10430
|
return {
|
|
9904
10431
|
slug: slugs.chatMessages,
|
|
@@ -9920,7 +10447,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9920
10447
|
}
|
|
9921
10448
|
return false;
|
|
9922
10449
|
},
|
|
9923
|
-
|
|
10450
|
+
// Staff and support-clients only — NOT "any authenticated principal":
|
|
10451
|
+
// a user of any other auth collection of the host app satisfied `!!req.user`.
|
|
10452
|
+
create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
|
|
9924
10453
|
update: ({ req }) => req.user?.collection === slugs.users,
|
|
9925
10454
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
9926
10455
|
},
|
|
@@ -9989,6 +10518,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9989
10518
|
}
|
|
9990
10519
|
}
|
|
9991
10520
|
],
|
|
10521
|
+
hooks: {
|
|
10522
|
+
beforeChange: [createRestrictClientChatWrite(slugs)]
|
|
10523
|
+
},
|
|
9992
10524
|
timestamps: true
|
|
9993
10525
|
};
|
|
9994
10526
|
}
|
|
@@ -10266,8 +10798,19 @@ function createAuthLogsCollection(slugs) {
|
|
|
10266
10798
|
timestamps: true
|
|
10267
10799
|
};
|
|
10268
10800
|
}
|
|
10269
|
-
|
|
10270
|
-
|
|
10801
|
+
function createValidateWebhookUrl() {
|
|
10802
|
+
return ({ data, operation, originalDoc }) => {
|
|
10803
|
+
const incoming = data?.url;
|
|
10804
|
+
if (incoming === void 0 || incoming === null) return data;
|
|
10805
|
+
const previous = originalDoc?.url;
|
|
10806
|
+
if (operation === "update" && incoming === previous) return data;
|
|
10807
|
+
const result = validateWebhookUrl(incoming);
|
|
10808
|
+
if (!result.ok) {
|
|
10809
|
+
throw new APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
|
|
10810
|
+
}
|
|
10811
|
+
return data;
|
|
10812
|
+
};
|
|
10813
|
+
}
|
|
10271
10814
|
function createWebhookEndpointsCollection(slugs) {
|
|
10272
10815
|
return {
|
|
10273
10816
|
slug: slugs.webhookEndpoints,
|
|
@@ -10301,8 +10844,11 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10301
10844
|
type: "text",
|
|
10302
10845
|
required: true,
|
|
10303
10846
|
label: "URL",
|
|
10847
|
+
// The SSRF check lives in the collection `beforeValidate` above, NOT in a
|
|
10848
|
+
// field `validate`: the latter re-runs on the merged document and would
|
|
10849
|
+
// freeze every pre-existing row on any unrelated edit.
|
|
10304
10850
|
admin: {
|
|
10305
|
-
description: "URL du webhook \xE0 appeler (POST)"
|
|
10851
|
+
description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
|
|
10306
10852
|
}
|
|
10307
10853
|
},
|
|
10308
10854
|
{
|
|
@@ -10356,6 +10902,9 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10356
10902
|
}
|
|
10357
10903
|
}
|
|
10358
10904
|
],
|
|
10905
|
+
hooks: {
|
|
10906
|
+
beforeValidate: [createValidateWebhookUrl()]
|
|
10907
|
+
},
|
|
10359
10908
|
timestamps: true
|
|
10360
10909
|
};
|
|
10361
10910
|
}
|
|
@@ -10807,11 +11356,17 @@ function createClientSummariesCollection(slugs) {
|
|
|
10807
11356
|
admin: { readOnly: true }
|
|
10808
11357
|
}
|
|
10809
11358
|
],
|
|
11359
|
+
// Staff-only, on the SAME source of truth as every other collection and as
|
|
11360
|
+
// `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
|
|
11361
|
+
// against is the DEFAULT slug, not the configured one — on a host app whose
|
|
11362
|
+
// staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
|
|
11363
|
+
// the front-office collection instead, opening read/create/update/delete on
|
|
11364
|
+
// AI-generated client intelligence to it while locking the real agents out.
|
|
10810
11365
|
access: {
|
|
10811
|
-
create: ({ req }) => req.user?.collection ===
|
|
10812
|
-
read: ({ req }) => req.user?.collection ===
|
|
10813
|
-
update: ({ req }) => req.user?.collection ===
|
|
10814
|
-
delete: ({ req }) => req.user?.collection ===
|
|
11366
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
11367
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
11368
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
11369
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10815
11370
|
},
|
|
10816
11371
|
timestamps: true
|
|
10817
11372
|
};
|
|
@@ -11275,6 +11830,17 @@ function supportPlugin(config) {
|
|
|
11275
11830
|
});
|
|
11276
11831
|
return {
|
|
11277
11832
|
...incomingConfig,
|
|
11833
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
11834
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
11835
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
11836
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
11837
|
+
// auth collection of the host app — a different collection on any app
|
|
11838
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
11839
|
+
// reopened right there.
|
|
11840
|
+
custom: {
|
|
11841
|
+
...incomingConfig.custom,
|
|
11842
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
|
|
11843
|
+
},
|
|
11278
11844
|
collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
|
|
11279
11845
|
endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
|
|
11280
11846
|
admin: {
|