@kb-labs/gateway-app 0.2.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.
Files changed (46) hide show
  1. package/.kb/database/kb.sqlite-shm +0 -0
  2. package/.kb/database/kb.sqlite-wal +0 -0
  3. package/package.json +49 -0
  4. package/src/__tests__/auth-routes.test.ts +279 -0
  5. package/src/__tests__/execute-routes.test.ts +408 -0
  6. package/src/__tests__/execution-registry.test.ts +218 -0
  7. package/src/__tests__/health.test.ts +215 -0
  8. package/src/__tests__/live-gateway.e2e.test.ts +648 -0
  9. package/src/__tests__/llm-gateway.test.ts +361 -0
  10. package/src/__tests__/observability-collector.test.ts +59 -0
  11. package/src/__tests__/platform-api.test.ts +317 -0
  12. package/src/__tests__/registry.test.ts +546 -0
  13. package/src/__tests__/retry-executor.test.ts +244 -0
  14. package/src/__tests__/server.integration.test.ts +417 -0
  15. package/src/__tests__/subscription-registry.test.ts +308 -0
  16. package/src/__tests__/telemetry-ingest.test.ts +309 -0
  17. package/src/__tests__/tokens.test.ts +83 -0
  18. package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
  19. package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
  20. package/src/auth/middleware.ts +50 -0
  21. package/src/auth/routes.ts +57 -0
  22. package/src/auth/tokens.ts +41 -0
  23. package/src/bootstrap.ts +98 -0
  24. package/src/clients/subscription-registry.ts +137 -0
  25. package/src/clients/ws-handler.ts +196 -0
  26. package/src/config.ts +20 -0
  27. package/src/docs/routes.ts +70 -0
  28. package/src/execute/errors.ts +21 -0
  29. package/src/execute/execution-registry.ts +84 -0
  30. package/src/execute/retry-executor.ts +159 -0
  31. package/src/execute/routes.ts +239 -0
  32. package/src/hosts/dispatcher.ts +2 -0
  33. package/src/hosts/registry.ts +305 -0
  34. package/src/hosts/ws-handler.ts +445 -0
  35. package/src/index.ts +7 -0
  36. package/src/llm/routes.ts +343 -0
  37. package/src/manifest.ts +21 -0
  38. package/src/observability/collector.ts +346 -0
  39. package/src/platform/routes.ts +195 -0
  40. package/src/server.ts +447 -0
  41. package/src/telemetry/routes.ts +89 -0
  42. package/src/ws/gateway-ws.ts +73 -0
  43. package/tsconfig.build.json +15 -0
  44. package/tsconfig.json +10 -0
  45. package/tsup.config.ts +8 -0
  46. package/vitest.config.ts +23 -0
