@topolo/sdk 0.4.1 → 0.4.3

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.
@@ -17,9 +17,95 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown; contentTy
17
17
 
18
18
  const AGENT = { clientName: 'topolo-test', clientVersion: '0.0.0' };
19
19
  const SERVICE_LOOKUP = { body: { success: true, data: { service_id: 'service_test' } } };
20
+ const MAIL_CATALOG = {
21
+ body: {
22
+ data: {
23
+ services: [
24
+ {
25
+ service_id: 'srv_mail',
26
+ app_slug: 'topolo-mail',
27
+ name: 'Topolo Mail',
28
+ base_url: 'https://mail-api.topolo.app',
29
+ surface_kind: 'api',
30
+ status: 'active',
31
+ granted_permissions: ['srv_mail.messages:read'],
32
+ },
33
+ ],
34
+ },
35
+ },
36
+ };
37
+ const ACTION_CATALOG = {
38
+ body: {
39
+ data: {
40
+ actions: [
41
+ {
42
+ action_id: 'srv_mail.messages.list',
43
+ name: 'messages.list',
44
+ tool_name: 'topolo_mail_messages_list',
45
+ title: 'List messages',
46
+ description: 'List Mail messages',
47
+ service_id: 'srv_mail',
48
+ service_slug: 'topolo-mail',
49
+ service_name: 'Topolo Mail',
50
+ method: 'GET',
51
+ path: '/api/mail/mailboxes/{mailboxId}/messages',
52
+ permission_name: 'messages:read',
53
+ required_permission: 'srv_mail.messages:read',
54
+ input_schema: { type: 'object' },
55
+ output_schema: {},
56
+ read_only: true,
57
+ destructive: false,
58
+ requires_confirmation: false,
59
+ },
60
+ {
61
+ action_id: 'srv_mail.messages.get',
62
+ name: 'messages.get',
63
+ tool_name: 'topolo_mail_messages_get',
64
+ title: 'Get message',
65
+ description: 'Get Mail message',
66
+ service_id: 'srv_mail',
67
+ service_slug: 'topolo-mail',
68
+ service_name: 'Topolo Mail',
69
+ method: 'GET',
70
+ path: '/api/mail/mailboxes/{mailboxId}/messages/{messageId}',
71
+ permission_name: 'messages:read',
72
+ required_permission: 'srv_mail.messages:read',
73
+ input_schema: { type: 'object' },
74
+ output_schema: {},
75
+ read_only: true,
76
+ destructive: false,
77
+ requires_confirmation: false,
78
+ },
79
+ {
80
+ action_id: 'srv_mail.messages.send',
81
+ name: 'messages.send',
82
+ tool_name: 'topolo_mail_messages_send',
83
+ title: 'Send message',
84
+ description: 'Send Mail message',
85
+ service_id: 'srv_mail',
86
+ service_slug: 'topolo-mail',
87
+ service_name: 'Topolo Mail',
88
+ method: 'POST',
89
+ path: '/api/mail/mailboxes/{mailboxId}/messages',
90
+ permission_name: 'messages:send',
91
+ required_permission: 'srv_mail.messages:send',
92
+ input_schema: { type: 'object' },
93
+ output_schema: {},
94
+ read_only: false,
95
+ destructive: false,
96
+ requires_confirmation: true,
97
+ },
98
+ ],
99
+ },
100
+ },
101
+ };
20
102
 
21
103
  function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
22
- return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
104
+ return calls.filter((call) =>
105
+ !call.url.includes('/api/services/by-slug/') &&
106
+ !call.url.includes('/api/services/catalog') &&
107
+ !call.url.includes('/api/actions/catalog')
108
+ );
23
109
  }
24
110
 
