@topolo/sdk 0.7.0 → 0.8.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,894 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { TopoloClient } from './client.js';
3
- import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
4
-
5
- function stubFetch(responses: Array<{ status?: number; body?: unknown; contentType?: string }>) {
6
- const calls: Array<{ url: string; init: RequestInit }> = [];
7
- const impl = vi.fn(async (url: string, init: RequestInit) => {
8
- calls.push({ url, init });
9
- const next = responses.shift() ?? { status: 200, body: { ok: true } };
10
- return new Response(JSON.stringify(next.body ?? {}), {
11
- status: next.status ?? 200,
12
- headers: { 'Content-Type': next.contentType ?? 'application/json' },
13
- });
14
- });
15
- return { impl: impl as unknown as typeof fetch, calls };
16
- }
17
-
18
- const AGENT = { clientName: 'topolo-test', clientVersion: '0.0.0' };
19
- const SERVICE_LOOKUP = { body: { success: true, data: { app_id: 'app_test' } } };
20
- const MAIL_CATALOG = {
21
- body: {
22
- data: {
23
- services: [
24
- {
25
- app_id: 'app_mail',
26
- app_slug: 'topolo-mail',
27
- name: 'Topolo Mail',
28
- api_base_url: 'https://mail-api.topolo.app',
29
- launch_url: 'https://mail.topolo.app',
30
- surface_kind: 'api',
31
- status: 'active',
32
- granted_permissions: ['app_mail.messages:read'],
33
- },
34
- ],
35
- },
36
- },
37
- };
38
- const ACTION_CATALOG = {
39
- body: {
40
- data: {
41
- actions: [
42
- {
43
- action_id: 'app_mail.messages.list',
44
- name: 'messages.list',
45
- tool_name: 'topolo_mail_messages_list',
46
- title: 'List messages',
47
- description: 'List Mail messages',
48
- app_id: 'app_mail',
49
- app_slug: 'topolo-mail',
50
- app_name: 'Topolo Mail',
51
- method: 'GET',
52
- path: '/api/mail/mailboxes/{mailboxId}/messages',
53
- permission_name: 'messages:read',
54
- required_permission: 'app_mail.messages:read',
55
- input_schema: { type: 'object' },
56
- output_schema: {},
57
- read_only: true,
58
- destructive: false,
59
- requires_confirmation: false,
60
- },
61
- {
62
- action_id: 'app_mail.messages.get',
63
- name: 'messages.get',
64
- tool_name: 'topolo_mail_messages_get',
65
- title: 'Get message',
66
- description: 'Get Mail message',
67
- app_id: 'app_mail',
68
- app_slug: 'topolo-mail',
69
- app_name: 'Topolo Mail',
70
- method: 'GET',
71
- path: '/api/mail/mailboxes/{mailboxId}/messages/{messageId}',
72
- permission_name: 'messages:read',
73
- required_permission: 'app_mail.messages:read',
74
- input_schema: { type: 'object' },
75
- output_schema: {},
76
- read_only: true,
77
- destructive: false,
78
- requires_confirmation: false,
79
- },
80
- {
81
- action_id: 'app_mail.messages.send',
82
- name: 'messages.send',
83
- tool_name: 'topolo_mail_messages_send',
84
- title: 'Send message',
85
- description: 'Send Mail message',
86
- app_id: 'app_mail',
87
- app_slug: 'topolo-mail',
88
- app_name: 'Topolo Mail',
89
- method: 'POST',
90
- path: '/api/mail/mailboxes/{mailboxId}/messages',
91
- permission_name: 'messages:send',
92
- required_permission: 'app_mail.messages:send',
93
- input_schema: { type: 'object' },
94
- output_schema: {},
95
- read_only: false,
96
- destructive: false,
97
- requires_confirmation: true,
98
- },
99
- ],
100
- },
101
- },
102
- };
103
-
104
- function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
105
- return calls.filter((call) =>
106
- !call.url.includes('/api/services/by-slug/') &&
107
- !call.url.includes('/api/services/catalog') &&
108
- !call.url.includes('/api/actions/catalog')
109
- );
110
- }
111
-
112
- beforeEach(() => {
113
- TopoloClient.clearAppIdCache();
114
- });
115
-
116
- describe('TopoloClient.request', () => {
117
- it('injects Bearer auth + audit headers for access_token credentials', async () => {
118
- const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
119
- const client = new TopoloClient({
120
- credential: { kind: 'access_token', accessToken: 'tok_abc' },
121
- agent: AGENT,
122
- fetch: impl,
123
- appIds: { auth: 'service_auth' },
124
- });
125
- await client.request({ service: 'auth', path: '/api/me' });
126
- const headers = new Headers(requestCalls(calls)[0]!.init.headers);
127
- expect(headers.get('Authorization')).toBe('Bearer tok_abc');
128
- expect(headers.get('X-App-ID')).toBe('service_auth');
129
- expect(headers.get('X-Topolo-Client')).toBe('topolo-test/0.0.0');
130
- expect(headers.get('X-Topolo-Request-Id')).toMatch(/.+/);
131
- });
132
-
133
- it('uses X-Api-Key header for api_key credentials', async () => {
134
- const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
135
- const client = new TopoloClient({
136
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
137
- agent: AGENT,
138
- fetch: impl,
139
- });
140
- await client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages' });
141
- const headers = new Headers(requestCalls(calls)[0]!.init.headers);
142
- expect(headers.get('X-Api-Key')).toBe('dak_xyz');
143
- expect(headers.get('Authorization')).toBeNull();
144
- });
145
-
146
- it('adds canonical resource headers for resource-scoped API keys', async () => {
147
- const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
148
- const client = new TopoloClient({
149
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
150
- agent: AGENT,
151
- fetch: impl,
152
- });
153
- await client.request({
154
- service: 'mail',
155
- path: '/api/mail/mailboxes/mbx_1/messages',
156
- resource: { resourceType: 'mailbox', resourceId: 'mbx_1' },
157
- });
158
- const headers = new Headers(requestCalls(calls)[0]!.init.headers);
159
- expect(headers.get('X-Topolo-Resource-Type')).toBe('mailbox');
160
- expect(headers.get('X-Topolo-Resource-ID')).toBe('mbx_1');
161
- });
162
-
163
- it('passes arbitrary cataloged workspace aliases through resource headers', async () => {
164
- const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
165
- const client = new TopoloClient({
166
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
167
- agent: AGENT,
168
- fetch: impl,
169
- });
170
- await client.request({
171
- service: 'mail',
172
- path: '/api/example',
173
- resource: { resourceType: 'studio_portfolio', resourceId: 'portfolio_1' },
174
- });
175
- const headers = new Headers(requestCalls(calls)[0]!.init.headers);
176
- expect(headers.get('X-Topolo-Resource-Type')).toBe('studio_portfolio');
177
- expect(headers.get('X-Topolo-Resource-ID')).toBe('portfolio_1');
178
- });
179
-
180
- it('refuses mutating requests without confirm', async () => {
181
- const { impl } = stubFetch([]);
182
- const client = new TopoloClient({
183
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
184
- agent: AGENT,
185
- fetch: impl,
186
- });
187
- await expect(
188
- client.request({ service: 'mail', method: 'POST', path: '/api/mail/mailboxes/primary/messages', body: {} }),
189
- ).rejects.toBeInstanceOf(TopoloAuthError);
190
- });
191
-
192
- it('allows mutating requests with confirm: true', async () => {
193
- const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { id: 'm_1' } }]);
194
- const client = new TopoloClient({
195
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
196
- agent: AGENT,
197
- fetch: impl,
198
- });
199
- await client.request({
200
- service: 'mail',
201
- method: 'POST',
202
- path: '/api/mail/mailboxes/primary/messages',
203
- body: { subject: 'Hello' },
204
- confirm: true,
205
- });
206
- expect(requestCalls(calls)[0]!.init.method).toBe('POST');
207
- });
208
-
209
- it('maps 401 → TopoloAuthError with unwrapped message', async () => {
210
- const { impl } = stubFetch([
211
- { status: 401, body: { error: { code: 'invalid_token', message: 'Token expired' } } },
212
- ]);
213
- const client = new TopoloClient({
214
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
215
- agent: AGENT,
216
- fetch: impl,
217
- });
218
- await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toMatchObject({
219
- name: 'TopoloAuthError',
220
- code: 'auth_error',
221
- message: 'Token expired',
222
- });
223
- });
224
-
225
- it('regression: nested error.message object never stringifies to [object Object]', async () => {
226
- const { impl } = stubFetch([
227
- { status: 401, body: { error: { code: 'bad_sig', message: 'Bad signature' } } },
228
- ]);
229
- const client = new TopoloClient({
230
- credential: { kind: 'access_token', accessToken: 'bad' },
231
- agent: AGENT,
232
- fetch: impl,
233
- });
234
- try {
235
- await client.request({ service: 'auth', path: '/api/me' });
236
- throw new Error('expected throw');
237
- } catch (err) {
238
- expect(err).toBeInstanceOf(TopoloAuthError);
239
- expect((err as Error).message).not.toBe('[object Object]');
240
- expect((err as Error).message).toContain('Bad signature');
241
- }
242
- });
243
-
244
- it('maps 403 → TopoloPermissionError with required scopes extracted', async () => {
245
- const { impl } = stubFetch([
246
- MAIL_CATALOG,
247
- { status: 403, body: { error: 'forbidden', required: ['mail.messages:send'] } },
248
- ]);
249
- const client = new TopoloClient({
250
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
251
- agent: AGENT,
252
- fetch: impl,
253
- });
254
- await expect(
255
- client.request({ service: 'mail', method: 'POST', path: '/api/mail/mailboxes/primary/messages', body: {}, confirm: true }),
256
- ).rejects.toBeInstanceOf(TopoloPermissionError);
257
- });
258
-
259
- it('maps non-401/403 failures → TopoloHttpError with status preserved', async () => {
260
- const { impl } = stubFetch([MAIL_CATALOG, { status: 500, body: { error: 'boom' } }]);
261
- const client = new TopoloClient({
262
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
263
- agent: AGENT,
264
- fetch: impl,
265
- });
266
- await expect(client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages' })).rejects.toMatchObject({
267
- name: 'TopoloHttpError',
268
- status: 500,
269
- });
270
- });
271
-
272
- it('appends query params as search string', async () => {
273
- const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { messages: [] } }]);
274
- const client = new TopoloClient({
275
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
276
- agent: AGENT,
277
- fetch: impl,
278
- });
279
- await client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages', query: { q: 'acme', page: 2 } });
280
- const u = new URL(requestCalls(calls)[0]!.url);
281
- expect(u.searchParams.get('q')).toBe('acme');
282
- expect(u.searchParams.get('page')).toBe('2');
283
- });
284
-
285
- it('rejects missing credential or agent.clientName at construction', () => {
286
- expect(
287
- () =>
288
- new TopoloClient({
289
- credential: undefined as never,
290
- agent: AGENT,
291
- }),
292
- ).toThrow(TopoloAuthError);
293
- expect(
294
- () =>
295
- new TopoloClient({
296
- credential: { kind: 'api_key', apiKey: 'x' },
297
- agent: { clientName: '' as unknown as string, clientVersion: '0' },
298
- }),
299
- ).toThrow(TopoloAuthError);
300
- });
301
- });
302
-
303
- describe('TopoloClient.debug hook', () => {
304
- it('fires request → response on a successful call with stable requestId + duration', async () => {
305
- const { impl } = stubFetch([SERVICE_LOOKUP, { body: { ok: true } }]);
306
- const events: unknown[] = [];
307
- const client = new TopoloClient({
308
- credential: { kind: 'api_key', apiKey: 'x' },
309
- agent: AGENT,
310
- fetch: impl,
311
- debug: (e) => events.push(e),
312
- });
313
- await client.request({ service: 'auth', path: '/api/me' });
314
- expect(events).toHaveLength(2);
315
- const typed = events as Array<{
316
- phase: string;
317
- method: string;
318
- service: string;
319
- path: string;
320
- url: string;
321
- requestId: string;
322
- status?: number;
323
- durationMs?: number;
324
- }>;
325
- const req = typed[0]!;
326
- const res = typed[1]!;
327
- expect(req.phase).toBe('request');
328
- expect(req.method).toBe('GET');
329
- expect(req.service).toBe('auth');
330
- expect(req.path).toBe('/api/me');
331
- expect(req.requestId).toMatch(/.+/);
332
- expect(res.phase).toBe('response');
333
- expect(res.status).toBe(200);
334
- expect(res.requestId).toBe(req.requestId);
335
- expect(typeof res.durationMs).toBe('number');
336
- });
337
-
338
- it('fires request → error on 4xx with the unwrapped message and status', async () => {
339
- const { impl } = stubFetch([
340
- { status: 401, body: { error: { code: 'auth_error', message: 'Expired' } } },
341
- ]);
342
- const events: Array<{ phase: string; error?: string; status?: number }> = [];
343
- const client = new TopoloClient({
344
- credential: { kind: 'api_key', apiKey: 'x' },
345
- agent: AGENT,
346
- fetch: impl,
347
- debug: (e) => events.push(e),
348
- });
349
- await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toThrow(TopoloAuthError);
350
- expect(events.map((e) => e.phase)).toEqual(['request', 'error']);
351
- expect(events[1]!.status).toBe(401);
352
- expect(events[1]!.error).toBe('Expired');
353
- });
354
-
355
- it('fires request → error on a thrown fetch (network failure)', async () => {
356
- const impl = (vi.fn(async () => {
357
- throw new Error('ECONNREFUSED');
358
- }) as unknown) as typeof fetch;
359
- const events: Array<{ phase: string; error?: string; status?: number }> = [];
360
- const client = new TopoloClient({
361
- credential: { kind: 'api_key', apiKey: 'x' },
362
- agent: AGENT,
363
- fetch: impl,
364
- debug: (e) => events.push(e),
365
- });
366
- await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toThrow('ECONNREFUSED');
367
- expect(events.map((e) => e.phase)).toEqual(['request', 'error']);
368
- expect(events[1]!.error).toBe('ECONNREFUSED');
369
- expect(events[1]!.status).toBeUndefined();
370
- });
371
-
372
- it('swallows throws from the debug callback without affecting the request path', async () => {
373
- const { impl } = stubFetch([{ body: { ok: true } }]);
374
- const client = new TopoloClient({
375
- credential: { kind: 'api_key', apiKey: 'x' },
376
- agent: AGENT,
377
- fetch: impl,
378
- debug: () => {
379
- throw new Error('callback blew up');
380
- },
381
- });
382
- // Returns normally despite the callback throwing on both events.
383
- await expect(client.request({ service: 'auth', path: '/api/me' })).resolves.toBeDefined();
384
- });
385
- });
386
-
387
- describe('TopoloClient.getAppIdBySlug', () => {
388
- function freshClient(impl: typeof fetch, serviceUrls?: Record<string, string>) {
389
- TopoloClient.clearAppIdCache();
390
- return new TopoloClient({
391
- credential: { kind: 'api_key', apiKey: 'x' },
392
- agent: AGENT,
393
- fetch: impl,
394
- serviceUrls: serviceUrls as never,
395
- });
396
- }
397
-
398
- it('uses configured app ids without calling Auth', async () => {
399
- const { impl, calls } = stubFetch([]);
400
- const client = new TopoloClient({
401
- credential: { kind: 'api_key', apiKey: 'x' },
402
- agent: AGENT,
403
- fetch: impl,
404
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' } as never,
405
- appIds: { notify: 'service_notify' },
406
- });
407
- const id = await client.getAppIdBySlug('topolo-notify');
408
- expect(id).toBe('service_notify');
409
- expect(calls).toEqual([]);
410
- });
411
-
412
- it('falls back to /api/services/by-slug/:slug for unknown slugs', async () => {
413
- const { impl, calls } = stubFetch([
414
- { body: { success: true, data: { app_id: 'service_abc123', api_base_url: 'https://x-api' } } },
415
- ]);
416
- const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
417
- const id = await client.getAppIdBySlug('external-service');
418
- expect(id).toBe('service_abc123');
419
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
420
- // No auth header — endpoint is public.
421
- const headers = new Headers(calls[0]!.init.headers);
422
- expect(headers.get('Authorization')).toBeNull();
423
- expect(headers.get('X-Api-Key')).toBeNull();
424
- });
425
-
426
- it('returns null on 404 and caches the negative result', async () => {
427
- const { impl, calls } = stubFetch([{ status: 404, body: { success: false } }]);
428
- const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
429
- expect(await client.getAppIdBySlug('nope')).toBeNull();
430
- expect(await client.getAppIdBySlug('nope')).toBeNull();
431
- expect(calls.length).toBe(1);
432
- });
433
-
434
- it('caches positive results across calls', async () => {
435
- const { impl, calls } = stubFetch([
436
- { body: { success: true, data: { app_id: 'service_external' } } },
437
- ]);
438
- const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
439
- expect(await client.getAppIdBySlug('external-service')).toBe('service_external');
440
- expect(await client.getAppIdBySlug('external-service')).toBe('service_external');
441
- expect(calls.length).toBe(1);
442
- });
443
-
444
- it('normalizes slug case + whitespace before hitting the network', async () => {
445
- const { impl, calls } = stubFetch([
446
- { body: { success: true, data: { app_id: 'service_x' } } },
447
- ]);
448
- const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
449
- await client.getAppIdBySlug(' EXTERNAL-SERVICE ');
450
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
451
- });
452
-
453
- it('returns null on network failure without poisoning cache', async () => {
454
- let throws = true;
455
- const impl = vi.fn(async (url: string) => {
456
- if (throws) {
457
- throws = false;
458
- throw new Error('ECONNREFUSED');
459
- }
460
- return new Response(JSON.stringify({ success: true, data: { app_id: 'service_y' } }), {
461
- status: 200,
462
- headers: { 'Content-Type': 'application/json' },
463
- });
464
- }) as unknown as typeof fetch;
465
- TopoloClient.clearAppIdCache();
466
- const client = new TopoloClient({
467
- credential: { kind: 'api_key', apiKey: 'x' },
468
- agent: AGENT,
469
- fetch: impl,
470
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' } as never,
471
- });
472
- expect(await client.getAppIdBySlug('flaky')).toBeNull();
473
- // Second call retries — earlier null was not cached.
474
- expect(await client.getAppIdBySlug('flaky')).toBe('service_y');
475
- });
476
- });
477
-
478
- describe('TopoloClient.listServices', () => {
479
- it('normalizes the live catalog and attaches the credential', async () => {
480
- const { impl, calls } = stubFetch([
481
- {
482
- body: {
483
- data: {
484
- services: [
485
- {
486
- app_id: 'app_topolo_director',
487
- app_slug: 'topolo-director',
488
- name: 'Topolo Director',
489
- description: 'Demo control plane',
490
- api_base_url: 'https://director-api.topolo.app',
491
- launch_url: 'https://director.topolo.app',
492
- status: 'active',
493
- surface_kind: 'application',
494
- launchable: true,
495
- api_key_resource: {
496
- resourceType: 'brand',
497
- label: 'Brands',
498
- emptyLabel: 'All brands in the organization',
499
- resourceConcept: 'workspace',
500
- canonicalResourceType: 'workspace',
501
- defaultEntitlement: 'enterprise',
502
- exceptionPolicy: 'app_or_organization',
503
- },
504
- api_key_resources: [
505
- {
506
- resourceType: 'brand',
507
- label: 'Brands',
508
- emptyLabel: 'All brands in the organization',
509
- resourceConcept: 'workspace',
510
- canonicalResourceType: 'workspace',
511
- defaultEntitlement: 'enterprise',
512
- exceptionPolicy: 'app_or_organization',
513
- },
514
- ],
515
- granted_permissions: ['app_topolo_director.dashboard:read'],
516
- },
517
- ],
518
- },
519
- },
520
- },
521
- ]);
522
- const client = new TopoloClient({
523
- credential: { kind: 'access_token', accessToken: 'tok_abc' },
524
- agent: AGENT,
525
- fetch: impl,
526
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
527
- });
528
- const out = await client.listServices();
529
- expect(out).toEqual([
530
- {
531
- appId: 'app_topolo_director',
532
- slug: 'topolo-director',
533
- name: 'Topolo Director',
534
- description: 'Demo control plane',
535
- apiBaseUrl: 'https://director-api.topolo.app',
536
- launchUrl: 'https://director.topolo.app',
537
- status: 'active',
538
- surfaceKind: 'application',
539
- launchable: true,
540
- apiKeyResource: {
541
- resourceType: 'brand',
542
- label: 'Brands',
543
- emptyLabel: 'All brands in the organization',
544
- resourceConcept: 'workspace',
545
- canonicalResourceType: 'workspace',
546
- defaultEntitlement: 'enterprise',
547
- exceptionPolicy: 'app_or_organization',
548
- },
549
- apiKeyResources: [
550
- {
551
- resourceType: 'brand',
552
- label: 'Brands',
553
- emptyLabel: 'All brands in the organization',
554
- resourceConcept: 'workspace',
555
- canonicalResourceType: 'workspace',
556
- defaultEntitlement: 'enterprise',
557
- exceptionPolicy: 'app_or_organization',
558
- },
559
- ],
560
- permissions: ['app_topolo_director.dashboard:read'],
561
- },
562
- ]);
563
- expect(calls[0]!.url).toBe(
564
- 'https://auth.example.com/api/services/catalog',
565
- );
566
- expect(new Headers(calls[0]!.init.headers).get('Authorization')).toBe(
567
- 'Bearer tok_abc',
568
- );
569
- });
570
-
571
- it('does not fall back to the generated global snapshot on HTTP failure', async () => {
572
- const { impl } = stubFetch([{ status: 503, body: { error: 'down' } }]);
573
- const client = new TopoloClient({
574
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
575
- agent: AGENT,
576
- fetch: impl,
577
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
578
- });
579
- await expect(client.listServices()).rejects.toBeInstanceOf(TopoloHttpError);
580
- });
581
-
582
- it('routes bundled service aliases only after the scoped live catalog grants access', async () => {
583
- const { impl, calls } = stubFetch([
584
- {
585
- body: {
586
- data: {
587
- services: [
588
- {
589
- app_id: 'app_mail',
590
- app_slug: 'topolo-mail',
591
- name: 'Topolo Mail',
592
- api_base_url: 'https://mail-api.topolo.app',
593
- launch_url: 'https://mail.topolo.app',
594
- surface_kind: 'application',
595
- status: 'active',
596
- granted_permissions: ['app_mail.messages:read'],
597
- },
598
- ],
599
- },
600
- },
601
- },
602
- { body: { ok: true } },
603
- ]);
604
- const client = new TopoloClient({
605
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
606
- agent: AGENT,
607
- fetch: impl,
608
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
609
- });
610
-
611
- await client.request({ service: 'mail', path: '/api/health' });
612
-
613
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
614
- expect(calls[1]!.url).toBe('https://mail-api.topolo.app/api/health');
615
- });
616
-
617
- it('routes known split-surface services to the callable API URL', async () => {
618
- const { impl, calls } = stubFetch([
619
- {
620
- body: {
621
- data: {
622
- services: [
623
- {
624
- app_id: 'app_crm',
625
- app_slug: 'topolo-crm',
626
- name: 'Topolo CRM',
627
- api_base_url: 'https://crm-api.topolo.app',
628
- launch_url: 'https://crm.topolo.app',
629
- surface_kind: 'application',
630
- status: 'active',
631
- granted_permissions: ['app_crm.contacts:read'],
632
- },
633
- ],
634
- },
635
- },
636
- },
637
- { body: { ok: true } },
638
- ]);
639
- const client = new TopoloClient({
640
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
641
- agent: AGENT,
642
- fetch: impl,
643
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
644
- });
645
-
646
- await client.request({ service: 'crm', path: '/health' });
647
-
648
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
649
- expect(calls[1]!.url).toBe('https://crm-api.topolo.app/health');
650
- });
651
-
652
- it('derives the catalog (developers) host from the auth url when developers is unset', async () => {
653
- const { impl, calls } = stubFetch([{ body: { data: { services: [] } } }]);
654
- const client = new TopoloClient({
655
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
656
- agent: AGENT,
657
- fetch: impl,
658
- // Fleet apps configure only `auth` per-env; the catalog host is derived.
659
- serviceUrls: { auth: 'https://auth.stg.topolo.us' },
660
- });
661
-
662
- await client.listServices();
663
-
664
- expect(calls[0]!.url).toBe('https://developers.stg.topolo.us/api/services/catalog');
665
- });
666
-
667
- it('prefers live catalog api_base_url for dynamic services', async () => {
668
- const { impl, calls } = stubFetch([
669
- {
670
- body: {
671
- data: {
672
- services: [
673
- {
674
- app_id: 'app_future',
675
- app_slug: 'topolo-future',
676
- name: 'Future',
677
- api_base_url: 'https://future-api.topolo.app',
678
- launch_url: 'https://future.topolo.app',
679
- surface_kind: 'api',
680
- status: 'active',
681
- granted_permissions: ['app_future.read:all'],
682
- },
683
- ],
684
- },
685
- },
686
- },
687
- { body: { ok: true } },
688
- ]);
689
- const client = new TopoloClient({
690
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
691
- agent: AGENT,
692
- fetch: impl,
693
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
694
- });
695
-
696
- await client.request({ service: 'future', path: '/health' });
697
-
698
- expect(calls[1]!.url).toBe('https://future-api.topolo.app/health');
699
- });
700
-
701
- it('refuses a bundled static service that is absent from the scoped live catalog', async () => {
702
- const { impl, calls } = stubFetch([
703
- {
704
- body: {
705
- data: {
706
- services: [
707
- {
708
- app_id: 'app_crm',
709
- app_slug: 'topolo-crm',
710
- name: 'Topolo CRM',
711
- api_base_url: 'https://crm-api.topolo.app',
712
- launch_url: 'https://crm.topolo.app',
713
- surface_kind: 'api',
714
- status: 'active',
715
- granted_permissions: ['app_crm.contacts:read'],
716
- },
717
- ],
718
- },
719
- },
720
- },
721
- ]);
722
- const client = new TopoloClient({
723
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
724
- agent: AGENT,
725
- fetch: impl,
726
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
727
- });
728
-
729
- await expect(client.request({ service: 'mail', path: '/api/health' })).rejects.toMatchObject({
730
- code: 'unknown_service',
731
- });
732
- expect(calls).toHaveLength(1);
733
- });
734
-
735
- it('does not treat live browser applications as generic API services', async () => {
736
- const { impl } = stubFetch([
737
- {
738
- body: {
739
- data: {
740
- services: [
741
- {
742
- app_id: 'app_future_app',
743
- app_slug: 'topolo-future-app',
744
- name: 'Future App',
745
- launch_url: 'https://future-app.topolo.app',
746
- surface_kind: 'application',
747
- status: 'active',
748
- granted_permissions: ['app_future_app.launch:read'],
749
- launchable: true,
750
- },
751
- ],
752
- },
753
- },
754
- },
755
- ]);
756
- const client = new TopoloClient({
757
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
758
- agent: AGENT,
759
- fetch: impl,
760
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
761
- });
762
-
763
- await expect(client.request({ service: 'future-app', path: '/api/health' })).rejects.toMatchObject({
764
- code: 'unknown_service',
765
- });
766
- });
767
-
768
- it('caches the live catalog per client for repeated dynamic service calls', async () => {
769
- const { impl, calls } = stubFetch([
770
- {
771
- body: {
772
- data: {
773
- services: [
774
- {
775
- app_id: 'app_future',
776
- app_slug: 'topolo-warehouse',
777
- name: 'Warehouse',
778
- api_base_url: 'https://warehouse.topolo.app',
779
- launch_url: 'https://warehouse.topolo.app',
780
- surface_kind: 'api',
781
- status: 'active',
782
- granted_permissions: ['app_future.read:all'],
783
- },
784
- ],
785
- },
786
- },
787
- },
788
- { body: { ok: true } },
789
- { body: { ok: true } },
790
- ]);
791
- const client = new TopoloClient({
792
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
793
- agent: AGENT,
794
- fetch: impl,
795
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
796
- });
797
-
798
- await client.request({ service: 'warehouse', path: '/api/one' });
799
- await client.request({ service: 'warehouse', path: '/api/two' });
800
-
801
- expect(calls.map((call) => call.url)).toEqual([
802
- 'https://auth.example.com/api/services/catalog',
803
- 'https://warehouse.topolo.app/api/one',
804
- 'https://warehouse.topolo.app/api/two',
805
- ]);
806
- });
807
- });
808
-
809
- describe('TopoloClient action catalog', () => {
810
- it('lists scoped actions from Auth', async () => {
811
- const { impl, calls } = stubFetch([ACTION_CATALOG]);
812
- const client = new TopoloClient({
813
- credential: { kind: 'access_token', accessToken: 'tok_abc' },
814
- agent: AGENT,
815
- fetch: impl,
816
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
817
- });
818
- const actions = await client.listActions();
819
- expect(actions.map((action) => action.name)).toEqual([
820
- 'messages.list',
821
- 'messages.get',
822
- 'messages.send',
823
- ]);
824
- expect(calls[0]!.url).toBe('https://auth.example.com/api/actions/catalog');
825
- });
826
-
827
- it('calls GET actions by mapping remaining input to query params', async () => {
828
- const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { messages: [] } }]);
829
- const client = new TopoloClient({
830
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
831
- agent: AGENT,
832
- fetch: impl,
833
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
834
- });
835
- await client.callAction('messages.list', { mailboxId: 'primary', q: 'Ada', page: 2 });
836
- const call = requestCalls(calls)[0]!;
837
- const url = new URL(call.url);
838
- expect(url.toString()).toBe('https://mail-api.topolo.app/api/mail/mailboxes/primary/messages?q=Ada&page=2');
839
- expect(call.init.method).toBe('GET');
840
- const headers = new Headers(call.init.headers);
841
- expect(headers.get('X-Topolo-Action-Id')).toBe('app_mail.messages.list');
842
- expect(headers.get('X-Topolo-Action-Name')).toBe('messages.list');
843
- });
844
-
845
- it('substitutes action path params and removes them from the query', async () => {
846
- const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { id: 'm1' } }]);
847
- const client = new TopoloClient({
848
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
849
- agent: AGENT,
850
- fetch: impl,
851
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
852
- });
853
- await client.callAction('topolo_mail_messages_get', {
854
- mailboxId: 'primary',
855
- messageId: 'm 1',
856
- includeDeleted: false,
857
- });
858
- const url = new URL(requestCalls(calls)[0]!.url);
859
- expect(url.pathname).toBe('/api/mail/mailboxes/primary/messages/m%201');
860
- expect(url.searchParams.get('includeDeleted')).toBe('false');
861
- expect(url.searchParams.has('mailboxId')).toBe(false);
862
- expect(url.searchParams.has('messageId')).toBe(false);
863
- });
864
-
865
- it('requires confirmation for mutating action calls', async () => {
866
- const { impl } = stubFetch([ACTION_CATALOG]);
867
- const client = new TopoloClient({
868
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
869
- agent: AGENT,
870
- fetch: impl,
871
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
872
- });
873
- await expect(
874
- client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }),
875
- ).rejects.toBeInstanceOf(TopoloAuthError);
876
- });
877
-
878
- it('sends remaining input as the body when a mutating action is confirmed', async () => {
879
- const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { id: 'm1' } }]);
880
- const client = new TopoloClient({
881
- credential: { kind: 'api_key', apiKey: 'dak_xyz' },
882
- agent: AGENT,
883
- fetch: impl,
884
- serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' },
885
- });
886
- await client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }, { confirm: true });
887
- const call = requestCalls(calls)[0]!;
888
- expect(call.init.method).toBe('POST');
889
- expect(JSON.parse(String(call.init.body))).toEqual({ subject: 'Hello' });
890
- });
891
- });
892
-
893
- // Silence unused-import warning if the bundler later tree-shakes these
894
- void TopoloHttpError;