@tjamescouch/agentchat 0.22.0 → 0.22.1

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/lib/server.js CHANGED
@@ -49,6 +49,12 @@ import {
49
49
  import {
50
50
  handleSetPresence,
51
51
  } from './server/handlers/presence.js';
52
+ import {
53
+ handleAdminApprove,
54
+ handleAdminRevoke,
55
+ handleAdminList,
56
+ } from './server/handlers/admin.js';
57
+ import { Allowlist } from './allowlist.js';
52
58
 
53
59
  export class AgentChatServer {
54
60
  constructor(options = {}) {
@@ -67,6 +73,10 @@ export class AgentChatServer {
67
73
  // Message buffer size per channel (for replay on join)
68
74
  this.messageBufferSize = options.messageBufferSize || 20;
69
75
 
76
+ // Per-IP connection limiting
77
+ this.maxConnectionsPerIp = options.maxConnectionsPerIp || 10;
78
+ this.connectionsByIp = new Map(); // ip -> Set<ws>
79
+
70
80
  // State
71
81
  this.agents = new Map(); // ws -> agent info
72
82
  this.agentById = new Map(); // id -> ws
@@ -118,6 +128,14 @@ export class AgentChatServer {
118
128
  this.pendingVerifications = new Map();
119
129
  this.verificationTimeoutMs = options.verificationTimeoutMs || 30000; // 30 seconds default
120
130
 
131
+ // Allowlist (opt-in)
132
+ this.allowlist = new Allowlist({
133
+ enabled: options.allowlistEnabled || false,
134
+ strict: options.allowlistStrict || false,
135
+ adminKey: options.adminKey || null,
136
+ filePath: options.allowlistFile || undefined,
137
+ });
138
+
121
139
  this.wss = null;
122
140
  this.httpServer = null;
123
141
  this.startedAt = null; // Set on start() for uptime tracking
@@ -236,18 +254,32 @@ export class AgentChatServer {
236
254
  const tls = !!(this.tlsCert && this.tlsKey);
237
255
  this.startedAt = Date.now();
238
256
 
257
+ // Security headers applied to all HTTP responses
258
+ const securityHeaders = {
259
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
260
+ 'X-Content-Type-Options': 'nosniff',
261
+ 'X-Frame-Options': 'DENY',
262
+ 'Content-Security-Policy': "default-src 'none'",
263
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
264
+ };
265
+
239
266
  // HTTP request handler for health endpoint
240
267
  const httpHandler = (req, res) => {
241
268
  if (req.method === 'GET' && req.url === '/health') {
242
269
  const health = this.getHealth();
243
- res.writeHead(200, { 'Content-Type': 'application/json' });
270
+ res.writeHead(200, { 'Content-Type': 'application/json', ...securityHeaders });
244
271
  res.end(JSON.stringify(health));
245
272
  } else {
246
- res.writeHead(404);
273
+ res.writeHead(404, securityHeaders);
247
274
  res.end('Not Found');
248
275
  }
249
276
  };
250
277
 
278
+ // WebSocket options: limit max message size to 256KB
279
+ const wsOptions = {
280
+ maxPayload: 256 * 1024,
281
+ };
282
+
251
283
  if (tls) {
252
284
  // TLS mode: create HTTPS server and attach WebSocket
253
285
  const httpsOptions = {
@@ -255,12 +287,12 @@ export class AgentChatServer {
255
287
  key: fs.readFileSync(this.tlsKey)
256
288
  };
257
289
  this.httpServer = https.createServer(httpsOptions, httpHandler);
258
- this.wss = new WebSocketServer({ server: this.httpServer });
290
+ this.wss = new WebSocketServer({ ...wsOptions, server: this.httpServer });
259
291
  this.httpServer.listen(this.port, this.host);
260
292
  } else {
261
293
  // Plain mode: create HTTP server for health endpoint + WebSocket
262
294
  this.httpServer = http.createServer(httpHandler);
263
- this.wss = new WebSocketServer({ server: this.httpServer });
295
+ this.wss = new WebSocketServer({ ...wsOptions, server: this.httpServer });
264
296
  this.httpServer.listen(this.port, this.host);
265
297
  }
266
298
 
@@ -272,6 +304,16 @@ export class AgentChatServer {
272
304
  const realIp = forwardedFor ? forwardedFor.split(',')[0].trim() : req.socket.remoteAddress;
273
305
  const userAgent = req.headers['user-agent'] || 'unknown';
274
306
 
307
+ // Per-IP connection limiting
308
+ const ipConns = this.connectionsByIp.get(realIp) || new Set();
309
+ if (ipConns.size >= this.maxConnectionsPerIp) {
310
+ this._log('connection_rejected', { ip: realIp, reason: 'max_connections_per_ip', count: ipConns.size });
311
+ ws.close(1008, 'Too many connections from this IP');
312
+ return;
313
+ }
314
+ ipConns.add(ws);
315
+ this.connectionsByIp.set(realIp, ipConns);
316
+
275
317
  // Store connection metadata on ws for later logging
276
318
  ws._connectedAt = Date.now();
277
319
  ws._realIp = realIp;
@@ -280,14 +322,22 @@ export class AgentChatServer {
280
322
  this._log('connection', {
281
323
  ip: realIp,
282
324
  proxy_ip: req.socket.remoteAddress,
283
- user_agent: userAgent
325
+ user_agent: userAgent,
326
+ ip_connections: ipConns.size
284
327
  });
285
-
328
+
286
329
  ws.on('message', (data) => {
287
330
  this._handleMessage(ws, data.toString());
288
331
  });
289
-
332
+
290
333
  ws.on('close', () => {
334
+ // Clean up per-IP tracking
335
+ const conns = this.connectionsByIp.get(realIp);
336
+ if (conns) {
337
+ conns.delete(ws);
338
+ if (conns.size === 0) this.connectionsByIp.delete(realIp);
339
+ }
340
+
291
341
  // Log if connection closed without ever identifying (drive-by)
292
342
  if (!this.agents.has(ws)) {
293
343
  const duration = ws._connectedAt ? Math.round((Date.now() - ws._connectedAt) / 1000) : 0;
@@ -340,7 +390,7 @@ export class AgentChatServer {
340
390
  const agentMentions = [];
341
391
  for (const ws of channel.agents) {
342
392
  const agent = this.agents.get(ws);
343
- if (agent) agentMentions.push(`@${agent.id}`);
393
+ if (agent) agentMentions.push(`${agent.name} (@${agent.id})`);
344
394
  }
345
395
 
346
396
  const prompt = `${agentMentions.join(', ')} - ${starter}`;
@@ -379,6 +429,45 @@ export class AgentChatServer {
379
429
  }
380
430
 
381
431
  _handleMessage(ws, data) {
432
+ // Application-level message size limit (defense-in-depth for proxy bypass)
433
+ const maxPayloadBytes = 256 * 1024; // 256KB - matches wsOptions.maxPayload
434
+ if (data.length > maxPayloadBytes) {
435
+ this._log('message_too_large', {
436
+ ip: ws._realIp,
437
+ size: data.length,
438
+ max: maxPayloadBytes
439
+ });
440
+ this._send(ws, createError(ErrorCode.INVALID_MSG, `Message too large (${data.length} bytes, max ${maxPayloadBytes})`));
441
+ return;
442
+ }
443
+
444
+ // Per-connection rate limiting (applies before auth check)
445
+ const now = Date.now();
446
+ if (!ws._msgTimestamps) ws._msgTimestamps = [];
447
+
448
+ // Sliding window: keep only timestamps from last 10 seconds
449
+ ws._msgTimestamps = ws._msgTimestamps.filter(t => now - t < 10000);
450
+ ws._msgTimestamps.push(now);
451
+
452
+ const isIdentified = this.agents.has(ws);
453
+ // Pre-auth: max 10 messages per 10s window (enough for IDENTIFY + JOINs)
454
+ // Post-auth: max 60 messages per 10s window (existing MSG rate limit also applies)
455
+ const maxMessages = isIdentified ? 60 : 10;
456
+
457
+ if (ws._msgTimestamps.length > maxMessages) {
458
+ if (!isIdentified) {
459
+ this._log('pre_auth_rate_limit', {
460
+ ip: ws._realIp,
461
+ count: ws._msgTimestamps.length,
462
+ window: '10s'
463
+ });
464
+ ws.close(1008, 'Rate limit exceeded');
465
+ return;
466
+ }
467
+ this._send(ws, createError(ErrorCode.RATE_LIMITED, 'Too many messages'));
468
+ return;
469
+ }
470
+
382
471
  const { valid, msg, error } = validateClientMessage(data);
383
472
 
384
473
  if (!valid) {
@@ -452,6 +541,16 @@ export class AgentChatServer {
452
541
  case ClientMessageType.VERIFY_RESPONSE:
453
542
  handleVerifyResponse(this, ws, msg);
454
543
  break;
544
+ // Admin messages (allowlist management)
545
+ case ClientMessageType.ADMIN_APPROVE:
546
+ handleAdminApprove(this, ws, msg);
547
+ break;
548
+ case ClientMessageType.ADMIN_REVOKE:
549
+ handleAdminRevoke(this, ws, msg);
550
+ break;
551
+ case ClientMessageType.ADMIN_LIST:
552
+ handleAdminList(this, ws, msg);
553
+ break;
455
554
  }
456
555
  }
457
556
 
@@ -479,7 +578,8 @@ export class AgentChatServer {
479
578
  channel.agents.delete(ws);
480
579
  this._broadcast(channelName, createMessage(ServerMessageType.AGENT_LEFT, {
481
580
  channel: channelName,
482
- agent: `@${agent.id}`
581
+ agent: `@${agent.id}`,
582
+ name: agent.name
483
583
  }));
484
584
  }
485
585
  }
@@ -502,7 +602,12 @@ export function startServer(options = {}) {
502
602
  cert: options.cert || process.env.TLS_CERT || null,
503
603
  key: options.key || process.env.TLS_KEY || null,
504
604
  rateLimitMs: options.rateLimitMs || parseInt(process.env.RATE_LIMIT_MS || 1000),
505
- messageBufferSize: options.messageBufferSize || parseInt(process.env.MESSAGE_BUFFER_SIZE || 20)
605
+ messageBufferSize: options.messageBufferSize || parseInt(process.env.MESSAGE_BUFFER_SIZE || 20),
606
+ // Allowlist settings
607
+ allowlistEnabled: options.allowlistEnabled || process.env.ALLOWLIST_ENABLED === 'true',
608
+ allowlistStrict: options.allowlistStrict || process.env.ALLOWLIST_STRICT === 'true',
609
+ adminKey: options.adminKey || process.env.ADMIN_KEY || null,
610
+ allowlistFile: options.allowlistFile || process.env.ALLOWLIST_FILE || undefined,
506
611
  };
507
612
 
508
613
  const server = new AgentChatServer(config);