@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.
- package/.kb/database/kb.sqlite-shm +0 -0
- package/.kb/database/kb.sqlite-wal +0 -0
- package/package.json +49 -0
- package/src/__tests__/auth-routes.test.ts +279 -0
- package/src/__tests__/execute-routes.test.ts +408 -0
- package/src/__tests__/execution-registry.test.ts +218 -0
- package/src/__tests__/health.test.ts +215 -0
- package/src/__tests__/live-gateway.e2e.test.ts +648 -0
- package/src/__tests__/llm-gateway.test.ts +361 -0
- package/src/__tests__/observability-collector.test.ts +59 -0
- package/src/__tests__/platform-api.test.ts +317 -0
- package/src/__tests__/registry.test.ts +546 -0
- package/src/__tests__/retry-executor.test.ts +244 -0
- package/src/__tests__/server.integration.test.ts +417 -0
- package/src/__tests__/subscription-registry.test.ts +308 -0
- package/src/__tests__/telemetry-ingest.test.ts +309 -0
- package/src/__tests__/tokens.test.ts +83 -0
- package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
- package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
- package/src/auth/middleware.ts +50 -0
- package/src/auth/routes.ts +57 -0
- package/src/auth/tokens.ts +41 -0
- package/src/bootstrap.ts +98 -0
- package/src/clients/subscription-registry.ts +137 -0
- package/src/clients/ws-handler.ts +196 -0
- package/src/config.ts +20 -0
- package/src/docs/routes.ts +70 -0
- package/src/execute/errors.ts +21 -0
- package/src/execute/execution-registry.ts +84 -0
- package/src/execute/retry-executor.ts +159 -0
- package/src/execute/routes.ts +239 -0
- package/src/hosts/dispatcher.ts +2 -0
- package/src/hosts/registry.ts +305 -0
- package/src/hosts/ws-handler.ts +445 -0
- package/src/index.ts +7 -0
- package/src/llm/routes.ts +343 -0
- package/src/manifest.ts +21 -0
- package/src/observability/collector.ts +346 -0
- package/src/platform/routes.ts +195 -0
- package/src/server.ts +447 -0
- package/src/telemetry/routes.ts +89 -0
- package/src/ws/gateway-ws.ts +73 -0
- package/tsconfig.build.json +15 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +8 -0
- 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
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -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
|
+
}
|