@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,317 @@
1
+ /**
2
+ * Integration tests for Unified Platform API.
3
+ *
4
+ * Covers:
5
+ * POST /platform/v1/{adapter}/{method}
6
+ * - 401 without auth
7
+ * - 404 unknown adapter
8
+ * - 403 method not in allowlist
9
+ * - 503 adapter not configured
10
+ * - 501 method not implemented on adapter
11
+ * - 200 cache/get returns value
12
+ * - 200 cache/set stores value
13
+ * - 200 llm/complete proxies to LLM
14
+ * - 200 analytics/track proxies to analytics
15
+ * - 502 adapter method throws
16
+ * - durationMs included in response
17
+ * - streaming for llm/stream → SSE
18
+ */
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
20
+ import Fastify, { type FastifyInstance } from 'fastify';
21
+ import type { ICache, ILogger, ILLM, IAnalytics } from '@kb-labs/core-platform';
22
+ import type { JwtConfig } from '@kb-labs/gateway-auth';
23
+ import { createAuthMiddleware } from '../auth/middleware.js';
24
+ import { registerPlatformRoutes } from '../platform/routes.js';
25
+
26
+ // ── Mocks ─────────────────────────────────────────────────────────────────
27
+
28
+ const mockLLM: ILLM & { chatWithTools: any } = {
29
+ complete: vi.fn(),
30
+ stream: vi.fn(),
31
+ chatWithTools: vi.fn(),
32
+ };
33
+
34
+ const mockAnalytics: Partial<IAnalytics> = {
35
+ track: vi.fn(),
36
+ identify: vi.fn(),
37
+ flush: vi.fn(),
38
+ };
39
+
40
+ const mockCacheStore = new Map<string, unknown>();
41
+ const mockCacheAdapter = {
42
+ get: vi.fn(async (k: string) => mockCacheStore.get(k) ?? null),
43
+ set: vi.fn(async (k: string, v: unknown) => { mockCacheStore.set(k, v); }),
44
+ delete: vi.fn(async (k: string) => { mockCacheStore.delete(k); }),
45
+ clear: vi.fn(async () => { mockCacheStore.clear(); }),
46
+ };
47
+
48
+ vi.mock('@kb-labs/core-runtime', () => ({
49
+ platform: {
50
+ get llm() { return mockLLM; },
51
+ get analytics() { return mockAnalytics; },
52
+ get cache() { return mockCacheAdapter; },
53
+ get vectorStore() { return undefined; },
54
+ get embeddings() { return undefined; },
55
+ get storage() { return undefined; },
56
+ get eventBus() { return undefined; },
57
+ get sqlDatabase() { return undefined; },
58
+ get documentDatabase() { return undefined; },
59
+ },
60
+ }));
61
+
62
+ // ── Helpers ───────────────────────────────────────────────────────────────
63
+
64
+ function makeAuthCache(): ICache {
65
+ const store = new Map<string, unknown>();
66
+ return {
67
+ async get<T>(k: string) { return (store.get(k) as T) ?? null; },
68
+ async set(k: string, v: unknown) { store.set(k, v); },
69
+ async delete(k: string) { store.delete(k); },
70
+ async clear() { store.clear(); },
71
+ } as unknown as ICache;
72
+ }
73
+
74
+ const noopLogger: ILogger = {
75
+ info: vi.fn(),
76
+ warn: vi.fn(),
77
+ error: vi.fn(),
78
+ debug: vi.fn(),
79
+ child: vi.fn(() => noopLogger),
80
+ } as unknown as ILogger;
81
+
82
+ const stubJwtConfig: JwtConfig = { secret: 'test-secret' };
83
+ const TEST_TOKEN = 'test-platform-token';
84
+ const TEST_AUTH = `Bearer ${TEST_TOKEN}`;
85
+
86
+ // ── Test app builder ──────────────────────────────────────────────────────
87
+
88
+ async function buildApp(): Promise<{ app: FastifyInstance; cache: ICache }> {
89
+ const cache = makeAuthCache();
90
+ await cache.set(`host:token:${TEST_TOKEN}`, {
91
+ hostId: 'host-test',
92
+ namespaceId: 'ns-test',
93
+ });
94
+
95
+ const app = Fastify({ logger: false });
96
+ await app.register(async function scope(s) {
97
+ s.addHook('onRequest', createAuthMiddleware(cache, stubJwtConfig));
98
+ registerPlatformRoutes(s as any, noopLogger);
99
+ });
100
+ await app.ready();
101
+ return { app, cache };
102
+ }
103
+
104
+ // ── Tests ─────────────────────────────────────────────────────────────────
105
+
106
+ describe('Unified Platform API – POST /platform/v1/:adapter/:method', () => {
107
+ let app: FastifyInstance;
108
+
109
+ beforeEach(async () => {
110
+ vi.clearAllMocks();
111
+ mockCacheStore.clear();
112
+ ({ app } = await buildApp());
113
+ });
114
+
115
+ afterEach(async () => {
116
+ await app.close();
117
+ });
118
+
119
+ // ── Auth ──────────────────────────────────────────────────────────────
120
+
121
+ it('returns 401 without auth', async () => {
122
+ const res = await app.inject({
123
+ method: 'POST',
124
+ url: '/platform/v1/cache/get',
125
+ payload: { args: ['key'] },
126
+ });
127
+ expect(res.statusCode).toBe(401);
128
+ });
129
+
130
+ // ── Validation ────────────────────────────────────────────────────────
131
+
132
+ it('returns 404 for unknown adapter', async () => {
133
+ const res = await app.inject({
134
+ method: 'POST',
135
+ url: '/platform/v1/unknownAdapter/get',
136
+ headers: { authorization: TEST_AUTH },
137
+ payload: { args: [] },
138
+ });
139
+ expect(res.statusCode).toBe(404);
140
+ expect(res.json().error.code).toBe('ADAPTER_NOT_FOUND');
141
+ });
142
+
143
+ it('returns 403 for disallowed method', async () => {
144
+ const res = await app.inject({
145
+ method: 'POST',
146
+ url: '/platform/v1/cache/setSource',
147
+ headers: { authorization: TEST_AUTH },
148
+ payload: { args: [] },
149
+ });
150
+ expect(res.statusCode).toBe(403);
151
+ expect(res.json().error.code).toBe('METHOD_NOT_ALLOWED');
152
+ });
153
+
154
+ it('returns 503 when adapter not configured', async () => {
155
+ const res = await app.inject({
156
+ method: 'POST',
157
+ url: '/platform/v1/vectorStore/search',
158
+ headers: { authorization: TEST_AUTH },
159
+ payload: { args: [{ query: 'test' }] },
160
+ });
161
+ expect(res.statusCode).toBe(503);
162
+ expect(res.json().error.code).toBe('ADAPTER_UNAVAILABLE');
163
+ });
164
+
165
+ // ── Cache adapter ─────────────────────────────────────────────────────
166
+
167
+ it('cache/get returns cached value', async () => {
168
+ mockCacheAdapter.get.mockResolvedValueOnce('hello-world');
169
+
170
+ const res = await app.inject({
171
+ method: 'POST',
172
+ url: '/platform/v1/cache/get',
173
+ headers: { authorization: TEST_AUTH },
174
+ payload: { args: ['my-key'] },
175
+ });
176
+
177
+ expect(res.statusCode).toBe(200);
178
+ const body = res.json();
179
+ expect(body.ok).toBe(true);
180
+ expect(body.result).toBe('hello-world');
181
+ expect(typeof body.durationMs).toBe('number');
182
+ expect(mockCacheAdapter.get).toHaveBeenCalledWith('my-key');
183
+ });
184
+
185
+ it('cache/set stores value', async () => {
186
+ mockCacheAdapter.set.mockResolvedValueOnce(undefined);
187
+
188
+ const res = await app.inject({
189
+ method: 'POST',
190
+ url: '/platform/v1/cache/set',
191
+ headers: { authorization: TEST_AUTH },
192
+ payload: { args: ['my-key', { data: 42 }, 60000] },
193
+ });
194
+
195
+ expect(res.statusCode).toBe(200);
196
+ expect(res.json().ok).toBe(true);
197
+ expect(mockCacheAdapter.set).toHaveBeenCalledWith('my-key', { data: 42 }, 60000);
198
+ });
199
+
200
+ // ── LLM adapter ──────────────────────────────────────────────────────
201
+
202
+ it('llm/complete proxies to LLM', async () => {
203
+ mockLLM.complete = vi.fn().mockResolvedValueOnce({
204
+ content: 'Hello!',
205
+ usage: { promptTokens: 10, completionTokens: 5 },
206
+ model: 'test-model',
207
+ });
208
+
209
+ const res = await app.inject({
210
+ method: 'POST',
211
+ url: '/platform/v1/llm/complete',
212
+ headers: { authorization: TEST_AUTH },
213
+ payload: { args: ['Say hello', { temperature: 0.5 }] },
214
+ });
215
+
216
+ expect(res.statusCode).toBe(200);
217
+ const body = res.json();
218
+ expect(body.ok).toBe(true);
219
+ expect(body.result.content).toBe('Hello!');
220
+ expect(mockLLM.complete).toHaveBeenCalledWith('Say hello', { temperature: 0.5 });
221
+ });
222
+
223
+ // ── Analytics adapter ─────────────────────────────────────────────────
224
+
225
+ it('analytics/track proxies to analytics', async () => {
226
+ (mockAnalytics.track as any).mockResolvedValueOnce(undefined);
227
+
228
+ const res = await app.inject({
229
+ method: 'POST',
230
+ url: '/platform/v1/analytics/track',
231
+ headers: { authorization: TEST_AUTH },
232
+ payload: { args: ['user.signup', { plan: 'pro' }] },
233
+ });
234
+
235
+ expect(res.statusCode).toBe(200);
236
+ expect(res.json().ok).toBe(true);
237
+ expect(mockAnalytics.track).toHaveBeenCalledWith('user.signup', { plan: 'pro' });
238
+ });
239
+
240
+ // ── Error handling ────────────────────────────────────────────────────
241
+
242
+ it('returns 502 when adapter method throws', async () => {
243
+ mockLLM.complete = vi.fn().mockRejectedValueOnce(new Error('Provider timeout'));
244
+
245
+ const res = await app.inject({
246
+ method: 'POST',
247
+ url: '/platform/v1/llm/complete',
248
+ headers: { authorization: TEST_AUTH },
249
+ payload: { args: ['test'] },
250
+ });
251
+
252
+ expect(res.statusCode).toBe(502);
253
+ const body = res.json();
254
+ expect(body.ok).toBe(false);
255
+ expect(body.error.message).toBe('Provider timeout');
256
+ expect(body.error.code).toBe('ADAPTER_ERROR');
257
+ expect(typeof body.durationMs).toBe('number');
258
+ });
259
+
260
+ // ── durationMs ────────────────────────────────────────────────────────
261
+
262
+ it('includes durationMs in response', async () => {
263
+ mockCacheAdapter.get.mockResolvedValueOnce(null);
264
+
265
+ const res = await app.inject({
266
+ method: 'POST',
267
+ url: '/platform/v1/cache/get',
268
+ headers: { authorization: TEST_AUTH },
269
+ payload: { args: ['nonexistent'] },
270
+ });
271
+
272
+ expect(res.statusCode).toBe(200);
273
+ expect(typeof res.json().durationMs).toBe('number');
274
+ expect(res.json().durationMs).toBeGreaterThanOrEqual(0);
275
+ });
276
+
277
+ // ── Args passing ──────────────────────────────────────────────────────
278
+
279
+ it('passes args correctly to adapter method', async () => {
280
+ mockCacheAdapter.set.mockResolvedValueOnce(undefined);
281
+
282
+ await app.inject({
283
+ method: 'POST',
284
+ url: '/platform/v1/cache/set',
285
+ headers: { authorization: TEST_AUTH },
286
+ payload: { args: ['k', 'v', 30000] },
287
+ });
288
+
289
+ expect(mockCacheAdapter.set).toHaveBeenCalledWith('k', 'v', 30000);
290
+ });
291
+
292
+ // ── Streaming ─────────────────────────────────────────────────────────
293
+
294
+ it('llm/stream returns SSE format', async () => {
295
+ async function* fakeStream() {
296
+ yield 'Hello';
297
+ yield ' world';
298
+ }
299
+ mockLLM.stream = vi.fn().mockReturnValueOnce(fakeStream());
300
+
301
+ const res = await app.inject({
302
+ method: 'POST',
303
+ url: '/platform/v1/llm/stream',
304
+ headers: { authorization: TEST_AUTH },
305
+ payload: { args: ['test prompt'] },
306
+ });
307
+
308
+ expect(res.statusCode).toBe(200);
309
+ expect(res.headers['content-type']).toBe('text/event-stream');
310
+
311
+ const lines = res.body.split('\n').filter((l: string) => l.startsWith('data: '));
312
+ expect(lines.length).toBeGreaterThanOrEqual(3); // Hello, world, [DONE]
313
+ expect(lines[0]).toBe('data: Hello');
314
+ expect(lines[1]).toBe('data: world');
315
+ expect(lines[lines.length - 1]).toBe('data: [DONE]');
316
+ });
317
+ });