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