@topolo/sdk 0.3.0 → 0.4.1

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.
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { TopoloClient } from './client.js';
3
3
  import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
4
4
 
@@ -16,6 +16,15 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown; contentTy
16
16
  }
17
17
 
18
18
  const AGENT = { clientName: 'topolo-test', clientVersion: '0.0.0' };
19
+ const SERVICE_LOOKUP = { body: { success: true, data: { service_id: 'service_test' } } };
20
+
21
+ function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
22
+ return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
23
+ }
24
+
25
+ beforeEach(() => {
26
+ TopoloClient.clearServiceIdCache();
27
+ });
19
28
 
20
29
  describe('TopoloClient.request', () => {
21
30
  it('injects Bearer auth + audit headers for access_token credentials', async () => {
@@ -24,10 +33,12 @@ describe('TopoloClient.request', () => {
24
33
  credential: { kind: 'access_token', accessToken: 'tok_abc' },
25
34
  agent: AGENT,
26
35
  fetch: impl,
36
+ serviceIds: { auth: 'service_auth' },
27
37
  });
28
38
  await client.request({ service: 'auth', path: '/api/me' });
29
- const headers = new Headers(calls[0]!.init.headers);
39
+ const headers = new Headers(requestCalls(calls)[0]!.init.headers);
30
40
  expect(headers.get('Authorization')).toBe('Bearer tok_abc');
41
+ expect(headers.get('X-Service-ID')).toBe('service_auth');
31
42
  expect(headers.get('X-Topolo-Client')).toBe('topolo-test/0.0.0');
32
43
  expect(headers.get('X-Topolo-Request-Id')).toMatch(/.+/);
33
44
  });
@@ -40,7 +51,7 @@ describe('TopoloClient.request', () => {
40
51
  fetch: impl,
41
52
  });
42
53
  await client.request({ service: 'crm', path: '/api/contacts' });
43
- const headers = new Headers(calls[0]!.init.headers);
54
+ const headers = new Headers(requestCalls(calls)[0]!.init.headers);
44
55
  expect(headers.get('X-Api-Key')).toBe('dak_xyz');
45
56
  expect(headers.get('Authorization')).toBeNull();
46
57
  });
@@ -71,7 +82,7 @@ describe('TopoloClient.request', () => {
71
82
  body: { email: 'a@b.co' },
72
83
  confirm: true,
73
84
  });
74
- expect(calls[0]!.init.method).toBe('POST');
85
+ expect(requestCalls(calls)[0]!.init.method).toBe('POST');
75
86
  });
76
87
 
77
88
  it('maps 401 → TopoloAuthError with unwrapped message', async () => {
@@ -144,7 +155,7 @@ describe('TopoloClient.request', () => {
144
155
  fetch: impl,
145
156
  });
146
157
  await client.request({ service: 'crm', path: '/api/contacts', query: { q: 'acme', page: 2 } });
147
- const u = new URL(calls[0]!.url);
158
+ const u = new URL(requestCalls(calls)[0]!.url);
148
159
  expect(u.searchParams.get('q')).toBe('acme');
149
160
  expect(u.searchParams.get('page')).toBe('2');
150
161
  });
