@topolo/sdk 0.4.9 → 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 +142 -138
- package/dist/index.d.cts +121 -120
- package/dist/index.d.ts +121 -120
- package/dist/index.js +142 -138
- 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 +51 -43
- package/src/index.test.ts +4 -4
- 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';
|
|
@@ -151,6 +151,8 @@ export interface TopoloActionCatalogEntry {
|
|
|
151
151
|
readOnly: boolean;
|
|
152
152
|
destructive: boolean;
|
|
153
153
|
requiresConfirmation: boolean;
|
|
154
|
+
/** Agent access tier. null/absent when the catalog does not specify one (consumers derive a default). */
|
|
155
|
+
agentAccess?: 'auto' | 'confirm' | 'off' | null;
|
|
154
156
|
status: string | null;
|
|
155
157
|
sourceRevision: string | null;
|
|
156
158
|
}
|
|
@@ -159,7 +161,7 @@ export class TopoloClient {
|
|
|
159
161
|
private readonly credential: TopoloCredential;
|
|
160
162
|
private readonly agent: AgentIdentity;
|
|
161
163
|
private readonly serviceUrls?: Record<string, string>;
|
|
162
|
-
private readonly
|
|
164
|
+
private readonly appIds?: Record<string, string>;
|
|
163
165
|
private readonly requireConfirmForWrites: boolean;
|
|
164
166
|
private readonly timeoutMs: number;
|
|
165
167
|
private readonly fetchImpl: typeof fetch;
|
|
@@ -168,19 +170,19 @@ export class TopoloClient {
|
|
|
168
170
|
private actionCatalogCache: { entries: TopoloActionCatalogEntry[]; expiresAt: number } | null = null;
|
|
169
171
|
|
|
170
172
|
/**
|
|
171
|
-
* Process-wide cache for slug →
|
|
173
|
+
* Process-wide cache for slug → app-id lookups. Keyed by
|
|
172
174
|
* `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
|
|
173
175
|
* the same Auth share, but staging vs prod don't poison each other.
|
|
174
|
-
* Cleared via `TopoloClient.
|
|
176
|
+
* Cleared via `TopoloClient.clearAppIdCache()` if a test needs it.
|
|
175
177
|
*/
|
|
176
|
-
private static readonly
|
|
178
|
+
private static readonly appIdCache = new Map<string, AppIdCacheEntry>();
|
|
177
179
|
|
|
178
180
|
/**
|
|
179
|
-
* Drop the slug→
|
|
181
|
+
* Drop the slug→app-id cache. Test-only — production callers should
|
|
180
182
|
* let the 5-minute TTL expire naturally.
|
|
181
183
|
*/
|
|
182
|
-
static
|
|
183
|
-
TopoloClient.
|
|
184
|
+
static clearAppIdCache(): void {
|
|
185
|
+
TopoloClient.appIdCache.clear();
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
constructor(options: TopoloClientOptions) {
|
|
@@ -189,7 +191,7 @@ export class TopoloClient {
|
|
|
189
191
|
this.credential = options.credential;
|
|
190
192
|
this.agent = options.agent;
|
|
191
193
|
this.serviceUrls = options.serviceUrls;
|
|
192
|
-
this.
|
|
194
|
+
this.appIds = options.appIds;
|
|
193
195
|
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
194
196
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
195
197
|
this.fetchImpl = options.fetch ?? fetch;
|
|
@@ -231,8 +233,8 @@ export class TopoloClient {
|
|
|
231
233
|
const headers = new Headers();
|
|
232
234
|
headers.set('Accept', 'application/json');
|
|
233
235
|
if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
|
|
234
|
-
const
|
|
235
|
-
if (
|
|
236
|
+
const platformAppId = resolveConfiguredAppId(resolvedService.serviceKey, this.appIds);
|
|
237
|
+
if (platformAppId) headers.set('X-App-ID', platformAppId);
|
|
236
238
|
|
|
237
239
|
applyAuthHeaders(headers, this.credential);
|
|
238
240
|
const requestId = generateRequestId();
|
|
@@ -377,7 +379,7 @@ export class TopoloClient {
|
|
|
377
379
|
}
|
|
378
380
|
|
|
379
381
|
/**
|
|
380
|
-
* Resolve a service-slug → opaque
|
|
382
|
+
* Resolve a service-slug → opaque app-id via caller config or TopoloAuth.
|
|
381
383
|
*
|
|
382
384
|
* Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
|
|
383
385
|
* results (404) are cached for 60s so a typo doesn't hot-loop the
|
|
@@ -388,19 +390,19 @@ export class TopoloClient {
|
|
|
388
390
|
* This keeps the resolver usable during early bootstrap before a caller
|
|
389
391
|
* has acquired an access token.
|
|
390
392
|
*/
|
|
391
|
-
async
|
|
393
|
+
async getAppIdBySlug(slug: string): Promise<string | null> {
|
|
392
394
|
if (typeof slug !== 'string') return null;
|
|
393
395
|
const normalized = slug.trim().toLowerCase();
|
|
394
396
|
if (!normalized) return null;
|
|
395
397
|
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
398
|
+
const configuredAppId = resolveConfiguredAppId(normalized, this.appIds);
|
|
399
|
+
if (configuredAppId) return configuredAppId;
|
|
398
400
|
|
|
399
401
|
// app-catalog greenfield: by-slug resolver lives on the Catalog (developers).
|
|
400
402
|
const authBase = resolveServiceUrl('developers', this.serviceUrls);
|
|
401
403
|
const cacheKey = `${authBase}|${normalized}`;
|
|
402
404
|
const now = Date.now();
|
|
403
|
-
const cached = TopoloClient.
|
|
405
|
+
const cached = TopoloClient.appIdCache.get(cacheKey);
|
|
404
406
|
if (cached && cached.expiresAt > now) {
|
|
405
407
|
return cached.value;
|
|
406
408
|
}
|
|
@@ -422,7 +424,7 @@ export class TopoloClient {
|
|
|
422
424
|
}
|
|
423
425
|
|
|
424
426
|
if (res.status === 404) {
|
|
425
|
-
TopoloClient.
|
|
427
|
+
TopoloClient.appIdCache.set(cacheKey, {
|
|
426
428
|
value: null,
|
|
427
429
|
expiresAt: now + NEGATIVE_CACHE_TTL_MS,
|
|
428
430
|
});
|
|
@@ -434,11 +436,11 @@ export class TopoloClient {
|
|
|
434
436
|
}
|
|
435
437
|
|
|
436
438
|
const body = (await res.json().catch(() => null)) as
|
|
437
|
-
| { success?: boolean; data?: {
|
|
439
|
+
| { success?: boolean; data?: { app_id?: unknown } }
|
|
438
440
|
| null;
|
|
439
|
-
const
|
|
440
|
-
const value = typeof
|
|
441
|
-
TopoloClient.
|
|
441
|
+
const appId = body?.data?.app_id;
|
|
442
|
+
const value = typeof appId === 'string' && appId ? appId : null;
|
|
443
|
+
TopoloClient.appIdCache.set(cacheKey, {
|
|
442
444
|
value,
|
|
443
445
|
expiresAt: now + POSITIVE_CACHE_TTL_MS,
|
|
444
446
|
});
|
|
@@ -481,13 +483,13 @@ export class TopoloClient {
|
|
|
481
483
|
const entries: TopoloServiceCatalogEntry[] = [];
|
|
482
484
|
for (const item of raw) {
|
|
483
485
|
const s = item as Record<string, unknown>;
|
|
484
|
-
const
|
|
485
|
-
typeof s.
|
|
486
|
-
? s.
|
|
486
|
+
const appId =
|
|
487
|
+
typeof s.app_id === 'string'
|
|
488
|
+
? s.app_id
|
|
487
489
|
: typeof s.id === 'string'
|
|
488
490
|
? s.id
|
|
489
491
|
: null;
|
|
490
|
-
if (!
|
|
492
|
+
if (!appId) continue;
|
|
491
493
|
const permissions = Array.isArray(s.granted_permissions)
|
|
492
494
|
? s.granted_permissions
|
|
493
495
|
.filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
|
|
@@ -505,7 +507,7 @@ export class TopoloClient {
|
|
|
505
507
|
? [apiKeyResource]
|
|
506
508
|
: [];
|
|
507
509
|
entries.push({
|
|
508
|
-
|
|
510
|
+
appId,
|
|
509
511
|
slug: typeof s.app_slug === 'string' ? s.app_slug : null,
|
|
510
512
|
name: typeof s.name === 'string' ? s.name : null,
|
|
511
513
|
description:
|
|
@@ -539,8 +541,9 @@ export class TopoloClient {
|
|
|
539
541
|
}
|
|
540
542
|
|
|
541
543
|
/**
|
|
542
|
-
* List callable actions from the live
|
|
543
|
-
* the
|
|
544
|
+
* List callable actions from the live registry-canonical catalog (definitions
|
|
545
|
+
* from the Catalog registry, scoping from Auth). The response is scoped to the
|
|
546
|
+
* caller's organization and effective permissions; no bundled/static app
|
|
544
547
|
* action list is used as an authorization source.
|
|
545
548
|
*/
|
|
546
549
|
async listActions(options: { service?: string } = {}): Promise<TopoloActionCatalogEntry[]> {
|
|
@@ -553,7 +556,7 @@ export class TopoloClient {
|
|
|
553
556
|
| { data?: { actions?: unknown[] } }
|
|
554
557
|
| { actions?: unknown[] }
|
|
555
558
|
>({
|
|
556
|
-
service: '
|
|
559
|
+
service: 'developers',
|
|
557
560
|
method: 'GET',
|
|
558
561
|
path: '/api/actions/catalog',
|
|
559
562
|
...(options.service ? { query: { service: options.service } } : {}),
|
|
@@ -600,7 +603,7 @@ export class TopoloClient {
|
|
|
600
603
|
): Promise<T> {
|
|
601
604
|
const entry = await this.getAction(action);
|
|
602
605
|
const mapped = mapActionInput(entry, input);
|
|
603
|
-
const service = entry.serviceSlug || entry.
|
|
606
|
+
const service = entry.serviceSlug || entry.appId;
|
|
604
607
|
return this.request<T>({
|
|
605
608
|
service,
|
|
606
609
|
method: entry.method,
|
|
@@ -626,7 +629,7 @@ export class TopoloClient {
|
|
|
626
629
|
productionUrl?: string | null;
|
|
627
630
|
}>;
|
|
628
631
|
return apps.map((app) => ({
|
|
629
|
-
|
|
632
|
+
appId: app.id,
|
|
630
633
|
slug: app.id,
|
|
631
634
|
name: app.name ?? app.id,
|
|
632
635
|
description: null,
|
|
@@ -685,15 +688,15 @@ export class TopoloClient {
|
|
|
685
688
|
}
|
|
686
689
|
}
|
|
687
690
|
|
|
688
|
-
function
|
|
691
|
+
function resolveConfiguredAppId(
|
|
689
692
|
slug: string,
|
|
690
|
-
|
|
693
|
+
appIds?: Record<string, string>,
|
|
691
694
|
): string | null {
|
|
692
695
|
for (const serviceKey of serviceKeyCandidates(slug)) {
|
|
693
|
-
const configured =
|
|
696
|
+
const configured = appIds?.[serviceKey as ServiceId];
|
|
694
697
|
if (typeof configured === 'string' && configured.trim()) return configured.trim();
|
|
695
698
|
|
|
696
|
-
const envKey = `
|
|
699
|
+
const envKey = `TOPOLO_APP_ID_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
|
|
697
700
|
const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
|
|
698
701
|
if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
|
|
699
702
|
}
|
|
@@ -811,7 +814,7 @@ function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string):
|
|
|
811
814
|
}
|
|
812
815
|
|
|
813
816
|
function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
|
|
814
|
-
const values = [entry.
|
|
817
|
+
const values = [entry.appId, entry.slug, entry.name].filter((value): value is string => (
|
|
815
818
|
typeof value === 'string' && value.trim().length > 0
|
|
816
819
|
));
|
|
817
820
|
const keys = new Set<string>();
|
|
@@ -830,10 +833,10 @@ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry |
|
|
|
830
833
|
const a = item as Record<string, unknown>;
|
|
831
834
|
const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
|
|
832
835
|
const name = pickActionString(a.name);
|
|
833
|
-
const
|
|
836
|
+
const appId = pickActionString(a.app_id) ?? pickActionString(a.appId);
|
|
834
837
|
const method = pickActionString(a.method)?.toUpperCase();
|
|
835
838
|
const path = pickActionString(a.path);
|
|
836
|
-
if (!actionId || !name || !
|
|
839
|
+
if (!actionId || !name || !appId || !method || !path || !isHttpMethod(method)) {
|
|
837
840
|
return null;
|
|
838
841
|
}
|
|
839
842
|
|
|
@@ -843,7 +846,7 @@ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry |
|
|
|
843
846
|
toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
|
|
844
847
|
title: pickActionString(a.title) ?? name,
|
|
845
848
|
description: pickActionString(a.description) ?? '',
|
|
846
|
-
|
|
849
|
+
appId,
|
|
847
850
|
serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
|
|
848
851
|
serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
|
|
849
852
|
method,
|
|
@@ -855,6 +858,7 @@ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry |
|
|
|
855
858
|
readOnly: Boolean(a.read_only ?? a.readOnly),
|
|
856
859
|
destructive: Boolean(a.destructive),
|
|
857
860
|
requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
|
|
861
|
+
agentAccess: pickAgentAccess(a.agent_access ?? a.agentAccess),
|
|
858
862
|
status: pickActionString(a.status),
|
|
859
863
|
sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision),
|
|
860
864
|
};
|
|
@@ -868,6 +872,10 @@ function pickActionString(value: unknown): string | null {
|
|
|
868
872
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
869
873
|
}
|
|
870
874
|
|
|
875
|
+
function pickAgentAccess(value: unknown): 'auto' | 'confirm' | 'off' | null {
|
|
876
|
+
return value === 'auto' || value === 'confirm' || value === 'off' ? value : null;
|
|
877
|
+
}
|
|
878
|
+
|
|
871
879
|
function plainObject(value: unknown): Record<string, unknown> {
|
|
872
880
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
873
881
|
? value as Record<string, unknown>
|