@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.
- package/README.md +40 -13
- package/dist/index.cjs +209 -57
- package/dist/index.d.cts +47 -10
- package/dist/index.d.ts +47 -10
- package/dist/index.js +209 -57
- package/package.json +1 -1
- package/src/client.test.ts +231 -28
- package/src/client.ts +285 -70
- package/src/index.ts +1 -0
- package/src/modules/p2p.test.ts +25 -2
- package/src/services.ts +1 -1
package/src/client.test.ts
CHANGED
|
@@ -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) =>
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
165
|
+
service: 'mail',
|
|
80
166
|
method: 'POST',
|
|
81
|
-
path: '/api/
|
|
82
|
-
body: {
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: {
|
|
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: '
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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: '
|
|
427
|
-
app_slug: 'topolo-
|
|
428
|
-
name: '
|
|
429
|
-
base_url: 'https://
|
|
430
|
-
surface_kind: '
|
|
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: '
|
|
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://
|
|
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;
|