@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,381 @@
1
+ /**
2
+ * E2E WebSocket tests for /clients/connect (CC5 — Multi-Client Pub/Sub).
3
+ * Spins up a real Fastify server on a random port.
4
+ *
5
+ * Tests the full client protocol:
6
+ * auth → client:hello → client:connected → subscribe/unsubscribe/cancel
7
+ *
8
+ * Does NOT mix with host-side ws-handler tests.
9
+ */
10
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
11
+ import Fastify, { type FastifyInstance } from 'fastify';
12
+ import fastifyWebsocket from '@fastify/websocket';
13
+ import fastifyCors from '@fastify/cors';
14
+ import { WebSocket, type RawData } from 'ws';
15
+ import type { ICache, ILogger } from '@kb-labs/core-platform';
16
+ import type { JwtConfig } from '@kb-labs/gateway-auth';
17
+ import { AuthService } from '@kb-labs/gateway-auth';
18
+ import { createClientWsHandler } from '../clients/ws-handler.js';
19
+ import { executionRegistry } from '../execute/execution-registry.js';
20
+
21
+ // ── In-memory ICache ─────────────────────────────────────────────────────────
22
+
23
+ function makeInMemoryCache(): ICache {
24
+ const store = new Map<string, unknown>();
25
+ return {
26
+ async get<T>(key: string): Promise<T | null> {
27
+ return (store.get(key) as T) ?? null;
28
+ },
29
+ async set(key: string, value: unknown): Promise<void> {
30
+ store.set(key, value);
31
+ },
32
+ async delete(key: string): Promise<void> {
33
+ store.delete(key);
34
+ },
35
+ async clear(): Promise<void> {
36
+ store.clear();
37
+ },
38
+ } as unknown as ICache;
39
+ }
40
+
41
+ // ── Minimal ILogger ──────────────────────────────────────────────────────────
42
+
43
+ const noopLogger: ILogger = {
44
+ info: () => {},
45
+ warn: () => {},
46
+ error: () => {},
47
+ debug: () => {},
48
+ child: () => noopLogger,
49
+ } as unknown as ILogger;
50
+
51
+ // ── Server setup ─────────────────────────────────────────────────────────────
52
+
53
+ const testJwtConfig: JwtConfig = { secret: 'test-client-secret' };
54
+
55
+ let app: FastifyInstance;
56
+ let wsUrl: string;
57
+ let cache: ICache;
58
+ let authService: AuthService;
59
+
60
+ beforeAll(async () => {
61
+ cache = makeInMemoryCache();
62
+ authService = new AuthService(cache, testJwtConfig);
63
+
64
+ app = Fastify({ logger: false });
65
+ await app.register(fastifyWebsocket);
66
+ await app.register(fastifyCors, { origin: true });
67
+
68
+ app.get('/clients/connect', { websocket: true }, createClientWsHandler(cache, testJwtConfig, noopLogger));
69
+
70
+ const address = await app.listen({ port: 0, host: '127.0.0.1' });
71
+ wsUrl = address.replace('http://', 'ws://');
72
+ }, 10_000);
73
+
74
+ afterAll(async () => {
75
+ await app.close();
76
+ }, 10_000);
77
+
78
+ beforeEach(() => {
79
+ // Clean up any leftover executions
80
+ for (const id of ['exec-ws-test-1', 'exec-ws-test-2', 'exec-ns-other']) {
81
+ executionRegistry.remove(id);
82
+ }
83
+ });
84
+
85
+ // ── Helpers ──────────────────────────────────────────────────────────────────
86
+
87
+ /** Get a valid access token via AuthService.register + issueTokens */
88
+ async function getAccessToken(namespaceId = 'ns-client-e2e'): Promise<string> {
89
+ const reg = await authService.register({ name: 'test-client', namespaceId, capabilities: [] });
90
+ const tokens = await authService.issueTokens(reg.clientId, reg.clientSecret);
91
+ return tokens!.accessToken;
92
+ }
93
+
94
+ /** Connect to /clients/connect with Bearer token */
95
+ function connectClient(token: string): WebSocket {
96
+ return new WebSocket(`${wsUrl}/clients/connect`, {
97
+ headers: { Authorization: `Bearer ${token}` },
98
+ });
99
+ }
100
+
101
+ /** Collect the next N messages from a WS connection */
102
+ function collectMessages(ws: WebSocket, count: number, timeout = 3000): Promise<unknown[]> {
103
+ return new Promise((resolve, reject) => {
104
+ const msgs: unknown[] = [];
105
+ const timer = setTimeout(() => {
106
+ reject(new Error(`Timeout: expected ${count} messages, got ${msgs.length}: ${JSON.stringify(msgs)}`));
107
+ }, timeout);
108
+
109
+ ws.on('message', (raw: RawData) => {
110
+ msgs.push(JSON.parse(raw.toString()));
111
+ if (msgs.length >= count) {
112
+ clearTimeout(timer);
113
+ resolve(msgs);
114
+ }
115
+ });
116
+
117
+ ws.on('error', (err: Error) => { clearTimeout(timer); reject(err); });
118
+ });
119
+ }
120
+
121
+ /** Wait for WS to open */
122
+ function waitForOpen(ws: WebSocket): Promise<void> {
123
+ return new Promise((resolve, reject) => {
124
+ ws.on('open', resolve);
125
+ ws.on('error', reject);
126
+ });
127
+ }
128
+
129
+ // ── Tests ────────────────────────────────────────────────────────────────────
130
+
131
+ describe('WebSocket /clients/connect: auth rejection', () => {
132
+ it('closes immediately when no Authorization header', async () => {
133
+ const ws = new WebSocket(`${wsUrl}/clients/connect`); // no auth
134
+ const closeCode = await new Promise<number>((resolve) => {
135
+ ws.on('close', (code: number) => resolve(code));
136
+ ws.on('error', () => {});
137
+ setTimeout(() => resolve(-1), 3000);
138
+ });
139
+ expect([1008, 1006]).toContain(closeCode);
140
+ }, 5000);
141
+
142
+ it('closes with 1008 for invalid token', async () => {
143
+ const ws = new WebSocket(`${wsUrl}/clients/connect`, {
144
+ headers: { Authorization: 'Bearer not-a-valid-token' },
145
+ });
146
+ const closeCode = await new Promise<number>((resolve) => {
147
+ ws.on('close', (code: number) => resolve(code));
148
+ ws.on('error', () => {});
149
+ setTimeout(() => resolve(-1), 3000);
150
+ });
151
+ expect([1008, 1006]).toContain(closeCode);
152
+ }, 5000);
153
+ });
154
+
155
+ describe('WebSocket /clients/connect: handshake', () => {
156
+ it('completes client:hello → client:connected flow', async () => {
157
+ const token = await getAccessToken();
158
+ const ws = connectClient(token);
159
+
160
+ await waitForOpen(ws);
161
+
162
+ const messagesP = collectMessages(ws, 1);
163
+ ws.send(JSON.stringify({ type: 'client:hello', clientVersion: '0.1.0' }));
164
+
165
+ const [connected] = await messagesP as [{ type: string; protocolVersion: string; connectionId: string }];
166
+ expect(connected.type).toBe('client:connected');
167
+ expect(connected.protocolVersion).toBe('1.0');
168
+ expect(typeof connected.connectionId).toBe('string');
169
+
170
+ ws.close(1000);
171
+ }, 8000);
172
+
173
+ it('closes with 1008 if client:hello not sent within timeout', async () => {
174
+ const token = await getAccessToken();
175
+ const ws = connectClient(token);
176
+
177
+ await waitForOpen(ws);
178
+ // Don't send hello — wait for timeout
179
+
180
+ const closeCode = await new Promise<number>((resolve) => {
181
+ ws.on('close', (code: number) => resolve(code));
182
+ setTimeout(() => resolve(-1), 8000);
183
+ });
184
+
185
+ expect(closeCode).toBe(1008);
186
+ }, 10_000);
187
+
188
+ it('closes with 1008 if client:hello has invalid format', async () => {
189
+ const token = await getAccessToken();
190
+ const ws = connectClient(token);
191
+
192
+ await waitForOpen(ws);
193
+ ws.send(JSON.stringify({ type: 'client:hello' })); // missing clientVersion
194
+
195
+ const closeCode = await new Promise<number>((resolve) => {
196
+ ws.on('close', (code: number) => resolve(code));
197
+ ws.on('error', () => {});
198
+ setTimeout(() => resolve(-1), 3000);
199
+ });
200
+
201
+ expect([1008, 1000, 1005]).toContain(closeCode);
202
+ }, 5000);
203
+
204
+ it('access_token query param is accepted as fallback auth', async () => {
205
+ const token = await getAccessToken();
206
+ const ws = new WebSocket(`${wsUrl}/clients/connect?access_token=${token}`);
207
+
208
+ await waitForOpen(ws);
209
+ const msgsP = collectMessages(ws, 1);
210
+ ws.send(JSON.stringify({ type: 'client:hello', clientVersion: '0.1.0' }));
211
+
212
+ const [connected] = await msgsP as [{ type: string }];
213
+ expect(connected.type).toBe('client:connected');
214
+
215
+ ws.close(1000);
216
+ }, 8000);
217
+ });
218
+
219
+ describe('WebSocket /clients/connect: subscribe / unsubscribe', () => {
220
+ async function connectAndHandshake(): Promise<{ ws: WebSocket; connectionId: string }> {
221
+ const token = await getAccessToken();
222
+ const ws = connectClient(token);
223
+ await waitForOpen(ws);
224
+
225
+ const msgsP = collectMessages(ws, 1);
226
+ ws.send(JSON.stringify({ type: 'client:hello', clientVersion: '0.1.0' }));
227
+ const [connected] = await msgsP as [{ type: string; connectionId: string }];
228
+ return { ws, connectionId: connected.connectionId };
229
+ }
230
+
231
+ it('receives client:error(EXECUTION_NOT_FOUND) when subscribing to unknown execution', async () => {
232
+ const { ws } = await connectAndHandshake();
233
+
234
+ const msgsP = collectMessages(ws, 1);
235
+ ws.send(JSON.stringify({ type: 'client:subscribe', executionId: '00000000-0000-0000-0000-000000000001' }));
236
+
237
+ const [errorMsg] = await msgsP as [{ type: string; code: string }];
238
+ expect(errorMsg.type).toBe('client:error');
239
+ expect(errorMsg.code).toBe('EXECUTION_NOT_FOUND');
240
+
241
+ ws.close(1000);
242
+ }, 8000);
243
+
244
+ it('receives client:error(FORBIDDEN) when subscribing to execution in different namespace', async () => {
245
+ // Register an execution in a different namespace
246
+ const execId = '00000000-0000-0000-0000-000000000002';
247
+ executionRegistry.register({
248
+ executionId: execId,
249
+ requestId: 'req-x',
250
+ namespaceId: 'ns-other-forbidden', // different namespace
251
+ hostId: 'host-x',
252
+ pluginId: 'p',
253
+ handlerRef: 'h',
254
+ });
255
+
256
+ const { ws } = await connectAndHandshake();
257
+
258
+ const msgsP = collectMessages(ws, 1);
259
+ ws.send(JSON.stringify({ type: 'client:subscribe', executionId: execId }));
260
+
261
+ const [errorMsg] = await msgsP as [{ type: string; code: string }];
262
+ expect(errorMsg.type).toBe('client:error');
263
+ expect(errorMsg.code).toBe('FORBIDDEN');
264
+
265
+ ws.close(1000);
266
+ executionRegistry.remove(execId);
267
+ }, 8000);
268
+
269
+ it('does not error on unsubscribe from unknown execution (graceful)', async () => {
270
+ const { ws } = await connectAndHandshake();
271
+
272
+ // Send unsubscribe without subscribing first — should not crash
273
+ ws.send(JSON.stringify({ type: 'client:unsubscribe', executionId: '00000000-0000-0000-0000-000000000003' }));
274
+
275
+ // If we get here without the connection closing, it's fine
276
+ await new Promise((r) => { setTimeout(r, 200); });
277
+ expect(ws.readyState).toBe(ws.OPEN);
278
+
279
+ ws.close(1000);
280
+ }, 5000);
281
+
282
+ it('receives client:error(INVALID_MESSAGE) for malformed JSON', async () => {
283
+ const { ws } = await connectAndHandshake();
284
+
285
+ const msgsP = collectMessages(ws, 1);
286
+ ws.send('not json at all {{{');
287
+
288
+ const [errorMsg] = await msgsP as [{ type: string; code: string }];
289
+ expect(errorMsg.type).toBe('client:error');
290
+ expect(errorMsg.code).toBe('INVALID_MESSAGE');
291
+
292
+ ws.close(1000);
293
+ }, 5000);
294
+
295
+ it('receives client:error(INVALID_MESSAGE) for unknown message type', async () => {
296
+ const { ws } = await connectAndHandshake();
297
+
298
+ const msgsP = collectMessages(ws, 1);
299
+ ws.send(JSON.stringify({ type: 'unknown:type' }));
300
+
301
+ const [errorMsg] = await msgsP as [{ type: string; code: string }];
302
+ expect(errorMsg.type).toBe('client:error');
303
+ expect(errorMsg.code).toBe('INVALID_MESSAGE');
304
+
305
+ ws.close(1000);
306
+ }, 5000);
307
+ });
308
+
309
+ describe('WebSocket /clients/connect: cancel', () => {
310
+ async function connectAndHandshake(): Promise<WebSocket> {
311
+ const token = await getAccessToken();
312
+ const ws = connectClient(token);
313
+ await waitForOpen(ws);
314
+ const msgsP = collectMessages(ws, 1);
315
+ ws.send(JSON.stringify({ type: 'client:hello', clientVersion: '0.1.0' }));
316
+ await msgsP;
317
+ return ws;
318
+ }
319
+
320
+ it('receives client:error(EXECUTION_NOT_FOUND) when cancelling unknown execution', async () => {
321
+ const ws = await connectAndHandshake();
322
+
323
+ const msgsP = collectMessages(ws, 1);
324
+ ws.send(JSON.stringify({ type: 'client:cancel', executionId: '00000000-0000-0000-0000-000000000004' }));
325
+
326
+ const [errorMsg] = await msgsP as [{ type: string; code: string }];
327
+ expect(errorMsg.type).toBe('client:error');
328
+ expect(errorMsg.code).toBe('EXECUTION_NOT_FOUND');
329
+
330
+ ws.close(1000);
331
+ }, 8000);
332
+
333
+ it('cancels execution and aborts the signal', async () => {
334
+ const execId = '00000000-0000-0000-0000-000000000005';
335
+ const signal = executionRegistry.register({
336
+ executionId: execId,
337
+ requestId: 'req-cancel',
338
+ namespaceId: 'ns-client-e2e', // same namespace as token
339
+ hostId: 'host-001',
340
+ pluginId: 'p',
341
+ handlerRef: 'h',
342
+ });
343
+
344
+ const ws = await connectAndHandshake();
345
+ ws.send(JSON.stringify({ type: 'client:cancel', executionId: execId, reason: 'user' }));
346
+
347
+ // Give the server time to process
348
+ await new Promise((r) => { setTimeout(r, 100); });
349
+
350
+ expect(signal.aborted).toBe(true);
351
+
352
+ ws.close(1000);
353
+ executionRegistry.remove(execId);
354
+ }, 8000);
355
+
356
+ it('receives client:error(CANCEL_FAILED) when execution already cancelled', async () => {
357
+ const execId = '00000000-0000-0000-0000-000000000006';
358
+ executionRegistry.register({
359
+ executionId: execId,
360
+ requestId: 'req-already',
361
+ namespaceId: 'ns-client-e2e',
362
+ hostId: 'host-001',
363
+ pluginId: 'p',
364
+ handlerRef: 'h',
365
+ });
366
+ // Cancel it first
367
+ executionRegistry.cancel(execId, 'user');
368
+
369
+ const ws = await connectAndHandshake();
370
+
371
+ const msgsP = collectMessages(ws, 1);
372
+ ws.send(JSON.stringify({ type: 'client:cancel', executionId: execId, reason: 'user' }));
373
+
374
+ const [errorMsg] = await msgsP as [{ type: string; code: string }];
375
+ expect(errorMsg.type).toBe('client:error');
376
+ expect(errorMsg.code).toBe('CANCEL_FAILED');
377
+
378
+ ws.close(1000);
379
+ executionRegistry.remove(execId);
380
+ }, 8000);
381
+ });
@@ -0,0 +1,288 @@
1
+ /**
2
+ * E2E WebSocket tests — spins up a real Fastify server on a random port.
3
+ * Tests the full handshake path: auth → hello → connected → heartbeat → ack → close.
4
+ */
5
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6
+ import Fastify, { type FastifyInstance } from 'fastify';
7
+ import fastifyWebsocket from '@fastify/websocket';
8
+ import fastifyCors from '@fastify/cors';
9
+ import { WebSocket, type RawData } from 'ws';
10
+ import type { ICache } from '@kb-labs/core-platform';
11
+ import { HostRegistrationSchema } from '@kb-labs/gateway-contracts';
12
+ import type { JwtConfig } from '@kb-labs/gateway-auth';
13
+ import { createAuthMiddleware } from '../auth/middleware.js';
14
+ import { HostRegistry } from '../hosts/registry.js';
15
+ import { createWsHandler } from '../hosts/ws-handler.js';
16
+ import type { ILogger } from '@kb-labs/core-platform';
17
+
18
+ // Minimal jwtConfig for tests — JWT auth is disabled (empty secret forces fallback to cache tokens)
19
+ const testJwtConfig: JwtConfig = { secret: 'test-secret-do-not-use' };
20
+
21
+ // ── In-memory ICache ─────────────────────────────────────────────────────────
22
+
23
+ function makeInMemoryCache(): ICache {
24
+ const store = new Map<string, unknown>();
25
+ return {
26
+ async get<T>(key: string): Promise<T | null> {
27
+ return (store.get(key) as T) ?? null;
28
+ },
29
+ async set(key: string, value: unknown): Promise<void> {
30
+ store.set(key, value);
31
+ },
32
+ async delete(key: string): Promise<void> {
33
+ store.delete(key);
34
+ },
35
+ async clear(): Promise<void> {
36
+ store.clear();
37
+ },
38
+ } as unknown as ICache;
39
+ }
40
+
41
+ // ── Server setup ─────────────────────────────────────────────────────────────
42
+
43
+ let app: FastifyInstance;
44
+ let baseUrl: string;
45
+ let wsUrl: string;
46
+ let cache: ICache;
47
+ const noopLogger: ILogger = {
48
+ info: () => {},
49
+ warn: () => {},
50
+ error: () => {},
51
+ debug: () => {},
52
+ child: () => noopLogger,
53
+ } as unknown as ILogger;
54
+
55
+ beforeAll(async () => {
56
+ cache = makeInMemoryCache();
57
+ app = Fastify({ logger: false });
58
+
59
+ await app.register(fastifyWebsocket);
60
+ await app.register(fastifyCors, { origin: true });
61
+ app.addHook('preHandler', createAuthMiddleware(cache, testJwtConfig));
62
+
63
+ app.get('/health', async () => ({ status: 'ok', version: '1.0' }));
64
+
65
+ const registry = new HostRegistry(cache);
66
+ app.post('/hosts/register', async (request, reply) => {
67
+ const parsed = HostRegistrationSchema.safeParse(request.body);
68
+ if (!parsed.success) {return reply.code(400).send({ error: 'Bad Request' });}
69
+ const result = await registry.register(parsed.data);
70
+ return reply.code(201).send({
71
+ hostId: result.descriptor.hostId,
72
+ machineToken: result.machineToken,
73
+ status: result.descriptor.status,
74
+ });
75
+ });
76
+
77
+ app.get('/hosts/connect', { websocket: true }, createWsHandler(cache, testJwtConfig, noopLogger));
78
+
79
+ const address = await app.listen({ port: 0, host: '127.0.0.1' }); // port 0 = random
80
+ baseUrl = address;
81
+ wsUrl = address.replace('http://', 'ws://');
82
+ }, 10_000);
83
+
84
+ afterAll(async () => {
85
+ await app.close();
86
+ }, 10_000);
87
+
88
+ // ── Helpers ──────────────────────────────────────────────────────────────────
89
+
90
+ async function registerHost(name = 'test-host'): Promise<{ hostId: string; machineToken: string }> {
91
+ const res = await fetch(`${baseUrl}/hosts/register`, {
92
+ method: 'POST',
93
+ headers: { 'content-type': 'application/json' },
94
+ body: JSON.stringify({ name, namespaceId: 'ns-e2e', capabilities: ['filesystem'], workspacePaths: [] }),
95
+ });
96
+ return res.json() as Promise<{ hostId: string; machineToken: string }>;
97
+ }
98
+
99
+ function connectWs(token: string): WebSocket {
100
+ return new WebSocket(`${wsUrl}/hosts/connect`, {
101
+ headers: { Authorization: `Bearer ${token}` },
102
+ });
103
+ }
104
+
105
+ function collectMessages(ws: WebSocket, count: number, timeout = 3000): Promise<unknown[]> {
106
+ return new Promise((resolve, reject) => {
107
+ const msgs: unknown[] = [];
108
+ const timer = setTimeout(() => {
109
+ reject(new Error(`Timeout: expected ${count} messages, got ${msgs.length}: ${JSON.stringify(msgs)}`));
110
+ }, timeout);
111
+
112
+ ws.on('message', (raw: RawData) => {
113
+ msgs.push(JSON.parse(raw.toString()));
114
+ if (msgs.length >= count) {
115
+ clearTimeout(timer);
116
+ resolve(msgs);
117
+ }
118
+ });
119
+
120
+ ws.on('error', (err: Error) => { clearTimeout(timer); reject(err); });
121
+ });
122
+ }
123
+
124
+ // ── Tests ────────────────────────────────────────────────────────────────────
125
+
126
+ describe('WebSocket: connection refused without auth', () => {
127
+ it('closes immediately when no Authorization header', async () => {
128
+ const ws = new WebSocket(`${wsUrl}/hosts/connect`); // no auth header
129
+ const closeCode = await new Promise<number>((resolve) => {
130
+ ws.on('close', (code: number) => resolve(code));
131
+ ws.on('error', () => {}); // suppress
132
+ });
133
+ // 1008 = Policy Violation (explicit close from server)
134
+ // 1006 = Abnormal closure (server drops connection at transport level)
135
+ // Both indicate the connection was rejected — either is correct depending on Fastify/ws version
136
+ expect([1008, 1006]).toContain(closeCode);
137
+ }, 5000);
138
+
139
+ it('closes with 1008 for invalid machine token', async () => {
140
+ const ws = connectWs('not-a-valid-machine-token');
141
+ // ws token resolves as CLI (not machine) — should be rejected in ws-handler
142
+ // The handler checks tokenEntry.type !== 'machine'
143
+ const closeCode = await new Promise<number>((resolve) => {
144
+ ws.on('close', (code: number) => resolve(code));
145
+ ws.on('open', () => {
146
+ // If opened, send hello — handler will auth-check and close
147
+ });
148
+ ws.on('error', () => resolve(1008));
149
+ setTimeout(() => resolve(1008), 3000);
150
+ });
151
+ // Either 1008 or connection never established
152
+ expect([1008, 1000, 1005]).toContain(closeCode);
153
+ }, 5000);
154
+ });
155
+
156
+ describe('WebSocket: full handshake', () => {
157
+ it('completes hello → connected flow', async () => {
158
+ const { machineToken, hostId } = await registerHost('e2e-host-1');
159
+ const ws = connectWs(machineToken);
160
+
161
+ const messages = collectMessages(ws, 1);
162
+
163
+ await new Promise<void>((resolve, reject) => {
164
+ ws.on('open', () => {
165
+ ws.send(JSON.stringify({ type: 'hello', protocolVersion: '1.0', agentVersion: '0.1.0' }));
166
+ resolve();
167
+ });
168
+ ws.on('error', reject);
169
+ });
170
+
171
+ const [connected] = await messages as [{ type: string; hostId: string; sessionId: string; protocolVersion: string }];
172
+ expect(connected.type).toBe('connected');
173
+ expect(connected.hostId).toBe(hostId);
174
+ expect(connected.protocolVersion).toBe('1.0');
175
+ expect(connected.sessionId).toBeTypeOf('string');
176
+
177
+ ws.close(1000);
178
+ }, 8000);
179
+
180
+ it('heartbeat gets ack response', async () => {
181
+ const { machineToken } = await registerHost('e2e-host-2');
182
+ const ws = connectWs(machineToken);
183
+
184
+ await new Promise<void>((resolve, reject) => {
185
+ ws.on('open', () => {
186
+ ws.send(JSON.stringify({ type: 'hello', protocolVersion: '1.0', agentVersion: '0.1.0' }));
187
+ resolve();
188
+ });
189
+ ws.on('error', reject);
190
+ });
191
+
192
+ // Wait for 'connected', then send heartbeat
193
+ const ack = await new Promise<unknown>((resolve, reject) => {
194
+ let gotConnected = false;
195
+ const timer = setTimeout(() => reject(new Error('timeout')), 5000);
196
+
197
+ ws.on('message', (raw: RawData) => {
198
+ const msg = JSON.parse(raw.toString()) as { type: string };
199
+ if (msg.type === 'connected' && !gotConnected) {
200
+ gotConnected = true;
201
+ ws.send(JSON.stringify({ type: 'heartbeat' }));
202
+ } else if (msg.type === 'ack') {
203
+ clearTimeout(timer);
204
+ resolve(msg);
205
+ }
206
+ });
207
+ ws.on('error', (e) => { clearTimeout(timer); reject(e); });
208
+ });
209
+
210
+ expect((ack as { type: string }).type).toBe('ack');
211
+ ws.close(1000);
212
+ }, 8000);
213
+
214
+ it('rejects unsupported protocol version', async () => {
215
+ const { machineToken } = await registerHost('e2e-host-3');
216
+ const ws = connectWs(machineToken);
217
+
218
+ const msgs: unknown[] = [];
219
+ await new Promise<void>((resolve, reject) => {
220
+ ws.on('open', () => {
221
+ ws.send(JSON.stringify({ type: 'hello', protocolVersion: '99.0', agentVersion: '0.1.0' }));
222
+ resolve();
223
+ });
224
+ ws.on('error', reject);
225
+ });
226
+
227
+ // Should receive 'negotiate' message and then close
228
+ const closeCode = await new Promise<number>((resolve) => {
229
+ ws.on('message', (raw: RawData) => { msgs.push(JSON.parse(raw.toString())); });
230
+ ws.on('close', (code: number) => resolve(code));
231
+ setTimeout(() => resolve(1008), 4000);
232
+ });
233
+
234
+ expect(closeCode).toBe(1008);
235
+ expect((msgs[0] as { type: string; supportedVersions: string[] }).type).toBe('negotiate');
236
+ expect((msgs[0] as { type: string; supportedVersions: string[] }).supportedVersions).toContain('1.0');
237
+ }, 8000);
238
+
239
+ it('closes with 1008 if hello not sent within timeout', async () => {
240
+ const { machineToken } = await registerHost('e2e-host-4');
241
+ const ws = connectWs(machineToken);
242
+
243
+ // Open but don't send hello
244
+ await new Promise<void>((resolve, reject) => {
245
+ ws.on('open', () => resolve());
246
+ ws.on('error', reject);
247
+ });
248
+
249
+ const closeCode = await new Promise<number>((resolve) => {
250
+ ws.on('close', (code: number) => resolve(code));
251
+ setTimeout(() => resolve(-1), 8000); // wait longer than HELLO_TIMEOUT_MS (5s)
252
+ });
253
+
254
+ expect(closeCode).toBe(1008);
255
+ }, 10_000);
256
+ });
257
+
258
+ describe('WebSocket: host status lifecycle', () => {
259
+ it('host goes online after successful handshake', async () => {
260
+ const { machineToken, hostId } = await registerHost('e2e-status-1');
261
+
262
+ const ws = connectWs(machineToken);
263
+ await new Promise<void>((resolve, reject) => {
264
+ ws.on('open', () => {
265
+ ws.send(JSON.stringify({ type: 'hello', protocolVersion: '1.0', agentVersion: '0.1.0' }));
266
+ });
267
+ ws.on('message', (raw: RawData) => {
268
+ const msg = JSON.parse(raw.toString()) as { type: string };
269
+ if (msg.type === 'connected') {resolve();}
270
+ });
271
+ ws.on('error', reject);
272
+ });
273
+
274
+ // Verify host is online in cache
275
+ const host = await cache.get<{ status: string; connections: string[] }>(`host:registry:ns-e2e:${hostId}`);
276
+ expect(host!.status).toBe('online');
277
+ expect(host!.connections.length).toBeGreaterThan(0);
278
+
279
+ ws.close(1000);
280
+
281
+ // Give server time to process close — status transitions to 'reconnecting' first,
282
+ // then to 'offline' after the reconnect grace period
283
+ await new Promise((r) => { setTimeout(r, 100); });
284
+
285
+ const hostAfter = await cache.get<{ status: string }>(`host:registry:ns-e2e:${hostId}`);
286
+ expect(['reconnecting', 'offline']).toContain(hostAfter!.status);
287
+ }, 8000);
288
+ });