25
111
  beforeEach(() => {
@@ -28,7 +114,7 @@ beforeEach(() => {
28
114
 
29
115
  describe('TopoloClient.request', () => {
30
116
  it('injects Bearer auth + audit headers for access_token credentials', async () => {
31
- const { impl, calls } = stubFetch([{ body: { ok: true } }]);
117
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
32
118
  const client = new TopoloClient({
33
119
  credential: { kind: 'access_token', accessToken: 'tok_abc' },
34
120
  agent: AGENT,
@@ -44,13 +130,13 @@ describe('TopoloClient.request', () => {
44
130
  });
45
131
 
46
132
  it('uses X-Api-Key header for api_key credentials', async () => {
47
- const { impl, calls } = stubFetch([{ body: { ok: true } }]);
133
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { ok: true } }]);
48
134
  const client = new TopoloClient({
49
135
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
50
136
  agent: AGENT,
51
137
  fetch: impl,
52
138
  });
53
- await client.request({ service: 'crm', path: '/api/contacts' });
139
+ await client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages' });
54
140
  const headers = new Headers(requestCalls(calls)[0]!.init.headers);
55
141
  expect(headers.get('X-Api-Key')).toBe('dak_xyz');
56
142
  expect(headers.get('Authorization')).toBeNull();
@@ -64,22 +150,22 @@ describe('TopoloClient.request', () => {
64
150
  fetch: impl,
65
151
  });
66
152
  await expect(
67
- client.request({ service: 'crm', method: 'POST', path: '/api/contacts', body: {} }),
153
+ client.request({ service: 'mail', method: 'POST', path: '/api/mail/mailboxes/primary/messages', body: {} }),
68
154
  ).rejects.toBeInstanceOf(TopoloAuthError);
69
155
  });
70
156
 
71
157
  it('allows mutating requests with confirm: true', async () => {
72
- const { impl, calls } = stubFetch([{ body: { id: 'c_1' } }]);
158
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { id: 'm_1' } }]);
73
159
  const client = new TopoloClient({
74
160
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
75
161
  agent: AGENT,
76
162
  fetch: impl,
77
163
  });
78
164
  await client.request({
79
- service: 'crm',
165
+ service: 'mail',
80
166
  method: 'POST',
81
- path: '/api/contacts',
82
- body: { email: 'a@b.co' },
167
+ path: '/api/mail/mailboxes/primary/messages',
168
+ body: { subject: 'Hello' },
83
169
  confirm: true,
84
170
  });
85
171
  expect(requestCalls(calls)[0]!.init.method).toBe('POST');
@@ -122,7 +208,8 @@ describe('TopoloClient.request', () => {
122
208
 
123
209
  it('maps 403 → TopoloPermissionError with required scopes extracted', async () => {
124
210
  const { impl } = stubFetch([
125
- { status: 403, body: { error: 'forbidden', required: ['crm.contacts:write'] } },
211
+ MAIL_CATALOG,
212
+ { status: 403, body: { error: 'forbidden', required: ['mail.messages:send'] } },
126
213
  ]);
127
214
  const client = new TopoloClient({
128
215
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
@@ -130,31 +217,31 @@ describe('TopoloClient.request', () => {
130
217
  fetch: impl,
131
218
  });
132
219
  await expect(
133
- client.request({ service: 'crm', method: 'POST', path: '/api/contacts', body: {}, confirm: true }),
220
+ client.request({ service: 'mail', method: 'POST', path: '/api/mail/mailboxes/primary/messages', body: {}, confirm: true }),
134
221
  ).rejects.toBeInstanceOf(TopoloPermissionError);
135
222
  });
136
223
 
137
224
  it('maps non-401/403 failures → TopoloHttpError with status preserved', async () => {
138
- const { impl } = stubFetch([{ status: 500, body: { error: 'boom' } }]);
225
+ const { impl } = stubFetch([MAIL_CATALOG, { status: 500, body: { error: 'boom' } }]);
139
226
  const client = new TopoloClient({
140
227
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
141
228
  agent: AGENT,
142
229
  fetch: impl,
143
230
  });
144
- await expect(client.request({ service: 'crm', path: '/api/contacts' })).rejects.toMatchObject({
231
+ await expect(client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages' })).rejects.toMatchObject({
145
232
  name: 'TopoloHttpError',
146
233
  status: 500,
147
234
  });
148
235
  });
149
236
 
150
237
  it('appends query params as search string', async () => {
151
- const { impl, calls } = stubFetch([{ body: { contacts: [] } }]);
238
+ const { impl, calls } = stubFetch([MAIL_CATALOG, { body: { messages: [] } }]);
152
239
  const client = new TopoloClient({
153
240
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
154
241
  agent: AGENT,
155
242
  fetch: impl,
156
243
  });
157
- await client.request({ service: 'crm', path: '/api/contacts', query: { q: 'acme', page: 2 } });
244
+ await client.request({ service: 'mail', path: '/api/mail/mailboxes/primary/messages', query: { q: 'acme', page: 2 } });
158
245
  const u = new URL(requestCalls(calls)[0]!.url);
159
246
  expect(u.searchParams.get('q')).toBe('acme');
160
247
  expect(u.searchParams.get('page')).toBe('2');
@@ -369,6 +456,7 @@ describe('TopoloClient.listServices', () => {
369
456
  status: 'active',
370
457
  surface_kind: 'application',
371
458
  launchable: true,
459
+ granted_permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
372
460
  },
373
461
  ],
374
462
  },
@@ -392,6 +480,7 @@ describe('TopoloClient.listServices', () => {
392
480
  status: 'active',
393
481
  surfaceKind: 'application',
394
482
  launchable: true,
483
+ permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
395
484
  },
396
485
  ]);
397
486
  expect(calls[0]!.url).toBe(
@@ -402,7 +491,7 @@ describe('TopoloClient.listServices', () => {
402
491
  );
403
492
  });
404
493
 
405
- it('falls back to the generated snapshot on HTTP failure', async () => {
494
+ it('does not fall back to the generated global snapshot on HTTP failure', async () => {
406
495
  const { impl } = stubFetch([{ status: 503, body: { error: 'down' } }]);
407
496
  const client = new TopoloClient({
408
497
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
@@ -410,25 +499,23 @@ describe('TopoloClient.listServices', () => {
410
499
  fetch: impl,
411
500
  serviceUrls: { auth: 'https://auth.example.com' },
412
501
  });
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);
502
+ await expect(client.listServices()).rejects.toBeInstanceOf(TopoloHttpError);
417
503
  });
418
504
 
419
- it('resolves future API services from the live catalog during request routing', async () => {
505
+ it('routes bundled service aliases only after the scoped live catalog grants access', async () => {
420
506
  const { impl, calls } = stubFetch([
421
507
  {
422
508
  body: {
423
509
  data: {
424
510
  services: [
425
511
  {
426
- service_id: 'srv_future',
427
- app_slug: 'topolo-warehouse',
428
- name: 'Warehouse',
429
- base_url: 'https://warehouse.topolo.app',
430
- surface_kind: 'api',
512
+ service_id: 'srv_mail',
513
+ app_slug: 'topolo-mail',
514
+ name: 'Topolo Mail',
515
+ base_url: 'https://mail.topolo.app',
516
+ surface_kind: 'application',
431
517
  status: 'active',
518
+ granted_permissions: ['srv_mail.messages:read'],
432
519
  },
433
520
  ],
434
521
  },
@@ -443,10 +530,43 @@ describe('TopoloClient.listServices', () => {
443
530
  serviceUrls: { auth: 'https://auth.example.com' },
444
531
  });
445
532
 
446
- await client.request({ service: 'warehouse', path: '/api/health' });
533
+ await client.request({ service: 'mail', path: '/api/health' });
447
534
 
448
535
  expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
449
- expect(calls[1]!.url).toBe('https://warehouse.topolo.app/api/health');
536
+ expect(calls[1]!.url).toBe('https://mail.topolo.app/api/health');
537
+ });
538
+
539
+ it('refuses a bundled static service that is absent from the scoped live catalog', async () => {
540
+ const { impl, calls } = stubFetch([
541
+ {
542
+ body: {
543
+ data: {
544
+ services: [
545
+ {
546
+ service_id: 'srv_crm',
547
+ app_slug: 'topolo-crm',
548
+ name: 'Topolo CRM',
549
+ base_url: 'https://crm-api.topolo.app',
550
+ surface_kind: 'api',
551
+ status: 'active',
552
+ granted_permissions: ['srv_crm.contacts:read'],
553
+ },
554
+ ],
555
+ },
556
+ },
557
+ },
558
+ ]);
559
+ const client = new TopoloClient({
560
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
561
+ agent: AGENT,
562
+ fetch: impl,
563
+ serviceUrls: { auth: 'https://auth.example.com' },
564
+ });
565
+
566
+ await expect(client.request({ service: 'mail', path: '/api/health' })).rejects.toMatchObject({
567
+ code: 'unknown_service',
568
+ });
569
+ expect(calls).toHaveLength(1);
450
570
  });
451
571
 
452
572
  it('does not treat live browser applications as generic API services', async () => {
@@ -462,6 +582,7 @@ describe('TopoloClient.listServices', () => {
462
582
  base_url: 'https://future-app.topolo.app',
463
583
  surface_kind: 'application',
464
584
  status: 'active',
585
+ granted_permissions: ['srv_future_app.launch:read'],
465
586
  launchable: true,
466
587
  },
467
588
  ],
@@ -494,6 +615,7 @@ describe('TopoloClient.listServices', () => {
494
615
  base_url: 'https://warehouse.topolo.app',
495
616
  surface_kind: 'api',
496
617
  status: 'active',
618
+ granted_permissions: ['srv_future.read:all'],
497
619
  },
498
620
  ],
499
621
  },
@@ -520,5 +642,86 @@ describe('TopoloClient.listServices', () => {
520
642
  });
521
643
  });
522
644
 
645
+ describe('TopoloClient action catalog', () => {
646
+ it('lists scoped actions from Auth', async () => {
647
+ const { impl, calls } = stubFetch([ACTION_CATALOG]);
648
+ const client = new TopoloClient({
649
+ credential: { kind: 'access_token', accessToken: 'tok_abc' },
650
+ agent: AGENT,
651
+ fetch: impl,
652
+ serviceUrls: { auth: 'https://auth.example.com' },
653
+ });
654
+ const actions = await client.listActions();
655
+ expect(actions.map((action) => action.name)).toEqual([
656
+ 'messages.list',
657
+ 'messages.get',
658
+ 'messages.send',
659
+ ]);
660
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/actions/catalog');
661
+ });
662
+
663
+ it('calls GET actions by mapping remaining input to query params', async () => {
664
+ const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { messages: [] } }]);
665
+ const client = new TopoloClient({
666
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
667
+ agent: AGENT,
668
+ fetch: impl,
669
+ serviceUrls: { auth: 'https://auth.example.com' },
670
+ });
671
+ await client.callAction('messages.list', { mailboxId: 'primary', q: 'Ada', page: 2 });
672
+ const call = requestCalls(calls)[0]!;
673
+ const url = new URL(call.url);
674
+ expect(url.toString()).toBe('https://mail-api.topolo.app/api/mail/mailboxes/primary/messages?q=Ada&page=2');
675
+ expect(call.init.method).toBe('GET');
676
+ });
677
+
678
+ it('substitutes action path params and removes them from the query', async () => {
679
+ const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { id: 'm1' } }]);
680
+ const client = new TopoloClient({
681
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
682
+ agent: AGENT,
683
+ fetch: impl,
684
+ serviceUrls: { auth: 'https://auth.example.com' },
685
+ });
686
+ await client.callAction('topolo_mail_messages_get', {
687
+ mailboxId: 'primary',
688
+ messageId: 'm 1',
689
+ includeDeleted: false,
690
+ });
691
+ const url = new URL(requestCalls(calls)[0]!.url);
692
+ expect(url.pathname).toBe('/api/mail/mailboxes/primary/messages/m%201');
693
+ expect(url.searchParams.get('includeDeleted')).toBe('false');
694
+ expect(url.searchParams.has('mailboxId')).toBe(false);
695
+ expect(url.searchParams.has('messageId')).toBe(false);
696
+ });
697
+
698
+ it('requires confirmation for mutating action calls', async () => {
699
+ const { impl } = stubFetch([ACTION_CATALOG]);
700
+ const client = new TopoloClient({
701
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
702
+ agent: AGENT,
703
+ fetch: impl,
704
+ serviceUrls: { auth: 'https://auth.example.com' },
705
+ });
706
+ await expect(
707
+ client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }),
708
+ ).rejects.toBeInstanceOf(TopoloAuthError);
709
+ });
710
+
711
+ it('sends remaining input as the body when a mutating action is confirmed', async () => {
712
+ const { impl, calls } = stubFetch([ACTION_CATALOG, MAIL_CATALOG, { body: { id: 'm1' } }]);
713
+ const client = new TopoloClient({
714
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
715
+ agent: AGENT,
716
+ fetch: impl,
717
+ serviceUrls: { auth: 'https://auth.example.com' },
718
+ });
719
+ await client.callAction('messages.send', { mailboxId: 'primary', subject: 'Hello' }, { confirm: true });
720
+ const call = requestCalls(calls)[0]!;
721
+ expect(call.init.method).toBe('POST');
722
+ expect(JSON.parse(String(call.init.body))).toEqual({ subject: 'Hello' });
723
+ });
724
+ });
725
+
523
726
  // Silence unused-import warning if the bundler later tree-shakes these
524
727
  void TopoloHttpError;