unknownfriend00007 commited on
Commit
17c634b
·
verified ·
1 Parent(s): 3e95192

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +17 -0
  2. package.json +20 -0
  3. server.js +1040 -0
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ ENV NODE_ENV=production
4
+
5
+ WORKDIR /app
6
+
7
+ COPY package.json ./
8
+ RUN npm install --omit=dev --no-audit --no-fund
9
+
10
+ COPY --chown=node:node server.js ./
11
+ RUN chown -R node:node /app
12
+
13
+ USER node
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["node", "server.js"]
package.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "flowise-proxy",
3
+ "version": "1.0.0",
4
+ "description": "Federated Proxy for Multiple Flowise Instances",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ },
9
+ "dependencies": {
10
+ "cors": "^2.8.5",
11
+ "dotenv": "^16.3.1",
12
+ "express": "^4.21.2",
13
+ "express-rate-limit": "^7.1.5",
14
+ "helmet": "^7.1.0",
15
+ "node-fetch": "^2.7.0"
16
+ },
17
+ "engines": {
18
+ "node": ">=20.0.0"
19
+ }
20
+ }
server.js ADDED
@@ -0,0 +1,1040 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const fetch = require('node-fetch');
3
+ const cors = require('cors');
4
+ const rateLimit = require('express-rate-limit');
5
+ const helmet = require('helmet');
6
+ const crypto = require('crypto');
7
+ require('dotenv').config();
8
+
9
+ const app = express();
10
+
11
+ // --- PRODUCTION MODE ---
12
+ const PRODUCTION_MODE = process.env.PRODUCTION_MODE === 'true';
13
+ const ALLOW_UNAUTHENTICATED_ACCESS = process.env.ALLOW_UNAUTHENTICATED_ACCESS === 'true';
14
+ const AUTH_MODE = (process.env.AUTH_MODE || 'api_key_only').trim().toLowerCase();
15
+ const CHAT_TOKEN_AUTH_ENABLED = process.env.CHAT_TOKEN_AUTH_ENABLED === 'true';
16
+
17
+ function parseCommaSeparatedList(value) {
18
+ if (!value || typeof value !== 'string') return [];
19
+ return value
20
+ .split(',')
21
+ .map((item) => item.trim())
22
+ .filter(Boolean);
23
+ }
24
+
25
+ function parseTrustProxyValue(value) {
26
+ if (!value || typeof value !== 'string') return false;
27
+ const normalized = value.trim().toLowerCase();
28
+ if (normalized === 'true') return true;
29
+ if (normalized === 'false') return false;
30
+ if (/^\d+$/.test(normalized)) return parseInt(normalized, 10);
31
+ return value.trim();
32
+ }
33
+
34
+ function parsePositiveInt(value, fallback) {
35
+ const parsed = Number.parseInt(String(value || ''), 10);
36
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
37
+ }
38
+
39
+ const API_KEYS = parseCommaSeparatedList(process.env.API_KEYS);
40
+ const allowedOrigins = parseCommaSeparatedList(process.env.ALLOWED_ORIGINS);
41
+ const TRUST_PROXY = parseTrustProxyValue(process.env.TRUST_PROXY);
42
+ const VALID_AUTH_MODES = new Set(['api_key_only', 'origin_or_api_key', 'origin_only']);
43
+ const TURNSTILE_SECRET_KEY = (process.env.TURNSTILE_SECRET_KEY || '').trim();
44
+ const TURNSTILE_VERIFY_URL = (process.env.TURNSTILE_VERIFY_URL || 'https://challenges.cloudflare.com/turnstile/v0/siteverify').trim();
45
+ const TURNSTILE_ALLOWED_HOSTNAMES = parseCommaSeparatedList(process.env.TURNSTILE_ALLOWED_HOSTNAMES).map((hostname) => hostname.toLowerCase());
46
+ const CHAT_TOKEN_SECRET = (process.env.CHAT_TOKEN_SECRET || '').trim();
47
+ const CHAT_TOKEN_ISSUER = (process.env.CHAT_TOKEN_ISSUER || 'federated-proxy').trim();
48
+ const CHAT_TOKEN_TTL_SECONDS = parsePositiveInt(process.env.CHAT_TOKEN_TTL_SECONDS, 900);
49
+ const CHAT_TOKEN_CLOCK_SKEW_SECONDS = parsePositiveInt(process.env.CHAT_TOKEN_CLOCK_SKEW_SECONDS, 30);
50
+ const CHAT_TOKEN_ALLOWED_SITE_IDS = parseCommaSeparatedList(process.env.CHAT_TOKEN_ALLOWED_SITE_IDS);
51
+ const CHAT_TOKEN_BIND_IP = process.env.CHAT_TOKEN_BIND_IP === 'true';
52
+ const CHAT_TOKEN_BIND_USER_AGENT = process.env.CHAT_TOKEN_BIND_USER_AGENT === 'true';
53
+
54
+ function log(message, level = 'info') {
55
+ if (PRODUCTION_MODE && level === 'debug') return;
56
+ console.log(message);
57
+ }
58
+
59
+ function logSensitive(message) {
60
+ if (!PRODUCTION_MODE) console.log(message);
61
+ }
62
+
63
+ if (!ALLOW_UNAUTHENTICATED_ACCESS && API_KEYS.length === 0) {
64
+ if (AUTH_MODE === 'api_key_only') {
65
+ log('CRITICAL ERROR: API_KEYS must include at least one key in api_key_only mode.');
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ if (PRODUCTION_MODE && ALLOW_UNAUTHENTICATED_ACCESS) {
71
+ log('CRITICAL ERROR: ALLOW_UNAUTHENTICATED_ACCESS=true is not allowed in production mode.');
72
+ process.exit(1);
73
+ }
74
+
75
+ if (!VALID_AUTH_MODES.has(AUTH_MODE)) {
76
+ log(`CRITICAL ERROR: AUTH_MODE '${AUTH_MODE}' is invalid. Valid modes: api_key_only, origin_or_api_key, origin_only.`);
77
+ process.exit(1);
78
+ }
79
+
80
+ if (!ALLOW_UNAUTHENTICATED_ACCESS && (AUTH_MODE === 'origin_or_api_key' || AUTH_MODE === 'origin_only') && allowedOrigins.length === 0) {
81
+ log('CRITICAL ERROR: ALLOWED_ORIGINS must include at least one origin when using origin-based auth modes.');
82
+ process.exit(1);
83
+ }
84
+
85
+ if (CHAT_TOKEN_AUTH_ENABLED && !TURNSTILE_SECRET_KEY) {
86
+ log('CRITICAL ERROR: TURNSTILE_SECRET_KEY is required when CHAT_TOKEN_AUTH_ENABLED=true.');
87
+ process.exit(1);
88
+ }
89
+
90
+ if (CHAT_TOKEN_AUTH_ENABLED && !CHAT_TOKEN_SECRET) {
91
+ log('CRITICAL ERROR: CHAT_TOKEN_SECRET is required when CHAT_TOKEN_AUTH_ENABLED=true.');
92
+ process.exit(1);
93
+ }
94
+
95
+ if (CHAT_TOKEN_AUTH_ENABLED && allowedOrigins.length === 0) {
96
+ log('CRITICAL ERROR: ALLOWED_ORIGINS must include at least one origin when CHAT_TOKEN_AUTH_ENABLED=true.');
97
+ process.exit(1);
98
+ }
99
+
100
+ // Mask sensitive data
101
+ function maskIP(ip) {
102
+ const normalized = normalizeIp(ip);
103
+ if (PRODUCTION_MODE) {
104
+ if (normalized.includes('.')) {
105
+ const parts = normalized.split('.');
106
+ return parts.length === 4 ? `${parts[0]}.${parts[1]}.***.**` : 'masked';
107
+ }
108
+
109
+ if (normalized.includes(':')) {
110
+ const parts = normalized.split(':').filter(Boolean);
111
+ return parts.length >= 2 ? `${parts[0]}:${parts[1]}::****` : 'masked';
112
+ }
113
+
114
+ return 'masked';
115
+ }
116
+ return normalized;
117
+ }
118
+
119
+ function maskOrigin(origin) {
120
+ if (PRODUCTION_MODE && origin && origin !== 'no-origin') {
121
+ try {
122
+ const url = new URL(origin);
123
+ return `${url.protocol}//${url.hostname.substring(0, 3)}***`;
124
+ } catch {
125
+ return 'masked';
126
+ }
127
+ }
128
+ return origin;
129
+ }
130
+
131
+ function normalizeIp(ip) {
132
+ if (!ip || typeof ip !== 'string') return 'unknown';
133
+ return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
134
+ }
135
+
136
+ function getClientIp(req) {
137
+ return normalizeIp(req.ip || req.socket?.remoteAddress || 'unknown');
138
+ }
139
+
140
+ function extractApiKey(req) {
141
+ const rawApiKey = req.headers['x-api-key'];
142
+ if (typeof rawApiKey === 'string' && rawApiKey.trim()) {
143
+ return rawApiKey.trim();
144
+ }
145
+
146
+ const authHeader = req.headers.authorization;
147
+ if (typeof authHeader === 'string') {
148
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
149
+ if (match && match[1] && match[1].trim()) {
150
+ return match[1].trim();
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ function timingSafeEquals(left, right) {
158
+ const leftBuffer = Buffer.from(left);
159
+ const rightBuffer = Buffer.from(right);
160
+ if (leftBuffer.length !== rightBuffer.length) return false;
161
+ return crypto.timingSafeEqual(leftBuffer, rightBuffer);
162
+ }
163
+
164
+ function isAllowedOrigin(origin) {
165
+ return typeof origin === 'string' && origin.length > 0 && allowedOrigins.includes(origin);
166
+ }
167
+
168
+ function authenticateRequest(req) {
169
+ if (ALLOW_UNAUTHENTICATED_ACCESS) {
170
+ return { valid: true, source: 'insecure-open-mode' };
171
+ }
172
+
173
+ const apiKey = extractApiKey(req);
174
+ if (apiKey) {
175
+ const keyValid = API_KEYS.some((configuredKey) => timingSafeEquals(configuredKey, apiKey));
176
+ if (keyValid) {
177
+ return { valid: true, source: 'api-key' };
178
+ }
179
+ }
180
+
181
+ const origin = req.headers.origin;
182
+ const hasAllowedOrigin = isAllowedOrigin(origin);
183
+ if (AUTH_MODE === 'origin_only' && hasAllowedOrigin) {
184
+ return { valid: true, source: 'allowed-origin' };
185
+ }
186
+
187
+ if (AUTH_MODE === 'origin_or_api_key' && hasAllowedOrigin) {
188
+ return { valid: true, source: 'allowed-origin' };
189
+ }
190
+
191
+ if (apiKey) {
192
+ return { valid: false, source: 'invalid-api-key' };
193
+ }
194
+
195
+ return { valid: false, source: 'missing-credentials' };
196
+ }
197
+
198
+ function extractBearerToken(req) {
199
+ const authorization = req.headers.authorization;
200
+ if (typeof authorization !== 'string') return null;
201
+ const match = authorization.match(/^Bearer\s+(.+)$/i);
202
+ return match && match[1] ? match[1].trim() : null;
203
+ }
204
+
205
+ function hasValidApiKey(req) {
206
+ const apiKey = extractApiKey(req);
207
+ return typeof apiKey === 'string' && API_KEYS.some((configuredKey) => timingSafeEquals(configuredKey, apiKey));
208
+ }
209
+
210
+ function base64UrlEncode(value) {
211
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
212
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
213
+ }
214
+
215
+ function base64UrlDecode(value) {
216
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
217
+ const padLength = (4 - (normalized.length % 4)) % 4;
218
+ return Buffer.from(normalized + '='.repeat(padLength), 'base64');
219
+ }
220
+
221
+ function normalizeBotName(rawBotName) {
222
+ if (typeof rawBotName !== 'string') return '';
223
+ return rawBotName.toLowerCase().replace(/\s+/g, '-').substring(0, 100);
224
+ }
225
+
226
+ function parseChatflowTarget(chatflowId) {
227
+ if (typeof chatflowId !== 'string') return null;
228
+ const normalized = chatflowId.trim().replace(/^\/+|\/+$/g, '');
229
+ if (!normalized) return null;
230
+
231
+ const parts = normalized.split('/');
232
+ if (parts.length !== 2) return null;
233
+
234
+ const instanceNum = Number.parseInt(parts[0], 10);
235
+ const botName = normalizeBotName(parts[1]);
236
+ if (!Number.isInteger(instanceNum) || instanceNum < 1 || !botName) return null;
237
+
238
+ return { instanceNum, botName };
239
+ }
240
+
241
+ function hashUserAgent(value) {
242
+ const userAgent = typeof value === 'string' ? value : '';
243
+ return crypto.createHash('sha256').update(userAgent).digest('hex');
244
+ }
245
+
246
+ function createChatAccessToken(payload) {
247
+ const header = { alg: 'HS256', typ: 'JWT' };
248
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
249
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
250
+ const unsigned = `${encodedHeader}.${encodedPayload}`;
251
+ const signature = crypto.createHmac('sha256', CHAT_TOKEN_SECRET).update(unsigned).digest();
252
+ return `${unsigned}.${base64UrlEncode(signature)}`;
253
+ }
254
+
255
+ function verifyChatAccessToken(token) {
256
+ if (typeof token !== 'string' || token.length < 20) {
257
+ return { valid: false, reason: 'missing-token' };
258
+ }
259
+
260
+ const parts = token.split('.');
261
+ if (parts.length !== 3) {
262
+ return { valid: false, reason: 'invalid-format' };
263
+ }
264
+
265
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
266
+ let header;
267
+ let payload;
268
+
269
+ try {
270
+ header = JSON.parse(base64UrlDecode(encodedHeader).toString('utf8'));
271
+ payload = JSON.parse(base64UrlDecode(encodedPayload).toString('utf8'));
272
+ } catch {
273
+ return { valid: false, reason: 'invalid-encoding' };
274
+ }
275
+
276
+ if (!header || header.alg !== 'HS256' || header.typ !== 'JWT') {
277
+ return { valid: false, reason: 'invalid-header' };
278
+ }
279
+
280
+ const unsigned = `${encodedHeader}.${encodedPayload}`;
281
+ const expectedSignature = base64UrlEncode(
282
+ crypto.createHmac('sha256', CHAT_TOKEN_SECRET).update(unsigned).digest()
283
+ );
284
+
285
+ if (!timingSafeEquals(expectedSignature, encodedSignature)) {
286
+ return { valid: false, reason: 'invalid-signature' };
287
+ }
288
+
289
+ const nowSeconds = Math.floor(Date.now() / 1000);
290
+ if (typeof payload.exp !== 'number' || payload.exp + CHAT_TOKEN_CLOCK_SKEW_SECONDS < nowSeconds) {
291
+ return { valid: false, reason: 'expired' };
292
+ }
293
+
294
+ if (typeof payload.iat !== 'number' || payload.iat - CHAT_TOKEN_CLOCK_SKEW_SECONDS > nowSeconds) {
295
+ return { valid: false, reason: 'invalid-issued-at' };
296
+ }
297
+
298
+ if (payload.iss !== CHAT_TOKEN_ISSUER) {
299
+ return { valid: false, reason: 'invalid-issuer' };
300
+ }
301
+
302
+ if (!Number.isInteger(payload.instanceNum) || payload.instanceNum < 1) {
303
+ return { valid: false, reason: 'invalid-instance' };
304
+ }
305
+
306
+ if (typeof payload.botName !== 'string' || payload.botName.length === 0) {
307
+ return { valid: false, reason: 'invalid-bot' };
308
+ }
309
+
310
+ return { valid: true, claims: payload };
311
+ }
312
+
313
+ async function verifyTurnstileChallenge(token, req) {
314
+ const body = new URLSearchParams();
315
+ body.set('secret', TURNSTILE_SECRET_KEY);
316
+ body.set('response', token);
317
+
318
+ const clientIp = getClientIp(req);
319
+ if (clientIp && clientIp !== 'unknown') {
320
+ body.set('remoteip', clientIp);
321
+ }
322
+
323
+ const response = await fetchWithTimeout(
324
+ TURNSTILE_VERIFY_URL,
325
+ {
326
+ method: 'POST',
327
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
328
+ body: body.toString()
329
+ },
330
+ 10000
331
+ );
332
+
333
+ if (!response.ok) {
334
+ throw new Error(`Turnstile verification failed with status ${response.status}`);
335
+ }
336
+
337
+ const result = await response.json();
338
+ if (!result || result.success !== true) {
339
+ return { valid: false, reason: 'captcha-rejected', result };
340
+ }
341
+
342
+ const hostname = typeof result.hostname === 'string' ? result.hostname.toLowerCase() : '';
343
+ if (TURNSTILE_ALLOWED_HOSTNAMES.length > 0 && !TURNSTILE_ALLOWED_HOSTNAMES.includes(hostname)) {
344
+ return { valid: false, reason: 'invalid-hostname', result };
345
+ }
346
+
347
+ return { valid: true, result };
348
+ }
349
+
350
+ function requirePredictionAccess(req, res, next) {
351
+ if (!CHAT_TOKEN_AUTH_ENABLED) {
352
+ return next();
353
+ }
354
+
355
+ if (hasValidApiKey(req)) {
356
+ return next();
357
+ }
358
+
359
+ const token = extractBearerToken(req);
360
+ if (!token) {
361
+ return res.status(401).json({
362
+ error: 'Unauthorized',
363
+ message: 'A valid chat token or API key is required.'
364
+ });
365
+ }
366
+
367
+ const verification = verifyChatAccessToken(token);
368
+ if (!verification.valid) {
369
+ log(`[Security] Invalid chat token: ${verification.reason}`);
370
+ return res.status(401).json({
371
+ error: 'Unauthorized',
372
+ message: 'Invalid or expired chat token.'
373
+ });
374
+ }
375
+
376
+ const claims = verification.claims;
377
+ const instanceNum = Number.parseInt(req.params.instanceNum, 10);
378
+ const botName = normalizeBotName(req.params.botName);
379
+
380
+ if (claims.instanceNum !== instanceNum || claims.botName !== botName) {
381
+ return res.status(403).json({
382
+ error: 'Access denied',
383
+ message: 'Token is not valid for this chatbot.'
384
+ });
385
+ }
386
+
387
+ if (CHAT_TOKEN_ALLOWED_SITE_IDS.length > 0 && !CHAT_TOKEN_ALLOWED_SITE_IDS.includes(claims.siteId || '')) {
388
+ return res.status(403).json({
389
+ error: 'Access denied',
390
+ message: 'Token site binding is not authorized.'
391
+ });
392
+ }
393
+
394
+ if (CHAT_TOKEN_BIND_IP && claims.ip !== getClientIp(req)) {
395
+ return res.status(403).json({
396
+ error: 'Access denied',
397
+ message: 'Token binding validation failed.'
398
+ });
399
+ }
400
+
401
+ if (CHAT_TOKEN_BIND_USER_AGENT && claims.uaHash !== hashUserAgent(req.headers['user-agent'] || '')) {
402
+ return res.status(403).json({
403
+ error: 'Access denied',
404
+ message: 'Token binding validation failed.'
405
+ });
406
+ }
407
+
408
+ req.chatTokenClaims = claims;
409
+ next();
410
+ }
411
+
412
+ app.disable('x-powered-by');
413
+ app.use(helmet({
414
+ contentSecurityPolicy: false,
415
+ crossOriginEmbedderPolicy: false
416
+ }));
417
+
418
+ app.set('trust proxy', TRUST_PROXY);
419
+ app.use(express.json({ limit: '1mb' }));
420
+
421
+ app.use(cors({
422
+ origin: function (origin, callback) {
423
+ if (!origin) {
424
+ return callback(null, true);
425
+ }
426
+ if (allowedOrigins.length === 0) {
427
+ log(`[Security] Blocked cross-origin request because ALLOWED_ORIGINS is empty: ${maskOrigin(origin)}`);
428
+ return callback(new Error('Not allowed by CORS'));
429
+ }
430
+ if (allowedOrigins.includes(origin)) {
431
+ callback(null, true);
432
+ } else {
433
+ log(`[Security] Blocked origin: ${maskOrigin(origin)}`);
434
+ callback(new Error('Not allowed by CORS'));
435
+ }
436
+ },
437
+ credentials: false,
438
+ methods: ['GET', 'POST', 'OPTIONS'],
439
+ allowedHeaders: ['Content-Type', 'X-API-Key', 'Authorization']
440
+ }));
441
+
442
+ app.use((err, req, res, next) => {
443
+ if (err.message === 'Not allowed by CORS') {
444
+ return res.status(403).json({ error: 'Access denied' });
445
+ }
446
+ next(err);
447
+ });
448
+
449
+ // --- API KEY AUTHENTICATION MIDDLEWARE ---
450
+ app.use((req, res, next) => {
451
+ const isPublicPath = req.path === '/' || req.path === '/health' || (!PRODUCTION_MODE && req.path.startsWith('/test-'));
452
+ const isTokenIssuePath = CHAT_TOKEN_AUTH_ENABLED && req.path === '/auth/chat-token' && req.method === 'POST';
453
+ const isTokenProtectedPrediction = CHAT_TOKEN_AUTH_ENABLED && req.method === 'POST' && req.path.startsWith('/api/v1/prediction/');
454
+
455
+ if (isPublicPath || isTokenIssuePath || isTokenProtectedPrediction) {
456
+ return next();
457
+ }
458
+
459
+ const auth = authenticateRequest(req);
460
+
461
+ if (!auth.valid) {
462
+ log(`[Security] Blocked unauthorized request from ${maskIP(getClientIp(req))}`);
463
+ return res.status(401).json({
464
+ error: 'Unauthorized',
465
+ message: AUTH_MODE === 'api_key_only'
466
+ ? 'Valid API key required'
467
+ : 'Valid API key or allowed browser origin required'
468
+ });
469
+ }
470
+
471
+ log(`[Auth] Request authorized from: ${auth.source}`);
472
+ next();
473
+ });
474
+
475
+ // --- RATE LIMITING ---
476
+ const limiter = rateLimit({
477
+ windowMs: 15 * 60 * 1000,
478
+ max: 100,
479
+ message: { error: "Too many requests" },
480
+ standardHeaders: 'draft-7',
481
+ legacyHeaders: false
482
+ });
483
+ app.use(limiter);
484
+
485
+ // --- REQUEST LOGGING (SAFE) ---
486
+ app.use((req, res, next) => {
487
+ const ip = maskIP(getClientIp(req));
488
+ const origin = maskOrigin(req.headers.origin || 'no-origin');
489
+ const apiKey = extractApiKey(req) ? '***' : 'none';
490
+ const path = PRODUCTION_MODE ? req.path.split('/').slice(0, 4).join('/') + '/***' : req.path;
491
+
492
+ log(`[${new Date().toISOString()}] ${ip} -> ${req.method} ${path} | Origin: ${origin} | Key: ${apiKey}`);
493
+ next();
494
+ });
495
+
496
+ // --- DAILY USAGE CAPS ---
497
+ const dailyUsage = new Map();
498
+ let lastResetDate = new Date().toDateString();
499
+
500
+ function checkDailyReset() {
501
+ const today = new Date().toDateString();
502
+ if (today !== lastResetDate) {
503
+ dailyUsage.clear();
504
+ lastResetDate = today;
505
+ log('[System] Daily usage counters reset');
506
+ }
507
+ }
508
+
509
+ setInterval(checkDailyReset, 60 * 60 * 1000);
510
+
511
+ app.use((req, res, next) => {
512
+ if (req.method === 'POST' && req.path.includes('/prediction/')) {
513
+ checkDailyReset();
514
+
515
+ const ip = getClientIp(req);
516
+ const count = dailyUsage.get(ip) || 0;
517
+
518
+ if (count >= 200) {
519
+ return res.status(429).json({
520
+ error: 'Daily limit reached',
521
+ message: 'You have reached your daily usage limit. Try again tomorrow.'
522
+ });
523
+ }
524
+
525
+ dailyUsage.set(ip, count + 1);
526
+
527
+ if (dailyUsage.size > 10000) {
528
+ log('[System] Daily usage map too large, clearing oldest entries', 'debug');
529
+ const entries = Array.from(dailyUsage.entries()).slice(0, 1000);
530
+ entries.forEach(([key]) => dailyUsage.delete(key));
531
+ }
532
+ }
533
+ next();
534
+ });
535
+
536
+ // --- BOT DETECTION ---
537
+ app.use((req, res, next) => {
538
+ if (req.method !== 'POST') {
539
+ return next();
540
+ }
541
+
542
+ const userAgent = (req.headers['user-agent'] || '').toLowerCase();
543
+ const suspiciousBots = ['python-requests', 'curl/', 'wget/', 'scrapy', 'crawler'];
544
+
545
+ const hasPrivilegedApiKey = hasValidApiKey(req);
546
+ if (hasPrivilegedApiKey) {
547
+ return next();
548
+ }
549
+
550
+ const isBot = suspiciousBots.some(bot => userAgent.includes(bot));
551
+
552
+ if (isBot) {
553
+ log(`[Security] Blocked bot from ${maskIP(getClientIp(req))}`);
554
+ return res.status(403).json({
555
+ error: 'Automated access detected',
556
+ message: 'This service is for web browsers only.'
557
+ });
558
+ }
559
+ next();
560
+ });
561
+
562
+ // --- INSTANCES CONFIGURATION ---
563
+ let INSTANCES = [];
564
+ try {
565
+ INSTANCES = JSON.parse(process.env.FLOWISE_INSTANCES || '[]');
566
+ log(`[System] Loaded ${INSTANCES.length} instances`);
567
+ if (!Array.isArray(INSTANCES) || INSTANCES.length === 0) {
568
+ log('CRITICAL ERROR: FLOWISE_INSTANCES must be a non-empty array');
569
+ process.exit(1);
570
+ }
571
+ } catch (e) {
572
+ log("CRITICAL ERROR: Could not parse FLOWISE_INSTANCES JSON");
573
+ process.exit(1);
574
+ }
575
+
576
+ // --- CACHE WITH AUTO-CLEANUP ---
577
+ const flowCache = new Map();
578
+
579
+ setInterval(() => {
580
+ const now = Date.now();
581
+ for (const [key, value] of flowCache.entries()) {
582
+ if (value.timestamp && now - value.timestamp > 10 * 60 * 1000) {
583
+ flowCache.delete(key);
584
+ }
585
+ }
586
+ }, 10 * 60 * 1000);
587
+
588
+ // --- FETCH WITH TIMEOUT ---
589
+ async function fetchWithTimeout(url, options, timeout = 10000) {
590
+ return Promise.race([
591
+ fetch(url, options),
592
+ new Promise((_, reject) =>
593
+ setTimeout(() => reject(new Error('Request timeout')), timeout)
594
+ )
595
+ ]);
596
+ }
597
+
598
+ // --- RESOLVE CHATFLOW ID ---
599
+ async function resolveChatflowId(instanceNum, botName) {
600
+ const cacheKey = `${instanceNum}-${botName}`;
601
+
602
+ const cached = flowCache.get(cacheKey);
603
+ if (cached && cached.timestamp && Date.now() - cached.timestamp < 5 * 60 * 1000) {
604
+ return { id: cached.id, instance: cached.instance };
605
+ }
606
+
607
+ if (isNaN(instanceNum) || instanceNum < 1 || instanceNum > INSTANCES.length) {
608
+ throw new Error(`Instance ${instanceNum} does not exist. Valid: 1-${INSTANCES.length}`);
609
+ }
610
+
611
+ const instance = INSTANCES[instanceNum - 1];
612
+ logSensitive(`[System] Looking up '${botName}' in instance ${instanceNum}...`);
613
+
614
+ const headers = {};
615
+ if (instance.key && instance.key.length > 0) {
616
+ headers['Authorization'] = `Bearer ${instance.key}`;
617
+ }
618
+
619
+ const response = await fetchWithTimeout(`${instance.url}/api/v1/chatflows`, { headers }, 10000);
620
+
621
+ if (!response.ok) {
622
+ throw new Error(`Instance ${instanceNum} returned status ${response.status}`);
623
+ }
624
+
625
+ const flows = await response.json();
626
+
627
+ if (!Array.isArray(flows)) {
628
+ throw new Error(`Instance ${instanceNum} returned invalid response`);
629
+ }
630
+
631
+ const match = flows.find(f => f.name && f.name.toLowerCase().replace(/\s+/g, '-') === botName);
632
+
633
+ if (!match || !match.id) {
634
+ throw new Error(`Bot '${botName}' not found in instance ${instanceNum}`);
635
+ }
636
+
637
+ flowCache.set(cacheKey, {
638
+ id: match.id,
639
+ instance: instance,
640
+ timestamp: Date.now()
641
+ });
642
+
643
+ logSensitive(`[System] Found '${botName}' -> ${match.id}`);
644
+
645
+ return { id: match.id, instance };
646
+ }
647
+
648
+ // --- STREAMING HANDLER ---
649
+ async function handleStreamingResponse(flowiseResponse, clientRes) {
650
+ clientRes.setHeader('Content-Type', 'text/event-stream');
651
+ clientRes.setHeader('Cache-Control', 'no-cache');
652
+ clientRes.setHeader('Connection', 'keep-alive');
653
+ clientRes.setHeader('X-Accel-Buffering', 'no');
654
+
655
+ log('[Streaming] Forwarding SSE stream...');
656
+
657
+ let streamStarted = false;
658
+ let dataReceived = false;
659
+ let lastDataTime = Date.now();
660
+ let totalBytes = 0;
661
+
662
+ const timeoutCheck = setInterval(() => {
663
+ const timeSinceData = Date.now() - lastDataTime;
664
+
665
+ if (timeSinceData > 45000) {
666
+ log(`[Streaming] Timeout - no data for ${(timeSinceData/1000).toFixed(1)}s`);
667
+ clearInterval(timeoutCheck);
668
+
669
+ if (!dataReceived) {
670
+ log('[Streaming] Stream completed with NO data received!');
671
+ if (!streamStarted) {
672
+ clientRes.status(504).json({
673
+ error: 'Gateway timeout',
674
+ message: 'No response from chatbot within 45 seconds'
675
+ });
676
+ } else {
677
+ clientRes.write('\n\nevent: error\ndata: {"error": "Response timeout - no data received"}\n\n');
678
+ }
679
+ }
680
+ clientRes.end();
681
+ }
682
+ }, 5000);
683
+
684
+ flowiseResponse.body.on('data', (chunk) => {
685
+ clearTimeout(timeoutCheck);
686
+ streamStarted = true;
687
+ dataReceived = true;
688
+ lastDataTime = Date.now();
689
+ totalBytes += chunk.length;
690
+
691
+ logSensitive(`[Streaming] Received chunk: ${chunk.length} bytes (total: ${totalBytes})`);
692
+ clientRes.write(chunk);
693
+ });
694
+
695
+ flowiseResponse.body.on('end', () => {
696
+ clearInterval(timeoutCheck);
697
+
698
+ if (dataReceived) {
699
+ log(`[Streaming] Stream completed - ${totalBytes} bytes`);
700
+ } else {
701
+ log('[Streaming] Stream completed but NO data received!');
702
+ }
703
+
704
+ clientRes.end();
705
+ });
706
+
707
+ flowiseResponse.body.on('error', (err) => {
708
+ clearInterval(timeoutCheck);
709
+ log('[Streaming Error]');
710
+
711
+ if (streamStarted && dataReceived) {
712
+ clientRes.write(`\n\nevent: error\ndata: {"error": "Stream interrupted"}\n\n`);
713
+ } else if (!streamStarted) {
714
+ clientRes.status(500).json({ error: 'Stream failed to start' });
715
+ }
716
+ clientRes.end();
717
+ });
718
+ }
719
+
720
+ const tokenIssueLimiter = rateLimit({
721
+ windowMs: 10 * 60 * 1000,
722
+ max: 30,
723
+ message: { error: 'Too many token requests' },
724
+ standardHeaders: 'draft-7',
725
+ legacyHeaders: false
726
+ });
727
+
728
+ // --- CHAT TOKEN ISSUE ROUTE ---
729
+ app.post('/auth/chat-token', tokenIssueLimiter, async (req, res) => {
730
+ if (!CHAT_TOKEN_AUTH_ENABLED) {
731
+ return res.status(404).json({ error: 'Route not found' });
732
+ }
733
+
734
+ try {
735
+ const origin = req.headers.origin;
736
+ if (!isAllowedOrigin(origin)) {
737
+ log(`[Security] Blocked token issuance for untrusted origin: ${maskOrigin(origin || 'no-origin')}`);
738
+ return res.status(403).json({
739
+ error: 'Access denied',
740
+ message: 'Origin is not allowed.'
741
+ });
742
+ }
743
+
744
+ const provider = typeof req.body.provider === 'string' ? req.body.provider.trim().toLowerCase() : 'turnstile';
745
+ if (provider !== 'turnstile') {
746
+ return res.status(400).json({
747
+ error: 'Invalid request',
748
+ message: 'Unsupported captcha provider.'
749
+ });
750
+ }
751
+
752
+ const captchaToken = typeof req.body.captchaToken === 'string' ? req.body.captchaToken.trim() : '';
753
+ if (!captchaToken || captchaToken.length > 5000) {
754
+ return res.status(400).json({
755
+ error: 'Invalid request',
756
+ message: 'captchaToken is required.'
757
+ });
758
+ }
759
+
760
+ const rawChatflowId = typeof req.body.chatflowid === 'string'
761
+ ? req.body.chatflowid
762
+ : (typeof req.body.chatflowId === 'string' ? req.body.chatflowId : '');
763
+ const target = parseChatflowTarget(rawChatflowId);
764
+ if (!target || target.instanceNum > INSTANCES.length) {
765
+ return res.status(400).json({
766
+ error: 'Invalid request',
767
+ message: 'chatflowid must match the format <instance>/<bot>.'
768
+ });
769
+ }
770
+
771
+ const siteId = typeof req.body.siteId === 'string' ? req.body.siteId.trim() : '';
772
+ if (CHAT_TOKEN_ALLOWED_SITE_IDS.length > 0 && !CHAT_TOKEN_ALLOWED_SITE_IDS.includes(siteId)) {
773
+ return res.status(403).json({
774
+ error: 'Access denied',
775
+ message: 'siteId is not authorized.'
776
+ });
777
+ }
778
+
779
+ const captchaVerification = await verifyTurnstileChallenge(captchaToken, req);
780
+ if (!captchaVerification.valid) {
781
+ log(`[Security] Captcha rejected: ${captchaVerification.reason}`);
782
+ return res.status(403).json({
783
+ error: 'Access denied',
784
+ message: 'Captcha verification failed.'
785
+ });
786
+ }
787
+
788
+ const nowSeconds = Math.floor(Date.now() / 1000);
789
+ const claims = {
790
+ iss: CHAT_TOKEN_ISSUER,
791
+ iat: nowSeconds,
792
+ exp: nowSeconds + CHAT_TOKEN_TTL_SECONDS,
793
+ jti: crypto.randomBytes(12).toString('hex'),
794
+ instanceNum: target.instanceNum,
795
+ botName: target.botName
796
+ };
797
+
798
+ if (siteId) {
799
+ claims.siteId = siteId;
800
+ }
801
+
802
+ if (CHAT_TOKEN_BIND_IP) {
803
+ claims.ip = getClientIp(req);
804
+ }
805
+
806
+ if (CHAT_TOKEN_BIND_USER_AGENT) {
807
+ claims.uaHash = hashUserAgent(req.headers['user-agent'] || '');
808
+ }
809
+
810
+ const token = createChatAccessToken(claims);
811
+ res.status(200).json({
812
+ token,
813
+ tokenType: 'Bearer',
814
+ expiresIn: CHAT_TOKEN_TTL_SECONDS
815
+ });
816
+ } catch (error) {
817
+ log(`[Error] Chat token issuance failed: ${error.message}`);
818
+ res.status(500).json({
819
+ error: 'Token issuance failed',
820
+ message: 'Unable to issue chat token.'
821
+ });
822
+ }
823
+ });
824
+
825
+ // --- PREDICTION ROUTE ---
826
+ app.post('/api/v1/prediction/:instanceNum/:botName', requirePredictionAccess, async (req, res) => {
827
+ try {
828
+ const instanceNum = parseInt(req.params.instanceNum);
829
+ const botName = normalizeBotName(req.params.botName);
830
+
831
+ if (!req.body.question || typeof req.body.question !== 'string') {
832
+ return res.status(400).json({
833
+ error: 'Invalid request',
834
+ message: 'Question must be a non-empty string.'
835
+ });
836
+ }
837
+
838
+ if (req.body.question.length > 2000) {
839
+ return res.status(400).json({
840
+ error: 'Message too long',
841
+ message: 'Please keep messages under 2000 characters.'
842
+ });
843
+ }
844
+
845
+ const { id, instance } = await resolveChatflowId(instanceNum, botName);
846
+
847
+ const headers = { 'Content-Type': 'application/json' };
848
+ if (instance.key && instance.key.length > 0) {
849
+ headers['Authorization'] = `Bearer ${instance.key}`;
850
+ }
851
+
852
+ const startTime = Date.now();
853
+ logSensitive(`[Timing] Calling Flowise at ${new Date().toISOString()}`);
854
+
855
+ const response = await fetchWithTimeout(
856
+ `${instance.url}/api/v1/prediction/${id}`,
857
+ {
858
+ method: 'POST',
859
+ headers,
860
+ body: JSON.stringify(req.body)
861
+ },
862
+ 60000
863
+ );
864
+
865
+ const duration = Date.now() - startTime;
866
+ log(`[Timing] Response received in ${(duration/1000).toFixed(1)}s`);
867
+
868
+ if (!response.ok) {
869
+ const errorText = await response.text();
870
+ logSensitive(`[Error] Instance returned ${response.status}: ${errorText.substring(0, 100)}`);
871
+ return res.status(response.status).json({
872
+ error: 'Flowise instance error',
873
+ message: 'The chatbot instance returned an error.'
874
+ });
875
+ }
876
+
877
+ const contentType = response.headers.get('content-type') || '';
878
+
879
+ if (contentType.includes('text/event-stream')) {
880
+ log('[Streaming] Detected SSE response');
881
+ return handleStreamingResponse(response, res);
882
+ }
883
+
884
+ log('[Non-streaming] Parsing JSON response');
885
+ const text = await response.text();
886
+
887
+ try {
888
+ const data = JSON.parse(text);
889
+ res.status(200).json(data);
890
+ } catch (e) {
891
+ log('[Error] Invalid JSON response');
892
+ res.status(500).json({ error: 'Invalid response from Flowise' });
893
+ }
894
+
895
+ } catch (error) {
896
+ log(`[Error] Prediction request failed: ${error.message}`);
897
+ res.status(500).json({
898
+ error: 'Request failed',
899
+ message: 'Unable to process the request'
900
+ });
901
+ }
902
+ });
903
+
904
+ // --- CONFIG ROUTE ---
905
+ app.get('/api/v1/public-chatbotConfig/:instanceNum/:botName', async (req, res) => {
906
+ try {
907
+ const instanceNum = parseInt(req.params.instanceNum);
908
+ const botName = normalizeBotName(req.params.botName);
909
+
910
+ const { id, instance } = await resolveChatflowId(instanceNum, botName);
911
+
912
+ const headers = {};
913
+ if (instance.key && instance.key.length > 0) {
914
+ headers['Authorization'] = `Bearer ${instance.key}`;
915
+ }
916
+
917
+ const response = await fetchWithTimeout(
918
+ `${instance.url}/api/v1/public-chatbotConfig/${id}`,
919
+ { headers },
920
+ 10000
921
+ );
922
+
923
+ if (!response.ok) {
924
+ return res.status(response.status).json({ error: 'Config not available' });
925
+ }
926
+
927
+ const data = await response.json();
928
+ res.status(200).json(data);
929
+
930
+ } catch (error) {
931
+ log(`[Error] Config request failed: ${error.message}`);
932
+ res.status(404).json({ error: 'Config not available' });
933
+ }
934
+ });
935
+
936
+ // --- STREAMING CHECK ROUTE ---
937
+ app.get('/api/v1/chatflows-streaming/:instanceNum/:botName', async (req, res) => {
938
+ try {
939
+ const instanceNum = parseInt(req.params.instanceNum);
940
+ const botName = normalizeBotName(req.params.botName);
941
+
942
+ const { id, instance } = await resolveChatflowId(instanceNum, botName);
943
+
944
+ const headers = {};
945
+ if (instance.key && instance.key.length > 0) {
946
+ headers['Authorization'] = `Bearer ${instance.key}`;
947
+ }
948
+
949
+ const response = await fetchWithTimeout(
950
+ `${instance.url}/api/v1/chatflows-streaming/${id}`,
951
+ { headers },
952
+ 10000
953
+ );
954
+
955
+ if (!response.ok) {
956
+ return res.status(200).json({ isStreaming: false });
957
+ }
958
+
959
+ const data = await response.json();
960
+ res.status(200).json(data);
961
+
962
+ } catch (error) {
963
+ log('[Error] Streaming check failed', 'debug');
964
+ res.status(200).json({ isStreaming: false });
965
+ }
966
+ });
967
+
968
+ // --- TEST ENDPOINTS (DISABLED IN PRODUCTION) ---
969
+ if (!PRODUCTION_MODE) {
970
+ app.get('/test-stream', (req, res) => {
971
+ res.setHeader('Content-Type', 'text/event-stream');
972
+ res.setHeader('Cache-Control', 'no-cache');
973
+ res.setHeader('Connection', 'keep-alive');
974
+
975
+ let count = 0;
976
+ const interval = setInterval(() => {
977
+ count++;
978
+ res.write(`data: {"message": "Test ${count}"}\n\n`);
979
+
980
+ if (count >= 5) {
981
+ clearInterval(interval);
982
+ res.end();
983
+ }
984
+ }, 500);
985
+ });
986
+ }
987
+
988
+ // --- HEALTH CHECK ---
989
+ app.get('/', (req, res) => res.send('Federated Proxy Active'));
990
+
991
+ app.get('/health', (req, res) => {
992
+ res.json({
993
+ status: 'healthy',
994
+ uptime: process.uptime()
995
+ });
996
+ });
997
+
998
+ // --- 404 HANDLER ---
999
+ app.use((req, res) => {
1000
+ res.status(404).json({ error: 'Route not found' });
1001
+ });
1002
+
1003
+ // --- GLOBAL ERROR HANDLER ---
1004
+ app.use((err, req, res, next) => {
1005
+ log('[Error] Unhandled error');
1006
+ res.status(500).json({ error: 'Internal server error' });
1007
+ });
1008
+
1009
+ // --- SERVER START ---
1010
+ const server = app.listen(7860, '0.0.0.0', () => {
1011
+ log('===== Federated Proxy Started =====');
1012
+ log(`Port: 7860`);
1013
+ log(`Mode: ${PRODUCTION_MODE ? 'PRODUCTION' : 'DEVELOPMENT'}`);
1014
+ log(`Auth Mode: ${AUTH_MODE}`);
1015
+ log(`Instances: ${INSTANCES.length}`);
1016
+ log(`Allowed Origins: ${allowedOrigins.length || 'None (cross-origin blocked)'}`);
1017
+ log(`API Keys: ${API_KEYS.length || (ALLOW_UNAUTHENTICATED_ACCESS ? 'None (INSECURE OPEN MODE)' : 'None')}`);
1018
+ log(`Trust Proxy: ${TRUST_PROXY}`);
1019
+ log(`Chat Token Auth: ${CHAT_TOKEN_AUTH_ENABLED ? `Enabled (${CHAT_TOKEN_TTL_SECONDS}s ttl)` : 'Disabled'}`);
1020
+ if (AUTH_MODE !== 'api_key_only') {
1021
+ log('[Security Warning] Origin-based auth helps for browser gating but is weaker than API keys.');
1022
+ }
1023
+ log('====================================');
1024
+ });
1025
+
1026
+ process.on('SIGTERM', () => {
1027
+ log('[System] Shutting down gracefully...');
1028
+ server.close(() => {
1029
+ log('[System] Server closed');
1030
+ process.exit(0);
1031
+ });
1032
+ });
1033
+
1034
+ process.on('SIGINT', () => {
1035
+ log('[System] Shutting down gracefully...');
1036
+ server.close(() => {
1037
+ log('[System] Server closed');
1038
+ process.exit(0);
1039
+ });
1040
+ });