@@ -169,7 +180,7 @@ describe('TopoloClient.request', () => {
169
180
 
170
181
  describe('TopoloClient.debug hook', () => {
171
182
  it('fires request → response on a successful call with stable requestId + duration', async () => {
172
- const { impl } = stubFetch([{ body: { ok: true } }]);
183
+ const { impl } = stubFetch([SERVICE_LOOKUP, { body: { ok: true } }]);
173
184
  const events: unknown[] = [];
174
185
  const client = new TopoloClient({
175
186
  credential: { kind: 'api_key', apiKey: 'x' },
@@ -262,14 +273,28 @@ describe('TopoloClient.getServiceIdBySlug', () => {
262
273
  });
263
274
  }
264
275
 
265
- it('resolves a known slug via /api/services/by-slug/:slug', async () => {
276
+ it('uses configured service ids without calling Auth', async () => {
277
+ const { impl, calls } = stubFetch([]);
278
+ const client = new TopoloClient({
279
+ credential: { kind: 'api_key', apiKey: 'x' },
280
+ agent: AGENT,
281
+ fetch: impl,
282
+ serviceUrls: { auth: 'https://auth.example.com' } as never,
283
+ serviceIds: { notify: 'service_notify' },
284
+ });
285
+ const id = await client.getServiceIdBySlug('topolo-notify');
286
+ expect(id).toBe('service_notify');
287
+ expect(calls).toEqual([]);
288
+ });
289
+
290
+ it('falls back to /api/services/by-slug/:slug for unknown slugs', async () => {
266
291
  const { impl, calls } = stubFetch([
267
- { body: { success: true, data: { service_id: 'srv_abc123', base_url: 'https://x' } } },
292
+ { body: { success: true, data: { service_id: 'service_abc123', base_url: 'https://x' } } },
268
293
  ]);
269
294
  const client = freshClient(impl, { auth: 'https://auth.example.com' });
270
- const id = await client.getServiceIdBySlug('topolo-notify');
271
- expect(id).toBe('srv_abc123');
272
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/topolo-notify');
295
+ const id = await client.getServiceIdBySlug('external-service');
296
+ expect(id).toBe('service_abc123');
297
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
273
298
  // No auth header — endpoint is public.
274
299
  const headers = new Headers(calls[0]!.init.headers);
275
300
  expect(headers.get('Authorization')).toBeNull();
@@ -286,21 +311,21 @@ describe('TopoloClient.getServiceIdBySlug', () => {
286
311
 
287
312
  it('caches positive results across calls', async () => {
288
313
  const { impl, calls } = stubFetch([
289
- { body: { success: true, data: { service_id: 'srv_iCwM4jGXcwlj' } } },
314
+ { body: { success: true, data: { service_id: 'service_external' } } },
290
315
  ]);
291
316
  const client = freshClient(impl, { auth: 'https://auth.example.com' });
292
- expect(await client.getServiceIdBySlug('topolo-crm')).toBe('srv_iCwM4jGXcwlj');
293
- expect(await client.getServiceIdBySlug('topolo-crm')).toBe('srv_iCwM4jGXcwlj');
317
+ expect(await client.getServiceIdBySlug('external-service')).toBe('service_external');
318
+ expect(await client.getServiceIdBySlug('external-service')).toBe('service_external');
294
319
  expect(calls.length).toBe(1);
295
320
  });
296
321
 
297
322
  it('normalizes slug case + whitespace before hitting the network', async () => {
298
323
  const { impl, calls } = stubFetch([
299
- { body: { success: true, data: { service_id: 'srv_x' } } },
324
+ { body: { success: true, data: { service_id: 'service_x' } } },
300
325
  ]);
301
326
  const client = freshClient(impl, { auth: 'https://auth.example.com' });
302
- await client.getServiceIdBySlug(' TOPOLO-NOTIFY ');
303
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/topolo-notify');
327
+ await client.getServiceIdBySlug(' EXTERNAL-SERVICE ');
328
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
304
329
  });
305
330
 
306
331
  it('returns null on network failure without poisoning cache', async () => {
@@ -310,7 +335,7 @@ describe('TopoloClient.getServiceIdBySlug', () => {
310
335
  throws = false;
311
336
  throw new Error('ECONNREFUSED');
312
337
  }
313
- return new Response(JSON.stringify({ success: true, data: { service_id: 'srv_y' } }), {
338
+ return new Response(JSON.stringify({ success: true, data: { service_id: 'service_y' } }), {
314
339
  status: 200,
315
340
  headers: { 'Content-Type': 'application/json' },
316
341
  });
@@ -324,7 +349,174 @@ describe('TopoloClient.getServiceIdBySlug', () => {
324
349
  });
325
350
  expect(await client.getServiceIdBySlug('flaky')).toBeNull();
326
351
  // Second call retries — earlier null was not cached.
327
- expect(await client.getServiceIdBySlug('flaky')).toBe('srv_y');
352
+ expect(await client.getServiceIdBySlug('flaky')).toBe('service_y');
353
+ });
354
+ });
355
+
356
+ describe('TopoloClient.listServices', () => {
357
+ it('normalizes the live catalog and attaches the credential', async () => {
358
+ const { impl, calls } = stubFetch([
359
+ {
360
+ body: {
361
+ data: {
362
+ services: [
363
+ {
364
+ service_id: 'srv_wRCnLLbtvmVT',
365
+ app_slug: 'topolo-director',
366
+ name: 'Topolo Director',
367
+ description: 'Demo control plane',
368
+ base_url: 'https://director.topolo.app',
369
+ status: 'active',
370
+ surface_kind: 'application',
371
+ launchable: true,
372
+ },
373
+ ],
374
+ },
375
+ },
376
+ },
377
+ ]);
378
+ const client = new TopoloClient({
379
+ credential: { kind: 'access_token', accessToken: 'tok_abc' },
380
+ agent: AGENT,
381
+ fetch: impl,
382
+ serviceUrls: { auth: 'https://auth.example.com' },
383
+ });
384
+ const out = await client.listServices();
385
+ expect(out).toEqual([
386
+ {
387
+ serviceId: 'srv_wRCnLLbtvmVT',
388
+ slug: 'topolo-director',
389
+ name: 'Topolo Director',
390
+ description: 'Demo control plane',
391
+ baseUrl: 'https://director.topolo.app',
392
+ status: 'active',
393
+ surfaceKind: 'application',
394
+ launchable: true,
395
+ },
396
+ ]);
397
+ expect(calls[0]!.url).toBe(
398
+ 'https://auth.example.com/api/services/catalog',
399
+ );
400
+ expect(new Headers(calls[0]!.init.headers).get('Authorization')).toBe(
401
+ 'Bearer tok_abc',
402
+ );
403
+ });
404
+
405
+ it('falls back to the generated snapshot on HTTP failure', async () => {
406
+ const { impl } = stubFetch([{ status: 503, body: { error: 'down' } }]);
407
+ const client = new TopoloClient({
408
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
409
+ agent: AGENT,
410
+ fetch: impl,
411
+ serviceUrls: { auth: 'https://auth.example.com' },
412
+ });
413
+ const out = await client.listServices();
414
+ expect(out.length).toBeGreaterThan(10);
415
+ expect(out.some((e) => e.serviceId === 'admin')).toBe(true);
416
+ expect(out.every((e) => typeof e.serviceId === 'string')).toBe(true);
417
+ });
418
+
419
+ it('resolves future API services from the live catalog during request routing', async () => {
420
+ const { impl, calls } = stubFetch([
421
+ {
422
+ body: {
423
+ data: {
424
+ services: [
425
+ {
426
+ service_id: 'srv_future',
427
+ app_slug: 'topolo-warehouse',
428
+ name: 'Warehouse',
429
+ base_url: 'https://warehouse.topolo.app',
430
+ surface_kind: 'api',
431
+ status: 'active',
432
+ },
433
+ ],
434
+ },
435
+ },
436
+ },
437
+ { body: { ok: true } },
438
+ ]);
439
+ const client = new TopoloClient({
440
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
441
+ agent: AGENT,
442
+ fetch: impl,
443
+ serviceUrls: { auth: 'https://auth.example.com' },
444
+ });
445
+
446
+ await client.request({ service: 'warehouse', path: '/api/health' });
447
+
448
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
449
+ expect(calls[1]!.url).toBe('https://warehouse.topolo.app/api/health');
450
+ });
451
+
452
+ it('does not treat live browser applications as generic API services', async () => {
453
+ const { impl } = stubFetch([
454
+ {
455
+ body: {
456
+ data: {
457
+ services: [
458
+ {
459
+ service_id: 'srv_future_app',
460
+ app_slug: 'topolo-future-app',
461
+ name: 'Future App',
462
+ base_url: 'https://future-app.topolo.app',
463
+ surface_kind: 'application',
464
+ status: 'active',
465
+ launchable: true,
466
+ },
467
+ ],
468
+ },
469
+ },
470
+ },
471
+ ]);
472
+ const client = new TopoloClient({
473
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
474
+ agent: AGENT,
475
+ fetch: impl,
476
+ serviceUrls: { auth: 'https://auth.example.com' },
477
+ });
478
+
479
+ await expect(client.request({ service: 'future-app', path: '/api/health' })).rejects.toMatchObject({
480
+ code: 'unknown_service',
481
+ });
482
+ });
483
+
484
+ it('caches the live catalog per client for repeated dynamic service calls', async () => {
485
+ const { impl, calls } = stubFetch([
486
+ {
487
+ body: {
488
+ data: {
489
+ services: [
490
+ {
491
+ service_id: 'srv_future',
492
+ app_slug: 'topolo-warehouse',
493
+ name: 'Warehouse',
494
+ base_url: 'https://warehouse.topolo.app',
495
+ surface_kind: 'api',
496
+ status: 'active',
497
+ },
498
+ ],
499
+ },
500
+ },
501
+ },
502
+ { body: { ok: true } },
503
+ { body: { ok: true } },
504
+ ]);
505
+ const client = new TopoloClient({
506
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
507
+ agent: AGENT,
508
+ fetch: impl,
509
+ serviceUrls: { auth: 'https://auth.example.com' },
510
+ });
511
+
512
+ await client.request({ service: 'warehouse', path: '/api/one' });
513
+ await client.request({ service: 'warehouse', path: '/api/two' });
514
+
515
+ expect(calls.map((call) => call.url)).toEqual([
516
+ 'https://auth.example.com/api/services/catalog',
517
+ 'https://warehouse.topolo.app/api/one',
518
+ 'https://warehouse.topolo.app/api/two',
519
+ ]);
328
520
  });
329
521
  });
330
522