@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,244 @@
1
+ /**
2
+ * Unit tests for executeWithRetry (CC3 — Retry logic).
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import { executeWithRetry } from '../execute/retry-executor.js';
6
+ import { CancelledError } from '../execute/errors.js';
7
+ import type { ExecutionEventMessage } from '@kb-labs/gateway-contracts';
8
+ import type { ExecutionRetryConfig } from '@kb-labs/core-contracts';
9
+
10
+ function makeCtx(overrides: {
11
+ signal?: AbortSignal;
12
+ maxAttempts?: number;
13
+ initialDelayMs?: number;
14
+ backoffMultiplier?: number;
15
+ maxDelayMs?: number;
16
+ onlyRetryable?: boolean;
17
+ } = {}) {
18
+ const controller = new AbortController();
19
+ const write = vi.fn<(event: ExecutionEventMessage) => void>();
20
+
21
+ // Build config with only the fields that were explicitly provided.
22
+ // Undefined keys must not be present — they would override DEFAULTS via spread.
23
+ const config: ExecutionRetryConfig = {};
24
+ if (overrides.maxAttempts !== undefined) {config['maxAttempts'] = overrides.maxAttempts;}
25
+ if (overrides.initialDelayMs !== undefined) {config['initialDelayMs'] = overrides.initialDelayMs;}
26
+ if (overrides.backoffMultiplier !== undefined) {config['backoffMultiplier'] = overrides.backoffMultiplier;}
27
+ if (overrides.maxDelayMs !== undefined) {config['maxDelayMs'] = overrides.maxDelayMs;}
28
+ if (overrides.onlyRetryable !== undefined) {config['onlyRetryable'] = overrides.onlyRetryable;}
29
+
30
+ return {
31
+ ctx: {
32
+ executionId: 'exec-test',
33
+ requestId: 'req-test',
34
+ signal: overrides.signal ?? controller.signal,
35
+ config: Object.keys(config).length > 0 ? config : undefined,
36
+ write,
37
+ },
38
+ controller,
39
+ write,
40
+ };
41
+ }
42
+
43
+ // ── basic success ─────────────────────────────────────────────────────────────
44
+
45
+ describe('executeWithRetry — success paths', () => {
46
+ it('returns result on first attempt', async () => {
47
+ const { ctx } = makeCtx();
48
+ const dispatch = vi.fn().mockResolvedValue('done');
49
+ const result = await executeWithRetry(ctx, dispatch);
50
+ expect(result).toBe('done');
51
+ expect(dispatch).toHaveBeenCalledTimes(1);
52
+ });
53
+
54
+ it('succeeds after one failure when maxAttempts=2', async () => {
55
+ const { ctx } = makeCtx({ maxAttempts: 2, initialDelayMs: 5 });
56
+ let calls = 0;
57
+ const dispatch = vi.fn(async () => {
58
+ calls++;
59
+ if (calls === 1) {throw new Error('503 Service Unavailable');}
60
+ return 'recovered';
61
+ });
62
+ const result = await executeWithRetry(ctx, dispatch);
63
+ expect(result).toBe('recovered');
64
+ expect(dispatch).toHaveBeenCalledTimes(2);
65
+ });
66
+
67
+ it('returns value from later attempt', async () => {
68
+ const { ctx } = makeCtx({ maxAttempts: 3, initialDelayMs: 5, backoffMultiplier: 1 });
69
+ let calls = 0;
70
+ const dispatch = vi.fn(async () => {
71
+ calls++;
72
+ if (calls < 3) {throw new Error('ECONNREFUSED');}
73
+ return 42;
74
+ });
75
+ const result = await executeWithRetry(ctx, dispatch);
76
+ expect(result).toBe(42);
77
+ expect(dispatch).toHaveBeenCalledTimes(3);
78
+ });
79
+ });
80
+
81
+ // ── retry limit ───────────────────────────────────────────────────────────────
82
+
83
+ describe('executeWithRetry — exhausted retries', () => {
84
+ it('throws last error after maxAttempts exhausted', async () => {
85
+ const { ctx } = makeCtx({ maxAttempts: 3, initialDelayMs: 5 });
86
+ const dispatch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
87
+ await expect(executeWithRetry(ctx, dispatch)).rejects.toThrow('ECONNREFUSED');
88
+ expect(dispatch).toHaveBeenCalledTimes(3);
89
+ });
90
+
91
+ it('emits execution:retry event between attempts', async () => {
92
+ const { ctx, write } = makeCtx({ maxAttempts: 2, initialDelayMs: 5 });
93
+ const dispatch = vi.fn()
94
+ .mockRejectedValueOnce(new Error('ECONNREFUSED'))
95
+ .mockResolvedValue('ok');
96
+ await executeWithRetry(ctx, dispatch);
97
+ const retryEvents = write.mock.calls.map(([e]) => e).filter((e) => e.type === 'execution:retry');
98
+ expect(retryEvents).toHaveLength(1);
99
+ expect(retryEvents[0]).toMatchObject({
100
+ type: 'execution:retry',
101
+ executionId: 'exec-test',
102
+ attempt: 1,
103
+ maxAttempts: 2,
104
+ });
105
+ });
106
+
107
+ it('does not retry non-retryable errors by default (onlyRetryable=true)', async () => {
108
+ const { ctx } = makeCtx({ maxAttempts: 5, initialDelayMs: 5 });
109
+ // "some handler error" is not in the retryable list
110
+ const dispatch = vi.fn().mockRejectedValue(new Error('Handler threw an exception'));
111
+ await expect(executeWithRetry(ctx, dispatch)).rejects.toThrow('Handler threw an exception');
112
+ expect(dispatch).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it('retries non-retryable when onlyRetryable=false', async () => {
116
+ const { ctx } = makeCtx({ maxAttempts: 3, initialDelayMs: 5, onlyRetryable: false });
117
+ let calls = 0;
118
+ const dispatch = vi.fn(async () => {
119
+ calls++;
120
+ if (calls < 3) {throw new Error('Handler threw an exception');}
121
+ return 'ok';
122
+ });
123
+ const result = await executeWithRetry(ctx, dispatch);
124
+ expect(result).toBe('ok');
125
+ expect(dispatch).toHaveBeenCalledTimes(3);
126
+ });
127
+ });
128
+
129
+ // ── cancellation ──────────────────────────────────────────────────────────────
130
+
131
+ describe('executeWithRetry — cancellation', () => {
132
+ it('throws CancelledError immediately if signal is already aborted', async () => {
133
+ const controller = new AbortController();
134
+ controller.abort('user');
135
+ const { ctx } = makeCtx({ signal: controller.signal });
136
+ const dispatch = vi.fn().mockResolvedValue('never');
137
+ await expect(executeWithRetry(ctx, dispatch)).rejects.toBeInstanceOf(CancelledError);
138
+ expect(dispatch).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('throws CancelledError when signal aborts during dispatch', async () => {
142
+ const controller = new AbortController();
143
+ const { ctx } = makeCtx({ signal: controller.signal, maxAttempts: 1 });
144
+ const dispatch = vi.fn(
145
+ () => new Promise<string>((_, reject) => {
146
+ // Simulate a long-running operation
147
+ setTimeout(() => reject(new Error('should not happen')), 5000);
148
+ }),
149
+ );
150
+ // Abort almost immediately
151
+ setTimeout(() => controller.abort('user'), 20);
152
+ await expect(executeWithRetry(ctx, dispatch)).rejects.toBeInstanceOf(CancelledError);
153
+ }, 3000);
154
+
155
+ it('aborts during retry backoff delay', async () => {
156
+ const controller = new AbortController();
157
+ const { ctx } = makeCtx({
158
+ signal: controller.signal,
159
+ maxAttempts: 5,
160
+ initialDelayMs: 2000, // long delay
161
+ });
162
+ const dispatch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
163
+ // Abort shortly after first failure triggers backoff
164
+ setTimeout(() => controller.abort('disconnect'), 30);
165
+ await expect(executeWithRetry(ctx, dispatch)).rejects.toBeInstanceOf(CancelledError);
166
+ // Should have called dispatch once and then been interrupted during delay
167
+ expect(dispatch).toHaveBeenCalledTimes(1);
168
+ }, 3000);
169
+
170
+ it('does not retry after CancelledError', async () => {
171
+ const { ctx } = makeCtx({ maxAttempts: 5, initialDelayMs: 5 });
172
+ const controller = new AbortController();
173
+ (ctx as { signal: AbortSignal }).signal = controller.signal;
174
+ const dispatch = vi.fn().mockRejectedValue(new CancelledError('user'));
175
+ await expect(executeWithRetry(ctx, dispatch)).rejects.toBeInstanceOf(CancelledError);
176
+ expect(dispatch).toHaveBeenCalledTimes(1);
177
+ });
178
+ });
179
+
180
+ // ── error classification ──────────────────────────────────────────────────────
181
+
182
+ describe('executeWithRetry — retryable error classification', () => {
183
+ const retryableMessages = [
184
+ 'ECONNREFUSED',
185
+ 'ECONNRESET',
186
+ 'ETIMEDOUT',
187
+ 'request timed out',
188
+ '503 Service Unavailable',
189
+ 'Host not connected',
190
+ ];
191
+
192
+ const nonRetryableMessages = [
193
+ 'TypeError: Cannot read property',
194
+ 'SyntaxError: unexpected token',
195
+ 'Handler threw',
196
+ ];
197
+
198
+ for (const msg of retryableMessages) {
199
+ it(`retries on: "${msg}"`, async () => {
200
+ const { ctx } = makeCtx({ maxAttempts: 2, initialDelayMs: 5 });
201
+ let calls = 0;
202
+ const dispatch = vi.fn(async () => {
203
+ calls++;
204
+ if (calls === 1) {throw new Error(msg);}
205
+ return 'ok';
206
+ });
207
+ const result = await executeWithRetry(ctx, dispatch);
208
+ expect(result).toBe('ok');
209
+ expect(dispatch).toHaveBeenCalledTimes(2);
210
+ });
211
+ }
212
+
213
+ for (const msg of nonRetryableMessages) {
214
+ it(`does not retry on: "${msg}"`, async () => {
215
+ const { ctx } = makeCtx({ maxAttempts: 5, initialDelayMs: 5 });
216
+ const dispatch = vi.fn().mockRejectedValue(new Error(msg));
217
+ await expect(executeWithRetry(ctx, dispatch)).rejects.toThrow(msg);
218
+ expect(dispatch).toHaveBeenCalledTimes(1);
219
+ });
220
+ }
221
+ });
222
+
223
+ // ── maxAttempts edge cases ────────────────────────────────────────────────────
224
+
225
+ describe('executeWithRetry — maxAttempts edge cases', () => {
226
+ it('treats maxAttempts=0 as 1 attempt', async () => {
227
+ const { ctx } = makeCtx({ maxAttempts: 0 });
228
+ const dispatch = vi.fn().mockResolvedValue('ok');
229
+ await executeWithRetry(ctx, dispatch);
230
+ expect(dispatch).toHaveBeenCalledTimes(1);
231
+ });
232
+
233
+ it('default config (undefined) is single attempt', async () => {
234
+ const controller = new AbortController();
235
+ const write = vi.fn<(event: ExecutionEventMessage) => void>();
236
+ const dispatch = vi.fn().mockResolvedValue('result');
237
+ const result = await executeWithRetry(
238
+ { executionId: 'e', requestId: 'r', signal: controller.signal, config: undefined, write },
239
+ dispatch,
240
+ );
241
+ expect(result).toBe('result');
242
+ expect(dispatch).toHaveBeenCalledTimes(1);
243
+ });
244
+ });
@@ -0,0 +1,417 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import Fastify, { type FastifyInstance } from 'fastify';
3
+ import fastifyWebsocket from '@fastify/websocket';
4
+ import fastifyCors from '@fastify/cors';
5
+ import type { ICache, ILogger } from '@kb-labs/core-platform';
6
+ import { HostRegistrationSchema } from '@kb-labs/gateway-contracts';
7
+ import { createAuthMiddleware } from '../auth/middleware.js';
8
+ import type { JwtConfig } from '@kb-labs/gateway-auth';
9
+ import { HostRegistry } from '../hosts/registry.js';
10
+ import { createWsHandler } from '../hosts/ws-handler.js';
11
+ import type { HostCallDispatcher } from '@kb-labs/gateway-core';
12
+
13
+ // ── Minimal mocks ────────────────────────────────────────────────────────────
14
+
15
+ function makeCache(): { cache: ICache; store: Map<string, unknown> } {
16
+ const store = new Map<string, unknown>();
17
+ const cache: ICache = {
18
+ get: vi.fn(async (key: string) => store.get(key) ?? null),
19
+ set: vi.fn(async (key: string, value: unknown) => { store.set(key, value); }),
20
+ delete: vi.fn(async (key: string) => { store.delete(key); }),
21
+ clear: vi.fn(async () => { store.clear(); }),
22
+ } as unknown as ICache;
23
+ return { cache, store };
24
+ }
25
+
26
+ const noopLogger: ILogger = {
27
+ info: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ debug: vi.fn(),
31
+ child: vi.fn(() => noopLogger),
32
+ } as unknown as ILogger;
33
+
34
+ // Build a minimal Fastify app matching server.ts structure (without proxy)
35
+ const stubJwtConfig: JwtConfig = { secret: 'test-secret' };
36
+
37
+ async function buildApp(cache: ICache): Promise<FastifyInstance> {
38
+ const app = Fastify({ logger: false });
39
+
40
+ await app.register(fastifyWebsocket);
41
+ await app.register(fastifyCors, { origin: true });
42
+ app.addHook('preHandler', createAuthMiddleware(cache, stubJwtConfig));
43
+
44
+ app.get('/health', async () => ({ status: 'ok', version: '1.0' }));
45
+
46
+ const registry = new HostRegistry(cache);
47
+
48
+ app.post('/hosts/register', async (request, reply) => {
49
+ const parsed = HostRegistrationSchema.safeParse(request.body);
50
+ if (!parsed.success) {
51
+ return reply.code(400).send({ error: 'Bad Request', issues: parsed.error.issues });
52
+ }
53
+ const result = await registry.register(parsed.data);
54
+ return reply.code(201).send({
55
+ hostId: result.descriptor.hostId,
56
+ machineToken: result.machineToken,
57
+ status: result.descriptor.status,
58
+ });
59
+ });
60
+
61
+ app.get('/hosts', async (request, reply) => {
62
+ const auth = request.authContext;
63
+ if (!auth) {return reply.code(401).send({ error: 'Unauthorized' });}
64
+ return { hosts: [] };
65
+ });
66
+
67
+ app.get('/hosts/connect', { websocket: true }, createWsHandler(cache, stubJwtConfig, noopLogger));
68
+
69
+ await app.ready();
70
+ return app;
71
+ }
72
+
73
+ // ── Tests ────────────────────────────────────────────────────────────────────
74
+
75
+ describe('GET /health (public)', () => {
76
+ let app: FastifyInstance;
77
+
78
+ beforeEach(async () => {
79
+ const { cache } = makeCache();
80
+ app = await buildApp(cache);
81
+ });
82
+
83
+ afterEach(async () => { await app.close(); });
84
+
85
+ it('returns 200 without auth', async () => {
86
+ const res = await app.inject({ method: 'GET', url: '/health' });
87
+ expect(res.statusCode).toBe(200);
88
+ expect(res.json()).toEqual({ status: 'ok', version: '1.0' });
89
+ });
90
+ });
91
+
92
+ describe('POST /hosts/register (public)', () => {
93
+ let app: FastifyInstance;
94
+
95
+ beforeEach(async () => {
96
+ const { cache } = makeCache();
97
+ app = await buildApp(cache);
98
+ });
99
+
100
+ afterEach(async () => { await app.close(); });
101
+
102
+ it('registers a host and returns 201 with hostId + machineToken', async () => {
103
+ const res = await app.inject({
104
+ method: 'POST',
105
+ url: '/hosts/register',
106
+ headers: { 'content-type': 'application/json' },
107
+ body: JSON.stringify({
108
+ name: 'laptop',
109
+ namespaceId: 'ns-1',
110
+ capabilities: ['filesystem', 'git'],
111
+ workspacePaths: ['/home/user/projects'],
112
+ }),
113
+ });
114
+
115
+ expect(res.statusCode).toBe(201);
116
+ const body = res.json();
117
+ expect(body.hostId).toBeTypeOf('string');
118
+ expect(body.machineToken).toBeTypeOf('string');
119
+ expect(body.status).toBe('offline');
120
+ });
121
+
122
+ it('returns 400 for missing required fields', async () => {
123
+ const res = await app.inject({
124
+ method: 'POST',
125
+ url: '/hosts/register',
126
+ headers: { 'content-type': 'application/json' },
127
+ body: JSON.stringify({ name: 'laptop' }), // missing namespaceId, capabilities, workspacePaths
128
+ });
129
+
130
+ expect(res.statusCode).toBe(400);
131
+ const body = res.json();
132
+ expect(body.error).toBe('Bad Request');
133
+ expect(body.issues).toBeDefined();
134
+ });
135
+
136
+ it('returns 400 for invalid capability enum value', async () => {
137
+ const res = await app.inject({
138
+ method: 'POST',
139
+ url: '/hosts/register',
140
+ headers: { 'content-type': 'application/json' },
141
+ body: JSON.stringify({
142
+ name: 'h',
143
+ namespaceId: 'ns',
144
+ capabilities: ['invalid-capability'],
145
+ workspacePaths: [],
146
+ }),
147
+ });
148
+
149
+ expect(res.statusCode).toBe(400);
150
+ });
151
+
152
+ it('does not require auth', async () => {
153
+ // No Authorization header
154
+ const res = await app.inject({
155
+ method: 'POST',
156
+ url: '/hosts/register',
157
+ headers: { 'content-type': 'application/json' },
158
+ body: JSON.stringify({ name: 'h', namespaceId: 'ns', capabilities: [], workspacePaths: [] }),
159
+ });
160
+ expect(res.statusCode).toBe(201);
161
+ });
162
+ });
163
+
164
+ describe('Auth middleware', () => {
165
+ let app: FastifyInstance;
166
+
167
+ beforeEach(async () => {
168
+ const { cache } = makeCache();
169
+ app = await buildApp(cache);
170
+ });
171
+
172
+ afterEach(async () => { await app.close(); });
173
+
174
+ it('returns 401 for protected routes without Authorization', async () => {
175
+ const res = await app.inject({ method: 'GET', url: '/hosts' });
176
+ expect(res.statusCode).toBe(401);
177
+ expect(res.json().error).toBe('Unauthorized');
178
+ });
179
+
180
+ it('returns 401 for unknown Bearer token (no CLI fallback)', async () => {
181
+ const res = await app.inject({
182
+ method: 'GET',
183
+ url: '/hosts',
184
+ headers: { authorization: 'Bearer some-random-token' },
185
+ });
186
+ expect(res.statusCode).toBe(401);
187
+ });
188
+
189
+ it('passes protected routes with valid machine token', async () => {
190
+ const { cache } = makeCache();
191
+ const localApp = await buildApp(cache);
192
+
193
+ const regRes = await localApp.inject({
194
+ method: 'POST',
195
+ url: '/hosts/register',
196
+ headers: { 'content-type': 'application/json' },
197
+ body: JSON.stringify({ name: 'h', namespaceId: 'ns', capabilities: [], workspacePaths: [] }),
198
+ });
199
+ const { machineToken } = regRes.json();
200
+
201
+ const res = await localApp.inject({
202
+ method: 'GET',
203
+ url: '/hosts',
204
+ headers: { authorization: `Bearer ${machineToken}` },
205
+ });
206
+ expect(res.statusCode).toBe(200);
207
+ await localApp.close();
208
+ });
209
+
210
+ it('/health is public — no 401', async () => {
211
+ const res = await app.inject({ method: 'GET', url: '/health' });
212
+ expect(res.statusCode).not.toBe(401);
213
+ });
214
+
215
+ it('/hosts/register is public — no 401', async () => {
216
+ const res = await app.inject({
217
+ method: 'POST',
218
+ url: '/hosts/register',
219
+ headers: { 'content-type': 'application/json' },
220
+ body: JSON.stringify({ name: 'h', namespaceId: 'ns', capabilities: [], workspacePaths: [] }),
221
+ });
222
+ expect(res.statusCode).not.toBe(401);
223
+ });
224
+
225
+ it('machine token resolves correctly', async () => {
226
+ const { cache } = makeCache();
227
+ const localApp = await buildApp(cache);
228
+
229
+ // Register host to get machine token
230
+ const regRes = await localApp.inject({
231
+ method: 'POST',
232
+ url: '/hosts/register',
233
+ headers: { 'content-type': 'application/json' },
234
+ body: JSON.stringify({ name: 'h', namespaceId: 'ns', capabilities: [], workspacePaths: [] }),
235
+ });
236
+ const { machineToken } = regRes.json();
237
+
238
+ // Use machine token on protected route
239
+ const res = await localApp.inject({
240
+ method: 'GET',
241
+ url: '/hosts',
242
+ headers: { authorization: `Bearer ${machineToken}` },
243
+ });
244
+ expect(res.statusCode).toBe(200);
245
+ await localApp.close();
246
+ });
247
+ });
248
+
249
+ // ── POST /internal/dispatch ───────────────────────────────────────────────────
250
+
251
+ /** Build a minimal app that includes the /internal/dispatch route with an injected dispatcher */
252
+ async function buildDispatchApp(
253
+ dispatcher: Pick<HostCallDispatcher, 'firstHost' | 'call'>,
254
+ internalSecret: string | undefined,
255
+ ): Promise<FastifyInstance> {
256
+ const app = Fastify({ logger: false });
257
+ await app.register(fastifyWebsocket);
258
+ await app.register(fastifyCors, { origin: true });
259
+
260
+ app.post('/internal/dispatch', async (request, reply) => {
261
+ const provided = request.headers['x-internal-secret'];
262
+ if (!internalSecret || provided !== internalSecret) {
263
+ return reply.code(403).send({ error: 'Forbidden' });
264
+ }
265
+
266
+ const body = request.body as {
267
+ namespaceId?: string;
268
+ hostId?: string;
269
+ adapter?: string;
270
+ method?: string;
271
+ args?: unknown[];
272
+ };
273
+
274
+ if (!body.namespaceId || !body.adapter || !body.method) {
275
+ return reply.code(400).send({ error: 'Missing required fields: namespaceId, adapter, method' });
276
+ }
277
+
278
+ const hostId = body.hostId ?? dispatcher.firstHost(body.namespaceId);
279
+ if (!hostId) {
280
+ return reply.code(503).send({ error: 'No host connected', namespaceId: body.namespaceId });
281
+ }
282
+
283
+ try {
284
+ const result = await dispatcher.call(body.namespaceId, hostId, body.adapter, body.method, body.args ?? []);
285
+ return { result };
286
+ } catch (err) {
287
+ const message = err instanceof Error ? err.message : String(err);
288
+ return reply.code(502).send({ error: message });
289
+ }
290
+ });
291
+
292
+ await app.ready();
293
+ return app;
294
+ }
295
+
296
+ const SECRET = 'test-internal-secret';
297
+
298
+ describe('POST /internal/dispatch', () => {
299
+ let app: FastifyInstance;
300
+ let mockDispatcher: { firstHost: ReturnType<typeof vi.fn>; call: ReturnType<typeof vi.fn> };
301
+
302
+ beforeEach(async () => {
303
+ mockDispatcher = {
304
+ firstHost: vi.fn(),
305
+ call: vi.fn(),
306
+ };
307
+ app = await buildDispatchApp(mockDispatcher as unknown as HostCallDispatcher, SECRET);
308
+ });
309
+
310
+ afterEach(async () => { await app.close(); });
311
+
312
+ it('returns 403 with wrong secret', async () => {
313
+ const res = await app.inject({
314
+ method: 'POST',
315
+ url: '/internal/dispatch',
316
+ headers: { 'content-type': 'application/json', 'x-internal-secret': 'wrong' },
317
+ body: JSON.stringify({ namespaceId: 'ns', adapter: 'filesystem', method: 'readFile', args: [] }),
318
+ });
319
+ expect(res.statusCode).toBe(403);
320
+ expect(res.json().error).toBe('Forbidden');
321
+ });
322
+
323
+ it('returns 403 with no secret header', async () => {
324
+ const res = await app.inject({
325
+ method: 'POST',
326
+ url: '/internal/dispatch',
327
+ headers: { 'content-type': 'application/json' },
328
+ body: JSON.stringify({ namespaceId: 'ns', adapter: 'filesystem', method: 'readFile', args: [] }),
329
+ });
330
+ expect(res.statusCode).toBe(403);
331
+ });
332
+
333
+ it('returns 400 when namespaceId is missing', async () => {
334
+ const res = await app.inject({
335
+ method: 'POST',
336
+ url: '/internal/dispatch',
337
+ headers: { 'content-type': 'application/json', 'x-internal-secret': SECRET },
338
+ body: JSON.stringify({ adapter: 'filesystem', method: 'readFile' }),
339
+ });
340
+ expect(res.statusCode).toBe(400);
341
+ expect(res.json().error).toMatch(/Missing required fields/);
342
+ });
343
+
344
+ it('returns 400 when adapter is missing', async () => {
345
+ const res = await app.inject({
346
+ method: 'POST',
347
+ url: '/internal/dispatch',
348
+ headers: { 'content-type': 'application/json', 'x-internal-secret': SECRET },
349
+ body: JSON.stringify({ namespaceId: 'ns', method: 'readFile' }),
350
+ });
351
+ expect(res.statusCode).toBe(400);
352
+ });
353
+
354
+ it('returns 503 when no host is connected in namespace', async () => {
355
+ mockDispatcher.firstHost.mockReturnValue(undefined);
356
+
357
+ const res = await app.inject({
358
+ method: 'POST',
359
+ url: '/internal/dispatch',
360
+ headers: { 'content-type': 'application/json', 'x-internal-secret': SECRET },
361
+ body: JSON.stringify({ namespaceId: 'ns-empty', adapter: 'filesystem', method: 'readFile', args: [] }),
362
+ });
363
+ expect(res.statusCode).toBe(503);
364
+ expect(res.json().error).toBe('No host connected');
365
+ });
366
+
367
+ it('routes call to provided hostId and returns result', async () => {
368
+ mockDispatcher.call.mockResolvedValue(['file-a.ts', 'file-b.ts']);
369
+
370
+ const res = await app.inject({
371
+ method: 'POST',
372
+ url: '/internal/dispatch',
373
+ headers: { 'content-type': 'application/json', 'x-internal-secret': SECRET },
374
+ body: JSON.stringify({
375
+ namespaceId: 'ns-1',
376
+ hostId: 'host-a',
377
+ adapter: 'filesystem',
378
+ method: 'listDir',
379
+ args: ['/workspace'],
380
+ }),
381
+ });
382
+
383
+ expect(res.statusCode).toBe(200);
384
+ expect(res.json()).toEqual({ result: ['file-a.ts', 'file-b.ts'] });
385
+ expect(mockDispatcher.call).toHaveBeenCalledWith('ns-1', 'host-a', 'filesystem', 'listDir', ['/workspace']);
386
+ });
387
+
388
+ it('falls back to firstHost when no hostId provided', async () => {
389
+ mockDispatcher.firstHost.mockReturnValue('host-auto');
390
+ mockDispatcher.call.mockResolvedValue({ ok: true });
391
+
392
+ const res = await app.inject({
393
+ method: 'POST',
394
+ url: '/internal/dispatch',
395
+ headers: { 'content-type': 'application/json', 'x-internal-secret': SECRET },
396
+ body: JSON.stringify({ namespaceId: 'ns-1', adapter: 'filesystem', method: 'exists', args: ['/f'] }),
397
+ });
398
+
399
+ expect(res.statusCode).toBe(200);
400
+ expect(mockDispatcher.call).toHaveBeenCalledWith('ns-1', 'host-auto', 'filesystem', 'exists', ['/f']);
401
+ });
402
+
403
+ it('returns 502 when dispatcher.call throws', async () => {
404
+ mockDispatcher.firstHost.mockReturnValue('host-a');
405
+ mockDispatcher.call.mockRejectedValue(new Error('Connection lost'));
406
+
407
+ const res = await app.inject({
408
+ method: 'POST',
409
+ url: '/internal/dispatch',
410
+ headers: { 'content-type': 'application/json', 'x-internal-secret': SECRET },
411
+ body: JSON.stringify({ namespaceId: 'ns-1', adapter: 'filesystem', method: 'readFile', args: [] }),
412
+ });
413
+
414
+ expect(res.statusCode).toBe(502);
415
+ expect(res.json().error).toBe('Connection lost');
416
+ });
417
+ });