@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.cjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
var payload = require('payload');
|
|
4
4
|
var crypto3 = require('crypto');
|
|
5
5
|
var PDFDocument = require('pdfkit');
|
|
6
|
+
var promises = require('dns/promises');
|
|
6
7
|
var webpush = require('web-push');
|
|
7
8
|
var sanitizeHtml = require('sanitize-html');
|
|
8
9
|
|
|
@@ -83,12 +84,43 @@ var DEFAULT_SLUGS = {
|
|
|
83
84
|
function resolveSlugs(overrides) {
|
|
84
85
|
return { ...DEFAULT_SLUGS, ...overrides };
|
|
85
86
|
}
|
|
87
|
+
var MAX_MEMORY_RATE_LIMIT_KEYS = 1e4;
|
|
86
88
|
var MemoryRateLimitStore = class {
|
|
89
|
+
constructor(maxKeys = MAX_MEMORY_RATE_LIMIT_KEYS) {
|
|
90
|
+
this.maxKeys = maxKeys;
|
|
91
|
+
}
|
|
92
|
+
maxKeys;
|
|
87
93
|
entries = /* @__PURE__ */ new Map();
|
|
94
|
+
/** Distinct keys currently held. Exposed so the ceiling can be asserted. */
|
|
95
|
+
get size() {
|
|
96
|
+
return this.entries.size;
|
|
97
|
+
}
|
|
98
|
+
/** Reclaims every window that has already closed. */
|
|
99
|
+
sweepExpired(now) {
|
|
100
|
+
for (const [key, entry] of this.entries) {
|
|
101
|
+
if (now > entry.resetAt) this.entries.delete(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Soonest-closing window first, so the ceiling drops the least useful entry. */
|
|
105
|
+
evictOldest() {
|
|
106
|
+
let oldestKey = null;
|
|
107
|
+
let oldestResetAt = Infinity;
|
|
108
|
+
for (const [key, entry] of this.entries) {
|
|
109
|
+
if (entry.resetAt < oldestResetAt) {
|
|
110
|
+
oldestResetAt = entry.resetAt;
|
|
111
|
+
oldestKey = key;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (oldestKey !== null) this.entries.delete(oldestKey);
|
|
115
|
+
}
|
|
88
116
|
async increment(key, windowMs) {
|
|
89
117
|
const now = Date.now();
|
|
90
118
|
const current = this.entries.get(key);
|
|
91
119
|
const next = !current || now > current.resetAt ? { count: 1, resetAt: now + windowMs } : { ...current, count: current.count + 1 };
|
|
120
|
+
if (!current && this.entries.size >= this.maxKeys) {
|
|
121
|
+
this.sweepExpired(now);
|
|
122
|
+
if (this.entries.size >= this.maxKeys) this.evictOldest();
|
|
123
|
+
}
|
|
92
124
|
this.entries.set(key, next);
|
|
93
125
|
return next;
|
|
94
126
|
}
|
|
@@ -167,22 +199,61 @@ var PayloadRateLimitStore = class {
|
|
|
167
199
|
return { payload: context };
|
|
168
200
|
}
|
|
169
201
|
};
|
|
202
|
+
function principalRateKey(user) {
|
|
203
|
+
if (!user || user.id === void 0 || user.id === null) return "anonymous";
|
|
204
|
+
const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
|
|
205
|
+
return `${collection}:${String(user.id)}`;
|
|
206
|
+
}
|
|
207
|
+
var MAX_IP_KEY_LENGTH = 45;
|
|
208
|
+
var IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
|
209
|
+
var IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/;
|
|
210
|
+
function clientIpRateKey(req) {
|
|
211
|
+
const candidate = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim() || "";
|
|
212
|
+
return normalizeIpKey(candidate);
|
|
213
|
+
}
|
|
214
|
+
function normalizeIpKey(candidate) {
|
|
215
|
+
if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return "unknown";
|
|
216
|
+
const host = candidate.startsWith("[") && candidate.endsWith("]") ? candidate.slice(1, -1) : candidate;
|
|
217
|
+
if (!host) return "unknown";
|
|
218
|
+
if (IPV4_PATTERN.test(host)) {
|
|
219
|
+
return host.split(".").every((octet) => Number(octet) <= 255) ? host : "unknown";
|
|
220
|
+
}
|
|
221
|
+
if (host.includes(":") && IPV6_PATTERN.test(host)) return host.toLowerCase();
|
|
222
|
+
return "unknown";
|
|
223
|
+
}
|
|
170
224
|
var RateLimiter = class {
|
|
171
|
-
|
|
225
|
+
/**
|
|
226
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
227
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
228
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
229
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
230
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
231
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
232
|
+
* `RateLimiter` is part of the published API surface.
|
|
233
|
+
*/
|
|
234
|
+
constructor(windowMs, maxRequests, store, namespace) {
|
|
172
235
|
this.windowMs = windowMs;
|
|
173
236
|
this.maxRequests = maxRequests;
|
|
174
237
|
this.store = store ?? new MemoryRateLimitStore();
|
|
238
|
+
this.prefix = namespace ? `${namespace}:` : "";
|
|
175
239
|
}
|
|
176
240
|
windowMs;
|
|
177
241
|
maxRequests;
|
|
178
242
|
store;
|
|
243
|
+
prefix;
|
|
244
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
245
|
+
scopedKey(key) {
|
|
246
|
+
return `${this.prefix}${key}`;
|
|
247
|
+
}
|
|
179
248
|
async check(key, context) {
|
|
180
|
-
const
|
|
249
|
+
const scoped = this.scopedKey(key);
|
|
250
|
+
const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
|
|
181
251
|
return entry.count > this.maxRequests;
|
|
182
252
|
}
|
|
183
253
|
async reset(key, context) {
|
|
184
|
-
|
|
185
|
-
|
|
254
|
+
const scoped = this.scopedKey(key);
|
|
255
|
+
if (context === void 0) await this.store.reset(scoped);
|
|
256
|
+
else await this.store.reset(scoped, context);
|
|
186
257
|
}
|
|
187
258
|
};
|
|
188
259
|
var DEFAULT_INBOUND_EMAIL_LIMITS = {
|
|
@@ -220,7 +291,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
|
|
|
220
291
|
|
|
221
292
|
// src/endpoints/capabilities.ts
|
|
222
293
|
function createInboundEmailEndpoint(capability, store) {
|
|
223
|
-
const limiter = new RateLimiter(6e4, 60, store);
|
|
294
|
+
const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
|
|
224
295
|
return {
|
|
225
296
|
path: "/support-webhook/inbound-email",
|
|
226
297
|
method: "post",
|
|
@@ -229,7 +300,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
229
300
|
if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
|
|
230
301
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
231
302
|
}
|
|
232
|
-
const ip =
|
|
303
|
+
const ip = clientIpRateKey(req);
|
|
233
304
|
if (await limiter.check(ip, req)) {
|
|
234
305
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
235
306
|
}
|
|
@@ -251,7 +322,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
251
322
|
};
|
|
252
323
|
}
|
|
253
324
|
function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
254
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
325
|
+
const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
|
|
255
326
|
return {
|
|
256
327
|
path: "/support/suggest-projects",
|
|
257
328
|
method: "post",
|
|
@@ -259,7 +330,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
259
330
|
if (!req.user || req.user.collection !== slugs.users) {
|
|
260
331
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
261
332
|
}
|
|
262
|
-
const key =
|
|
333
|
+
const key = principalRateKey(req.user);
|
|
263
334
|
if (await limiter.check(key, req)) {
|
|
264
335
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
265
336
|
}
|
|
@@ -268,7 +339,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
268
339
|
};
|
|
269
340
|
}
|
|
270
341
|
function createTicketTitleEndpoint(slugs, capability, store) {
|
|
271
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
342
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
|
|
272
343
|
return {
|
|
273
344
|
path: "/support/ticket-title",
|
|
274
345
|
method: "post",
|
|
@@ -290,7 +361,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
|
|
|
290
361
|
};
|
|
291
362
|
}
|
|
292
363
|
function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
|
|
293
|
-
const limiter = new RateLimiter(6e4, 5, store);
|
|
364
|
+
const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
|
|
294
365
|
return {
|
|
295
366
|
path: "/support/generate-missing-titles",
|
|
296
367
|
method: "post",
|
|
@@ -446,6 +517,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
|
|
|
446
517
|
var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
|
|
447
518
|
var USER_PREFS_KEY_PREFIX = "support-user-prefs";
|
|
448
519
|
var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
|
|
520
|
+
var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
|
|
521
|
+
function resolveStaffPrefSlug(payload, staffSlug) {
|
|
522
|
+
if (staffSlug) return staffSlug;
|
|
523
|
+
const config = payload.config;
|
|
524
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
|
|
525
|
+
if (typeof registered === "string" && registered) return registered;
|
|
526
|
+
return config?.admin?.user || "users";
|
|
527
|
+
}
|
|
449
528
|
var DEFAULT_SETTINGS = {
|
|
450
529
|
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
451
530
|
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
|
|
@@ -457,10 +536,13 @@ var DEFAULT_USER_PREFS = {
|
|
|
457
536
|
locale: "fr",
|
|
458
537
|
signature: ""
|
|
459
538
|
};
|
|
460
|
-
var settingsCache =
|
|
539
|
+
var settingsCache = /* @__PURE__ */ new Map();
|
|
461
540
|
var SETTINGS_TTL_MS = 6e4;
|
|
541
|
+
var SETTINGS_CACHE_MAX = 8;
|
|
542
|
+
var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
|
|
462
543
|
function invalidateSupportSettingsCache() {
|
|
463
|
-
settingsCache
|
|
544
|
+
settingsCache.clear();
|
|
545
|
+
warnedForeignSettingsRow.clear();
|
|
464
546
|
}
|
|
465
547
|
function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
466
548
|
const autoClose = { ...base.autoClose, ...stored?.autoClose };
|
|
@@ -477,9 +559,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
|
477
559
|
)
|
|
478
560
|
};
|
|
479
561
|
}
|
|
480
|
-
async function readSupportSettingsState(payload) {
|
|
481
|
-
|
|
482
|
-
|
|
562
|
+
async function readSupportSettingsState(payload, staffSlug) {
|
|
563
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug);
|
|
564
|
+
const cached = settingsCache.get(staff);
|
|
565
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
566
|
+
return cached.value;
|
|
483
567
|
}
|
|
484
568
|
let value = {
|
|
485
569
|
settings: mergeSupportSettings(null),
|
|
@@ -487,7 +571,10 @@ async function readSupportSettingsState(payload) {
|
|
|
487
571
|
};
|
|
488
572
|
try {
|
|
489
573
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
490
|
-
|
|
574
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
575
|
+
// security boundary: without it any authenticated principal can plant a
|
|
576
|
+
// `support-settings` row and own the plugin's server settings.
|
|
577
|
+
where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
|
|
491
578
|
// The upsert is scoped per admin user, so several rows can share the key.
|
|
492
579
|
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
493
580
|
sort: "-updatedAt",
|
|
@@ -500,22 +587,43 @@ async function readSupportSettingsState(payload) {
|
|
|
500
587
|
const featuresConfigured = !!stored.features && typeof stored.features === "object";
|
|
501
588
|
const settings = mergeSupportSettings(stored);
|
|
502
589
|
if (!featuresConfigured) {
|
|
503
|
-
settings.features.roundRobin = await readLegacyRoundRobin(payload);
|
|
590
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
|
|
504
591
|
}
|
|
505
592
|
value = { settings, featuresConfigured };
|
|
593
|
+
} else {
|
|
594
|
+
await warnOnForeignSettingsRow(payload, staff);
|
|
506
595
|
}
|
|
507
596
|
} catch {
|
|
508
597
|
}
|
|
509
|
-
settingsCache
|
|
598
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
|
|
599
|
+
settingsCache.set(staff, { value, ts: Date.now() });
|
|
510
600
|
return value;
|
|
511
601
|
}
|
|
512
|
-
async function
|
|
513
|
-
|
|
602
|
+
async function warnOnForeignSettingsRow(payload, staff) {
|
|
603
|
+
if (warnedForeignSettingsRow.has(staff)) return;
|
|
604
|
+
try {
|
|
605
|
+
const any = await dbFind(payload, "payload-preferences", {
|
|
606
|
+
where: { key: { equals: PREF_KEY } },
|
|
607
|
+
limit: 1,
|
|
608
|
+
depth: 0,
|
|
609
|
+
overrideAccess: true
|
|
610
|
+
});
|
|
611
|
+
if (any.docs.length === 0) return;
|
|
612
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
|
|
613
|
+
warnedForeignSettingsRow.add(staff);
|
|
614
|
+
console.warn(
|
|
615
|
+
`[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.`
|
|
616
|
+
);
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async function readSupportSettings(payload, staffSlug) {
|
|
621
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings;
|
|
514
622
|
}
|
|
515
|
-
async function readLegacyRoundRobin(payload) {
|
|
623
|
+
async function readLegacyRoundRobin(payload, staff) {
|
|
516
624
|
try {
|
|
517
625
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
518
|
-
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
|
|
626
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
|
|
519
627
|
limit: 1,
|
|
520
628
|
depth: 0,
|
|
521
629
|
overrideAccess: true
|
|
@@ -527,11 +635,14 @@ async function readLegacyRoundRobin(payload) {
|
|
|
527
635
|
}
|
|
528
636
|
return DEFAULT_TICKETING_FEATURES.roundRobin;
|
|
529
637
|
}
|
|
530
|
-
async function readUserPrefs(payload, userId) {
|
|
638
|
+
async function readUserPrefs(payload, userId, staffSlug) {
|
|
531
639
|
try {
|
|
532
640
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
|
|
533
641
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
534
|
-
where: {
|
|
642
|
+
where: {
|
|
643
|
+
key: { equals: key },
|
|
644
|
+
"user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
|
|
645
|
+
},
|
|
535
646
|
limit: 1,
|
|
536
647
|
depth: 0,
|
|
537
648
|
overrideAccess: true
|
|
@@ -569,7 +680,7 @@ function getModel(aiSettings) {
|
|
|
569
680
|
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
570
681
|
}
|
|
571
682
|
function createAiEndpoint(slugs, store) {
|
|
572
|
-
const limiter = new RateLimiter(6e4, 30, store);
|
|
683
|
+
const limiter = new RateLimiter(6e4, 30, store, "ai");
|
|
573
684
|
return {
|
|
574
685
|
path: "/support/ai",
|
|
575
686
|
method: "post",
|
|
@@ -577,7 +688,7 @@ function createAiEndpoint(slugs, store) {
|
|
|
577
688
|
try {
|
|
578
689
|
const payload = req.payload;
|
|
579
690
|
requireAdmin(req, slugs);
|
|
580
|
-
if (await limiter.check(
|
|
691
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
581
692
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
582
693
|
}
|
|
583
694
|
const settings = await readSupportSettings(payload);
|
|
@@ -822,14 +933,14 @@ ${kbText || "(vide)"}`;
|
|
|
822
933
|
|
|
823
934
|
// src/endpoints/ai-agent.ts
|
|
824
935
|
function createAiAgentEndpoint(slugs, store) {
|
|
825
|
-
const limiter = new RateLimiter(6e4, 10, store);
|
|
936
|
+
const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
|
|
826
937
|
return {
|
|
827
938
|
path: "/support/ai-agent",
|
|
828
939
|
method: "post",
|
|
829
940
|
handler: async (req) => {
|
|
830
941
|
try {
|
|
831
942
|
requireAdmin(req, slugs);
|
|
832
|
-
if (await limiter.check(
|
|
943
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
833
944
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
834
945
|
}
|
|
835
946
|
let body = {};
|
|
@@ -869,11 +980,11 @@ function getModel2(aiSettings) {
|
|
|
869
980
|
}
|
|
870
981
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
871
982
|
function createClientIntelligenceEndpoint(slugs, store) {
|
|
872
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
983
|
+
const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
|
|
873
984
|
const getHandler = async (req) => {
|
|
874
985
|
try {
|
|
875
986
|
requireAdmin(req, slugs);
|
|
876
|
-
if (await limiter.check(
|
|
987
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
877
988
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
878
989
|
}
|
|
879
990
|
const payload = req.payload;
|
|
@@ -905,7 +1016,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
|
|
|
905
1016
|
const postHandler = async (req) => {
|
|
906
1017
|
try {
|
|
907
1018
|
requireAdmin(req, slugs);
|
|
908
|
-
if (await limiter.check(
|
|
1019
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
909
1020
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
910
1021
|
}
|
|
911
1022
|
const payload = req.payload;
|
|
@@ -1534,6 +1645,14 @@ function createSplitTicketEndpoint(slugs) {
|
|
|
1534
1645
|
// src/endpoints/typing.ts
|
|
1535
1646
|
var typingState = /* @__PURE__ */ new Map();
|
|
1536
1647
|
var TYPING_TTL = 5e3;
|
|
1648
|
+
var MAX_TYPING_KEYS = 500;
|
|
1649
|
+
var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1650
|
+
function normalizeTicketId(raw) {
|
|
1651
|
+
if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
|
|
1652
|
+
if (typeof raw !== "string") return null;
|
|
1653
|
+
const value = raw.trim();
|
|
1654
|
+
return TICKET_ID_PATTERN.test(value) ? value : null;
|
|
1655
|
+
}
|
|
1537
1656
|
function cleanExpired(ticketId) {
|
|
1538
1657
|
const state = typingState.get(ticketId);
|
|
1539
1658
|
if (!state) return;
|
|
@@ -1548,6 +1667,36 @@ function cleanExpired(ticketId) {
|
|
|
1548
1667
|
}
|
|
1549
1668
|
if (!state.admin && !state.client) typingState.delete(ticketId);
|
|
1550
1669
|
}
|
|
1670
|
+
function sweepExpired() {
|
|
1671
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key);
|
|
1672
|
+
}
|
|
1673
|
+
function evictOldest() {
|
|
1674
|
+
let oldestKey = null;
|
|
1675
|
+
let oldestTs = Infinity;
|
|
1676
|
+
for (const [key, state] of typingState) {
|
|
1677
|
+
const ts = Math.max(state.admin || 0, state.client || 0);
|
|
1678
|
+
if (ts < oldestTs) {
|
|
1679
|
+
oldestTs = ts;
|
|
1680
|
+
oldestKey = key;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
if (oldestKey !== null) typingState.delete(oldestKey);
|
|
1684
|
+
}
|
|
1685
|
+
async function mayAccessTicket(req, slugs, ticketId) {
|
|
1686
|
+
const collection = req.user?.collection;
|
|
1687
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false;
|
|
1688
|
+
try {
|
|
1689
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
1690
|
+
id: ticketId,
|
|
1691
|
+
depth: 0,
|
|
1692
|
+
overrideAccess: false,
|
|
1693
|
+
user: req.user
|
|
1694
|
+
});
|
|
1695
|
+
return !!doc;
|
|
1696
|
+
} catch {
|
|
1697
|
+
return false;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1551
1700
|
function createTypingPostEndpoint(slugs) {
|
|
1552
1701
|
return {
|
|
1553
1702
|
path: "/support/typing",
|
|
@@ -1558,11 +1707,18 @@ function createTypingPostEndpoint(slugs) {
|
|
|
1558
1707
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1559
1708
|
}
|
|
1560
1709
|
const { ticketId } = await req.json();
|
|
1561
|
-
|
|
1710
|
+
const key = normalizeTicketId(ticketId);
|
|
1711
|
+
if (!key) {
|
|
1562
1712
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1563
1713
|
}
|
|
1564
|
-
|
|
1714
|
+
if (!await mayAccessTicket(req, slugs, key)) {
|
|
1715
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
1716
|
+
}
|
|
1565
1717
|
const state = typingState.get(key) || {};
|
|
1718
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
1719
|
+
sweepExpired();
|
|
1720
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
|
|
1721
|
+
}
|
|
1566
1722
|
if (req.user.collection === slugs.users) {
|
|
1567
1723
|
state.admin = Date.now();
|
|
1568
1724
|
state.adminName = req.user.firstName || "Support";
|
|
@@ -1583,30 +1739,33 @@ function createTypingGetEndpoint(slugs) {
|
|
|
1583
1739
|
path: "/support/typing",
|
|
1584
1740
|
method: "get",
|
|
1585
1741
|
handler: async (req) => {
|
|
1742
|
+
const idle = { typing: false, name: null };
|
|
1586
1743
|
try {
|
|
1587
1744
|
if (!req.user) {
|
|
1588
1745
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1589
1746
|
}
|
|
1590
1747
|
const url = new URL(req.url);
|
|
1591
|
-
const
|
|
1592
|
-
if (!
|
|
1748
|
+
const key = normalizeTicketId(url.searchParams.get("ticketId"));
|
|
1749
|
+
if (!key) {
|
|
1593
1750
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1594
1751
|
}
|
|
1595
|
-
cleanExpired(
|
|
1596
|
-
const state = typingState.get(
|
|
1752
|
+
cleanExpired(key);
|
|
1753
|
+
const state = typingState.get(key);
|
|
1754
|
+
if (!state) return Response.json(idle);
|
|
1755
|
+
if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
|
|
1597
1756
|
if (req.user.collection === slugs.users) {
|
|
1598
1757
|
return Response.json({
|
|
1599
|
-
typing: !!state
|
|
1600
|
-
name: state
|
|
1758
|
+
typing: !!state.client,
|
|
1759
|
+
name: state.clientName || null
|
|
1601
1760
|
});
|
|
1602
1761
|
} else {
|
|
1603
1762
|
return Response.json({
|
|
1604
|
-
typing: !!state
|
|
1605
|
-
name: state
|
|
1763
|
+
typing: !!state.admin,
|
|
1764
|
+
name: state.adminName || null
|
|
1606
1765
|
});
|
|
1607
1766
|
}
|
|
1608
1767
|
} catch {
|
|
1609
|
-
return Response.json(
|
|
1768
|
+
return Response.json(idle);
|
|
1610
1769
|
}
|
|
1611
1770
|
}
|
|
1612
1771
|
};
|
|
@@ -1769,7 +1928,14 @@ function createSignatureGetEndpoint(slugs) {
|
|
|
1769
1928
|
const payload = req.payload;
|
|
1770
1929
|
requireAdmin(req, slugs);
|
|
1771
1930
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
1772
|
-
|
|
1931
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
1932
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
1933
|
+
// collections — a support-client with the same id would otherwise own
|
|
1934
|
+
// the `email-signature-<id>` row read back for the agent.
|
|
1935
|
+
where: {
|
|
1936
|
+
key: { equals: `${PREF_KEY2}-${req.user.id}` },
|
|
1937
|
+
"user.relationTo": { equals: slugs.users }
|
|
1938
|
+
},
|
|
1773
1939
|
limit: 1,
|
|
1774
1940
|
depth: 0,
|
|
1775
1941
|
overrideAccess: true
|
|
@@ -1796,7 +1962,7 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1796
1962
|
const { signature } = await req.json();
|
|
1797
1963
|
const key = `${PREF_KEY2}-${req.user.id}`;
|
|
1798
1964
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
1799
|
-
where: { key: { equals: key } },
|
|
1965
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
1800
1966
|
limit: 1,
|
|
1801
1967
|
depth: 0,
|
|
1802
1968
|
overrideAccess: true
|
|
@@ -2557,7 +2723,7 @@ function formatFr(date, withTime) {
|
|
|
2557
2723
|
});
|
|
2558
2724
|
}
|
|
2559
2725
|
function createSendReminderEndpoint(slugs, store) {
|
|
2560
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
|
|
2726
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
|
|
2561
2727
|
return {
|
|
2562
2728
|
path: "/support/send-reminder",
|
|
2563
2729
|
method: "post",
|
|
@@ -2565,7 +2731,7 @@ function createSendReminderEndpoint(slugs, store) {
|
|
|
2565
2731
|
try {
|
|
2566
2732
|
const payload = req.payload;
|
|
2567
2733
|
requireAdmin(req, slugs);
|
|
2568
|
-
if (await reminderLimiter.check(
|
|
2734
|
+
if (await reminderLimiter.check(principalRateKey(req.user), req)) {
|
|
2569
2735
|
return Response.json(
|
|
2570
2736
|
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2571
2737
|
{ status: 429 }
|
|
@@ -2691,6 +2857,10 @@ function createStatusesEndpoint(slugs) {
|
|
|
2691
2857
|
if (!req.user) {
|
|
2692
2858
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
2693
2859
|
}
|
|
2860
|
+
const collection = req.user.collection;
|
|
2861
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) {
|
|
2862
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
2863
|
+
}
|
|
2694
2864
|
const { docs } = await dbFind(payload, slugs.ticketStatuses, {
|
|
2695
2865
|
sort: "sortOrder",
|
|
2696
2866
|
limit: 100,
|
|
@@ -2868,14 +3038,20 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2868
3038
|
}
|
|
2869
3039
|
|
|
2870
3040
|
// src/endpoints/chatbot.ts
|
|
2871
|
-
|
|
2872
|
-
|
|
3041
|
+
var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
|
|
3042
|
+
function resolveMaxPerHour(explicit) {
|
|
3043
|
+
const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
|
|
3044
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
|
|
3045
|
+
}
|
|
3046
|
+
function createChatbotEndpoint(slugs, store, maxPerHour) {
|
|
3047
|
+
const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
|
|
3048
|
+
const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
|
|
2873
3049
|
return {
|
|
2874
3050
|
path: "/support/chatbot",
|
|
2875
3051
|
method: "post",
|
|
2876
3052
|
handler: async (req) => {
|
|
2877
3053
|
try {
|
|
2878
|
-
const ip =
|
|
3054
|
+
const ip = clientIpRateKey(req);
|
|
2879
3055
|
if (await chatbotLimiter.check(ip, req)) {
|
|
2880
3056
|
return Response.json({ error: "Too many requests. Please wait a moment." }, { status: 429 });
|
|
2881
3057
|
}
|
|
@@ -2889,6 +3065,15 @@ function createChatbotEndpoint(slugs, store) {
|
|
|
2889
3065
|
if (!question?.trim() || question.trim().length < 5) {
|
|
2890
3066
|
return Response.json({ error: "Question too short" }, { status: 400 });
|
|
2891
3067
|
}
|
|
3068
|
+
if (await globalLimiter.check("all", req)) {
|
|
3069
|
+
return Response.json({
|
|
3070
|
+
answer: null,
|
|
3071
|
+
confidence: 0,
|
|
3072
|
+
suggestion: "create_ticket",
|
|
3073
|
+
aiUnavailable: true,
|
|
3074
|
+
message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
2892
3077
|
const payload = req.payload;
|
|
2893
3078
|
const articles = await dbFind(payload, slugs.knowledgeBase, {
|
|
2894
3079
|
where: { published: { equals: true } },
|
|
@@ -3001,8 +3186,8 @@ function createChatGetEndpoint(slugs) {
|
|
|
3001
3186
|
};
|
|
3002
3187
|
}
|
|
3003
3188
|
function createChatPostEndpoint(slugs, store) {
|
|
3004
|
-
const chatSessionLimiter = new RateLimiter(36e5, 5, store);
|
|
3005
|
-
const chatMessageLimiter = new RateLimiter(6e4, 15, store);
|
|
3189
|
+
const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
|
|
3190
|
+
const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
|
|
3006
3191
|
return {
|
|
3007
3192
|
path: "/support/chat",
|
|
3008
3193
|
method: "post",
|
|
@@ -3018,8 +3203,9 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3018
3203
|
}
|
|
3019
3204
|
const { action, session, message } = body;
|
|
3020
3205
|
const userId = String(req.user.id);
|
|
3206
|
+
const rateKey = principalRateKey(req.user);
|
|
3021
3207
|
if (action === "start") {
|
|
3022
|
-
if (await chatSessionLimiter.check(
|
|
3208
|
+
if (await chatSessionLimiter.check(rateKey, req)) {
|
|
3023
3209
|
return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
|
|
3024
3210
|
}
|
|
3025
3211
|
const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
|
|
@@ -3036,7 +3222,7 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3036
3222
|
return Response.json({ session: sessionId, messages: [systemMsg] });
|
|
3037
3223
|
}
|
|
3038
3224
|
if (action === "send" && session && message) {
|
|
3039
|
-
if (await chatMessageLimiter.check(
|
|
3225
|
+
if (await chatMessageLimiter.check(rateKey, req)) {
|
|
3040
3226
|
return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
|
|
3041
3227
|
}
|
|
3042
3228
|
const trimmedMessage = String(message).trim();
|
|
@@ -3276,7 +3462,7 @@ function createAdminChatGetEndpoint(slugs) {
|
|
|
3276
3462
|
};
|
|
3277
3463
|
}
|
|
3278
3464
|
function createAdminChatPostEndpoint(slugs, store) {
|
|
3279
|
-
const adminChatLimiter = new RateLimiter(6e4, 30, store);
|
|
3465
|
+
const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
|
|
3280
3466
|
return {
|
|
3281
3467
|
path: "/support/admin-chat",
|
|
3282
3468
|
method: "post",
|
|
@@ -3305,7 +3491,7 @@ function createAdminChatPostEndpoint(slugs, store) {
|
|
|
3305
3491
|
}
|
|
3306
3492
|
const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
|
|
3307
3493
|
if (action === "send" && message) {
|
|
3308
|
-
if (await adminChatLimiter.check(
|
|
3494
|
+
if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
|
|
3309
3495
|
return Response.json({ error: "Rate limit atteint." }, { status: 429 });
|
|
3310
3496
|
}
|
|
3311
3497
|
const trimmedMessage = String(message).trim();
|
|
@@ -4260,14 +4446,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
|
|
|
4260
4446
|
|
|
4261
4447
|
// src/endpoints/ticket-synthesis.ts
|
|
4262
4448
|
function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
4263
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
4449
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
|
|
4264
4450
|
return {
|
|
4265
4451
|
path: "/support/ticket-synthesis",
|
|
4266
4452
|
method: "post",
|
|
4267
4453
|
handler: async (req) => {
|
|
4268
4454
|
try {
|
|
4269
4455
|
requireAdmin(req, slugs);
|
|
4270
|
-
if (await limiter.check(
|
|
4456
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
4271
4457
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
4272
4458
|
}
|
|
4273
4459
|
const payload = req.payload;
|
|
@@ -4311,15 +4497,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
|
4311
4497
|
}
|
|
4312
4498
|
|
|
4313
4499
|
// src/endpoints/email-stats.ts
|
|
4314
|
-
function createEmailStatsEndpoint(slugs) {
|
|
4500
|
+
function createEmailStatsEndpoint(slugs, store) {
|
|
4501
|
+
const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
|
|
4315
4502
|
return {
|
|
4316
4503
|
path: "/support/email-stats",
|
|
4317
4504
|
method: "get",
|
|
4318
4505
|
handler: async (req) => {
|
|
4319
4506
|
try {
|
|
4320
4507
|
const payload = req.payload;
|
|
4321
|
-
|
|
4322
|
-
|
|
4508
|
+
requireAdmin(req, slugs);
|
|
4509
|
+
if (await statsLimiter.check(principalRateKey(req.user), req)) {
|
|
4510
|
+
return Response.json({ error: "Too many requests." }, { status: 429 });
|
|
4323
4511
|
}
|
|
4324
4512
|
const url = new URL(req.url);
|
|
4325
4513
|
const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
|
|
@@ -4386,6 +4574,8 @@ function createEmailStatsEndpoint(slugs) {
|
|
|
4386
4574
|
actions: Object.fromEntries(actionMap)
|
|
4387
4575
|
});
|
|
4388
4576
|
} catch (err) {
|
|
4577
|
+
const authResponse = handleAuthError(err);
|
|
4578
|
+
if (authResponse) return authResponse;
|
|
4389
4579
|
console.error("[email-stats] Error:", err);
|
|
4390
4580
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
4391
4581
|
}
|
|
@@ -4818,7 +5008,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
|
|
|
4818
5008
|
|
|
4819
5009
|
// src/endpoints/resend-notification.ts
|
|
4820
5010
|
function createResendNotificationEndpoint(slugs, store) {
|
|
4821
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
5011
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
|
|
4822
5012
|
return {
|
|
4823
5013
|
path: "/support/resend-notification",
|
|
4824
5014
|
method: "post",
|
|
@@ -4826,7 +5016,7 @@ function createResendNotificationEndpoint(slugs, store) {
|
|
|
4826
5016
|
try {
|
|
4827
5017
|
const payload = req.payload;
|
|
4828
5018
|
requireAdmin(req, slugs);
|
|
4829
|
-
if (await resendLimiter.check(
|
|
5019
|
+
if (await resendLimiter.check(principalRateKey(req.user), req)) {
|
|
4830
5020
|
return Response.json(
|
|
4831
5021
|
{ error: "Trop de renvois. R\xE9essayez dans une heure." },
|
|
4832
5022
|
{ status: 429 }
|
|
@@ -5027,15 +5217,54 @@ function createSeedKbEndpoint(slugs) {
|
|
|
5027
5217
|
}
|
|
5028
5218
|
};
|
|
5029
5219
|
}
|
|
5220
|
+
var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
|
|
5221
|
+
function challengeSecret() {
|
|
5222
|
+
const secret = process.env.PAYLOAD_SECRET;
|
|
5223
|
+
if (!secret) {
|
|
5224
|
+
throw new Error(
|
|
5225
|
+
"[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
|
|
5226
|
+
);
|
|
5227
|
+
}
|
|
5228
|
+
return secret;
|
|
5229
|
+
}
|
|
5230
|
+
function normalizeEmail(email) {
|
|
5231
|
+
return String(email).trim().toLowerCase();
|
|
5232
|
+
}
|
|
5233
|
+
function sign(email, expiresAt) {
|
|
5234
|
+
return crypto3.createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
|
|
5235
|
+
}
|
|
5236
|
+
function issueTwoFactorChallenge(email, now = Date.now()) {
|
|
5237
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
|
|
5238
|
+
return `${expiresAt}.${sign(email, expiresAt)}`;
|
|
5239
|
+
}
|
|
5240
|
+
function verifyTwoFactorChallenge(email, token, now = Date.now()) {
|
|
5241
|
+
if (typeof email !== "string" || !email || typeof token !== "string") return false;
|
|
5242
|
+
const separator = token.indexOf(".");
|
|
5243
|
+
if (separator <= 0) return false;
|
|
5244
|
+
const expiresAt = Number(token.slice(0, separator));
|
|
5245
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
|
|
5246
|
+
const received = token.slice(separator + 1);
|
|
5247
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
|
|
5248
|
+
let expected;
|
|
5249
|
+
try {
|
|
5250
|
+
expected = sign(email, expiresAt);
|
|
5251
|
+
} catch {
|
|
5252
|
+
return false;
|
|
5253
|
+
}
|
|
5254
|
+
const a = Buffer.from(expected, "hex");
|
|
5255
|
+
const b = Buffer.from(received.toLowerCase(), "hex");
|
|
5256
|
+
return a.length === b.length && crypto3.timingSafeEqual(a, b);
|
|
5257
|
+
}
|
|
5030
5258
|
|
|
5031
5259
|
// src/endpoints/login.ts
|
|
5260
|
+
var MAX_LOGGED_USER_AGENT = 256;
|
|
5032
5261
|
function createLoginEndpoint(slugs, store) {
|
|
5033
|
-
const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
|
|
5262
|
+
const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
|
|
5034
5263
|
return {
|
|
5035
5264
|
path: "/support/login",
|
|
5036
5265
|
method: "post",
|
|
5037
5266
|
handler: async (req) => {
|
|
5038
|
-
const ip =
|
|
5267
|
+
const ip = clientIpRateKey(req);
|
|
5039
5268
|
if (await loginLimiter.check(ip, req)) {
|
|
5040
5269
|
return Response.json(
|
|
5041
5270
|
{ error: "Trop de tentatives. R\xE9essayez dans quelques minutes." },
|
|
@@ -5050,7 +5279,7 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5050
5279
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5051
5280
|
}
|
|
5052
5281
|
const { email, password } = body;
|
|
5053
|
-
const userAgent = req.headers.get("user-agent") || "";
|
|
5282
|
+
const userAgent = (req.headers.get("user-agent") || "").slice(0, MAX_LOGGED_USER_AGENT);
|
|
5054
5283
|
if (!email || !password) {
|
|
5055
5284
|
return Response.json({ error: "Email et mot de passe requis." }, { status: 400 });
|
|
5056
5285
|
}
|
|
@@ -5083,7 +5312,12 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5083
5312
|
} catch (err) {
|
|
5084
5313
|
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
|
|
5085
5314
|
if (errorMessage.includes("2FA_REQUIRED")) {
|
|
5086
|
-
|
|
5315
|
+
let challenge;
|
|
5316
|
+
try {
|
|
5317
|
+
challenge = issueTwoFactorChallenge(email);
|
|
5318
|
+
} catch {
|
|
5319
|
+
}
|
|
5320
|
+
return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
|
|
5087
5321
|
}
|
|
5088
5322
|
let errorReason = "Identifiants incorrects";
|
|
5089
5323
|
if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
|
|
@@ -5115,8 +5349,8 @@ function hashCode(code) {
|
|
|
5115
5349
|
return crypto3.createHmac("sha256", secret).update(code).digest("hex");
|
|
5116
5350
|
}
|
|
5117
5351
|
function createAuth2faEndpoint(slugs, store) {
|
|
5118
|
-
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
|
|
5119
|
-
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
|
|
5352
|
+
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
|
|
5353
|
+
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
|
|
5120
5354
|
return {
|
|
5121
5355
|
path: "/support/2fa",
|
|
5122
5356
|
method: "post",
|
|
@@ -5129,14 +5363,32 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5129
5363
|
} catch {
|
|
5130
5364
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5131
5365
|
}
|
|
5132
|
-
const { action, email, code } = body;
|
|
5366
|
+
const { action, email, code, challenge } = body;
|
|
5133
5367
|
if (!action || !email) {
|
|
5134
5368
|
return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
|
|
5135
5369
|
}
|
|
5136
|
-
const
|
|
5370
|
+
const limiterKey = normalizeEmail(email);
|
|
5371
|
+
const sendResponse = () => {
|
|
5372
|
+
let refreshed;
|
|
5373
|
+
try {
|
|
5374
|
+
refreshed = issueTwoFactorChallenge(email);
|
|
5375
|
+
} catch {
|
|
5376
|
+
}
|
|
5377
|
+
return Response.json({
|
|
5378
|
+
success: true,
|
|
5379
|
+
message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9.",
|
|
5380
|
+
...refreshed ? { challenge: refreshed } : {}
|
|
5381
|
+
});
|
|
5382
|
+
};
|
|
5137
5383
|
if (action === "send") {
|
|
5138
|
-
if (
|
|
5139
|
-
return Response.json(
|
|
5384
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5385
|
+
return Response.json(
|
|
5386
|
+
{ error: "Authentification requise avant l'envoi d'un code." },
|
|
5387
|
+
{ status: 401 }
|
|
5388
|
+
);
|
|
5389
|
+
}
|
|
5390
|
+
if (await sendLimiter.check(limiterKey, req)) {
|
|
5391
|
+
return sendResponse();
|
|
5140
5392
|
}
|
|
5141
5393
|
const clients = await dbFind(payload, slugs.supportClients, {
|
|
5142
5394
|
where: { email: { equals: email } },
|
|
@@ -5145,7 +5397,7 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5145
5397
|
overrideAccess: true
|
|
5146
5398
|
});
|
|
5147
5399
|
if (clients.docs.length === 0) {
|
|
5148
|
-
return
|
|
5400
|
+
return sendResponse();
|
|
5149
5401
|
}
|
|
5150
5402
|
const client = clients.docs[0];
|
|
5151
5403
|
const plainCode = generateSecureCode();
|
|
@@ -5170,13 +5422,19 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5170
5422
|
<p style="font-size: 13px; color: #6b7280;">Ce code est valable 10 minutes.</p>
|
|
5171
5423
|
</div>`
|
|
5172
5424
|
});
|
|
5173
|
-
return
|
|
5425
|
+
return sendResponse();
|
|
5174
5426
|
}
|
|
5175
5427
|
if (action === "verify") {
|
|
5428
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5429
|
+
return Response.json(
|
|
5430
|
+
{ error: "Authentification requise avant la v\xE9rification d'un code." },
|
|
5431
|
+
{ status: 401 }
|
|
5432
|
+
);
|
|
5433
|
+
}
|
|
5176
5434
|
if (!code) {
|
|
5177
5435
|
return Response.json({ error: "Code manquant" }, { status: 400 });
|
|
5178
5436
|
}
|
|
5179
|
-
if (await verifyLimiter.check(
|
|
5437
|
+
if (await verifyLimiter.check(limiterKey, req)) {
|
|
5180
5438
|
return Response.json(
|
|
5181
5439
|
{ error: "Trop de tentatives. R\xE9essayez dans 15 minutes." },
|
|
5182
5440
|
{ status: 429 }
|
|
@@ -5217,7 +5475,7 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5217
5475
|
data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
5218
5476
|
overrideAccess: true
|
|
5219
5477
|
});
|
|
5220
|
-
verifyLimiter.reset(
|
|
5478
|
+
await verifyLimiter.reset(limiterKey, req);
|
|
5221
5479
|
return Response.json({ success: true, verified: true });
|
|
5222
5480
|
}
|
|
5223
5481
|
return Response.json({ error: "Action invalide" }, { status: 400 });
|
|
@@ -5228,6 +5486,33 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5228
5486
|
}
|
|
5229
5487
|
};
|
|
5230
5488
|
}
|
|
5489
|
+
var OAUTH_STATE_COOKIE = "support-oauth-state";
|
|
5490
|
+
var OAUTH_STATE_MAX_AGE = 600;
|
|
5491
|
+
var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
|
|
5492
|
+
function readCookie(header, name) {
|
|
5493
|
+
if (!header) return null;
|
|
5494
|
+
for (const part of header.split(";")) {
|
|
5495
|
+
const eq = part.indexOf("=");
|
|
5496
|
+
if (eq === -1) continue;
|
|
5497
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
5498
|
+
try {
|
|
5499
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
5500
|
+
} catch {
|
|
5501
|
+
return part.slice(eq + 1).trim();
|
|
5502
|
+
}
|
|
5503
|
+
}
|
|
5504
|
+
return null;
|
|
5505
|
+
}
|
|
5506
|
+
function clearedStateCookie() {
|
|
5507
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5508
|
+
return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
|
|
5509
|
+
}
|
|
5510
|
+
function statesMatch(a, b) {
|
|
5511
|
+
if (!a || !b) return false;
|
|
5512
|
+
const left = crypto3__default.default.createHash("sha256").update(a).digest();
|
|
5513
|
+
const right = crypto3__default.default.createHash("sha256").update(b).digest();
|
|
5514
|
+
return crypto3__default.default.timingSafeEqual(left, right);
|
|
5515
|
+
}
|
|
5231
5516
|
function createOAuthGoogleEndpoint(slugs, options) {
|
|
5232
5517
|
return {
|
|
5233
5518
|
path: "/support/oauth/google",
|
|
@@ -5244,7 +5529,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5244
5529
|
}
|
|
5245
5530
|
try {
|
|
5246
5531
|
const body = await req.json();
|
|
5247
|
-
const { action, code, state: queryState
|
|
5532
|
+
const { action, code, state: queryState } = body;
|
|
5248
5533
|
if (action === "login") {
|
|
5249
5534
|
const oauthState = crypto3__default.default.randomBytes(32).toString("hex");
|
|
5250
5535
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5256,13 +5541,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5256
5541
|
state: oauthState,
|
|
5257
5542
|
prompt: "select_account"
|
|
5258
5543
|
});
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5544
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5545
|
+
const loginHeaders = new Headers({ "Content-Type": "application/json" });
|
|
5546
|
+
loginHeaders.append(
|
|
5547
|
+
"Set-Cookie",
|
|
5548
|
+
`${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
|
|
5549
|
+
);
|
|
5550
|
+
return new Response(
|
|
5551
|
+
JSON.stringify({
|
|
5552
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
5553
|
+
// Kept for callers that echo it back in the redirect URL; the
|
|
5554
|
+
// server no longer trusts anything the caller returns.
|
|
5555
|
+
state: oauthState
|
|
5556
|
+
}),
|
|
5557
|
+
{ status: 200, headers: loginHeaders }
|
|
5558
|
+
);
|
|
5263
5559
|
}
|
|
5264
5560
|
if (code) {
|
|
5265
|
-
|
|
5561
|
+
const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
|
|
5562
|
+
if (!statesMatch(issuedState, queryState)) {
|
|
5266
5563
|
return Response.json({ error: "state_mismatch" }, { status: 400 });
|
|
5267
5564
|
}
|
|
5268
5565
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5344,6 +5641,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5344
5641
|
overrideAccess: true
|
|
5345
5642
|
});
|
|
5346
5643
|
}
|
|
5644
|
+
const twoFactorDoc = await dbFindByID(payload$1, slugs.supportClients, {
|
|
5645
|
+
id: clientDoc.id,
|
|
5646
|
+
depth: 0,
|
|
5647
|
+
overrideAccess: true,
|
|
5648
|
+
showHiddenFields: true
|
|
5649
|
+
});
|
|
5650
|
+
if (twoFactorDoc?.twoFactorEnabled) {
|
|
5651
|
+
const raw = twoFactorDoc.twoFactorVerifiedAt;
|
|
5652
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
5653
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
5654
|
+
let challenge;
|
|
5655
|
+
try {
|
|
5656
|
+
challenge = issueTwoFactorChallenge(clientDoc.email);
|
|
5657
|
+
} catch {
|
|
5658
|
+
}
|
|
5659
|
+
return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
|
|
5660
|
+
status: 200,
|
|
5661
|
+
headers: new Headers({
|
|
5662
|
+
"Content-Type": "application/json",
|
|
5663
|
+
"Set-Cookie": clearedStateCookie()
|
|
5664
|
+
})
|
|
5665
|
+
});
|
|
5666
|
+
}
|
|
5667
|
+
await dbUpdate(payload$1, slugs.supportClients, {
|
|
5668
|
+
id: clientDoc.id,
|
|
5669
|
+
data: { twoFactorVerifiedAt: null },
|
|
5670
|
+
overrideAccess: true
|
|
5671
|
+
});
|
|
5672
|
+
}
|
|
5347
5673
|
const secret = process.env.PAYLOAD_SECRET;
|
|
5348
5674
|
if (!secret) {
|
|
5349
5675
|
return Response.json({ error: "server_misconfigured" }, { status: 500 });
|
|
@@ -5387,6 +5713,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5387
5713
|
"Set-Cookie",
|
|
5388
5714
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
|
|
5389
5715
|
);
|
|
5716
|
+
headers.append("Set-Cookie", clearedStateCookie());
|
|
5390
5717
|
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
5391
5718
|
status: 200,
|
|
5392
5719
|
headers
|
|
@@ -5693,7 +6020,7 @@ ONLY JSON, nothing else.`
|
|
|
5693
6020
|
}
|
|
5694
6021
|
}
|
|
5695
6022
|
function createImportConversationEndpoint(slugs, store) {
|
|
5696
|
-
const importLimiter = new RateLimiter(36e5, 10, store);
|
|
6023
|
+
const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
|
|
5697
6024
|
return {
|
|
5698
6025
|
path: "/support/import-conversation",
|
|
5699
6026
|
method: "post",
|
|
@@ -5710,7 +6037,7 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5710
6037
|
if (!isAuthed) {
|
|
5711
6038
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
5712
6039
|
}
|
|
5713
|
-
const ip = req
|
|
6040
|
+
const ip = clientIpRateKey(req);
|
|
5714
6041
|
if (await importLimiter.check(ip, req)) {
|
|
5715
6042
|
return Response.json({ error: "Rate limit exceeded. Maximum 10 imports per hour." }, { status: 429 });
|
|
5716
6043
|
}
|
|
@@ -5836,6 +6163,152 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5836
6163
|
}
|
|
5837
6164
|
};
|
|
5838
6165
|
}
|
|
6166
|
+
function httpAllowed() {
|
|
6167
|
+
return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
|
|
6168
|
+
}
|
|
6169
|
+
var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
|
|
6170
|
+
function parseIPv4(host) {
|
|
6171
|
+
const parts = host.split(".");
|
|
6172
|
+
if (parts.length !== 4) return null;
|
|
6173
|
+
const octets = [];
|
|
6174
|
+
for (const part of parts) {
|
|
6175
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
6176
|
+
const n = Number(part);
|
|
6177
|
+
if (n > 255) return null;
|
|
6178
|
+
octets.push(n);
|
|
6179
|
+
}
|
|
6180
|
+
return octets;
|
|
6181
|
+
}
|
|
6182
|
+
function isPrivateIPv4(octets) {
|
|
6183
|
+
const [a, b] = octets;
|
|
6184
|
+
if (a === 0) return true;
|
|
6185
|
+
if (a === 10) return true;
|
|
6186
|
+
if (a === 127) return true;
|
|
6187
|
+
if (a === 169 && b === 254) return true;
|
|
6188
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
6189
|
+
if (a === 192 && b === 168) return true;
|
|
6190
|
+
if (a === 192 && b === 0) return true;
|
|
6191
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
6192
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
6193
|
+
if (a >= 224) return true;
|
|
6194
|
+
return false;
|
|
6195
|
+
}
|
|
6196
|
+
function isPrivateIPv6(host) {
|
|
6197
|
+
const lower = host.toLowerCase();
|
|
6198
|
+
if (lower === "::" || lower === "::1") return true;
|
|
6199
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
|
|
6200
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
6201
|
+
if (lower.startsWith("ff")) return true;
|
|
6202
|
+
const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
6203
|
+
if (dotted) {
|
|
6204
|
+
const octets = parseIPv4(dotted[1]);
|
|
6205
|
+
return octets ? isPrivateIPv4(octets) : true;
|
|
6206
|
+
}
|
|
6207
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
6208
|
+
if (hex) {
|
|
6209
|
+
const high = parseInt(hex[1], 16);
|
|
6210
|
+
const low = parseInt(hex[2], 16);
|
|
6211
|
+
return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
|
|
6212
|
+
}
|
|
6213
|
+
return false;
|
|
6214
|
+
}
|
|
6215
|
+
function normalizeHost(hostname) {
|
|
6216
|
+
const host = hostname.trim().toLowerCase();
|
|
6217
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
6218
|
+
}
|
|
6219
|
+
function isBlockedHost(hostname) {
|
|
6220
|
+
const host = normalizeHost(hostname);
|
|
6221
|
+
if (!host) return true;
|
|
6222
|
+
if (host === "localhost") return true;
|
|
6223
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
6224
|
+
const v4 = parseIPv4(host);
|
|
6225
|
+
if (v4) return isPrivateIPv4(v4);
|
|
6226
|
+
if (host.includes(":")) return isPrivateIPv6(host);
|
|
6227
|
+
return false;
|
|
6228
|
+
}
|
|
6229
|
+
function validateWebhookUrl(raw) {
|
|
6230
|
+
if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
|
|
6231
|
+
let url;
|
|
6232
|
+
try {
|
|
6233
|
+
url = new URL(raw.trim());
|
|
6234
|
+
} catch {
|
|
6235
|
+
return { ok: false, reason: "invalid_url" };
|
|
6236
|
+
}
|
|
6237
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
|
|
6238
|
+
return { ok: false, reason: "scheme_not_allowed" };
|
|
6239
|
+
}
|
|
6240
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6241
|
+
return { ok: true, url };
|
|
6242
|
+
}
|
|
6243
|
+
var WEBHOOK_URL_MESSAGES = {
|
|
6244
|
+
invalid_url: "URL invalide.",
|
|
6245
|
+
scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
|
|
6246
|
+
private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
|
|
6247
|
+
};
|
|
6248
|
+
var MAX_PUSH_ENDPOINT_LENGTH = 2048;
|
|
6249
|
+
function validatePushEndpoint(raw) {
|
|
6250
|
+
if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_PUSH_ENDPOINT_LENGTH) {
|
|
6251
|
+
return { ok: false, reason: "invalid_url" };
|
|
6252
|
+
}
|
|
6253
|
+
let url;
|
|
6254
|
+
try {
|
|
6255
|
+
url = new URL(raw.trim());
|
|
6256
|
+
} catch {
|
|
6257
|
+
return { ok: false, reason: "invalid_url" };
|
|
6258
|
+
}
|
|
6259
|
+
if (url.protocol !== "https:") return { ok: false, reason: "scheme_not_allowed" };
|
|
6260
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6261
|
+
return { ok: true, url };
|
|
6262
|
+
}
|
|
6263
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
6264
|
+
var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
|
|
6265
|
+
var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
|
|
6266
|
+
async function assertPublicHost(hostname) {
|
|
6267
|
+
const host = normalizeHost(hostname);
|
|
6268
|
+
if (isBlockedHost(host)) return false;
|
|
6269
|
+
if (parseIPv4(host) || host.includes(":")) return true;
|
|
6270
|
+
try {
|
|
6271
|
+
const addresses = await Promise.race([
|
|
6272
|
+
promises.lookup(host, { all: true }),
|
|
6273
|
+
new Promise(
|
|
6274
|
+
(resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
|
|
6275
|
+
)
|
|
6276
|
+
]);
|
|
6277
|
+
if (addresses === LOOKUP_TIMED_OUT) return false;
|
|
6278
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return false;
|
|
6279
|
+
return addresses.every((entry) => !isBlockedHost(entry.address));
|
|
6280
|
+
} catch (error) {
|
|
6281
|
+
const code = error?.code;
|
|
6282
|
+
return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
6285
|
+
var BlockedRequestError = class extends Error {
|
|
6286
|
+
constructor(reason) {
|
|
6287
|
+
super(`Blocked outbound request: ${reason}`);
|
|
6288
|
+
this.reason = reason;
|
|
6289
|
+
this.name = "BlockedRequestError";
|
|
6290
|
+
}
|
|
6291
|
+
reason;
|
|
6292
|
+
};
|
|
6293
|
+
var MAX_REDIRECTS = 3;
|
|
6294
|
+
async function safeFetch(rawUrl, init) {
|
|
6295
|
+
let current = rawUrl;
|
|
6296
|
+
let body = init.body;
|
|
6297
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
6298
|
+
const validation = validateWebhookUrl(current);
|
|
6299
|
+
if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
|
|
6300
|
+
if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
|
|
6301
|
+
const response = await fetch(current, { ...init, body, redirect: "manual" });
|
|
6302
|
+
if (response.status < 300 || response.status > 399) return response;
|
|
6303
|
+
const location = response.headers.get("location");
|
|
6304
|
+
if (!location) return response;
|
|
6305
|
+
current = new URL(location, current).toString();
|
|
6306
|
+
if (response.status === 303) body = void 0;
|
|
6307
|
+
}
|
|
6308
|
+
throw new BlockedRequestError("too_many_redirects");
|
|
6309
|
+
}
|
|
6310
|
+
|
|
6311
|
+
// src/utils/webhookDispatcher.ts
|
|
5839
6312
|
function dispatchWebhook(data, event, payload, slugs) {
|
|
5840
6313
|
const done = _dispatch(data, event, payload, slugs);
|
|
5841
6314
|
return done;
|
|
@@ -5872,7 +6345,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
|
5872
6345
|
const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
5873
6346
|
headers["X-Webhook-Signature"] = signature;
|
|
5874
6347
|
}
|
|
5875
|
-
const response = await
|
|
6348
|
+
const response = await safeFetch(endpoint.url, {
|
|
5876
6349
|
method: "POST",
|
|
5877
6350
|
headers,
|
|
5878
6351
|
body,
|
|
@@ -6242,6 +6715,15 @@ async function sendPushToUser(payload, slugs, userId, notification) {
|
|
|
6242
6715
|
for (const s of subs.docs) {
|
|
6243
6716
|
const row = s;
|
|
6244
6717
|
if (!row.endpoint || !row.p256dh || !row.auth) continue;
|
|
6718
|
+
const check = validatePushEndpoint(row.endpoint);
|
|
6719
|
+
if (!check.ok || !check.url) {
|
|
6720
|
+
console.warn("[support] Push endpoint refused (unsafe URL):", check.reason);
|
|
6721
|
+
continue;
|
|
6722
|
+
}
|
|
6723
|
+
if (!await assertPublicHost(check.url.hostname)) {
|
|
6724
|
+
console.warn("[support] Push endpoint refused (host resolves to a private address)");
|
|
6725
|
+
continue;
|
|
6726
|
+
}
|
|
6245
6727
|
try {
|
|
6246
6728
|
await webpush__default.default.sendNotification(
|
|
6247
6729
|
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|
|
@@ -6292,7 +6774,14 @@ function createPushSubscribeEndpoint(slugs) {
|
|
|
6292
6774
|
if (!endpoint || !p256dh || !auth) {
|
|
6293
6775
|
return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
|
|
6294
6776
|
}
|
|
6295
|
-
const
|
|
6777
|
+
const endpointCheck = validatePushEndpoint(endpoint);
|
|
6778
|
+
if (!endpointCheck.ok) {
|
|
6779
|
+
return Response.json(
|
|
6780
|
+
{ error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || "invalid_url"] },
|
|
6781
|
+
{ status: 400 }
|
|
6782
|
+
);
|
|
6783
|
+
}
|
|
6784
|
+
const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: (req.headers.get("user-agent") || "").slice(0, 256) };
|
|
6296
6785
|
const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
|
|
6297
6786
|
if (existing.docs.length > 0) {
|
|
6298
6787
|
await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
|
|
@@ -6326,7 +6815,7 @@ function createUserPrefsGetEndpoint(slugs) {
|
|
|
6326
6815
|
requireAdmin(req, slugs);
|
|
6327
6816
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6328
6817
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
6329
|
-
where: { key: { equals: key } },
|
|
6818
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6330
6819
|
limit: 1,
|
|
6331
6820
|
depth: 0,
|
|
6332
6821
|
overrideAccess: true
|
|
@@ -6360,7 +6849,7 @@ function createUserPrefsPostEndpoint(slugs) {
|
|
|
6360
6849
|
const body = await req.json();
|
|
6361
6850
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6362
6851
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
6363
|
-
where: { key: { equals: key } },
|
|
6852
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6364
6853
|
limit: 1,
|
|
6365
6854
|
depth: 0,
|
|
6366
6855
|
overrideAccess: true
|
|
@@ -6516,8 +7005,10 @@ function createTicketFeedbackEndpoint(slugs) {
|
|
|
6516
7005
|
// src/endpoints/transfer-ticket.ts
|
|
6517
7006
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6518
7007
|
var MAX_TRANSFERS_PER_DAY = 5;
|
|
7008
|
+
var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
|
|
6519
7009
|
function createTransferTicketEndpoint(slugs, store) {
|
|
6520
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
|
|
7010
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
|
|
7011
|
+
const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
|
|
6521
7012
|
return {
|
|
6522
7013
|
path: "/support/tickets/:id/transfer",
|
|
6523
7014
|
method: "post",
|
|
@@ -6552,12 +7043,18 @@ function createTransferTicketEndpoint(slugs, store) {
|
|
|
6552
7043
|
if (!isAdmin && !isOwner) {
|
|
6553
7044
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6554
7045
|
}
|
|
6555
|
-
if (await transferLimiter.check(`${req.user
|
|
7046
|
+
if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
|
|
6556
7047
|
return Response.json(
|
|
6557
7048
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
6558
7049
|
{ status: 429 }
|
|
6559
7050
|
);
|
|
6560
7051
|
}
|
|
7052
|
+
if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
|
|
7053
|
+
return Response.json(
|
|
7054
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
|
|
7055
|
+
{ status: 429 }
|
|
7056
|
+
);
|
|
7057
|
+
}
|
|
6561
7058
|
try {
|
|
6562
7059
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
|
|
6563
7060
|
const existing = await dbCount(payload, slugs.emailLogs, {
|
|
@@ -6699,8 +7196,11 @@ function statusToLabel(status) {
|
|
|
6699
7196
|
}
|
|
6700
7197
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6701
7198
|
var MAX_COLLABORATORS_PER_TICKET = 20;
|
|
7199
|
+
var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
|
|
7200
|
+
var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
6702
7201
|
function createInviteCollaboratorEndpoint(slugs, store) {
|
|
6703
|
-
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
7202
|
+
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
|
|
7203
|
+
const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
|
|
6704
7204
|
return {
|
|
6705
7205
|
path: "/support/tickets/:id/invite",
|
|
6706
7206
|
method: "post",
|
|
@@ -6735,7 +7235,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6735
7235
|
if (!isAdmin && !isOwner) {
|
|
6736
7236
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6737
7237
|
}
|
|
6738
|
-
if (await inviteLimiter.check(
|
|
7238
|
+
if (await inviteLimiter.check(principalRateKey(req.user), req)) {
|
|
6739
7239
|
return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
|
|
6740
7240
|
}
|
|
6741
7241
|
const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
|
|
@@ -6759,6 +7259,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6759
7259
|
if (existing.docs.length > 0) {
|
|
6760
7260
|
inviteeId = existing.docs[0].id;
|
|
6761
7261
|
} else {
|
|
7262
|
+
if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
|
|
7263
|
+
return Response.json(
|
|
7264
|
+
{ error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
|
|
7265
|
+
{ status: 429 }
|
|
7266
|
+
);
|
|
7267
|
+
}
|
|
6762
7268
|
const tempPassword = crypto3.randomBytes(16).toString("hex");
|
|
6763
7269
|
const created = await dbCreate(payload, slugs.supportClients, {
|
|
6764
7270
|
data: {
|
|
@@ -6930,7 +7436,7 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6930
7436
|
}
|
|
6931
7437
|
if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
|
|
6932
7438
|
if (!f || f.emailTracking !== false) {
|
|
6933
|
-
endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
|
|
7439
|
+
endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
|
|
6934
7440
|
}
|
|
6935
7441
|
if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
|
|
6936
7442
|
if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
|
|
@@ -8415,7 +8921,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8415
8921
|
}
|
|
8416
8922
|
|
|
8417
8923
|
// src/utils/ticketAccess.ts
|
|
8418
|
-
async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
8924
|
+
async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
|
|
8419
8925
|
const ids = /* @__PURE__ */ new Set();
|
|
8420
8926
|
try {
|
|
8421
8927
|
const owned = await dbFind(payload, slugs.tickets, {
|
|
@@ -8436,6 +8942,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
|
8436
8942
|
});
|
|
8437
8943
|
for (const r of collab.docs) {
|
|
8438
8944
|
const row = r;
|
|
8945
|
+
if (mode === "write" && row.role !== "collaborator") continue;
|
|
8439
8946
|
const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
|
|
8440
8947
|
if (tid !== void 0 && tid !== null) ids.add(tid);
|
|
8441
8948
|
}
|
|
@@ -8542,7 +9049,7 @@ function createRestrictClientTicketTarget(slugs) {
|
|
|
8542
9049
|
if (targetId === void 0 || targetId === null || targetId === "") {
|
|
8543
9050
|
throw new payload.APIError("Ticket cible requis.", 400);
|
|
8544
9051
|
}
|
|
8545
|
-
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
|
|
9052
|
+
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
|
|
8546
9053
|
if (!accessible.some((id) => String(id) === String(targetId))) {
|
|
8547
9054
|
throw new payload.APIError("Ticket inaccessible.", 403);
|
|
8548
9055
|
}
|
|
@@ -9059,13 +9566,13 @@ function createSendInvitationOnCreate(slugs) {
|
|
|
9059
9566
|
return doc;
|
|
9060
9567
|
};
|
|
9061
9568
|
}
|
|
9062
|
-
var
|
|
9569
|
+
var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
9063
9570
|
function createEnforce2FA(slugs) {
|
|
9064
9571
|
return async ({ req, user }) => {
|
|
9065
9572
|
if (!user?.twoFactorEnabled) return user;
|
|
9066
9573
|
const raw = user.twoFactorVerifiedAt;
|
|
9067
9574
|
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
9068
|
-
if (!(verifiedAt > Date.now() -
|
|
9575
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
|
|
9069
9576
|
throw new payload.APIError("2FA_REQUIRED", 401);
|
|
9070
9577
|
}
|
|
9071
9578
|
await req.payload.update({
|
|
@@ -9906,8 +10413,28 @@ function createKnowledgeBaseCollection(slugs) {
|
|
|
9906
10413
|
timestamps: true
|
|
9907
10414
|
};
|
|
9908
10415
|
}
|
|
9909
|
-
|
|
9910
|
-
|
|
10416
|
+
function createRestrictClientChatWrite(slugs) {
|
|
10417
|
+
return async ({ data, req }) => {
|
|
10418
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
10419
|
+
data.client = req.user.id;
|
|
10420
|
+
data.senderType = "client";
|
|
10421
|
+
delete data.agent;
|
|
10422
|
+
const session = typeof data.session === "string" ? data.session : null;
|
|
10423
|
+
if (!session) return data;
|
|
10424
|
+
const existing = await dbFind(req.payload, slugs.chatMessages, {
|
|
10425
|
+
where: { session: { equals: session } },
|
|
10426
|
+
limit: 1,
|
|
10427
|
+
depth: 0,
|
|
10428
|
+
overrideAccess: true
|
|
10429
|
+
});
|
|
10430
|
+
const owner = existing.docs[0]?.client;
|
|
10431
|
+
const ownerId = owner && typeof owner === "object" ? owner.id : owner;
|
|
10432
|
+
if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
|
|
10433
|
+
throw new payload.APIError("Session inaccessible.", 403);
|
|
10434
|
+
}
|
|
10435
|
+
return data;
|
|
10436
|
+
};
|
|
10437
|
+
}
|
|
9911
10438
|
function createChatMessagesCollection(slugs) {
|
|
9912
10439
|
return {
|
|
9913
10440
|
slug: slugs.chatMessages,
|
|
@@ -9929,7 +10456,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9929
10456
|
}
|
|
9930
10457
|
return false;
|
|
9931
10458
|
},
|
|
9932
|
-
|
|
10459
|
+
// Staff and support-clients only — NOT "any authenticated principal":
|
|
10460
|
+
// a user of any other auth collection of the host app satisfied `!!req.user`.
|
|
10461
|
+
create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
|
|
9933
10462
|
update: ({ req }) => req.user?.collection === slugs.users,
|
|
9934
10463
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
9935
10464
|
},
|
|
@@ -9998,6 +10527,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9998
10527
|
}
|
|
9999
10528
|
}
|
|
10000
10529
|
],
|
|
10530
|
+
hooks: {
|
|
10531
|
+
beforeChange: [createRestrictClientChatWrite(slugs)]
|
|
10532
|
+
},
|
|
10001
10533
|
timestamps: true
|
|
10002
10534
|
};
|
|
10003
10535
|
}
|
|
@@ -10275,8 +10807,19 @@ function createAuthLogsCollection(slugs) {
|
|
|
10275
10807
|
timestamps: true
|
|
10276
10808
|
};
|
|
10277
10809
|
}
|
|
10278
|
-
|
|
10279
|
-
|
|
10810
|
+
function createValidateWebhookUrl() {
|
|
10811
|
+
return ({ data, operation, originalDoc }) => {
|
|
10812
|
+
const incoming = data?.url;
|
|
10813
|
+
if (incoming === void 0 || incoming === null) return data;
|
|
10814
|
+
const previous = originalDoc?.url;
|
|
10815
|
+
if (operation === "update" && incoming === previous) return data;
|
|
10816
|
+
const result = validateWebhookUrl(incoming);
|
|
10817
|
+
if (!result.ok) {
|
|
10818
|
+
throw new payload.APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
|
|
10819
|
+
}
|
|
10820
|
+
return data;
|
|
10821
|
+
};
|
|
10822
|
+
}
|
|
10280
10823
|
function createWebhookEndpointsCollection(slugs) {
|
|
10281
10824
|
return {
|
|
10282
10825
|
slug: slugs.webhookEndpoints,
|
|
@@ -10310,8 +10853,11 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10310
10853
|
type: "text",
|
|
10311
10854
|
required: true,
|
|
10312
10855
|
label: "URL",
|
|
10856
|
+
// The SSRF check lives in the collection `beforeValidate` above, NOT in a
|
|
10857
|
+
// field `validate`: the latter re-runs on the merged document and would
|
|
10858
|
+
// freeze every pre-existing row on any unrelated edit.
|
|
10313
10859
|
admin: {
|
|
10314
|
-
description: "URL du webhook \xE0 appeler (POST)"
|
|
10860
|
+
description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
|
|
10315
10861
|
}
|
|
10316
10862
|
},
|
|
10317
10863
|
{
|
|
@@ -10365,6 +10911,9 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10365
10911
|
}
|
|
10366
10912
|
}
|
|
10367
10913
|
],
|
|
10914
|
+
hooks: {
|
|
10915
|
+
beforeValidate: [createValidateWebhookUrl()]
|
|
10916
|
+
},
|
|
10368
10917
|
timestamps: true
|
|
10369
10918
|
};
|
|
10370
10919
|
}
|
|
@@ -10816,11 +11365,17 @@ function createClientSummariesCollection(slugs) {
|
|
|
10816
11365
|
admin: { readOnly: true }
|
|
10817
11366
|
}
|
|
10818
11367
|
],
|
|
11368
|
+
// Staff-only, on the SAME source of truth as every other collection and as
|
|
11369
|
+
// `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
|
|
11370
|
+
// against is the DEFAULT slug, not the configured one — on a host app whose
|
|
11371
|
+
// staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
|
|
11372
|
+
// the front-office collection instead, opening read/create/update/delete on
|
|
11373
|
+
// AI-generated client intelligence to it while locking the real agents out.
|
|
10819
11374
|
access: {
|
|
10820
|
-
create: ({ req }) => req.user?.collection ===
|
|
10821
|
-
read: ({ req }) => req.user?.collection ===
|
|
10822
|
-
update: ({ req }) => req.user?.collection ===
|
|
10823
|
-
delete: ({ req }) => req.user?.collection ===
|
|
11375
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
11376
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
11377
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
11378
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10824
11379
|
},
|
|
10825
11380
|
timestamps: true
|
|
10826
11381
|
};
|
|
@@ -11284,6 +11839,17 @@ function supportPlugin(config) {
|
|
|
11284
11839
|
});
|
|
11285
11840
|
return {
|
|
11286
11841
|
...incomingConfig,
|
|
11842
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
11843
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
11844
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
11845
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
11846
|
+
// auth collection of the host app — a different collection on any app
|
|
11847
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
11848
|
+
// reopened right there.
|
|
11849
|
+
custom: {
|
|
11850
|
+
...incomingConfig.custom,
|
|
11851
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
|
|
11852
|
+
},
|
|
11287
11853
|
collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
|
|
11288
11854
|
endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
|
|
11289
11855
|
admin: {
|