@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
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@kb-labs/gateway-app",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "clean": "rimraf dist",
8
+ "build": "tsup",
9
+ "dev": "tsx watch src/index.ts",
10
+ "start": "node dist/index.js",
11
+ "type-check": "tsc --noEmit",
12
+ "lint": "eslint .",
13
+ "lint:fix": "eslint . --fix",
14
+ "test": "vitest run",
15
+ "test:live": "vitest run src/__tests__/live-gateway.e2e.test.ts",
16
+ "test:watch": "vitest"
17
+ },
18
+ "dependencies": {
19
+ "@fastify/cors": "^10.0.1",
20
+ "@fastify/http-proxy": "^11.4.1",
21
+ "@fastify/swagger": ">=9.5.1",
22
+ "@fastify/swagger-ui": "^5.2.0",
23
+ "@fastify/websocket": "^11.0.1",
24
+ "fastify-type-provider-zod": "^6.0.0",
25
+ "ws": "^8.18.3",
26
+ "@kb-labs/shared-http": "^1.3.0",
27
+ "@kb-labs/core-config": "^1.5.0",
28
+ "@kb-labs/core-contracts": "^1.5.0",
29
+ "@kb-labs/core-platform": "^1.5.0",
30
+ "@kb-labs/core-registry": "^1.5.0",
31
+ "@kb-labs/core-runtime": "^1.5.0",
32
+ "@kb-labs/core-sys": "^1.5.0",
33
+ "@kb-labs/gateway-auth": "^0.2.0",
34
+ "@kb-labs/gateway-contracts": "^0.2.0",
35
+ "@kb-labs/gateway-core": "^0.2.0",
36
+ "fastify": "^5.2.0"
37
+ },
38
+ "devDependencies": {
39
+ "@kb-labs/devkit": "link:../../../kb-labs-devkit",
40
+ "@types/ws": "^8.5.14",
41
+ "eslint": "^9",
42
+ "tsx": "^4.20.5",
43
+ "tsup": "^8.5.0",
44
+ "vitest": "^3.2.4"
45
+ },
46
+ "kb": {
47
+ "manifest": "./dist/manifest.js"
48
+ }
49
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Integration tests for auth routes (CC4 — Auth Flow).
3
+ * Spins up a real Fastify instance with mocked AuthService.
4
+ *
5
+ * Covers:
6
+ * POST /auth/register — happy path, bad body (400)
7
+ * POST /auth/token — happy path, bad creds (401), bad body (400)
8
+ * POST /auth/refresh — happy path, expired token (401), bad body (400)
9
+ */
10
+ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
11
+ import Fastify, { type FastifyInstance } from 'fastify';
12
+ import type { ICache } from '@kb-labs/core-platform';
13
+ import type { JwtConfig } from '@kb-labs/gateway-auth';
14
+ import { createAuthMiddleware } from '../auth/middleware.js';
15
+ import { registerAuthRoutes } from '../auth/routes.js';
16
+
17
+ // ── Minimal stub cache ────────────────────────────────────────────────────────
18
+
19
+ function makeCache(): ICache {
20
+ const store = new Map<string, unknown>();
21
+ return {
22
+ async get<T>(k: string) { return (store.get(k) as T) ?? null; },
23
+ async set(k: string, v: unknown) { store.set(k, v); },
24
+ async delete(k: string) { store.delete(k); },
25
+ async clear() { store.clear(); },
26
+ } as unknown as ICache;
27
+ }
28
+
29
+ // ── Mocked AuthService ────────────────────────────────────────────────────────
30
+
31
+ function makeAuthService() {
32
+ return {
33
+ register: vi.fn(),
34
+ issueTokens: vi.fn(),
35
+ refreshTokens: vi.fn(),
36
+ verify: vi.fn(),
37
+ };
38
+ }
39
+
40
+ const testJwtConfig: JwtConfig = { secret: 'test-secret' };
41
+
42
+ let app: FastifyInstance;
43
+ let authService: ReturnType<typeof makeAuthService>;
44
+
45
+ beforeAll(async () => {
46
+ authService = makeAuthService();
47
+ app = Fastify({ logger: false });
48
+
49
+ const cache = makeCache();
50
+ app.addHook('preHandler', createAuthMiddleware(cache, testJwtConfig));
51
+
52
+ // We inject the mocked authService via cast — same interface
53
+ registerAuthRoutes(app, authService as never);
54
+
55
+ await app.ready();
56
+ });
57
+
58
+ afterAll(async () => {
59
+ await app.close();
60
+ });
61
+
62
+ // ── POST /auth/register ───────────────────────────────────────────────────────
63
+
64
+ describe('POST /auth/register', () => {
65
+ it('returns 201 with clientId, clientSecret, hostId on success', async () => {
66
+ authService.register.mockResolvedValue({
67
+ clientId: 'client-abc',
68
+ clientSecret: 'secret-xyz',
69
+ hostId: 'host-001',
70
+ });
71
+
72
+ const res = await app.inject({
73
+ method: 'POST',
74
+ url: '/auth/register',
75
+ payload: { name: 'My Agent', namespaceId: 'ns-test' },
76
+ });
77
+
78
+ expect(res.statusCode).toBe(201);
79
+ const body = res.json() as { clientId: string; clientSecret: string; hostId: string };
80
+ expect(body.clientId).toBe('client-abc');
81
+ expect(body.clientSecret).toBe('secret-xyz');
82
+ expect(body.hostId).toBe('host-001');
83
+ expect(authService.register).toHaveBeenCalledWith(
84
+ expect.objectContaining({ name: 'My Agent', namespaceId: 'ns-test' }),
85
+ );
86
+ });
87
+
88
+ it('returns 400 when name is missing', async () => {
89
+ const res = await app.inject({
90
+ method: 'POST',
91
+ url: '/auth/register',
92
+ payload: { namespaceId: 'ns-test' },
93
+ });
94
+ expect(res.statusCode).toBe(400);
95
+ const body = res.json() as { error: string };
96
+ expect(body.error).toBe('Bad Request');
97
+ });
98
+
99
+ it('returns 400 when namespaceId is missing', async () => {
100
+ const res = await app.inject({
101
+ method: 'POST',
102
+ url: '/auth/register',
103
+ payload: { name: 'agent' },
104
+ });
105
+ expect(res.statusCode).toBe(400);
106
+ });
107
+
108
+ it('returns 400 when body is empty', async () => {
109
+ const res = await app.inject({
110
+ method: 'POST',
111
+ url: '/auth/register',
112
+ payload: {},
113
+ });
114
+ expect(res.statusCode).toBe(400);
115
+ });
116
+
117
+ it('forwards capabilities array to authService', async () => {
118
+ authService.register.mockResolvedValue({
119
+ clientId: 'c-2',
120
+ clientSecret: 's-2',
121
+ hostId: 'h-2',
122
+ });
123
+
124
+ await app.inject({
125
+ method: 'POST',
126
+ url: '/auth/register',
127
+ payload: { name: 'Cap Agent', namespaceId: 'ns', capabilities: ['read', 'write'] },
128
+ });
129
+
130
+ expect(authService.register).toHaveBeenCalledWith(
131
+ expect.objectContaining({ capabilities: ['read', 'write'] }),
132
+ );
133
+ });
134
+ });
135
+
136
+ // ── POST /auth/token ──────────────────────────────────────────────────────────
137
+
138
+ describe('POST /auth/token', () => {
139
+ it('returns 200 with token pair on valid credentials', async () => {
140
+ authService.issueTokens.mockResolvedValue({
141
+ accessToken: 'access.jwt.token',
142
+ refreshToken: 'refresh.jwt.token',
143
+ expiresIn: 3600,
144
+ tokenType: 'Bearer',
145
+ });
146
+
147
+ const res = await app.inject({
148
+ method: 'POST',
149
+ url: '/auth/token',
150
+ payload: { clientId: 'client-abc', clientSecret: 'secret-xyz' },
151
+ });
152
+
153
+ expect(res.statusCode).toBe(200);
154
+ const body = res.json() as { accessToken: string; tokenType: string };
155
+ expect(body.accessToken).toBe('access.jwt.token');
156
+ expect(body.tokenType).toBe('Bearer');
157
+ expect(authService.issueTokens).toHaveBeenCalledWith('client-abc', 'secret-xyz');
158
+ });
159
+
160
+ it('returns 401 when credentials are invalid (issueTokens returns null)', async () => {
161
+ authService.issueTokens.mockResolvedValue(null);
162
+
163
+ const res = await app.inject({
164
+ method: 'POST',
165
+ url: '/auth/token',
166
+ payload: { clientId: 'bad', clientSecret: 'wrong' },
167
+ });
168
+
169
+ expect(res.statusCode).toBe(401);
170
+ const body = res.json() as { error: string; message: string };
171
+ expect(body.error).toBe('Unauthorized');
172
+ expect(body.message).toContain('Invalid credentials');
173
+ });
174
+
175
+ it('returns 400 when clientId is missing', async () => {
176
+ const res = await app.inject({
177
+ method: 'POST',
178
+ url: '/auth/token',
179
+ payload: { clientSecret: 'xyz' },
180
+ });
181
+ expect(res.statusCode).toBe(400);
182
+ });
183
+
184
+ it('returns 400 when clientSecret is missing', async () => {
185
+ const res = await app.inject({
186
+ method: 'POST',
187
+ url: '/auth/token',
188
+ payload: { clientId: 'abc' },
189
+ });
190
+ expect(res.statusCode).toBe(400);
191
+ });
192
+
193
+ it('returns 400 when body is empty', async () => {
194
+ const res = await app.inject({
195
+ method: 'POST',
196
+ url: '/auth/token',
197
+ payload: {},
198
+ });
199
+ expect(res.statusCode).toBe(400);
200
+ });
201
+ });
202
+
203
+ // ── POST /auth/refresh ────────────────────────────────────────────────────────
204
+
205
+ describe('POST /auth/refresh', () => {
206
+ it('returns 200 with new token pair on valid refresh token', async () => {
207
+ authService.refreshTokens.mockResolvedValue({
208
+ accessToken: 'new.access.token',
209
+ refreshToken: 'new.refresh.token',
210
+ expiresIn: 3600,
211
+ tokenType: 'Bearer',
212
+ });
213
+
214
+ const res = await app.inject({
215
+ method: 'POST',
216
+ url: '/auth/refresh',
217
+ payload: { refreshToken: 'valid.refresh.token' },
218
+ });
219
+
220
+ expect(res.statusCode).toBe(200);
221
+ const body = res.json() as { accessToken: string; refreshToken: string };
222
+ expect(body.accessToken).toBe('new.access.token');
223
+ expect(body.refreshToken).toBe('new.refresh.token');
224
+ expect(authService.refreshTokens).toHaveBeenCalledWith('valid.refresh.token');
225
+ });
226
+
227
+ it('returns 401 when refresh token is invalid (refreshTokens returns null)', async () => {
228
+ authService.refreshTokens.mockResolvedValue(null);
229
+
230
+ const res = await app.inject({
231
+ method: 'POST',
232
+ url: '/auth/refresh',
233
+ payload: { refreshToken: 'expired.or.invalid' },
234
+ });
235
+
236
+ expect(res.statusCode).toBe(401);
237
+ const body = res.json() as { error: string; message: string };
238
+ expect(body.error).toBe('Unauthorized');
239
+ expect(body.message).toContain('Invalid or expired');
240
+ });
241
+
242
+ it('returns 400 when refreshToken field is missing', async () => {
243
+ const res = await app.inject({
244
+ method: 'POST',
245
+ url: '/auth/refresh',
246
+ payload: {},
247
+ });
248
+ expect(res.statusCode).toBe(400);
249
+ });
250
+
251
+ it('returns 400 when body contains wrong field', async () => {
252
+ const res = await app.inject({
253
+ method: 'POST',
254
+ url: '/auth/refresh',
255
+ payload: { token: 'wrong-field-name' },
256
+ });
257
+ expect(res.statusCode).toBe(400);
258
+ });
259
+ });
260
+
261
+ // ── Auth middleware: public routes skip auth check ────────────────────────────
262
+
263
+ describe('Auth middleware — public routes', () => {
264
+ it('/auth/register is accessible without Authorization header', async () => {
265
+ authService.register.mockResolvedValue({
266
+ clientId: 'c', clientSecret: 's', hostId: 'h',
267
+ });
268
+
269
+ const res = await app.inject({
270
+ method: 'POST',
271
+ url: '/auth/register',
272
+ payload: { name: 'x', namespaceId: 'y' },
273
+ // No Authorization header
274
+ });
275
+
276
+ // Should reach the route handler, not be blocked by middleware
277
+ expect(res.statusCode).not.toBe(401);
278
+ });
279
+ });