@auxiora/gateway 1.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/dist/server.js ADDED
@@ -0,0 +1,669 @@
1
+ import express from 'express';
2
+ import { createServer } from 'node:http';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import * as crypto from 'node:crypto';
5
+ import * as argon2 from 'argon2';
6
+ import jwt from 'jsonwebtoken';
7
+ import { audit } from '@auxiora/audit';
8
+ import { getLogger } from '@auxiora/logger';
9
+ import { RateLimiter } from './rate-limiter.js';
10
+ const logger = getLogger('gateway');
11
+ import { PairingManager } from './pairing.js';
12
+ export class Gateway {
13
+ app;
14
+ server;
15
+ wss;
16
+ config;
17
+ rateLimiter;
18
+ pairingManager;
19
+ clients = new Map();
20
+ messageHandler;
21
+ voiceHandler;
22
+ audioBuffers = new Map();
23
+ needsSetup;
24
+ constructor(options) {
25
+ this.config = options.config;
26
+ this.needsSetup = options.needsSetup;
27
+ this.app = express();
28
+ this.server = createServer(this.app);
29
+ this.wss = new WebSocketServer({ server: this.server });
30
+ this.rateLimiter = new RateLimiter({
31
+ windowMs: this.config.rateLimit.windowMs,
32
+ maxRequests: this.config.rateLimit.maxRequests,
33
+ });
34
+ this.pairingManager = new PairingManager({
35
+ codeLength: this.config.pairing.codeLength,
36
+ expiryMinutes: this.config.pairing.expiryMinutes,
37
+ autoApproveChannels: this.config.pairing.autoApproveChannels,
38
+ persistPath: this.config.pairing.persistPath,
39
+ });
40
+ this.setupMiddleware();
41
+ this.setupRoutes();
42
+ this.setupWebSocket();
43
+ }
44
+ setupMiddleware() {
45
+ // Parse JSON bodies
46
+ this.app.use(express.json());
47
+ // CORS
48
+ this.app.use((req, res, next) => {
49
+ const origin = req.headers.origin;
50
+ if (origin && this.config.gateway.corsOrigins.includes(origin)) {
51
+ res.header('Access-Control-Allow-Origin', origin);
52
+ res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
53
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
54
+ res.header('Access-Control-Allow-Credentials', 'true');
55
+ }
56
+ if (req.method === 'OPTIONS') {
57
+ res.sendStatus(200);
58
+ return;
59
+ }
60
+ next();
61
+ });
62
+ // Security headers
63
+ this.app.use((req, res, next) => {
64
+ res.header('X-Content-Type-Options', 'nosniff');
65
+ res.header('X-Frame-Options', 'DENY');
66
+ res.header('X-XSS-Protection', '1; mode=block');
67
+ next();
68
+ });
69
+ // Rate limiting
70
+ this.app.use((req, res, next) => {
71
+ if (!this.config.rateLimit.enabled) {
72
+ next();
73
+ return;
74
+ }
75
+ const ip = req.ip || req.socket.remoteAddress || 'unknown';
76
+ const result = this.rateLimiter.check(ip);
77
+ res.header('X-RateLimit-Limit', String(this.config.rateLimit.maxRequests));
78
+ res.header('X-RateLimit-Remaining', String(result.remaining));
79
+ res.header('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
80
+ if (!result.allowed) {
81
+ audit('rate_limit.exceeded', { ip });
82
+ res.status(429).json({ error: 'Too many requests' });
83
+ return;
84
+ }
85
+ next();
86
+ });
87
+ }
88
+ setupRoutes() {
89
+ // Health check
90
+ this.app.get('/health', (req, res) => {
91
+ res.json({
92
+ status: 'ok',
93
+ version: '1.0.0',
94
+ uptime: process.uptime(),
95
+ });
96
+ });
97
+ // API info
98
+ this.app.get('/api/v1', (req, res) => {
99
+ res.json({
100
+ name: 'Auxiora Gateway',
101
+ version: '1.0.0',
102
+ endpoints: {
103
+ health: '/health',
104
+ sessions: '/api/v1/sessions',
105
+ pairing: '/api/v1/pairing',
106
+ },
107
+ });
108
+ });
109
+ // Pairing endpoints
110
+ this.app.get('/api/v1/pairing/pending', (req, res) => {
111
+ const pending = this.pairingManager.getPendingCodes();
112
+ res.json({ pending });
113
+ });
114
+ this.app.post('/api/v1/pairing/accept', (req, res) => {
115
+ const { code } = req.body;
116
+ if (!code) {
117
+ res.status(400).json({ error: 'Missing code' });
118
+ return;
119
+ }
120
+ const success = this.pairingManager.acceptCode(code);
121
+ if (success) {
122
+ audit('pairing.code_accepted', { code });
123
+ res.json({ success: true });
124
+ }
125
+ else {
126
+ res.status(404).json({ error: 'Invalid or expired code' });
127
+ }
128
+ });
129
+ this.app.post('/api/v1/pairing/reject', (req, res) => {
130
+ const { code } = req.body;
131
+ if (!code) {
132
+ res.status(400).json({ error: 'Missing code' });
133
+ return;
134
+ }
135
+ const success = this.pairingManager.rejectCode(code);
136
+ if (success) {
137
+ audit('pairing.code_rejected', { code });
138
+ res.json({ success: true });
139
+ }
140
+ else {
141
+ res.status(404).json({ error: 'Invalid or expired code' });
142
+ }
143
+ });
144
+ this.app.get('/api/v1/pairing/allowed', (req, res) => {
145
+ const allowed = this.pairingManager.getAllowedSenders();
146
+ res.json({ allowed });
147
+ });
148
+ // Root redirects to dashboard
149
+ this.app.get('/', (req, res) => {
150
+ res.redirect('/dashboard');
151
+ });
152
+ }
153
+ setupWebSocket() {
154
+ this.wss.on('connection', (ws, req) => {
155
+ const clientId = crypto.randomUUID();
156
+ const ip = req.socket.remoteAddress || 'unknown';
157
+ const client = {
158
+ id: clientId,
159
+ ws,
160
+ authenticated: this.config.auth.mode === 'none',
161
+ channelType: 'webchat',
162
+ lastActive: Date.now(),
163
+ };
164
+ this.clients.set(clientId, client);
165
+ audit('channel.connected', { clientId, ip, channelType: 'webchat' });
166
+ // Send welcome message
167
+ this.send(client, {
168
+ type: 'connected',
169
+ payload: {
170
+ clientId,
171
+ authenticated: client.authenticated,
172
+ requiresAuth: this.config.auth.mode !== 'none',
173
+ },
174
+ });
175
+ ws.on('message', async (data, isBinary) => {
176
+ client.lastActive = Date.now();
177
+ if (isBinary) {
178
+ this.handleAudioFrame(client, data);
179
+ return;
180
+ }
181
+ try {
182
+ const message = JSON.parse(data.toString());
183
+ await this.handleMessage(client, message);
184
+ }
185
+ catch (error) {
186
+ this.send(client, {
187
+ type: 'error',
188
+ payload: { message: 'Invalid message format' },
189
+ });
190
+ }
191
+ });
192
+ ws.on('close', () => {
193
+ this.audioBuffers.delete(clientId);
194
+ this.clients.delete(clientId);
195
+ audit('channel.disconnected', { clientId });
196
+ });
197
+ ws.on('error', (error) => {
198
+ audit('channel.error', { clientId, error: error.message });
199
+ });
200
+ });
201
+ }
202
+ async handleMessage(client, message) {
203
+ const { type, id, payload } = message;
204
+ switch (type) {
205
+ case 'ping':
206
+ this.send(client, { type: 'pong', id });
207
+ break;
208
+ case 'auth':
209
+ await this.handleAuth(client, payload, id);
210
+ break;
211
+ case 'message':
212
+ if (!client.authenticated) {
213
+ this.send(client, {
214
+ type: 'error',
215
+ id,
216
+ payload: { message: 'Not authenticated' },
217
+ });
218
+ return;
219
+ }
220
+ audit('message.received', {
221
+ clientId: client.id,
222
+ senderId: client.senderId,
223
+ });
224
+ // Delegate to message handler if set
225
+ if (this.messageHandler) {
226
+ await this.messageHandler(client, message);
227
+ }
228
+ else {
229
+ // Echo for now (will be replaced with AI handling)
230
+ this.send(client, {
231
+ type: 'message',
232
+ id,
233
+ payload: {
234
+ role: 'assistant',
235
+ content: `Echo: ${payload?.content || ''}`,
236
+ },
237
+ });
238
+ }
239
+ break;
240
+ case 'voice_start':
241
+ case 'voice_end':
242
+ case 'voice_cancel':
243
+ if (!client.authenticated) {
244
+ this.send(client, {
245
+ type: 'error',
246
+ id,
247
+ payload: { message: 'Not authenticated' },
248
+ });
249
+ return;
250
+ }
251
+ await this.handleVoiceControl(client, type, payload, id);
252
+ break;
253
+ default:
254
+ this.send(client, {
255
+ type: 'error',
256
+ id,
257
+ payload: { message: `Unknown message type: ${type}` },
258
+ });
259
+ }
260
+ }
261
+ async handleAuth(client, payload, requestId) {
262
+ if (this.config.auth.mode === 'none') {
263
+ client.authenticated = true;
264
+ this.send(client, { type: 'auth_success', id: requestId });
265
+ return;
266
+ }
267
+ if (this.config.auth.mode === 'password') {
268
+ if (!payload.password) {
269
+ audit('auth.failed', { clientId: client.id, reason: 'missing_password' });
270
+ this.send(client, {
271
+ type: 'auth_failure',
272
+ id: requestId,
273
+ payload: { message: 'Password required' },
274
+ });
275
+ return;
276
+ }
277
+ // Verify password against stored hash
278
+ const passwordHash = this.config.auth.passwordHash;
279
+ if (!passwordHash) {
280
+ audit('auth.failed', { clientId: client.id, reason: 'no_password_configured' });
281
+ this.send(client, {
282
+ type: 'auth_failure',
283
+ id: requestId,
284
+ payload: { message: 'Password auth not configured. Run: auxiora auth set-password' },
285
+ });
286
+ return;
287
+ }
288
+ try {
289
+ const valid = await argon2.verify(passwordHash, payload.password);
290
+ if (valid) {
291
+ client.authenticated = true;
292
+ audit('auth.login', { clientId: client.id, method: 'password' });
293
+ this.send(client, { type: 'auth_success', id: requestId });
294
+ }
295
+ else {
296
+ audit('auth.failed', { clientId: client.id, reason: 'invalid_password' });
297
+ this.send(client, {
298
+ type: 'auth_failure',
299
+ id: requestId,
300
+ payload: { message: 'Invalid password' },
301
+ });
302
+ }
303
+ }
304
+ catch (error) {
305
+ audit('auth.failed', { clientId: client.id, reason: 'password_verify_error' });
306
+ this.send(client, {
307
+ type: 'auth_failure',
308
+ id: requestId,
309
+ payload: { message: 'Authentication error' },
310
+ });
311
+ }
312
+ return;
313
+ }
314
+ // JWT auth
315
+ if (!payload.token) {
316
+ audit('auth.failed', { clientId: client.id, reason: 'missing_token' });
317
+ this.send(client, {
318
+ type: 'auth_failure',
319
+ id: requestId,
320
+ payload: { message: 'Token required' },
321
+ });
322
+ return;
323
+ }
324
+ const jwtSecret = this.config.auth.jwtSecret;
325
+ if (!jwtSecret) {
326
+ audit('auth.failed', { clientId: client.id, reason: 'no_jwt_secret_configured' });
327
+ this.send(client, {
328
+ type: 'auth_failure',
329
+ id: requestId,
330
+ payload: { message: 'JWT auth not configured. Set auth.jwtSecret in config' },
331
+ });
332
+ return;
333
+ }
334
+ try {
335
+ const decoded = jwt.verify(payload.token, jwtSecret);
336
+ client.authenticated = true;
337
+ client.senderId = decoded.sub;
338
+ audit('auth.login', { clientId: client.id, method: 'jwt', subject: decoded.sub });
339
+ this.send(client, { type: 'auth_success', id: requestId });
340
+ }
341
+ catch (error) {
342
+ const reason = error instanceof jwt.TokenExpiredError ? 'token_expired' :
343
+ error instanceof jwt.JsonWebTokenError ? 'invalid_token' : 'jwt_error';
344
+ audit('auth.failed', { clientId: client.id, reason });
345
+ this.send(client, {
346
+ type: 'auth_failure',
347
+ id: requestId,
348
+ payload: { message: reason === 'token_expired' ? 'Token expired' : 'Invalid token' },
349
+ });
350
+ }
351
+ }
352
+ send(client, message) {
353
+ if (client.ws.readyState === WebSocket.OPEN) {
354
+ client.ws.send(JSON.stringify(message));
355
+ }
356
+ }
357
+ broadcast(message, filter) {
358
+ for (const client of this.clients.values()) {
359
+ if (!filter || filter(client)) {
360
+ this.send(client, message);
361
+ }
362
+ }
363
+ }
364
+ onVoiceMessage(handler) {
365
+ this.voiceHandler = handler;
366
+ }
367
+ sendBinary(client, data) {
368
+ if (client.ws.readyState === WebSocket.OPEN) {
369
+ client.ws.send(data);
370
+ }
371
+ }
372
+ mountRouter(path, router) {
373
+ this.app.use(path, router);
374
+ }
375
+ handleAudioFrame(client, frame) {
376
+ if (!client.authenticated || !client.voiceActive)
377
+ return;
378
+ const maxFrame = 64 * 1024;
379
+ const maxBuffer = 960_000;
380
+ if (frame.length > maxFrame)
381
+ return;
382
+ const buf = this.audioBuffers.get(client.id);
383
+ if (!buf)
384
+ return;
385
+ if (buf.size + frame.length > maxBuffer)
386
+ return;
387
+ buf.frames.push(frame);
388
+ buf.size += frame.length;
389
+ }
390
+ async handleVoiceControl(client, type, payload, requestId) {
391
+ if (type === 'voice_start') {
392
+ client.voiceActive = true;
393
+ this.audioBuffers.set(client.id, { frames: [], size: 0 });
394
+ }
395
+ let audioBuffer;
396
+ if (type === 'voice_end') {
397
+ const buf = this.audioBuffers.get(client.id);
398
+ if (buf && buf.frames.length > 0) {
399
+ audioBuffer = Buffer.concat(buf.frames);
400
+ }
401
+ this.audioBuffers.delete(client.id);
402
+ client.voiceActive = false;
403
+ }
404
+ if (type === 'voice_cancel') {
405
+ this.audioBuffers.delete(client.id);
406
+ client.voiceActive = false;
407
+ }
408
+ if (this.voiceHandler) {
409
+ await this.voiceHandler(client, type, payload, audioBuffer);
410
+ }
411
+ }
412
+ onMessage(handler) {
413
+ this.messageHandler = handler;
414
+ }
415
+ getClient(id) {
416
+ return this.clients.get(id);
417
+ }
418
+ getConnections() {
419
+ return Array.from(this.clients.values());
420
+ }
421
+ getPairingManager() {
422
+ return this.pairingManager;
423
+ }
424
+ async start() {
425
+ const { host, port } = this.config.gateway;
426
+ return new Promise((resolve) => {
427
+ this.server.listen(port, host, () => {
428
+ audit('system.startup', { host, port });
429
+ logger.info(`Auxiora Gateway running at http://${host}:${port}`);
430
+ resolve();
431
+ });
432
+ });
433
+ }
434
+ async stop() {
435
+ return new Promise((resolve, reject) => {
436
+ // Close all WebSocket connections
437
+ for (const client of this.clients.values()) {
438
+ client.ws.close(1001, 'Server shutting down');
439
+ }
440
+ this.clients.clear();
441
+ // Cleanup
442
+ this.pairingManager.destroy();
443
+ this.rateLimiter.destroy();
444
+ // Close server
445
+ this.server.close((err) => {
446
+ if (err) {
447
+ reject(err);
448
+ }
449
+ else {
450
+ audit('system.shutdown', {});
451
+ resolve();
452
+ }
453
+ });
454
+ });
455
+ }
456
+ getWebChatHtml() {
457
+ return `<!DOCTYPE html>
458
+ <html lang="en">
459
+ <head>
460
+ <meta charset="UTF-8">
461
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
462
+ <title>Auxiora</title>
463
+ <link rel="icon" href="/dashboard/icon.svg" type="image/svg+xml">
464
+ <style>
465
+ * { box-sizing: border-box; margin: 0; padding: 0; }
466
+ body {
467
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
468
+ background: #0a0a0f;
469
+ color: #e0e0e0;
470
+ height: 100vh;
471
+ display: flex;
472
+ flex-direction: column;
473
+ }
474
+ header {
475
+ padding: 1rem;
476
+ background: #12121a;
477
+ border-bottom: 1px solid #2a2a3a;
478
+ display: flex;
479
+ align-items: center;
480
+ gap: 0.5rem;
481
+ }
482
+ header h1 { font-size: 1.25rem; font-weight: 600; }
483
+ .status {
484
+ width: 8px; height: 8px;
485
+ border-radius: 50%;
486
+ background: #666;
487
+ }
488
+ .status.connected { background: #22c55e; }
489
+ #messages {
490
+ flex: 1;
491
+ overflow-y: auto;
492
+ padding: 1rem;
493
+ display: flex;
494
+ flex-direction: column;
495
+ gap: 0.75rem;
496
+ }
497
+ .message {
498
+ max-width: 80%;
499
+ padding: 0.75rem 1rem;
500
+ border-radius: 1rem;
501
+ line-height: 1.5;
502
+ }
503
+ .message.user {
504
+ align-self: flex-end;
505
+ background: #3b82f6;
506
+ color: white;
507
+ border-bottom-right-radius: 0.25rem;
508
+ }
509
+ .message.assistant {
510
+ align-self: flex-start;
511
+ background: #1e1e2e;
512
+ border-bottom-left-radius: 0.25rem;
513
+ }
514
+ .message.system {
515
+ align-self: center;
516
+ background: transparent;
517
+ color: #888;
518
+ font-size: 0.875rem;
519
+ }
520
+ #input-area {
521
+ padding: 1rem;
522
+ background: #12121a;
523
+ border-top: 1px solid #2a2a3a;
524
+ display: flex;
525
+ gap: 0.5rem;
526
+ }
527
+ #input {
528
+ flex: 1;
529
+ padding: 0.75rem 1rem;
530
+ border: 1px solid #2a2a3a;
531
+ border-radius: 1.5rem;
532
+ background: #0a0a0f;
533
+ color: #e0e0e0;
534
+ font-size: 1rem;
535
+ outline: none;
536
+ }
537
+ #input:focus { border-color: #3b82f6; }
538
+ #send {
539
+ padding: 0.75rem 1.5rem;
540
+ background: #3b82f6;
541
+ color: white;
542
+ border: none;
543
+ border-radius: 1.5rem;
544
+ font-size: 1rem;
545
+ cursor: pointer;
546
+ }
547
+ #send:hover { background: #2563eb; }
548
+ #send:disabled { background: #444; cursor: not-allowed; }
549
+ .dashboard-link {
550
+ margin-left: auto;
551
+ color: #888;
552
+ text-decoration: none;
553
+ font-size: 0.85rem;
554
+ padding: 0.4rem 0.75rem;
555
+ border: 1px solid #2a2a3a;
556
+ border-radius: 1rem;
557
+ transition: color 0.2s, border-color 0.2s;
558
+ }
559
+ .dashboard-link:hover { color: #3b82f6; border-color: #3b82f6; }
560
+ </style>
561
+ </head>
562
+ <body>
563
+ <header>
564
+ <div class="status" id="status"></div>
565
+ <h1>Auxiora</h1>
566
+ <a href="/dashboard" class="dashboard-link">Dashboard</a>
567
+ </header>
568
+ <div id="messages"></div>
569
+ <div id="input-area">
570
+ <input type="text" id="input" placeholder="Type a message..." autocomplete="off">
571
+ <button id="send">Send</button>
572
+ </div>
573
+
574
+ <script>
575
+ const messages = document.getElementById('messages');
576
+ const input = document.getElementById('input');
577
+ const send = document.getElementById('send');
578
+ const status = document.getElementById('status');
579
+
580
+ let ws;
581
+ let authenticated = false;
582
+ let messageId = 0;
583
+
584
+ function connect() {
585
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
586
+ ws = new WebSocket(protocol + '//' + location.host);
587
+
588
+ ws.onopen = () => {
589
+ status.classList.add('connected');
590
+ addMessage('system', 'Connected to Auxiora');
591
+ };
592
+
593
+ ws.onclose = () => {
594
+ status.classList.remove('connected');
595
+ addMessage('system', 'Disconnected. Reconnecting...');
596
+ setTimeout(connect, 2000);
597
+ };
598
+
599
+ ws.onmessage = (event) => {
600
+ const msg = JSON.parse(event.data);
601
+ handleMessage(msg);
602
+ };
603
+ }
604
+
605
+ function handleMessage(msg) {
606
+ switch (msg.type) {
607
+ case 'connected':
608
+ authenticated = msg.payload.authenticated;
609
+ if (!authenticated && !msg.payload.requiresAuth) {
610
+ authenticated = true;
611
+ }
612
+ break;
613
+ case 'auth_success':
614
+ authenticated = true;
615
+ addMessage('system', 'Authenticated');
616
+ break;
617
+ case 'message':
618
+ addMessage(msg.payload.role || 'assistant', msg.payload.content);
619
+ break;
620
+ case 'chunk':
621
+ appendToLast(msg.payload.content);
622
+ break;
623
+ case 'error':
624
+ addMessage('system', 'Error: ' + msg.payload.message);
625
+ break;
626
+ }
627
+ }
628
+
629
+ function addMessage(role, content) {
630
+ const div = document.createElement('div');
631
+ div.className = 'message ' + role;
632
+ div.textContent = content;
633
+ messages.appendChild(div);
634
+ messages.scrollTop = messages.scrollHeight;
635
+ }
636
+
637
+ function appendToLast(content) {
638
+ const last = messages.lastElementChild;
639
+ if (last && last.classList.contains('assistant')) {
640
+ last.textContent += content;
641
+ } else {
642
+ addMessage('assistant', content);
643
+ }
644
+ messages.scrollTop = messages.scrollHeight;
645
+ }
646
+
647
+ function sendMessage() {
648
+ const text = input.value.trim();
649
+ if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
650
+
651
+ addMessage('user', text);
652
+ ws.send(JSON.stringify({
653
+ type: 'message',
654
+ id: String(++messageId),
655
+ payload: { content: text }
656
+ }));
657
+ input.value = '';
658
+ }
659
+
660
+ send.onclick = sendMessage;
661
+ input.onkeypress = (e) => { if (e.key === 'Enter') sendMessage(); };
662
+
663
+ connect();
664
+ </script>
665
+ </body>
666
+ </html>`;
667
+ }
668
+ }
669
+ //# sourceMappingURL=server.js.map