@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,195 @@
1
+ /**
2
+ * @module Unified Platform API — single dispatch for any adapter.
3
+ *
4
+ * Route: POST /platform/v1/{adapter}/{method}
5
+ *
6
+ * Provides a single entry point for all platform adapters (LLM, Cache,
7
+ * VectorStore, Analytics, Storage, Embeddings, etc.). Adapter and method
8
+ * come from URL params; arguments come from the request body.
9
+ *
10
+ * Security: only methods explicitly listed in ALLOWED_METHODS are callable.
11
+ */
12
+ import type { FastifyInstance } from 'fastify';
13
+ import { platform } from '@kb-labs/core-runtime';
14
+ import type { ILogger } from '@kb-labs/core-platform';
15
+ import {
16
+ PlatformCallRequestSchema,
17
+ type PlatformCallResponse,
18
+ } from '@kb-labs/gateway-contracts';
19
+
20
+ // ── Method allowlist ──────────────────────────────────────────────────────
21
+ // Only methods listed here are callable via the Platform API.
22
+ // Internal/lifecycle methods (setSource, shutdown, etc.) are NOT exposed.
23
+
24
+ const ALLOWED_METHODS: Record<string, Set<string>> = {
25
+ llm: new Set(['complete', 'stream', 'chatWithTools']),
26
+ cache: new Set(['get', 'set', 'delete', 'clear']),
27
+ vectorStore: new Set(['search', 'upsert', 'delete', 'count']),
28
+ analytics: new Set(['track', 'identify', 'flush', 'getEvents', 'getStats', 'getDailyStats']),
29
+ embeddings: new Set(['embed']),
30
+ storage: new Set(['read', 'write', 'delete', 'list', 'exists']),
31
+ eventBus: new Set(['publish', 'subscribe']),
32
+ sqlDatabase: new Set(['query', 'execute']),
33
+ documentDatabase: new Set(['find', 'findOne', 'insert', 'update', 'delete']),
34
+ };
35
+
36
+ // ── Adapter resolution ────────────────────────────────────────────────────
37
+
38
+ function resolveAdapter(name: string): unknown | undefined {
39
+ // Map URL param to platform property
40
+ const adapterMap: Record<string, () => unknown> = {
41
+ llm: () => platform.llm,
42
+ cache: () => platform.cache,
43
+ analytics: () => platform.analytics,
44
+ vectorStore: () => platform.vectorStore,
45
+ embeddings: () => platform.embeddings,
46
+ storage: () => platform.storage,
47
+ eventBus: () => platform.eventBus,
48
+ sqlDatabase: () => platform.sqlDatabase,
49
+ documentDatabase: () => platform.documentDatabase,
50
+ };
51
+
52
+ const getter = adapterMap[name];
53
+ return getter ? getter() : undefined;
54
+ }
55
+
56
+ // ── Streaming detection ───────────────────────────────────────────────────
57
+
58
+ function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
59
+ return (
60
+ value !== null &&
61
+ typeof value === 'object' &&
62
+ Symbol.asyncIterator in (value as object)
63
+ );
64
+ }
65
+
66
+ // ── Route registration ────────────────────────────────────────────────────
67
+
68
+ export function registerPlatformRoutes(app: FastifyInstance, logger: ILogger): void {
69
+ // hide: true — can return SSE (text/event-stream) for streaming adapter calls, incompatible with OpenAPI response schema
70
+ app.post<{ Params: { adapter: string; method: string } }>(
71
+ '/platform/v1/:adapter/:method',
72
+ { schema: { tags: ['Platform'], summary: 'Invoke a platform adapter method', hide: true } },
73
+ async (request, reply) => {
74
+ const auth = request.authContext;
75
+ if (!auth) {
76
+ return reply.code(401).send({ error: 'Unauthorized' });
77
+ }
78
+
79
+ const { adapter: adapterName, method: methodName } = request.params;
80
+
81
+ // 1. Check adapter is in allowlist
82
+ const allowedMethods = ALLOWED_METHODS[adapterName];
83
+ if (!allowedMethods) {
84
+ return reply.code(404).send({
85
+ ok: false,
86
+ error: { message: `Unknown adapter: "${adapterName}"`, code: 'ADAPTER_NOT_FOUND' },
87
+ durationMs: 0,
88
+ } satisfies PlatformCallResponse);
89
+ }
90
+
91
+ // 2. Check method is allowed
92
+ if (!allowedMethods.has(methodName)) {
93
+ return reply.code(403).send({
94
+ ok: false,
95
+ error: { message: `Method "${methodName}" not allowed on adapter "${adapterName}"`, code: 'METHOD_NOT_ALLOWED' },
96
+ durationMs: 0,
97
+ } satisfies PlatformCallResponse);
98
+ }
99
+
100
+ // 3. Resolve adapter instance
101
+ const adapter = resolveAdapter(adapterName);
102
+ if (!adapter) {
103
+ return reply.code(503).send({
104
+ ok: false,
105
+ error: { message: `Adapter "${adapterName}" not configured`, code: 'ADAPTER_UNAVAILABLE' },
106
+ durationMs: 0,
107
+ } satisfies PlatformCallResponse);
108
+ }
109
+
110
+ // 4. Check method exists on adapter
111
+ const method = (adapter as Record<string, unknown>)[methodName];
112
+ if (typeof method !== 'function') {
113
+ return reply.code(501).send({
114
+ ok: false,
115
+ error: { message: `Method "${methodName}" not implemented on adapter "${adapterName}"`, code: 'METHOD_NOT_IMPLEMENTED' },
116
+ durationMs: 0,
117
+ } satisfies PlatformCallResponse);
118
+ }
119
+
120
+ // 5. Parse args
121
+ const parsed = PlatformCallRequestSchema.safeParse(request.body);
122
+ if (!parsed.success) {
123
+ return reply.code(400).send({
124
+ ok: false,
125
+ error: { message: 'Invalid request body', code: 'VALIDATION_ERROR' },
126
+ durationMs: 0,
127
+ } satisfies PlatformCallResponse);
128
+ }
129
+
130
+ const { args } = parsed.data;
131
+
132
+ logger.info('Platform API call', {
133
+ adapter: adapterName,
134
+ method: methodName,
135
+ argCount: args.length,
136
+ tenantId: auth.namespaceId,
137
+ });
138
+
139
+ // 6. Execute
140
+ const startTime = Date.now();
141
+
142
+ try {
143
+ const result = method.apply(adapter, args);
144
+
145
+ // Handle async results
146
+ const resolved = result instanceof Promise ? await result : result;
147
+
148
+ // 7. Detect streaming response (e.g., llm.stream)
149
+ if (isAsyncIterable(resolved)) {
150
+ reply.raw.writeHead(200, {
151
+ 'Content-Type': 'text/event-stream',
152
+ 'Cache-Control': 'no-cache',
153
+ 'Connection': 'keep-alive',
154
+ });
155
+ reply.raw.flushHeaders();
156
+
157
+ for await (const chunk of resolved) {
158
+ if (!reply.raw.writableEnded) {
159
+ const data = typeof chunk === 'string' ? chunk : JSON.stringify(chunk);
160
+ reply.raw.write(`data: ${data}\n\n`);
161
+ }
162
+ }
163
+
164
+ if (!reply.raw.writableEnded) {
165
+ reply.raw.write('data: [DONE]\n\n');
166
+ reply.raw.end();
167
+ }
168
+
169
+ return reply;
170
+ }
171
+
172
+ // 8. Regular response
173
+ const durationMs = Date.now() - startTime;
174
+ return reply.code(200).send({
175
+ ok: true,
176
+ result: resolved,
177
+ durationMs,
178
+ } satisfies PlatformCallResponse);
179
+ } catch (err) {
180
+ const durationMs = Date.now() - startTime;
181
+ const error = err instanceof Error ? err : new Error(String(err));
182
+ logger.error('Platform API error', error, {
183
+ adapter: adapterName,
184
+ method: methodName,
185
+ tenantId: auth.namespaceId,
186
+ });
187
+ return reply.code(502).send({
188
+ ok: false,
189
+ error: { message: error.message, code: 'ADAPTER_ERROR' },
190
+ durationMs,
191
+ } satisfies PlatformCallResponse);
192
+ }
193
+ },
194
+ );
195
+ }
package/src/server.ts ADDED
@@ -0,0 +1,447 @@
1
+ import Fastify from 'fastify';
2
+ import fastifyCors from '@fastify/cors';
3
+ import fastifyHttpProxy from '@fastify/http-proxy';
4
+ import { platform } from '@kb-labs/core-runtime';
5
+ import {
6
+ createCorrelatedLogger,
7
+ createServiceReadyResponse,
8
+ registerOpenAPI,
9
+ } from '@kb-labs/shared-http';
10
+ import { logDiagnosticEvent, type ICache, type ILogger } from '@kb-labs/core-platform';
11
+ import type { GatewayConfig } from '@kb-labs/gateway-contracts';
12
+ import { HostRegistrationSchema } from '@kb-labs/gateway-contracts';
13
+ import { AuthService, type JwtConfig } from '@kb-labs/gateway-auth';
14
+ import { createAuthMiddleware } from './auth/middleware.js';
15
+ import { registerAuthRoutes } from './auth/routes.js';
16
+ import { registerExecuteRoutes } from './execute/routes.js';
17
+ import { registerLLMGatewayRoutes } from './llm/routes.js';
18
+ import { registerTelemetryRoutes } from './telemetry/routes.js';
19
+ import { registerPlatformRoutes } from './platform/routes.js';
20
+ import { registerAggregatedDocsRoutes } from './docs/routes.js';
21
+ import { HostRegistry } from './hosts/registry.js';
22
+ import { globalDispatcher } from './hosts/dispatcher.js';
23
+ import { attachGatewayWs } from './ws/gateway-ws.js';
24
+ import { GatewayObservabilityCollector } from './observability/collector.js';
25
+ import { randomUUID } from 'node:crypto';
26
+
27
+ export async function createServer(
28
+ config: GatewayConfig,
29
+ cache: ICache,
30
+ logger: ILogger,
31
+ jwtConfig: JwtConfig,
32
+ registry?: HostRegistry,
33
+ ) {
34
+ const gatewayLogger = createCorrelatedLogger(logger, {
35
+ serviceId: 'gateway',
36
+ logsSource: 'gateway',
37
+ layer: 'gateway',
38
+ service: 'server',
39
+ operation: 'gateway.http',
40
+ });
41
+ const app = Fastify({
42
+ logger: false,
43
+ });
44
+
45
+ const isProduction = process.env.NODE_ENV === 'production';
46
+
47
+ // OpenAPI / Swagger UI — must be registered before routes
48
+ await registerOpenAPI(app, {
49
+ title: 'KB Labs Gateway',
50
+ description: 'Central API gateway — auth, LLM, telemetry, platform dispatch',
51
+ version: '1.0.0',
52
+ servers: [{ url: 'http://localhost:4000', description: 'Local dev' }],
53
+ ui: !isProduction,
54
+ });
55
+
56
+ await app.register(fastifyCors, { origin: true });
57
+ const observability = new GatewayObservabilityCollector(config);
58
+ observability.register(app);
59
+ app.addHook('onRequest', async (request, reply) => {
60
+ const requestId = (request.headers['x-request-id'] as string | undefined) || request.id || randomUUID();
61
+ const traceId = (request.headers['x-trace-id'] as string | undefined) || randomUUID();
62
+
63
+ request.id = requestId;
64
+ reply.header('X-Request-Id', requestId);
65
+ reply.header('X-Trace-Id', traceId);
66
+
67
+ (request as any).kbLogger = createCorrelatedLogger(logger, {
68
+ serviceId: 'gateway',
69
+ logsSource: 'gateway',
70
+ layer: 'gateway',
71
+ service: 'request',
72
+ requestId,
73
+ traceId,
74
+ method: request.method,
75
+ url: request.url,
76
+ operation: 'http.request',
77
+ });
78
+ (request as any).kbLogger.info(`→ ${request.method.toUpperCase()} ${request.url}`);
79
+ });
80
+
81
+ app.addHook('onResponse', async (request, reply) => {
82
+ const requestLogger = (request as any).kbLogger as { info: (message: string, meta?: Record<string, unknown>) => void } | undefined;
83
+ if (!requestLogger) {
84
+ return;
85
+ }
86
+
87
+ requestLogger.info(`✓ ${request.method.toUpperCase()} ${request.url} ${reply.statusCode}`, {
88
+ statusCode: reply.statusCode,
89
+ });
90
+ });
91
+
92
+ // ── Proxy upstreams ────────────────────────────────────────────────
93
+ // Registered FIRST, before any hooks. Auth is handled by upstreams themselves.
94
+ // @fastify/http-proxy with websocket:true intercepts upgrades at the HTTP
95
+ // server level — no Fastify hooks must touch these requests.
96
+ // Gateway is a dumb proxy — real per-route timeout enforcement lives in REST API.
97
+ // 1 hour hard ceiling; anything longer should be a background job.
98
+ const PROXY_TIMEOUT_MS = 3_600_000;
99
+
100
+ for (const [name, upstream] of Object.entries(config.upstreams)) {
101
+ await app.register(fastifyHttpProxy, {
102
+ upstream: upstream.url,
103
+ prefix: upstream.prefix,
104
+ rewritePrefix: upstream.rewritePrefix ?? upstream.prefix,
105
+ disableCache: true,
106
+ websocket: upstream.websocket ?? false,
107
+ http: {
108
+ requestOptions: {
109
+ timeout: PROXY_TIMEOUT_MS,
110
+ },
111
+ },
112
+ });
113
+ gatewayLogger.info(`Upstream registered: ${name} → ${upstream.url} (${upstream.prefix}${upstream.websocket ? ', ws' : ''})`);
114
+ }
115
+
116
+ // ── Gateway's own routes (with auth) ───────────────────────────────
117
+ // Encapsulated scope: auth hook only applies to gateway-owned routes,
118
+ // not to proxy upstreams registered above.
119
+ await app.register(async function gatewayRoutes(scope) {
120
+ scope.addHook('onRequest', createAuthMiddleware(cache, jwtConfig));
121
+
122
+ // Auth service + public routes (/auth/register, /auth/token, /auth/refresh)
123
+ const authService = new AuthService(cache, jwtConfig);
124
+ registerAuthRoutes(scope as unknown as Parameters<typeof registerAuthRoutes>[0], authService);
125
+
126
+ // Health (public) — comprehensive adapter + upstream health
127
+ const HEALTH_CACHE_KEY = '__gateway_health';
128
+ const HEALTH_CACHE_TTL = 15_000; // 15s cache to prevent health DDoS
129
+ const startupTime = Date.now();
130
+
131
+ const collectHealthSnapshot = async () => {
132
+ const cached = await cache.get<Record<string, unknown>>(HEALTH_CACHE_KEY).catch(() => null);
133
+ if (cached) {
134
+ return cached;
135
+ }
136
+
137
+ const adapterNames = ['llm', 'cache', 'analytics', 'vectorStore', 'embeddings'] as const;
138
+ const adapters: Record<string, { available: boolean; latencyMs?: number }> = {};
139
+
140
+ for (const name of adapterNames) {
141
+ await observability.observeOperation(`gateway.adapter.${name}`, async () => {
142
+ const probeStart = Date.now();
143
+ try {
144
+ const adapter = (platform as any)[name];
145
+ adapters[name] = { available: !!adapter, latencyMs: Date.now() - probeStart };
146
+ } catch {
147
+ adapters[name] = { available: false, latencyMs: Date.now() - probeStart };
148
+ }
149
+ });
150
+ }
151
+
152
+ const upstreams: Record<string, { status: string; latencyMs?: number }> = {};
153
+ for (const [name, upstream] of Object.entries(config.upstreams)) {
154
+ await observability.observeOperation(`gateway.upstream.${name}.health`, async () => {
155
+ const probeStart = Date.now();
156
+ try {
157
+ const res = await fetch(`${upstream.url}/health`, {
158
+ signal: AbortSignal.timeout(2000),
159
+ });
160
+ const latencyMs = Date.now() - probeStart;
161
+ upstreams[name] = { status: res.ok ? 'up' : 'down', latencyMs };
162
+ if (!res.ok) {
163
+ logDiagnosticEvent(logger, {
164
+ domain: 'service',
165
+ event: 'gateway.upstream.health',
166
+ level: 'warn',
167
+ reasonCode: 'upstream_unavailable',
168
+ message: 'Gateway upstream health probe failed',
169
+ outcome: 'failed',
170
+ serviceId: 'gateway',
171
+ route: `${upstream.prefix}/health`,
172
+ evidence: {
173
+ upstreamId: name,
174
+ upstreamUrl: upstream.url,
175
+ statusCode: res.status,
176
+ latencyMs,
177
+ },
178
+ });
179
+ }
180
+ } catch (error) {
181
+ const latencyMs = Date.now() - probeStart;
182
+ upstreams[name] = { status: 'down', latencyMs };
183
+ logDiagnosticEvent(logger, {
184
+ domain: 'service',
185
+ event: 'gateway.upstream.health',
186
+ level: 'warn',
187
+ reasonCode: 'upstream_unavailable',
188
+ message: 'Gateway upstream health probe failed',
189
+ outcome: 'failed',
190
+ error: error instanceof Error ? error : new Error(String(error)),
191
+ serviceId: 'gateway',
192
+ route: `${upstream.prefix}/health`,
193
+ evidence: {
194
+ upstreamId: name,
195
+ upstreamUrl: upstream.url,
196
+ latencyMs,
197
+ },
198
+ });
199
+ }
200
+ });
201
+ }
202
+
203
+ const llmOk = adapters.llm?.available ?? false;
204
+ const allOk = Object.values(adapters).every((a) => a.available);
205
+ const snapshot = {
206
+ status: llmOk ? (allOk ? 'healthy' : 'degraded') : 'unhealthy',
207
+ version: '1.0',
208
+ uptime: Math.floor((Date.now() - startupTime) / 1000),
209
+ timestamp: new Date().toISOString(),
210
+ adapters,
211
+ upstreams,
212
+ };
213
+
214
+ await cache.set(HEALTH_CACHE_KEY, snapshot, HEALTH_CACHE_TTL).catch(() => {});
215
+ return snapshot;
216
+ };
217
+
218
+ scope.get('/health', { schema: { tags: ['System'], summary: 'Gateway health check' } }, async () => {
219
+ return collectHealthSnapshot();
220
+ });
221
+
222
+ scope.get('/ready', { schema: { tags: ['System'], summary: 'Gateway readiness check' } }, async (_request, reply) => {
223
+ const health = await collectHealthSnapshot();
224
+ const upstreams = (health.upstreams as Record<string, { status?: string }> | undefined) ?? {};
225
+ const missingRequiredUpstreams = ['rest']
226
+ .filter((id) => (upstreams[id]?.status ?? 'down') !== 'up');
227
+ const ready = missingRequiredUpstreams.length === 0;
228
+
229
+ return reply.code(ready ? 200 : 503).send(createServiceReadyResponse({
230
+ ready,
231
+ status: ready ? 'ready' : 'degraded',
232
+ reason: ready ? 'ready' : `upstream_unavailable:${missingRequiredUpstreams.join(',')}`,
233
+ components: {
234
+ gatewayAdapters: {
235
+ ready: true,
236
+ },
237
+ restUpstream: {
238
+ ready: (upstreams.rest?.status ?? 'down') === 'up',
239
+ status: upstreams.rest?.status ?? 'down',
240
+ },
241
+ workflowUpstream: {
242
+ ready: (upstreams.workflow?.status ?? 'down') === 'up',
243
+ status: upstreams.workflow?.status ?? 'down',
244
+ },
245
+ marketplaceUpstream: {
246
+ ready: (upstreams.marketplace?.status ?? 'down') === 'up',
247
+ status: upstreams.marketplace?.status ?? 'down',
248
+ },
249
+ },
250
+ }));
251
+ });
252
+
253
+ scope.get('/metrics', { schema: { tags: ['Observability'], summary: 'Gateway metrics in Prometheus format' } }, async (_request, reply) => {
254
+ const health = await collectHealthSnapshot();
255
+ const status = (health.status as 'healthy' | 'degraded' | 'unhealthy' | undefined) ?? 'healthy';
256
+ reply.header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
257
+ return observability.renderPrometheusMetrics(status);
258
+ });
259
+
260
+ scope.get('/observability/describe', {
261
+ schema: { tags: ['Observability'], summary: 'Gateway observability contract descriptor' },
262
+ }, async () => observability.buildDescribe());
263
+
264
+ scope.get('/observability/health', {
265
+ schema: { tags: ['Observability'], summary: 'Gateway observability health snapshot' },
266
+ }, async () => {
267
+ const health = await collectHealthSnapshot();
268
+ const adapterChecks = Object.entries((health.adapters as Record<string, { available?: boolean; latencyMs?: number }> | undefined) ?? {})
269
+ .map(([id, value]) => ({ id, available: !!value?.available, latencyMs: value?.latencyMs }));
270
+ const upstreamChecks = Object.entries((health.upstreams as Record<string, { status?: string; latencyMs?: number }> | undefined) ?? {})
271
+ .map(([id, value]) => ({ id, status: value?.status ?? 'unknown', latencyMs: value?.latencyMs }));
272
+ const status = (health.status as 'healthy' | 'degraded' | 'unhealthy' | undefined) ?? 'healthy';
273
+ return observability.buildHealth({ status, adapterChecks, upstreamChecks });
274
+ });
275
+
276
+ // Host registration (public)
277
+ // Use injected registry (with persistence) or fallback to cache-only
278
+ if (!registry) {
279
+ gatewayLogger.warn('No persistent HostRegistry injected — hosts will be lost on restart');
280
+ }
281
+ const hostRegistry = registry ?? new HostRegistry(cache);
282
+ scope.post('/hosts/register', { schema: { tags: ['Hosts'], summary: 'Register a host' } }, async (request, reply) => {
283
+ const parsed = HostRegistrationSchema.safeParse(request.body);
284
+ if (!parsed.success) {
285
+ return reply.code(400).send({ error: 'Bad Request', issues: parsed.error.issues });
286
+ }
287
+ const result = await hostRegistry.register(parsed.data);
288
+ return reply.code(201).send({
289
+ hostId: result.descriptor.hostId,
290
+ machineToken: result.machineToken,
291
+ status: result.descriptor.status,
292
+ });
293
+ });
294
+
295
+ // List hosts (auth required)
296
+ scope.get('/hosts', { schema: { tags: ['Hosts'], summary: 'List registered hosts' } }, async (request, reply) => {
297
+ const auth = request.authContext;
298
+ if (!auth) {
299
+ return reply.code(401).send({ error: 'Unauthorized' });
300
+ }
301
+ const hosts = await hostRegistry.list(auth.namespaceId);
302
+ return { hosts };
303
+ });
304
+
305
+ // Get host by ID (auth required)
306
+ scope.get<{ Params: { hostId: string } }>('/hosts/:hostId', { schema: { tags: ['Hosts'], summary: 'Get host by ID' } }, async (request, reply) => {
307
+ const auth = request.authContext;
308
+ if (!auth) {
309
+ return reply.code(401).send({ error: 'Unauthorized' });
310
+ }
311
+ const { hostId } = request.params;
312
+ const host = await hostRegistry.get(hostId, auth.namespaceId);
313
+ if (!host) {
314
+ return reply.code(404).send({ error: 'Host not found' });
315
+ }
316
+ return host;
317
+ });
318
+
319
+ // Deregister host (auth required)
320
+ scope.delete<{ Params: { hostId: string } }>('/hosts/:hostId', { schema: { tags: ['Hosts'], summary: 'Deregister a host' } }, async (request, reply) => {
321
+ const auth = request.authContext;
322
+ if (!auth) {
323
+ return reply.code(401).send({ error: 'Unauthorized' });
324
+ }
325
+ const { hostId } = request.params;
326
+ const deleted = await hostRegistry.deregister(hostId, auth.namespaceId);
327
+ if (!deleted) {
328
+ return reply.code(404).send({ error: 'Host not found' });
329
+ }
330
+ return reply.code(204).send();
331
+ });
332
+
333
+ // Execute endpoint — public API for CLI/Studio clients (auth required)
334
+ registerExecuteRoutes(scope as unknown as Parameters<typeof registerExecuteRoutes>[0], logger);
335
+
336
+ // AI Gateway — OpenAI-compatible LLM endpoint (auth required)
337
+ registerLLMGatewayRoutes(scope as unknown as Parameters<typeof registerLLMGatewayRoutes>[0], logger);
338
+
339
+ // Telemetry ingestion — unified event collection (auth required)
340
+ registerTelemetryRoutes(scope as unknown as Parameters<typeof registerTelemetryRoutes>[0], logger);
341
+
342
+ // Unified Platform API — single dispatch for any adapter (auth required)
343
+ registerPlatformRoutes(scope as unknown as Parameters<typeof registerPlatformRoutes>[0], logger);
344
+
345
+ // Aggregated docs — /openapi-merged.json + /docs-all
346
+ registerAggregatedDocsRoutes(scope as unknown as Parameters<typeof registerAggregatedDocsRoutes>[0], cache);
347
+
348
+
349
+ // Internal dispatch endpoint
350
+ const internalSecret = process.env.GATEWAY_INTERNAL_SECRET;
351
+ scope.post('/internal/dispatch', async (request, reply) => {
352
+ const provided = request.headers['x-internal-secret'];
353
+ if (!internalSecret || provided !== internalSecret) {
354
+ return reply.code(403).send({ error: 'Forbidden' });
355
+ }
356
+
357
+ const body = request.body as {
358
+ namespaceId?: string;
359
+ hostId?: string;
360
+ adapter?: string;
361
+ method?: string;
362
+ args?: unknown[];
363
+ };
364
+
365
+ if (!body.namespaceId || !body.adapter || !body.method) {
366
+ return reply.code(400).send({ error: 'Missing required fields: namespaceId, adapter, method' });
367
+ }
368
+
369
+ const hostId = body.hostId
370
+ ?? globalDispatcher.firstHostWithCapability(body.namespaceId, body.adapter)
371
+ ?? globalDispatcher.firstHost(body.namespaceId);
372
+ if (!hostId) {
373
+ return reply.code(503).send({
374
+ error: 'No host connected',
375
+ namespaceId: body.namespaceId,
376
+ });
377
+ }
378
+
379
+ try {
380
+ const result = await globalDispatcher.call(
381
+ body.namespaceId,
382
+ hostId,
383
+ body.adapter,
384
+ body.method,
385
+ body.args ?? [],
386
+ );
387
+ return { result };
388
+ } catch (err) {
389
+ const message = err instanceof Error ? err.message : String(err);
390
+ if (message.includes('Host not connected')) {
391
+ return reply.code(503).send({ error: message });
392
+ }
393
+ return reply.code(502).send({ error: message });
394
+ }
395
+ });
396
+
397
+ // Internal host resolution endpoint
398
+ scope.post('/internal/resolve-host', async (request, reply) => {
399
+ const provided = request.headers['x-internal-secret'];
400
+ if (!internalSecret || provided !== internalSecret) {
401
+ return reply.code(403).send({ error: 'Forbidden' });
402
+ }
403
+
404
+ const body = request.body as {
405
+ namespaceId?: string;
406
+ target?: {
407
+ hostId?: string;
408
+ hostSelection?: string;
409
+ repoFingerprint?: string;
410
+ };
411
+ };
412
+
413
+ const namespaceId = body.namespaceId ?? 'default';
414
+ const target = body.target ?? {};
415
+ const strategy = (target.hostSelection ?? 'any-matching') as string;
416
+
417
+ let hostId: string | undefined;
418
+
419
+ if (strategy === 'pinned' && target.hostId) {
420
+ // Verify host exists and is reachable (online or reconnecting)
421
+ const host = await hostRegistry.get(target.hostId, namespaceId);
422
+ if (host?.status === 'online' || host?.status === 'reconnecting') {
423
+ hostId = target.hostId;
424
+ }
425
+ } else {
426
+ // any-matching / prefer-local / prefer-cloud: find first with execution capability
427
+ hostId = globalDispatcher.firstHostWithCapability(namespaceId, 'execution');
428
+ }
429
+
430
+ if (!hostId) {
431
+ return reply.code(404).send({ error: 'No matching host found' });
432
+ }
433
+
434
+ return { hostId, strategy, namespaceId };
435
+ });
436
+ });
437
+
438
+ // ── Gateway WebSocket endpoints ────────────────────────────────────
439
+ // Must be after ready() so http-proxy's upgrade listener is registered.
440
+ // attachGatewayWs captures it, removes it, and installs a unified handler
441
+ // that dispatches gateway WS paths to raw ws handlers and delegates
442
+ // everything else (upstream WS proxy) to http-proxy.
443
+ await app.ready();
444
+ attachGatewayWs(app.server, cache, jwtConfig, logger, registry);
445
+
446
+ return app;
447
+ }