@@ -0,0 +1,445 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { WebSocket } from 'ws';
3
+
4
+ interface WsRequest {
5
+ headers: { authorization?: string };
6
+ url?: string;
7
+ }
8
+ import { logDiagnosticEvent, type ICache, type ILogger } from '@kb-labs/core-platform';
9
+ import {
10
+ HelloMessageSchema,
11
+ AdapterCallMessageSchema,
12
+ AdapterNameSchema,
13
+ HostCapabilitySchema,
14
+ SUPPORTED_PROTOCOL_VERSIONS,
15
+ type OutboundMessage,
16
+ } from '@kb-labs/gateway-contracts';
17
+ import { AdaptiveBuffer } from '@kb-labs/gateway-core';
18
+ import { getClientByHostId, type JwtConfig } from '@kb-labs/gateway-auth';
19
+ import { HostRegistry } from './registry.js';
20
+ import { extractBearerToken, resolveToken } from '../auth/tokens.js';
21
+ import { globalDispatcher } from './dispatcher.js';
22
+ import { executionRegistry } from '../execute/execution-registry.js';
23
+
24
+ const HELLO_TIMEOUT_MS = 5_000;
25
+ const HEARTBEAT_INTERVAL_MS = 30_000;
26
+ const HEARTBEAT_GRACE_MS = 10_000;
27
+
28
+ function send(ws: WebSocket, msg: OutboundMessage): void {
29
+ ws.send(JSON.stringify(msg));
30
+ }
31
+
32
+ export function createWsHandler(
33
+ cache: ICache,
34
+ jwtConfig: JwtConfig,
35
+ logger: ILogger,
36
+ hostRegistry?: HostRegistry,
37
+ ) {
38
+ const registry = hostRegistry ?? new HostRegistry(cache);
39
+ const buffer = new AdaptiveBuffer(cache);
40
+
41
+ return async function wsHandler(
42
+ socket: WebSocket,
43
+ request: WsRequest,
44
+ ): Promise<void> {
45
+ // 1. Auth — machine token required
46
+ const token = extractBearerToken(request.headers.authorization);
47
+ if (!token) {
48
+ logDiagnosticEvent(logger, {
49
+ domain: 'service',
50
+ event: 'gateway.hosts.ws.auth',
51
+ level: 'warn',
52
+ reasonCode: 'websocket_auth_failed',
53
+ message: 'Host WebSocket connection missing authorization token',
54
+ outcome: 'failed',
55
+ serviceId: 'gateway',
56
+ route: '/hosts/connect',
57
+ });
58
+ socket.close(1008, 'Missing Authorization header');
59
+ return;
60
+ }
61
+
62
+ const tokenEntry = await resolveToken(token, cache, jwtConfig);
63
+ if (!tokenEntry || tokenEntry.type !== 'machine') {
64
+ logDiagnosticEvent(logger, {
65
+ domain: 'service',
66
+ event: 'gateway.hosts.ws.auth',
67
+ level: 'warn',
68
+ reasonCode: 'websocket_auth_failed',
69
+ message: 'Host WebSocket machine token rejected',
70
+ outcome: 'failed',
71
+ serviceId: 'gateway',
72
+ route: '/hosts/connect',
73
+ });
74
+ socket.close(1008, 'Invalid machine token');
75
+ return;
76
+ }
77
+
78
+ const { userId: hostId, namespaceId } = tokenEntry;
79
+ const connectionId = randomUUID();
80
+ const sessionId = randomUUID();
81
+
82
+ // 2. Wait for hello (with timeout)
83
+ let protocolVersion: string | null = null;
84
+ let helloCaps: string[] = [];
85
+ let helloDone = false;
86
+
87
+ const protocolVersions: readonly string[] = SUPPORTED_PROTOCOL_VERSIONS;
88
+
89
+ await new Promise<void>((resolve, reject) => {
90
+ const helloTimeout = setTimeout(() => {
91
+ if (!helloDone) {
92
+ helloDone = true;
93
+ logDiagnosticEvent(logger, {
94
+ domain: 'service',
95
+ event: 'gateway.hosts.ws.handshake',
96
+ level: 'warn',
97
+ reasonCode: 'websocket_hello_timeout',
98
+ message: 'Host WebSocket hello timed out',
99
+ outcome: 'failed',
100
+ serviceId: 'gateway',
101
+ route: '/hosts/connect',
102
+ evidence: {
103
+ hostId,
104
+ namespaceId,
105
+ },
106
+ });
107
+ socket.close(1008, 'Hello timeout');
108
+ reject(new Error('Hello timeout'));
109
+ }
110
+ }, HELLO_TIMEOUT_MS);
111
+
112
+ socket.once('message', (raw) => {
113
+ if (helloDone) {return;}
114
+ helloDone = true;
115
+ clearTimeout(helloTimeout);
116
+
117
+ try {
118
+ const msg = HelloMessageSchema.parse(JSON.parse(raw.toString()));
119
+
120
+ // Version negotiation
121
+ if (!protocolVersions.includes(msg.protocolVersion)) {
122
+ logDiagnosticEvent(logger, {
123
+ domain: 'service',
124
+ event: 'gateway.hosts.ws.handshake',
125
+ level: 'warn',
126
+ reasonCode: 'websocket_protocol_unsupported',
127
+ message: 'Host WebSocket protocol version is unsupported',
128
+ outcome: 'failed',
129
+ serviceId: 'gateway',
130
+ route: '/hosts/connect',
131
+ evidence: {
132
+ hostId,
133
+ namespaceId,
134
+ protocolVersion: msg.protocolVersion,
135
+ supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
136
+ },
137
+ });
138
+ send(socket, {
139
+ type: 'negotiate',
140
+ supportedVersions: [...SUPPORTED_PROTOCOL_VERSIONS],
141
+ });
142
+ socket.close(1008, 'Unsupported protocol version');
143
+ reject(new Error('Unsupported protocol version'));
144
+ return;
145
+ }
146
+
147
+ protocolVersion = msg.protocolVersion;
148
+ helloCaps = msg.capabilities ?? [];
149
+ resolve();
150
+ } catch (error) {
151
+ logDiagnosticEvent(logger, {
152
+ domain: 'service',
153
+ event: 'gateway.hosts.ws.handshake',
154
+ level: 'warn',
155
+ reasonCode: 'websocket_handshake_invalid',
156
+ message: 'Host WebSocket hello message is invalid',
157
+ outcome: 'failed',
158
+ error: error instanceof Error ? error : new Error(String(error)),
159
+ serviceId: 'gateway',
160
+ route: '/hosts/connect',
161
+ evidence: {
162
+ hostId,
163
+ namespaceId,
164
+ },
165
+ });
166
+ socket.close(1008, 'Invalid hello message');
167
+ reject(new Error('Invalid hello'));
168
+ }
169
+ });
170
+ }).catch(() => {
171
+ // socket already closed — errors logged above
172
+ });
173
+
174
+ if (!protocolVersion) {return;}
175
+
176
+ // 3. Ensure host descriptor exists (JWT-registered hosts have no registry entry yet)
177
+ const clientRecord = await getClientByHostId(cache, hostId);
178
+ const registryCaps = (clientRecord?.capabilities ?? [])
179
+ .map((c) => HostCapabilitySchema.safeParse(c))
180
+ .filter((r) => r.success)
181
+ .map((r) => r.data);
182
+
183
+ // Security: for JWT-registered hosts use capabilities from clientRecord only.
184
+ // For static-token hosts (no clientRecord) accept capabilities from hello message,
185
+ // but validate each against HostCapabilitySchema to reject unknown values.
186
+ const validatedHelloCaps = clientRecord ? [] : helloCaps
187
+ .map((c) => HostCapabilitySchema.safeParse(c))
188
+ .filter((r) => r.success)
189
+ .map((r) => r.data);
190
+
191
+ const capabilities = clientRecord ? registryCaps : validatedHelloCaps;
192
+ await registry.ensureRegistered(hostId, namespaceId, clientRecord?.name ?? hostId, capabilities);
193
+
194
+ // 4. Set online + register in dispatcher (with capabilities for routing) + send connected
195
+ await registry.setOnline(hostId, namespaceId, connectionId);
196
+ globalDispatcher.registerConnection(hostId, namespaceId, socket, capabilities);
197
+
198
+ send(socket, {
199
+ type: 'connected',
200
+ protocolVersion,
201
+ hostId,
202
+ sessionId,
203
+ });
204
+
205
+ // 4. Flush buffered calls
206
+ const buffered = await buffer.flush(hostId);
207
+ for (const call of buffered) {
208
+ send(socket, {
209
+ type: 'call',
210
+ requestId: call.requestId,
211
+ adapter: call.adapter,
212
+ method: call.method,
213
+ args: call.args,
214
+ trace: { traceId: call.requestId, spanId: randomUUID() },
215
+ });
216
+ }
217
+
218
+ // 5. Heartbeat watchdog
219
+ let lastHeartbeat = Date.now();
220
+ const heartbeatWatchdog = setInterval(async () => {
221
+ const elapsed = Date.now() - lastHeartbeat;
222
+ if (elapsed > HEARTBEAT_INTERVAL_MS + HEARTBEAT_GRACE_MS) {
223
+ // Mark as degraded (don't close — allow recovery)
224
+ const host = await registry.get(hostId, namespaceId);
225
+ if (host && host.status !== 'degraded') {
226
+ await cache.set(`host:registry:${namespaceId}:${hostId}`, {
227
+ ...host,
228
+ status: 'degraded',
229
+ });
230
+ }
231
+ }
232
+ }, HEARTBEAT_INTERVAL_MS);
233
+
234
+ // 6. Message handler
235
+ socket.on('message', async (raw) => {
236
+ try {
237
+ const msg = JSON.parse(raw.toString()) as { type: string; requestId?: string };
238
+
239
+ switch (msg.type) {
240
+ case 'heartbeat':
241
+ lastHeartbeat = Date.now();
242
+ await registry.heartbeat(hostId, namespaceId);
243
+ send(socket, { type: 'ack' });
244
+ break;
245
+
246
+ case 'chunk':
247
+ case 'result':
248
+ case 'error':
249
+ globalDispatcher.handleInbound(msg as { type: string; requestId?: string; data?: unknown; error?: unknown });
250
+ break;
251
+
252
+ case 'adapter:call':
253
+ void handleAdapterCall(msg, socket, hostId, namespaceId);
254
+ break;
255
+ }
256
+ } catch (error) {
257
+ logDiagnosticEvent(logger, {
258
+ domain: 'service',
259
+ event: 'gateway.hosts.ws.message',
260
+ level: 'warn',
261
+ reasonCode: 'websocket_message_invalid',
262
+ message: 'Host WebSocket message is malformed',
263
+ outcome: 'failed',
264
+ error: error instanceof Error ? error : new Error(String(error)),
265
+ serviceId: 'gateway',
266
+ route: '/hosts/connect',
267
+ evidence: {
268
+ hostId,
269
+ namespaceId,
270
+ },
271
+ });
272
+ }
273
+ });
274
+
275
+ // 7. Disconnect cleanup
276
+ socket.on('close', async () => {
277
+ clearInterval(heartbeatWatchdog);
278
+ globalDispatcher.removeConnection(hostId, namespaceId);
279
+
280
+ // Cancel all executions dispatched to this host (CC2)
281
+ const cancelled = executionRegistry.cancelByHost(hostId, 'disconnect');
282
+ if (cancelled.length > 0) {
283
+ logDiagnosticEvent(logger, {
284
+ domain: 'service',
285
+ event: 'gateway.hosts.ws.disconnect',
286
+ level: 'warn',
287
+ reasonCode: 'execution_dispatch_failed',
288
+ message: 'Host disconnected and active executions were cancelled',
289
+ outcome: 'failed',
290
+ serviceId: 'gateway',
291
+ route: '/hosts/connect',
292
+ evidence: {
293
+ hostId,
294
+ namespaceId,
295
+ cancelledExecutions: cancelled.length,
296
+ },
297
+ });
298
+ }
299
+
300
+ await registry.setOffline(hostId, namespaceId, connectionId);
301
+ });
302
+ };
303
+
304
+ /**
305
+ * Handle adapter:call from Host — forward to REST API for platform service execution.
306
+ * Flow: Host → WS adapter:call → Gateway → HTTP POST /api/v1/internal/adapter-call → REST API
307
+ *
308
+ * @see ADR-0051: Bidirectional Gateway Protocol
309
+ */
310
+ async function handleAdapterCall(
311
+ msg: Record<string, unknown>,
312
+ socket: WebSocket,
313
+ hostId: string,
314
+ namespaceId: string,
315
+ ): Promise<void> {
316
+ const requestId = msg['requestId'] as string;
317
+
318
+ // 1. Validate message schema
319
+ const parsed = AdapterCallMessageSchema.safeParse(msg);
320
+ if (!parsed.success) {
321
+ logDiagnosticEvent(logger, {
322
+ domain: 'service',
323
+ event: 'gateway.hosts.adapter-call',
324
+ level: 'warn',
325
+ reasonCode: 'websocket_message_invalid',
326
+ message: 'Host adapter call message is invalid',
327
+ outcome: 'failed',
328
+ serviceId: 'gateway',
329
+ route: '/hosts/connect',
330
+ evidence: {
331
+ hostId,
332
+ namespaceId,
333
+ requestId,
334
+ },
335
+ });
336
+ send(socket, {
337
+ type: 'adapter:error',
338
+ requestId: requestId ?? 'unknown',
339
+ error: { code: 'INVALID_MESSAGE', message: parsed.error.message, retryable: false },
340
+ });
341
+ return;
342
+ }
343
+
344
+ const call = parsed.data;
345
+
346
+ // 2. Validate adapter is in allowlist
347
+ const adapterCheck = AdapterNameSchema.safeParse(call.adapter);
348
+ if (!adapterCheck.success) {
349
+ logDiagnosticEvent(logger, {
350
+ domain: 'service',
351
+ event: 'gateway.hosts.adapter-call',
352
+ level: 'warn',
353
+ reasonCode: 'adapter_call_rejected',
354
+ message: 'Host adapter call rejected by gateway allowlist',
355
+ outcome: 'failed',
356
+ serviceId: 'gateway',
357
+ route: '/hosts/connect',
358
+ evidence: {
359
+ hostId,
360
+ namespaceId,
361
+ requestId: call.requestId,
362
+ adapter: call.adapter,
363
+ method: call.method,
364
+ },
365
+ });
366
+ send(socket, {
367
+ type: 'adapter:error',
368
+ requestId: call.requestId,
369
+ error: { code: 'ADAPTER_CALL_REJECTED', message: `Adapter not allowed: ${call.adapter}`, retryable: false },
370
+ });
371
+ return;
372
+ }
373
+
374
+ // 3. Forward to REST API
375
+ const restApiUrl = process.env.REST_API_URL ?? 'http://localhost:5050';
376
+ const internalSecret = process.env.GATEWAY_INTERNAL_SECRET ?? '';
377
+
378
+ try {
379
+ const response = await fetch(`${restApiUrl}/api/v1/internal/adapter-call`, {
380
+ method: 'POST',
381
+ headers: {
382
+ 'Content-Type': 'application/json',
383
+ 'x-internal-secret': internalSecret,
384
+ },
385
+ body: JSON.stringify({
386
+ requestId: call.requestId,
387
+ adapter: call.adapter,
388
+ method: call.method,
389
+ args: call.args,
390
+ context: {
391
+ ...call.context,
392
+ namespaceId,
393
+ hostId,
394
+ },
395
+ }),
396
+ });
397
+
398
+ const body = await response.json() as {
399
+ ok: boolean;
400
+ result?: unknown;
401
+ error?: { code: string; message: string; retryable: boolean; details?: unknown };
402
+ };
403
+
404
+ if (body.ok) {
405
+ send(socket, {
406
+ type: 'adapter:response',
407
+ requestId: call.requestId,
408
+ result: body.result,
409
+ });
410
+ } else {
411
+ send(socket, {
412
+ type: 'adapter:error',
413
+ requestId: call.requestId,
414
+ error: body.error ?? { code: 'ADAPTER_ERROR', message: 'Unknown error', retryable: false },
415
+ });
416
+ }
417
+ } catch (err) {
418
+ const message = err instanceof Error ? err.message : String(err);
419
+ logDiagnosticEvent(logger, {
420
+ domain: 'service',
421
+ event: 'gateway.hosts.adapter-call',
422
+ level: 'error',
423
+ reasonCode: 'adapter_bridge_unavailable',
424
+ message: 'Gateway could not reach REST adapter bridge',
425
+ outcome: 'failed',
426
+ error: err instanceof Error ? err : new Error(String(err)),
427
+ serviceId: 'gateway',
428
+ route: '/hosts/connect',
429
+ evidence: {
430
+ hostId,
431
+ namespaceId,
432
+ requestId: call.requestId,
433
+ adapter: call.adapter,
434
+ method: call.method,
435
+ restApiUrl,
436
+ },
437
+ });
438
+ send(socket, {
439
+ type: 'adapter:error',
440
+ requestId: call.requestId,
441
+ error: { code: 'ADAPTER_CALL_TIMEOUT', message: `REST API unreachable: ${message}`, retryable: true },
442
+ });
443
+ }
444
+ }
445
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { bootstrap } from './bootstrap.js';
2
+
3
+ // process.cwd() = workspace root when launched via `node ./infra/kb-labs-gateway/.../dist/index.js`
4
+ bootstrap(process.cwd()).catch((error) => {
5
+ console.error('Failed to start gateway:', error);
6
+ process.exit(1);
7
+ });