@topolo/sdk 0.4.10 → 0.5.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.
- package/README.md +1 -1
- package/dist/index.cjs +134 -135
- package/dist/index.d.cts +116 -118
- package/dist/index.d.ts +116 -118
- package/dist/index.js +134 -135
- package/package.json +1 -1
- package/src/application-requirements.ts +3 -3
- package/src/applications.generated.ts +98 -98
- package/src/client.test.ts +49 -49
- package/src/client.ts +40 -40
- package/src/index.test.ts +3 -3
- package/src/modules/crm.test.ts +4 -4
- package/src/modules/identity.test.ts +4 -4
- package/src/modules/p2p.test.ts +4 -8
- package/src/modules/p2p.ts +0 -3
- package/src/services.test.ts +15 -15
- package/src/services.ts +2 -2
- package/src/widgets.test.ts +14 -14
- package/src/widgets.ts +8 -8
package/src/client.test.ts
CHANGED
|
@@ -16,20 +16,20 @@ 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: {
|
|
19
|
+
const SERVICE_LOOKUP = { body: { success: true, data: { app_id: 'app_test' } } };
|
|
20
20
|
const MAIL_CATALOG = {
|
|
21
21
|
body: {
|
|
22
22
|
data: {
|
|
23
23
|
services: [
|
|
24
24
|
{
|
|
25
|
-
|
|
25
|
+
app_id: 'app_mail',
|
|
26
26
|
app_slug: 'topolo-mail',
|
|
27
27
|
name: 'Topolo Mail',
|
|
28
28
|
api_base_url: 'https://mail-api.topolo.app',
|
|
29
29
|
launch_url: 'https://mail.topolo.app',
|
|
30
30
|
surface_kind: 'api',
|
|
31
31
|
status: 'active',
|
|
32
|
-
granted_permissions: ['
|
|
32
|
+
granted_permissions: ['app_mail.messages:read'],
|
|
33
33
|
},
|
|
34
34
|
],
|
|
35
35
|
},
|
|
@@ -40,18 +40,18 @@ const ACTION_CATALOG = {
|
|
|
40
40
|
data: {
|
|
41
41
|
actions: [
|
|
42
42
|
{
|
|
43
|
-
action_id: '
|
|
43
|
+
action_id: 'app_mail.messages.list',
|
|
44
44
|
name: 'messages.list',
|
|
45
45
|
tool_name: 'topolo_mail_messages_list',
|
|
46
46
|
title: 'List messages',
|
|
47
47
|
description: 'List Mail messages',
|
|
48
|
-
|
|
48
|
+
app_id: 'app_mail',
|
|
49
49
|
service_slug: 'topolo-mail',
|
|
50
50
|
service_name: 'Topolo Mail',
|
|
51
51
|
method: 'GET',
|
|
52
52
|
path: '/api/mail/mailboxes/{mailboxId}/messages',
|
|
53
53
|
permission_name: 'messages:read',
|
|
54
|
-
required_permission: '
|
|
54
|
+
required_permission: 'app_mail.messages:read',
|
|
55
55
|
input_schema: { type: 'object' },
|
|
56
56
|
output_schema: {},
|
|
57
57
|
read_only: true,
|
|
@@ -59,18 +59,18 @@ const ACTION_CATALOG = {
|
|
|
59
59
|
requires_confirmation: false,
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
|
-
action_id: '
|
|
62
|
+
action_id: 'app_mail.messages.get',
|
|
63
63
|
name: 'messages.get',
|
|
64
64
|
tool_name: 'topolo_mail_messages_get',
|
|
65
65
|
title: 'Get message',
|
|
66
66
|
description: 'Get Mail message',
|
|
67
|
-
|
|
67
|
+
app_id: 'app_mail',
|
|
68
68
|
service_slug: 'topolo-mail',
|
|
69
69
|
service_name: 'Topolo Mail',
|
|
70
70
|
method: 'GET',
|
|
71
71
|
path: '/api/mail/mailboxes/{mailboxId}/messages/{messageId}',
|
|
72
72
|
permission_name: 'messages:read',
|
|
73
|
-
required_permission: '
|
|
73
|
+
required_permission: 'app_mail.messages:read',
|
|
74
74
|
input_schema: { type: 'object' },
|
|
75
75
|
output_schema: {},
|
|
76
76
|
read_only: true,
|
|
@@ -78,18 +78,18 @@ const ACTION_CATALOG = {
|
|
|
78
78
|
requires_confirmation: false,
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
|
-
action_id: '
|
|
81
|
+
action_id: 'app_mail.messages.send',
|
|
82
82
|
name: 'messages.send',
|
|
83
83
|
tool_name: 'topolo_mail_messages_send',
|
|
84
84
|
title: 'Send message',
|
|
85
85
|
description: 'Send Mail message',
|
|
86
|
-
|
|
86
|
+
app_id: 'app_mail',
|
|
87
87
|
service_slug: 'topolo-mail',
|
|
88
88
|
service_name: 'Topolo Mail',
|
|
89
89
|
method: 'POST',
|
|
90
90
|
path: '/api/mail/mailboxes/{mailboxId}/messages',
|
|
91
91
|
permission_name: 'messages:send',
|
|
92
|
-
required_permission: '
|
|
92
|
+
required_permission: 'app_mail.messages:send',
|
|
93
93
|
input_schema: { type: 'object' },
|
|
94
94
|
output_schema: {},
|
|
95
95
|
read_only: false,
|
|
@@ -110,7 +110,7 @@ function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
beforeEach(() => {
|
|
113
|
-
TopoloClient.
|
|
113
|
+
TopoloClient.clearAppIdCache();
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
describe('TopoloClient.request', () => {
|
|
@@ -120,12 +120,12 @@ describe('TopoloClient.request', () => {
|
|
|
120
120
|
credential: { kind: 'access_token', accessToken: 'tok_abc' },
|
|
121
121
|
agent: AGENT,
|
|
122
122
|
fetch: impl,
|
|
123
|
-
|
|
123
|
+
appIds: { auth: 'service_auth' },
|
|
124
124
|
});
|
|
125
125
|
await client.request({ service: 'auth', path: '/api/me' });
|
|
126
126
|
const headers = new Headers(requestCalls(calls)[0]!.init.headers);
|
|
127
127
|
expect(headers.get('Authorization')).toBe('Bearer tok_abc');
|
|
128
|
-
expect(headers.get('X-
|
|
128
|
+
expect(headers.get('X-App-ID')).toBe('service_auth');
|
|
129
129
|
expect(headers.get('X-Topolo-Client')).toBe('topolo-test/0.0.0');
|
|
130
130
|
expect(headers.get('X-Topolo-Request-Id')).toMatch(/.+/);
|
|
131
131
|
});
|
|
@@ -384,9 +384,9 @@ describe('TopoloClient.debug hook', () => {
|
|
|
384
384
|
});
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
-
describe('TopoloClient.
|
|
387
|
+
describe('TopoloClient.getAppIdBySlug', () => {
|
|
388
388
|
function freshClient(impl: typeof fetch, serviceUrls?: Record<string, string>) {
|
|
389
|
-
TopoloClient.
|
|
389
|
+
TopoloClient.clearAppIdCache();
|
|
390
390
|
return new TopoloClient({
|
|
391
391
|
credential: { kind: 'api_key', apiKey: 'x' },
|
|
392
392
|
agent: AGENT,
|
|
@@ -402,19 +402,19 @@ describe('TopoloClient.getServiceIdBySlug', () => {
|
|
|
402
402
|
agent: AGENT,
|
|
403
403
|
fetch: impl,
|
|
404
404
|
serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' } as never,
|
|
405
|
-
|
|
405
|
+
appIds: { notify: 'service_notify' },
|
|
406
406
|
});
|
|
407
|
-
const id = await client.
|
|
407
|
+
const id = await client.getAppIdBySlug('topolo-notify');
|
|
408
408
|
expect(id).toBe('service_notify');
|
|
409
409
|
expect(calls).toEqual([]);
|
|
410
410
|
});
|
|
411
411
|
|
|
412
412
|
it('falls back to /api/services/by-slug/:slug for unknown slugs', async () => {
|
|
413
413
|
const { impl, calls } = stubFetch([
|
|
414
|
-
{ body: { success: true, data: {
|
|
414
|
+
{ body: { success: true, data: { app_id: 'service_abc123', api_base_url: 'https://x-api' } } },
|
|
415
415
|
]);
|
|
416
416
|
const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
|
|
417
|
-
const id = await client.
|
|
417
|
+
const id = await client.getAppIdBySlug('external-service');
|
|
418
418
|
expect(id).toBe('service_abc123');
|
|
419
419
|
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
|
|
420
420
|
// No auth header — endpoint is public.
|
|
@@ -426,27 +426,27 @@ describe('TopoloClient.getServiceIdBySlug', () => {
|
|
|
426
426
|
it('returns null on 404 and caches the negative result', async () => {
|
|
427
427
|
const { impl, calls } = stubFetch([{ status: 404, body: { success: false } }]);
|
|
428
428
|
const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
|
|
429
|
-
expect(await client.
|
|
430
|
-
expect(await client.
|
|
429
|
+
expect(await client.getAppIdBySlug('nope')).toBeNull();
|
|
430
|
+
expect(await client.getAppIdBySlug('nope')).toBeNull();
|
|
431
431
|
expect(calls.length).toBe(1);
|
|
432
432
|
});
|
|
433
433
|
|
|
434
434
|
it('caches positive results across calls', async () => {
|
|
435
435
|
const { impl, calls } = stubFetch([
|
|
436
|
-
{ body: { success: true, data: {
|
|
436
|
+
{ body: { success: true, data: { app_id: 'service_external' } } },
|
|
437
437
|
]);
|
|
438
438
|
const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
|
|
439
|
-
expect(await client.
|
|
440
|
-
expect(await client.
|
|
439
|
+
expect(await client.getAppIdBySlug('external-service')).toBe('service_external');
|
|
440
|
+
expect(await client.getAppIdBySlug('external-service')).toBe('service_external');
|
|
441
441
|
expect(calls.length).toBe(1);
|
|
442
442
|
});
|
|
443
443
|
|
|
444
444
|
it('normalizes slug case + whitespace before hitting the network', async () => {
|
|
445
445
|
const { impl, calls } = stubFetch([
|
|
446
|
-
{ body: { success: true, data: {
|
|
446
|
+
{ body: { success: true, data: { app_id: 'service_x' } } },
|
|
447
447
|
]);
|
|
448
448
|
const client = freshClient(impl, { auth: 'https://auth.example.com', developers: 'https://auth.example.com' });
|
|
449
|
-
await client.
|
|
449
|
+
await client.getAppIdBySlug(' EXTERNAL-SERVICE ');
|
|
450
450
|
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
|
|
451
451
|
});
|
|
452
452
|
|
|
@@ -457,21 +457,21 @@ describe('TopoloClient.getServiceIdBySlug', () => {
|
|
|
457
457
|
throws = false;
|
|
458
458
|
throw new Error('ECONNREFUSED');
|
|
459
459
|
}
|
|
460
|
-
return new Response(JSON.stringify({ success: true, data: {
|
|
460
|
+
return new Response(JSON.stringify({ success: true, data: { app_id: 'service_y' } }), {
|
|
461
461
|
status: 200,
|
|
462
462
|
headers: { 'Content-Type': 'application/json' },
|
|
463
463
|
});
|
|
464
464
|
}) as unknown as typeof fetch;
|
|
465
|
-
TopoloClient.
|
|
465
|
+
TopoloClient.clearAppIdCache();
|
|
466
466
|
const client = new TopoloClient({
|
|
467
467
|
credential: { kind: 'api_key', apiKey: 'x' },
|
|
468
468
|
agent: AGENT,
|
|
469
469
|
fetch: impl,
|
|
470
470
|
serviceUrls: { auth: 'https://auth.example.com', developers: 'https://auth.example.com' } as never,
|
|
471
471
|
});
|
|
472
|
-
expect(await client.
|
|
472
|
+
expect(await client.getAppIdBySlug('flaky')).toBeNull();
|
|
473
473
|
// Second call retries — earlier null was not cached.
|
|
474
|
-
expect(await client.
|
|
474
|
+
expect(await client.getAppIdBySlug('flaky')).toBe('service_y');
|
|
475
475
|
});
|
|
476
476
|
});
|
|
477
477
|
|
|
@@ -483,7 +483,7 @@ describe('TopoloClient.listServices', () => {
|
|
|
483
483
|
data: {
|
|
484
484
|
services: [
|
|
485
485
|
{
|
|
486
|
-
|
|
486
|
+
app_id: 'app_topolo_director',
|
|
487
487
|
app_slug: 'topolo-director',
|
|
488
488
|
name: 'Topolo Director',
|
|
489
489
|
description: 'Demo control plane',
|
|
@@ -512,7 +512,7 @@ describe('TopoloClient.listServices', () => {
|
|
|
512
512
|
exceptionPolicy: 'app_or_organization',
|
|
513
513
|
},
|
|
514
514
|
],
|
|
515
|
-
granted_permissions: ['
|
|
515
|
+
granted_permissions: ['app_topolo_director.dashboard:read'],
|
|
516
516
|
},
|
|
517
517
|
],
|
|
518
518
|
},
|
|
@@ -528,7 +528,7 @@ describe('TopoloClient.listServices', () => {
|
|
|
528
528
|
const out = await client.listServices();
|
|
529
529
|
expect(out).toEqual([
|
|
530
530
|
{
|
|
531
|
-
|
|
531
|
+
appId: 'app_topolo_director',
|
|
532
532
|
slug: 'topolo-director',
|
|
533
533
|
name: 'Topolo Director',
|
|
534
534
|
description: 'Demo control plane',
|
|
@@ -557,7 +557,7 @@ describe('TopoloClient.listServices', () => {
|
|
|
557
557
|
exceptionPolicy: 'app_or_organization',
|
|
558
558
|
},
|
|
559
559
|
],
|
|
560
|
-
permissions: ['
|
|
560
|
+
permissions: ['app_topolo_director.dashboard:read'],
|
|
561
561
|
},
|
|
562
562
|
]);
|
|
563
563
|
expect(calls[0]!.url).toBe(
|
|
@@ -586,14 +586,14 @@ describe('TopoloClient.listServices', () => {
|
|
|
586
586
|
data: {
|
|
587
587
|
services: [
|
|
588
588
|
{
|
|
589
|
-
|
|
589
|
+
app_id: 'app_mail',
|
|
590
590
|
app_slug: 'topolo-mail',
|
|
591
591
|
name: 'Topolo Mail',
|
|
592
592
|
api_base_url: 'https://mail-api.topolo.app',
|
|
593
593
|
launch_url: 'https://mail.topolo.app',
|
|
594
594
|
surface_kind: 'application',
|
|
595
595
|
status: 'active',
|
|
596
|
-
granted_permissions: ['
|
|
596
|
+
granted_permissions: ['app_mail.messages:read'],
|
|
597
597
|
},
|
|
598
598
|
],
|
|
599
599
|
},
|
|
@@ -621,14 +621,14 @@ describe('TopoloClient.listServices', () => {
|
|
|
621
621
|
data: {
|
|
622
622
|
services: [
|
|
623
623
|
{
|
|
624
|
-
|
|
624
|
+
app_id: 'app_crm',
|
|
625
625
|
app_slug: 'topolo-crm',
|
|
626
626
|
name: 'Topolo CRM',
|
|
627
627
|
api_base_url: 'https://crm-api.topolo.app',
|
|
628
628
|
launch_url: 'https://crm.topolo.app',
|
|
629
629
|
surface_kind: 'application',
|
|
630
630
|
status: 'active',
|
|
631
|
-
granted_permissions: ['
|
|
631
|
+
granted_permissions: ['app_crm.contacts:read'],
|
|
632
632
|
},
|
|
633
633
|
],
|
|
634
634
|
},
|
|
@@ -671,14 +671,14 @@ describe('TopoloClient.listServices', () => {
|
|
|
671
671
|
data: {
|
|
672
672
|
services: [
|
|
673
673
|
{
|
|
674
|
-
|
|
674
|
+
app_id: 'app_future',
|
|
675
675
|
app_slug: 'topolo-future',
|
|
676
676
|
name: 'Future',
|
|
677
677
|
api_base_url: 'https://future-api.topolo.app',
|
|
678
678
|
launch_url: 'https://future.topolo.app',
|
|
679
679
|
surface_kind: 'api',
|
|
680
680
|
status: 'active',
|
|
681
|
-
granted_permissions: ['
|
|
681
|
+
granted_permissions: ['app_future.read:all'],
|
|
682
682
|
},
|
|
683
683
|
],
|
|
684
684
|
},
|
|
@@ -705,14 +705,14 @@ describe('TopoloClient.listServices', () => {
|
|
|
705
705
|
data: {
|
|
706
706
|
services: [
|
|
707
707
|
{
|
|
708
|
-
|
|
708
|
+
app_id: 'app_crm',
|
|
709
709
|
app_slug: 'topolo-crm',
|
|
710
710
|
name: 'Topolo CRM',
|
|
711
711
|
api_base_url: 'https://crm-api.topolo.app',
|
|
712
712
|
launch_url: 'https://crm.topolo.app',
|
|
713
713
|
surface_kind: 'api',
|
|
714
714
|
status: 'active',
|
|
715
|
-
granted_permissions: ['
|
|
715
|
+
granted_permissions: ['app_crm.contacts:read'],
|
|
716
716
|
},
|
|
717
717
|
],
|
|
718
718
|
},
|
|
@@ -739,13 +739,13 @@ describe('TopoloClient.listServices', () => {
|
|
|
739
739
|
data: {
|
|
740
740
|
services: [
|
|
741
741
|
{
|
|
742
|
-
|
|
742
|
+
app_id: 'app_future_app',
|
|
743
743
|
app_slug: 'topolo-future-app',
|
|
744
744
|
name: 'Future App',
|
|
745
745
|
launch_url: 'https://future-app.topolo.app',
|
|
746
746
|
surface_kind: 'application',
|
|
747
747
|
status: 'active',
|
|
748
|
-
granted_permissions: ['
|
|
748
|
+
granted_permissions: ['app_future_app.launch:read'],
|
|
749
749
|
launchable: true,
|
|
750
750
|
},
|
|
751
751
|
],
|
|
@@ -772,14 +772,14 @@ describe('TopoloClient.listServices', () => {
|
|
|
772
772
|
data: {
|
|
773
773
|
services: [
|
|
774
774
|
{
|
|
775
|
-
|
|
775
|
+
app_id: 'app_future',
|
|
776
776
|
app_slug: 'topolo-warehouse',
|
|
777
777
|
name: 'Warehouse',
|
|
778
778
|
api_base_url: 'https://warehouse.topolo.app',
|
|
779
779
|
launch_url: 'https://warehouse.topolo.app',
|
|
780
780
|
surface_kind: 'api',
|
|
781
781
|
status: 'active',
|
|
782
|
-
granted_permissions: ['
|
|
782
|
+
granted_permissions: ['app_future.read:all'],
|
|
783
783
|
},
|
|
784
784
|
],
|
|
785
785
|
},
|
|
@@ -838,7 +838,7 @@ describe('TopoloClient action catalog', () => {
|
|
|
838
838
|
expect(url.toString()).toBe('https://mail-api.topolo.app/api/mail/mailboxes/primary/messages?q=Ada&page=2');
|
|
839
839
|
expect(call.init.method).toBe('GET');
|
|
840
840
|
const headers = new Headers(call.init.headers);
|
|
841
|
-
expect(headers.get('X-Topolo-Action-Id')).toBe('
|
|
841
|
+
expect(headers.get('X-Topolo-Action-Id')).toBe('app_mail.messages.list');
|
|
842
842
|
expect(headers.get('X-Topolo-Action-Name')).toBe('messages.list');
|
|
843
843
|
});
|
|
844
844
|
|
package/src/client.ts
CHANGED
|
@@ -19,8 +19,8 @@ export interface TopoloClientOptions {
|
|
|
19
19
|
agent: AgentIdentity;
|
|
20
20
|
/** Per-service URL overrides. Useful for staging/dev. */
|
|
21
21
|
serviceUrls?: Record<string, string>;
|
|
22
|
-
/** Per-service TopoloAuth
|
|
23
|
-
|
|
22
|
+
/** Per-service TopoloAuth app id overrides from the owning app/deployment. */
|
|
23
|
+
appIds?: Record<string, string>;
|
|
24
24
|
/**
|
|
25
25
|
* When true, mutating helpers (POST/PUT/PATCH/DELETE) require callers to pass
|
|
26
26
|
* `{ confirm: true }`. Defaults to true. Set false only for trusted surfaces.
|
|
@@ -98,7 +98,7 @@ const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
98
98
|
const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
99
99
|
const CATALOG_CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
100
100
|
|
|
101
|
-
interface
|
|
101
|
+
interface AppIdCacheEntry {
|
|
102
102
|
value: string | null;
|
|
103
103
|
expiresAt: number;
|
|
104
104
|
}
|
|
@@ -109,7 +109,7 @@ interface ServiceIdCacheEntry {
|
|
|
109
109
|
* from TopoloAuth `GET /api/services/catalog`.
|
|
110
110
|
*/
|
|
111
111
|
export interface TopoloServiceCatalogEntry {
|
|
112
|
-
|
|
112
|
+
appId: string;
|
|
113
113
|
slug: string | null;
|
|
114
114
|
name: string | null;
|
|
115
115
|
description: string | null;
|
|
@@ -139,7 +139,7 @@ export interface TopoloActionCatalogEntry {
|
|
|
139
139
|
toolName: string | null;
|
|
140
140
|
title: string;
|
|
141
141
|
description: string;
|
|
142
|
-
|
|
142
|
+
appId: string;
|
|
143
143
|
serviceSlug: string | null;
|
|
144
144
|
serviceName: string | null;
|
|
145
145
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
@@ -161,7 +161,7 @@ export class TopoloClient {
|
|
|
161
161
|
private readonly credential: TopoloCredential;
|
|
162
162
|
private readonly agent: AgentIdentity;
|
|
163
163
|
private readonly serviceUrls?: Record<string, string>;
|
|
164
|
-
private readonly
|
|
164
|
+
private readonly appIds?: Record<string, string>;
|
|
165
165
|
private readonly requireConfirmForWrites: boolean;
|
|
166
166
|
private readonly timeoutMs: number;
|
|
167
167
|
private readonly fetchImpl: typeof fetch;
|
|
@@ -170,19 +170,19 @@ export class TopoloClient {
|
|
|
170
170
|
private actionCatalogCache: { entries: TopoloActionCatalogEntry[]; expiresAt: number } | null = null;
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
|
-
* Process-wide cache for slug →
|
|
173
|
+
* Process-wide cache for slug → app-id lookups. Keyed by
|
|
174
174
|
* `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
|
|
175
175
|
* the same Auth share, but staging vs prod don't poison each other.
|
|
176
|
-
* Cleared via `TopoloClient.
|
|
176
|
+
* Cleared via `TopoloClient.clearAppIdCache()` if a test needs it.
|
|
177
177
|
*/
|
|
178
|
-
private static readonly
|
|
178
|
+
private static readonly appIdCache = new Map<string, AppIdCacheEntry>();
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
|
-
* Drop the slug→
|
|
181
|
+
* Drop the slug→app-id cache. Test-only — production callers should
|
|
182
182
|
* let the 5-minute TTL expire naturally.
|
|
183
183
|
*/
|
|
184
|
-
static
|
|
185
|
-
TopoloClient.
|
|
184
|
+
static clearAppIdCache(): void {
|
|
185
|
+
TopoloClient.appIdCache.clear();
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
constructor(options: TopoloClientOptions) {
|
|
@@ -191,7 +191,7 @@ export class TopoloClient {
|
|
|
191
191
|
this.credential = options.credential;
|
|
192
192
|
this.agent = options.agent;
|
|
193
193
|
this.serviceUrls = options.serviceUrls;
|
|
194
|
-
this.
|
|
194
|
+
this.appIds = options.appIds;
|
|
195
195
|
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
196
196
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
197
197
|
this.fetchImpl = options.fetch ?? fetch;
|
|
@@ -233,8 +233,8 @@ export class TopoloClient {
|
|
|
233
233
|
const headers = new Headers();
|
|
234
234
|
headers.set('Accept', 'application/json');
|
|
235
235
|
if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
236
|
+
const platformAppId = resolveConfiguredAppId(resolvedService.serviceKey, this.appIds);
|
|
237
|
+
if (platformAppId) headers.set('X-App-ID', platformAppId);
|
|
238
238
|
|
|
239
239
|
applyAuthHeaders(headers, this.credential);
|
|
240
240
|
const requestId = generateRequestId();
|
|
@@ -379,7 +379,7 @@ export class TopoloClient {
|
|
|
379
379
|
}
|
|
380
380
|
|
|
381
381
|
/**
|
|
382
|
-
* Resolve a service-slug → opaque
|
|
382
|
+
* Resolve a service-slug → opaque app-id via caller config or TopoloAuth.
|
|
383
383
|
*
|
|
384
384
|
* Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
|
|
385
385
|
* results (404) are cached for 60s so a typo doesn't hot-loop the
|
|
@@ -390,19 +390,19 @@ export class TopoloClient {
|
|
|
390
390
|
* This keeps the resolver usable during early bootstrap before a caller
|
|
391
391
|
* has acquired an access token.
|
|
392
392
|
*/
|
|
393
|
-
async
|
|
393
|
+
async getAppIdBySlug(slug: string): Promise<string | null> {
|
|
394
394
|
if (typeof slug !== 'string') return null;
|
|
395
395
|
const normalized = slug.trim().toLowerCase();
|
|
396
396
|
if (!normalized) return null;
|
|
397
397
|
|
|
398
|
-
const
|
|
399
|
-
if (
|
|
398
|
+
const configuredAppId = resolveConfiguredAppId(normalized, this.appIds);
|
|
399
|
+
if (configuredAppId) return configuredAppId;
|
|
400
400
|
|
|
401
401
|
// app-catalog greenfield: by-slug resolver lives on the Catalog (developers).
|
|
402
402
|
const authBase = resolveServiceUrl('developers', this.serviceUrls);
|
|
403
403
|
const cacheKey = `${authBase}|${normalized}`;
|
|
404
404
|
const now = Date.now();
|
|
405
|
-
const cached = TopoloClient.
|
|
405
|
+
const cached = TopoloClient.appIdCache.get(cacheKey);
|
|
406
406
|
if (cached && cached.expiresAt > now) {
|
|
407
407
|
return cached.value;
|
|
408
408
|
}
|
|
@@ -424,7 +424,7 @@ export class TopoloClient {
|
|
|
424
424
|
}
|
|
425
425
|
|
|
426
426
|
if (res.status === 404) {
|
|
427
|
-
TopoloClient.
|
|
427
|
+
TopoloClient.appIdCache.set(cacheKey, {
|
|
428
428
|
value: null,
|
|
429
429
|
expiresAt: now + NEGATIVE_CACHE_TTL_MS,
|
|
430
430
|
});
|
|
@@ -436,11 +436,11 @@ export class TopoloClient {
|
|
|
436
436
|
}
|
|
437
437
|
|
|
438
438
|
const body = (await res.json().catch(() => null)) as
|
|
439
|
-
| { success?: boolean; data?: {
|
|
439
|
+
| { success?: boolean; data?: { app_id?: unknown } }
|
|
440
440
|
| null;
|
|
441
|
-
const
|
|
442
|
-
const value = typeof
|
|
443
|
-
TopoloClient.
|
|
441
|
+
const appId = body?.data?.app_id;
|
|
442
|
+
const value = typeof appId === 'string' && appId ? appId : null;
|
|
443
|
+
TopoloClient.appIdCache.set(cacheKey, {
|
|
444
444
|
value,
|
|
445
445
|
expiresAt: now + POSITIVE_CACHE_TTL_MS,
|
|
446
446
|
});
|
|
@@ -483,13 +483,13 @@ export class TopoloClient {
|
|
|
483
483
|
const entries: TopoloServiceCatalogEntry[] = [];
|
|
484
484
|
for (const item of raw) {
|
|
485
485
|
const s = item as Record<string, unknown>;
|
|
486
|
-
const
|
|
487
|
-
typeof s.
|
|
488
|
-
? s.
|
|
486
|
+
const appId =
|
|
487
|
+
typeof s.app_id === 'string'
|
|
488
|
+
? s.app_id
|
|
489
489
|
: typeof s.id === 'string'
|
|
490
490
|
? s.id
|
|
491
491
|
: null;
|
|
492
|
-
if (!
|
|
492
|
+
if (!appId) continue;
|
|
493
493
|
const permissions = Array.isArray(s.granted_permissions)
|
|
494
494
|
? s.granted_permissions
|
|
495
495
|
.filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
|
|
@@ -507,7 +507,7 @@ export class TopoloClient {
|
|
|
507
507
|
? [apiKeyResource]
|
|
508
508
|
: [];
|
|
509
509
|
entries.push({
|
|
510
|
-
|
|
510
|
+
appId,
|
|
511
511
|
slug: typeof s.app_slug === 'string' ? s.app_slug : null,
|
|
512
512
|
name: typeof s.name === 'string' ? s.name : null,
|
|
513
513
|
description:
|
|
@@ -603,7 +603,7 @@ export class TopoloClient {
|
|
|
603
603
|
): Promise<T> {
|
|
604
604
|
const entry = await this.getAction(action);
|
|
605
605
|
const mapped = mapActionInput(entry, input);
|
|
606
|
-
const service = entry.serviceSlug || entry.
|
|
606
|
+
const service = entry.serviceSlug || entry.appId;
|
|
607
607
|
return this.request<T>({
|
|
608
608
|
service,
|
|
609
609
|
method: entry.method,
|
|
@@ -629,7 +629,7 @@ export class TopoloClient {
|
|
|
629
629
|
productionUrl?: string | null;
|
|
630
630
|
}>;
|
|
631
631
|
return apps.map((app) => ({
|
|
632
|
-
|
|
632
|
+
appId: app.id,
|
|
633
633
|
slug: app.id,
|
|
634
634
|
name: app.name ?? app.id,
|
|
635
635
|
description: null,
|
|
@@ -688,15 +688,15 @@ export class TopoloClient {
|
|
|
688
688
|
}
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
-
function
|
|
691
|
+
function resolveConfiguredAppId(
|
|
692
692
|
slug: string,
|
|
693
|
-
|
|
693
|
+
appIds?: Record<string, string>,
|
|
694
694
|
): string | null {
|
|
695
695
|
for (const serviceKey of serviceKeyCandidates(slug)) {
|
|
696
|
-
const configured =
|
|
696
|
+
const configured = appIds?.[serviceKey as ServiceId];
|
|
697
697
|
if (typeof configured === 'string' && configured.trim()) return configured.trim();
|
|
698
698
|
|
|
699
|
-
const envKey = `
|
|
699
|
+
const envKey = `TOPOLO_APP_ID_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
|
|
700
700
|
const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
|
|
701
701
|
if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
|
|
702
702
|
}
|
|
@@ -814,7 +814,7 @@ function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string):
|
|
|
814
814
|
}
|
|
815
815
|
|
|
816
816
|
function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
|
|
817
|
-
const values = [entry.
|
|
817
|
+
const values = [entry.appId, entry.slug, entry.name].filter((value): value is string => (
|
|
818
818
|
typeof value === 'string' && value.trim().length > 0
|
|
819
819
|
));
|
|
820
820
|
const keys = new Set<string>();
|
|
@@ -833,10 +833,10 @@ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry |
|
|
|
833
833
|
const a = item as Record<string, unknown>;
|
|
834
834
|
const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
|
|
835
835
|
const name = pickActionString(a.name);
|
|
836
|
-
const
|
|
836
|
+
const appId = pickActionString(a.app_id) ?? pickActionString(a.appId);
|
|
837
837
|
const method = pickActionString(a.method)?.toUpperCase();
|
|
838
838
|
const path = pickActionString(a.path);
|
|
839
|
-
if (!actionId || !name || !
|
|
839
|
+
if (!actionId || !name || !appId || !method || !path || !isHttpMethod(method)) {
|
|
840
840
|
return null;
|
|
841
841
|
}
|
|
842
842
|
|
|
@@ -846,7 +846,7 @@ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry |
|
|
|
846
846
|
toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
|
|
847
847
|
title: pickActionString(a.title) ?? name,
|
|
848
848
|
description: pickActionString(a.description) ?? '',
|
|
849
|
-
|
|
849
|
+
appId,
|
|
850
850
|
serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
|
|
851
851
|
serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
|
|
852
852
|
method,
|
package/src/index.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ describe('createTopolo', () => {
|
|
|
29
29
|
const impl = vi.fn(async (url: string) => {
|
|
30
30
|
calls.push(String(url));
|
|
31
31
|
if (String(url).includes('/api/services/by-slug/')) {
|
|
32
|
-
return new Response(JSON.stringify({ success: true, data: {
|
|
32
|
+
return new Response(JSON.stringify({ success: true, data: { app_id: 'service_auth' } }), {
|
|
33
33
|
status: 200,
|
|
34
34
|
headers: { 'Content-Type': 'application/json' },
|
|
35
35
|
});
|
|
@@ -46,12 +46,12 @@ describe('createTopolo', () => {
|
|
|
46
46
|
);
|
|
47
47
|
}) as unknown as typeof fetch;
|
|
48
48
|
|
|
49
|
-
TopoloClient.
|
|
49
|
+
TopoloClient.clearAppIdCache();
|
|
50
50
|
const topolo = createTopolo({
|
|
51
51
|
credential: { kind: 'access_token', accessToken: 'tok' },
|
|
52
52
|
agent: AGENT,
|
|
53
53
|
fetch: impl,
|
|
54
|
-
|
|
54
|
+
appIds: { auth: 'service_auth' },
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
const me = await topolo.identity.whoami();
|
package/src/modules/crm.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
9
9
|
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
10
|
calls.push({ url, init });
|
|
11
11
|
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
-
return new Response(JSON.stringify({ success: true, data: {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { app_id: 'service_crm' } }), {
|
|
13
13
|
status: 200,
|
|
14
14
|
headers: { 'Content-Type': 'application/json' },
|
|
15
15
|
});
|
|
@@ -20,14 +20,14 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
20
20
|
data: {
|
|
21
21
|
services: [
|
|
22
22
|
{
|
|
23
|
-
|
|
23
|
+
app_id: 'app_crm',
|
|
24
24
|
app_slug: 'topolo-crm',
|
|
25
25
|
name: 'Topolo CRM',
|
|
26
26
|
api_base_url: 'https://crm.topolo.app',
|
|
27
27
|
launch_url: 'https://crm.topolo.app',
|
|
28
28
|
surface_kind: 'api',
|
|
29
29
|
status: 'active',
|
|
30
|
-
granted_permissions: ['
|
|
30
|
+
granted_permissions: ['app_crm.contacts:read'],
|
|
31
31
|
},
|
|
32
32
|
],
|
|
33
33
|
},
|
|
@@ -61,7 +61,7 @@ function clientWith(impl: typeof fetch) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
beforeEach(() => {
|
|
64
|
-
TopoloClient.
|
|
64
|
+
TopoloClient.clearAppIdCache();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
describe('CrmModule.listContacts', () => {
|