@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,50 @@
1
+ import type { FastifyRequest, FastifyReply } from 'fastify';
2
+ import type { ICache } from '@kb-labs/core-platform';
3
+ import type { AuthContext } from '@kb-labs/gateway-contracts';
4
+ import type { JwtConfig } from '@kb-labs/gateway-auth';
5
+ import { resolveToken, extractBearerToken } from './tokens.js';
6
+
7
+ // Routes that don't require auth (handle their own auth internally)
8
+ const PUBLIC_ROUTES = new Set([
9
+ '/health',
10
+ '/hosts/register',
11
+ // /hosts/connect and /clients/connect are handled at the HTTP upgrade level
12
+ // by gateway-ws.ts (raw ws) — they never reach Fastify routing.
13
+ '/auth/register',
14
+ '/auth/token',
15
+ '/auth/refresh',
16
+ '/internal/dispatch', // has its own x-internal-secret auth
17
+ '/internal/resolve-host', // has its own x-internal-secret auth
18
+ ]);
19
+
20
+ declare module 'fastify' {
21
+ interface FastifyRequest {
22
+ authContext?: AuthContext;
23
+ }
24
+ }
25
+
26
+ export function createAuthMiddleware(cache: ICache, jwtConfig: JwtConfig) {
27
+ return async function authMiddleware(
28
+ request: FastifyRequest,
29
+ reply: FastifyReply,
30
+ ): Promise<void> {
31
+ const rawPath = new URL(request.url, 'http://localhost').pathname;
32
+ const routePath = rawPath.replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
33
+ if (PUBLIC_ROUTES.has(routePath)) {return;}
34
+
35
+ // Bearer header takes precedence; fall back to ?access_token= for SSE
36
+ // connections where browsers cannot set custom headers.
37
+ const queryToken = (request.query as Record<string, string | undefined>)['access_token'];
38
+ const token = extractBearerToken(request.headers.authorization) ?? queryToken ?? null;
39
+ if (!token) {
40
+ return reply.code(401).send({ error: 'Unauthorized', message: 'Missing Authorization header' });
41
+ }
42
+
43
+ const authContext = await resolveToken(token, cache, jwtConfig);
44
+ if (!authContext) {
45
+ return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid token' });
46
+ }
47
+
48
+ request.authContext = authContext;
49
+ };
50
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Auth routes — public endpoints for agent registration and token management.
3
+ * POST /auth/register — register new agent, get clientId + clientSecret
4
+ * POST /auth/token — exchange credentials for JWT pair
5
+ * POST /auth/refresh — rotate refresh token, get new pair
6
+ */
7
+
8
+ import type { FastifyInstance } from 'fastify';
9
+ import type { AuthService } from '@kb-labs/gateway-auth';
10
+ import {
11
+ RegisterRequestSchema,
12
+ TokenRequestSchema,
13
+ RefreshRequestSchema,
14
+ } from '@kb-labs/gateway-contracts';
15
+
16
+ export function registerAuthRoutes(app: FastifyInstance, authService: AuthService): void {
17
+ // Register new agent
18
+ app.post('/auth/register', { schema: { tags: ['Auth'], summary: 'Register new agent and get credentials' } }, async (request, reply) => {
19
+ const parsed = RegisterRequestSchema.safeParse(request.body);
20
+ if (!parsed.success) {
21
+ return reply.code(400).send({ error: 'Bad Request', issues: parsed.error.issues });
22
+ }
23
+
24
+ const result = await authService.register(parsed.data);
25
+ return reply.code(201).send(result);
26
+ });
27
+
28
+ // Issue token pair
29
+ app.post('/auth/token', { schema: { tags: ['Auth'], summary: 'Issue JWT token pair' } }, async (request, reply) => {
30
+ const parsed = TokenRequestSchema.safeParse(request.body);
31
+ if (!parsed.success) {
32
+ return reply.code(400).send({ error: 'Bad Request', issues: parsed.error.issues });
33
+ }
34
+
35
+ const tokens = await authService.issueTokens(parsed.data.clientId, parsed.data.clientSecret);
36
+ if (!tokens) {
37
+ return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid credentials' });
38
+ }
39
+
40
+ return reply.send(tokens);
41
+ });
42
+
43
+ // Refresh token pair
44
+ app.post('/auth/refresh', { schema: { tags: ['Auth'], summary: 'Refresh JWT token pair' } }, async (request, reply) => {
45
+ const parsed = RefreshRequestSchema.safeParse(request.body);
46
+ if (!parsed.success) {
47
+ return reply.code(400).send({ error: 'Bad Request', issues: parsed.error.issues });
48
+ }
49
+
50
+ const tokens = await authService.refreshTokens(parsed.data.refreshToken);
51
+ if (!tokens) {
52
+ return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or expired refresh token' });
53
+ }
54
+
55
+ return reply.send(tokens);
56
+ });
57
+ }
@@ -0,0 +1,41 @@
1
+ import type { ICache } from '@kb-labs/core-platform';
2
+ import type { AuthContext } from '@kb-labs/gateway-contracts';
3
+ import { AuthService, type JwtConfig } from '@kb-labs/gateway-auth';
4
+
5
+ /**
6
+ * Resolve a Bearer token to an AuthContext.
7
+ * Tries JWT verification first, falls back to static machine token (dev compat).
8
+ */
9
+ export async function resolveToken(
10
+ token: string,
11
+ cache: ICache,
12
+ jwtConfig: JwtConfig,
13
+ ): Promise<AuthContext | null> {
14
+ const authService = new AuthService(cache, jwtConfig);
15
+
16
+ // Try JWT first (v2)
17
+ const jwtContext = await authService.verify(token);
18
+ if (jwtContext) {return jwtContext;}
19
+
20
+ // Fallback: static machine token in ICache (v1 compat — dev-studio-token etc.)
21
+ const machineEntry = await cache.get<{ hostId: string; namespaceId: string }>(
22
+ `host:token:${token}`,
23
+ );
24
+ if (machineEntry) {
25
+ return {
26
+ type: 'machine',
27
+ userId: machineEntry.hostId,
28
+ namespaceId: machineEntry.namespaceId,
29
+ tier: 'free',
30
+ permissions: ['host:connect'],
31
+ };
32
+ }
33
+
34
+ return null;
35
+ }
36
+
37
+ export function extractBearerToken(authHeader: string | undefined): string | null {
38
+ if (!authHeader) {return null;}
39
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
40
+ return match ? (match[1] ?? null) : null;
41
+ }
@@ -0,0 +1,98 @@
1
+ import { logDiagnosticEvent } from '@kb-labs/core-platform';
2
+ import { platform, createServiceBootstrap } from '@kb-labs/core-runtime';
3
+ import { createCorrelatedLogger } from '@kb-labs/shared-http';
4
+ import type { IHostStore } from '@kb-labs/gateway-contracts';
5
+ import { SqliteHostStore } from '@kb-labs/gateway-core';
6
+ import { loadGatewayConfig } from './config.js';
7
+ import { createServer } from './server.js';
8
+ import { HostRegistry } from './hosts/registry.js';
9
+
10
+ export async function bootstrap(repoRoot: string = process.cwd()): Promise<void> {
11
+ // 1. Initialize platform (loads .env + adapters from kb.config.json)
12
+ await createServiceBootstrap({ appId: 'gateway', repoRoot });
13
+
14
+ const logger = createCorrelatedLogger(platform.logger, {
15
+ serviceId: 'gateway',
16
+ logsSource: 'gateway',
17
+ layer: 'gateway',
18
+ service: 'bootstrap',
19
+ operation: 'gateway.bootstrap',
20
+ });
21
+ logger.info('Platform initialized', { repoRoot });
22
+
23
+ // 2. Load gateway config — reads gateway.upstreams from kb.config.json
24
+ const config = await loadGatewayConfig(repoRoot);
25
+ logger.info('Gateway config loaded', {
26
+ port: config.port,
27
+ upstreams: Object.keys(config.upstreams),
28
+ });
29
+
30
+ // 3. Create persistent host store (SQLite if available, otherwise cache-only)
31
+ let hostStore: IHostStore | undefined;
32
+ const db = platform.getAdapter<import('@kb-labs/core-platform').ISQLDatabase>('sqlDatabase');
33
+ if (db) {
34
+ hostStore = new SqliteHostStore(db);
35
+ logger.info('Host store: SQLite (persistent)');
36
+ } else {
37
+ logger.warn('Host store: none (cache-only, hosts will be lost on restart)');
38
+ }
39
+
40
+ // 4. Create host registry with cache + store
41
+ const registry = new HostRegistry(platform.cache, hostStore);
42
+
43
+ // 5. Restore persisted hosts into cache
44
+ let restoredCount = 0;
45
+ try {
46
+ restoredCount = await registry.restore();
47
+ } catch (error) {
48
+ logDiagnosticEvent(platform.logger, {
49
+ domain: 'registry',
50
+ event: 'gateway.hosts.restore',
51
+ level: 'error',
52
+ reasonCode: 'registry_restore_failed',
53
+ message: 'Failed to restore gateway host registry',
54
+ outcome: 'failed',
55
+ error: error instanceof Error ? error : new Error(String(error)),
56
+ serviceId: 'gateway',
57
+ evidence: {
58
+ persistentStore: !!hostStore,
59
+ },
60
+ });
61
+ throw error;
62
+ }
63
+ if (restoredCount > 0) {
64
+ logger.info('Restored hosts from store', { count: restoredCount });
65
+ }
66
+
67
+ // 6. Seed static tokens into cache so resolveToken() accepts them
68
+ for (const [token, entry] of Object.entries(config.staticTokens)) {
69
+ await platform.cache.set(`host:token:${token}`, entry);
70
+ logger.info('Static token seeded', { hostId: entry.hostId, namespaceId: entry.namespaceId });
71
+ }
72
+
73
+ // 7. Build JWT config — secret from env, required in production
74
+ const jwtSecret = process.env.GATEWAY_JWT_SECRET;
75
+ if (!jwtSecret) {
76
+ logger.warn('GATEWAY_JWT_SECRET not set — using insecure default (dev only!)');
77
+ }
78
+ const jwtConfig = { secret: jwtSecret ?? 'dev-insecure-secret-change-me' };
79
+
80
+ // 8. Create server with injected registry
81
+ const server = await createServer(config, platform.cache, platform.logger, jwtConfig, registry);
82
+
83
+ // 9. Listen
84
+ const address = await server.listen({ port: config.port, host: '0.0.0.0' });
85
+ logger.info('Gateway listening', { address });
86
+
87
+ // 10. Graceful shutdown
88
+ const shutdown = async (signal: string) => {
89
+ logger.warn('Received shutdown signal', { signal });
90
+ await platform.shutdown();
91
+ await server.close();
92
+ logger.info('Gateway shutdown complete');
93
+ process.exit(0);
94
+ };
95
+
96
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
97
+ process.on('SIGINT', () => shutdown('SIGINT'));
98
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @module gateway-app/clients/subscription-registry
3
+ *
4
+ * Tracks which client connections are subscribed to which executions.
5
+ * Gateway broadcasts ExecutionEvents to all subscribers of an execution.
6
+ *
7
+ * Lifecycle:
8
+ * - client sends client:subscribe → subscribe(connectionId, executionId)
9
+ * - client sends client:unsubscribe → unsubscribe(connectionId, executionId)
10
+ * - execution emits event → broadcast(executionId, event) → all subscribers
11
+ * - client disconnects → removeConnection(connectionId) → cleanup
12
+ *
13
+ * The initiator of POST /api/v1/execute is NOT auto-subscribed here —
14
+ * they receive events inline via ndjson stream. This registry is for
15
+ * secondary observers joining an in-flight execution.
16
+ */
17
+
18
+ import type { WebSocket } from '@fastify/websocket';
19
+ import type { ExecutionEventMessage } from '@kb-labs/gateway-contracts';
20
+
21
+ export interface ISubscriptionRegistry {
22
+ subscribe(connectionId: string, executionId: string): void;
23
+ unsubscribe(connectionId: string, executionId: string): void;
24
+ getSubscribers(executionId: string): ReadonlySet<string>;
25
+ getSubscriptions(connectionId: string): ReadonlySet<string>;
26
+ /** Called on client disconnect. Returns executionIds that now have zero subscribers. */
27
+ removeConnection(connectionId: string): string[];
28
+ broadcast(executionId: string, event: ExecutionEventMessage): void;
29
+ }
30
+
31
+ export class SubscriptionRegistry implements ISubscriptionRegistry {
32
+ /** executionId → Set<connectionId> */
33
+ private readonly byExecution = new Map<string, Set<string>>();
34
+ /** connectionId → Set<executionId> */
35
+ private readonly byConnection = new Map<string, Set<string>>();
36
+ /** connectionId → WebSocket (for sending events) */
37
+ private readonly sockets = new Map<string, WebSocket>();
38
+
39
+ /** Register a WebSocket for a connection. Must be called before subscribe(). */
40
+ registerSocket(connectionId: string, socket: WebSocket): void {
41
+ this.sockets.set(connectionId, socket);
42
+ }
43
+
44
+ /** Remove a connection's socket on disconnect. */
45
+ removeSocket(connectionId: string): void {
46
+ this.sockets.delete(connectionId);
47
+ }
48
+
49
+ subscribe(connectionId: string, executionId: string): void {
50
+ let executionSubs = this.byExecution.get(executionId);
51
+ if (!executionSubs) {
52
+ executionSubs = new Set();
53
+ this.byExecution.set(executionId, executionSubs);
54
+ }
55
+ executionSubs.add(connectionId);
56
+
57
+ let connectionSubs = this.byConnection.get(connectionId);
58
+ if (!connectionSubs) {
59
+ connectionSubs = new Set();
60
+ this.byConnection.set(connectionId, connectionSubs);
61
+ }
62
+ connectionSubs.add(executionId);
63
+ }
64
+
65
+ unsubscribe(connectionId: string, executionId: string): void {
66
+ this.byExecution.get(executionId)?.delete(connectionId);
67
+ this.byConnection.get(connectionId)?.delete(executionId);
68
+
69
+ // GC empty sets
70
+ if (this.byExecution.get(executionId)?.size === 0) {
71
+ this.byExecution.delete(executionId);
72
+ }
73
+ if (this.byConnection.get(connectionId)?.size === 0) {
74
+ this.byConnection.delete(connectionId);
75
+ }
76
+ }
77
+
78
+ getSubscribers(executionId: string): ReadonlySet<string> {
79
+ return this.byExecution.get(executionId) ?? new Set();
80
+ }
81
+
82
+ getSubscriptions(connectionId: string): ReadonlySet<string> {
83
+ return this.byConnection.get(connectionId) ?? new Set();
84
+ }
85
+
86
+ removeConnection(connectionId: string): string[] {
87
+ const subscriptions = this.byConnection.get(connectionId);
88
+ if (!subscriptions) {
89
+ this.sockets.delete(connectionId);
90
+ return [];
91
+ }
92
+
93
+ const orphaned: string[] = [];
94
+ for (const executionId of subscriptions) {
95
+ const subs = this.byExecution.get(executionId);
96
+ if (subs) {
97
+ subs.delete(connectionId);
98
+ if (subs.size === 0) {
99
+ this.byExecution.delete(executionId);
100
+ orphaned.push(executionId);
101
+ }
102
+ }
103
+ }
104
+
105
+ this.byConnection.delete(connectionId);
106
+ this.sockets.delete(connectionId);
107
+ return orphaned;
108
+ }
109
+
110
+ broadcast(executionId: string, event: ExecutionEventMessage): void {
111
+ const subscribers = this.byExecution.get(executionId);
112
+ if (!subscribers || subscribers.size === 0) { return; }
113
+
114
+ const payload = JSON.stringify(event);
115
+ for (const connectionId of subscribers) {
116
+ const socket = this.sockets.get(connectionId);
117
+ if (socket && socket.readyState === socket.OPEN) {
118
+ socket.send(payload);
119
+ }
120
+ }
121
+ }
122
+
123
+ get connectionCount(): number {
124
+ return this.byConnection.size;
125
+ }
126
+
127
+ get subscriptionCount(): number {
128
+ let total = 0;
129
+ for (const subs of this.byExecution.values()) {
130
+ total += subs.size;
131
+ }
132
+ return total;
133
+ }
134
+ }
135
+
136
+ /** Singleton — shared across Gateway process. */
137
+ export const subscriptionRegistry = new SubscriptionRegistry();
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @module gateway-app/clients/ws-handler
3
+ *
4
+ * WebSocket handler for observer clients (CLI, Studio, IDE).
5
+ * Endpoint: GET /clients/connect (requires Bearer JWT)
6
+ *
7
+ * Supports:
8
+ * - client:subscribe — start receiving events for an executionId
9
+ * - client:unsubscribe — stop receiving events
10
+ * - client:cancel — cancel an execution (proxied to ExecutionRegistry)
11
+ *
12
+ * Auth: same Bearer JWT as HTTP endpoints (user or machine token, NOT host machine token).
13
+ */
14
+
15
+ import { randomUUID } from 'node:crypto';
16
+ import type { WebSocket } from 'ws';
17
+
18
+ interface WsRequest {
19
+ headers: { authorization?: string };
20
+ url?: string;
21
+ }
22
+ import type { ICache, ILogger } from '@kb-labs/core-platform';
23
+ import type { CancellationReason } from '@kb-labs/core-contracts';
24
+ import {
25
+ ClientHelloSchema,
26
+ ClientSubscribeSchema,
27
+ ClientUnsubscribeSchema,
28
+ ClientCancelSchema,
29
+ CLIENT_PROTOCOL_VERSION,
30
+ type ClientOutboundMessage,
31
+ } from '@kb-labs/gateway-contracts';
32
+ import { type JwtConfig } from '@kb-labs/gateway-auth';
33
+ import { extractBearerToken, resolveToken } from '../auth/tokens.js';
34
+ import { executionRegistry } from '../execute/execution-registry.js';
35
+ import { subscriptionRegistry } from './subscription-registry.js';
36
+
37
+ const HELLO_TIMEOUT_MS = 5_000;
38
+
39
+ function send(ws: WebSocket, msg: ClientOutboundMessage): void {
40
+ if (ws.readyState === ws.OPEN) {
41
+ ws.send(JSON.stringify(msg));
42
+ }
43
+ }
44
+
45
+ export function createClientWsHandler(cache: ICache, jwtConfig: JwtConfig, logger: ILogger) {
46
+ return async function clientWsHandler(
47
+ socket: WebSocket,
48
+ request: WsRequest,
49
+ ): Promise<void> {
50
+ // 1. Auth — Bearer JWT required (user or machine token)
51
+ const queryToken = new URL(request.url ?? '/', 'http://localhost').searchParams.get('access_token');
52
+ const token = extractBearerToken(request.headers.authorization)
53
+ ?? queryToken
54
+ ?? null;
55
+
56
+ if (!token) {
57
+ socket.close(1008, 'Missing Authorization');
58
+ return;
59
+ }
60
+
61
+ const authContext = await resolveToken(token, cache, jwtConfig);
62
+ if (!authContext) {
63
+ socket.close(1008, 'Invalid token');
64
+ return;
65
+ }
66
+
67
+ const connectionId = randomUUID();
68
+
69
+ // 2. Wait for client:hello (with timeout)
70
+ let helloDone = false;
71
+
72
+ await new Promise<void>((resolve, reject) => {
73
+ const helloTimeout = setTimeout(() => {
74
+ if (!helloDone) {
75
+ helloDone = true;
76
+ socket.close(1008, 'Hello timeout');
77
+ reject(new Error('Hello timeout'));
78
+ }
79
+ }, HELLO_TIMEOUT_MS);
80
+
81
+ socket.once('message', (raw) => {
82
+ if (helloDone) { return; }
83
+ helloDone = true;
84
+ clearTimeout(helloTimeout);
85
+
86
+ try {
87
+ const msg = ClientHelloSchema.parse(JSON.parse(raw.toString()));
88
+ void msg; // clientVersion logged below if needed
89
+ resolve();
90
+ } catch {
91
+ socket.close(1008, 'Invalid hello message');
92
+ reject(new Error('Invalid client:hello'));
93
+ }
94
+ });
95
+ }).catch(() => {
96
+ // socket already closed
97
+ });
98
+
99
+ if (!helloDone || socket.readyState !== socket.OPEN) { return; }
100
+
101
+ // 3. Register connection
102
+ subscriptionRegistry.registerSocket(connectionId, socket);
103
+
104
+ send(socket, {
105
+ type: 'client:connected',
106
+ protocolVersion: CLIENT_PROTOCOL_VERSION,
107
+ connectionId,
108
+ });
109
+
110
+ logger.debug('Client connected', { connectionId, namespaceId: authContext.namespaceId });
111
+
112
+ // 4. Message handler
113
+ socket.on('message', (raw) => {
114
+ let parsed: { type: string };
115
+ try {
116
+ parsed = JSON.parse(raw.toString()) as { type: string };
117
+ } catch {
118
+ send(socket, {
119
+ type: 'client:error',
120
+ code: 'INVALID_MESSAGE',
121
+ message: 'Malformed JSON',
122
+ });
123
+ return;
124
+ }
125
+
126
+ switch (parsed.type) {
127
+ case 'client:subscribe': {
128
+ const result = ClientSubscribeSchema.safeParse(parsed);
129
+ if (!result.success) {
130
+ send(socket, { type: 'client:error', code: 'INVALID_MESSAGE', message: 'Invalid subscribe message' });
131
+ return;
132
+ }
133
+ const { executionId } = result.data;
134
+
135
+ // Verify execution exists and belongs to this namespace
136
+ const execution = executionRegistry.get(executionId);
137
+ if (!execution) {
138
+ send(socket, { type: 'client:error', code: 'EXECUTION_NOT_FOUND', message: `Execution ${executionId} not found`, executionId });
139
+ return;
140
+ }
141
+ if (execution.namespaceId !== authContext.namespaceId) {
142
+ send(socket, { type: 'client:error', code: 'FORBIDDEN', message: 'Execution belongs to another namespace', executionId });
143
+ return;
144
+ }
145
+
146
+ subscriptionRegistry.subscribe(connectionId, executionId);
147
+ break;
148
+ }
149
+
150
+ case 'client:unsubscribe': {
151
+ const result = ClientUnsubscribeSchema.safeParse(parsed);
152
+ if (!result.success) {
153
+ send(socket, { type: 'client:error', code: 'INVALID_MESSAGE', message: 'Invalid unsubscribe message' });
154
+ return;
155
+ }
156
+ subscriptionRegistry.unsubscribe(connectionId, result.data.executionId);
157
+ break;
158
+ }
159
+
160
+ case 'client:cancel': {
161
+ const result = ClientCancelSchema.safeParse(parsed);
162
+ if (!result.success) {
163
+ send(socket, { type: 'client:error', code: 'INVALID_MESSAGE', message: 'Invalid cancel message' });
164
+ return;
165
+ }
166
+ const { executionId, reason } = result.data;
167
+
168
+ const execution = executionRegistry.get(executionId);
169
+ if (!execution) {
170
+ send(socket, { type: 'client:error', code: 'EXECUTION_NOT_FOUND', message: `Execution ${executionId} not found`, executionId });
171
+ return;
172
+ }
173
+ if (execution.namespaceId !== authContext.namespaceId) {
174
+ send(socket, { type: 'client:error', code: 'FORBIDDEN', message: 'Execution belongs to another namespace', executionId });
175
+ return;
176
+ }
177
+
178
+ const cancelled = executionRegistry.cancel(executionId, (reason ?? 'user') as CancellationReason);
179
+ if (!cancelled) {
180
+ send(socket, { type: 'client:error', code: 'CANCEL_FAILED', message: 'Execution already completed or cancelled', executionId });
181
+ }
182
+ break;
183
+ }
184
+
185
+ default:
186
+ send(socket, { type: 'client:error', code: 'INVALID_MESSAGE', message: `Unknown message type: ${String(parsed.type)}` });
187
+ }
188
+ });
189
+
190
+ // 5. Disconnect cleanup
191
+ socket.on('close', () => {
192
+ subscriptionRegistry.removeConnection(connectionId);
193
+ logger.debug('Client disconnected', { connectionId });
194
+ });
195
+ };
196
+ }
package/src/config.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { findNearestConfig, readJsonWithDiagnostics } from '@kb-labs/core-config';
2
+ import { GatewayConfigSchema, type GatewayConfig } from '@kb-labs/gateway-contracts';
3
+
4
+ export async function loadGatewayConfig(repoRoot: string): Promise<GatewayConfig> {
5
+ const { path: configPath } = await findNearestConfig({
6
+ startDir: repoRoot,
7
+ filenames: ['.kb/kb.config.json', 'kb.config.json'],
8
+ });
9
+
10
+ if (!configPath) {
11
+ return GatewayConfigSchema.parse({});
12
+ }
13
+
14
+ const result = await readJsonWithDiagnostics<{ gateway?: unknown }>(configPath);
15
+ if (!result.ok || !result.data.gateway) {
16
+ return GatewayConfigSchema.parse({});
17
+ }
18
+
19
+ return GatewayConfigSchema.parse(result.data.gateway);
20
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @module gateway-app/docs/routes
3
+ * Aggregated OpenAPI documentation endpoints.
4
+ *
5
+ * GET /openapi-merged.json — merged spec from all upstream services
6
+ * GET /docs-all — Swagger UI pointing at the merged spec
7
+ */
8
+
9
+ import type { FastifyInstance } from 'fastify';
10
+ import { mergeOpenAPISpecs } from '@kb-labs/core-registry';
11
+ import type { ICache } from '@kb-labs/core-platform';
12
+
13
+ const MERGED_CACHE_KEY = '__gateway_merged_openapi';
14
+ const MERGED_CACHE_TTL = 30_000; // 30 second cache
15
+
16
+ const UPSTREAM_SPEC_URLS = [
17
+ 'http://localhost:5050/openapi.json',
18
+ 'http://localhost:7778/openapi.json',
19
+ ];
20
+
21
+ export function registerAggregatedDocsRoutes(app: FastifyInstance, cache?: ICache): void {
22
+ // Merged OpenAPI spec from all upstreams
23
+ app.get('/openapi-merged.json', async (_req, reply) => {
24
+ // Try cache first
25
+ if (cache) {
26
+ try {
27
+ const hit = await cache.get<Record<string, unknown>>(MERGED_CACHE_KEY);
28
+ if (hit) {
29
+ return reply.send(hit);
30
+ }
31
+ } catch { /* cache miss */ }
32
+ }
33
+
34
+ // Fetch all upstream specs in parallel
35
+ const results = await Promise.allSettled(
36
+ UPSTREAM_SPEC_URLS.map((url) =>
37
+ fetch(url, { signal: AbortSignal.timeout(3000) }).then((r) => r.json()),
38
+ ),
39
+ );
40
+
41
+ const specs = results
42
+ .filter((r): r is PromiseFulfilledResult<unknown> => r.status === 'fulfilled')
43
+ .map((r) => r.value);
44
+
45
+ const merged = mergeOpenAPISpecs(specs as Parameters<typeof mergeOpenAPISpecs>[0]);
46
+
47
+ // Cache result
48
+ if (cache) {
49
+ try {
50
+ await cache.set(MERGED_CACHE_KEY, merged as unknown as Record<string, unknown>, MERGED_CACHE_TTL);
51
+ } catch { /* cache write failure is non-critical */ }
52
+ }
53
+
54
+ return reply.send(merged);
55
+ });
56
+
57
+ // Second Swagger UI pointing at merged spec
58
+ // Registered last so /docs (gateway-native) is already bound at this point
59
+ app.register(async function docsAll(scope) {
60
+ const swaggerUi = await import('@fastify/swagger-ui');
61
+ await scope.register(swaggerUi.default ?? swaggerUi, {
62
+ routePrefix: '/docs-all',
63
+ uiConfig: {
64
+ url: '/openapi-merged.json',
65
+ docExpansion: 'list',
66
+ deepLinking: true,
67
+ },
68
+ });
69
+ });
70
+ }