@topolo/sdk 0.4.0 → 0.4.2
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 +19 -4
- package/dist/index.cjs +482 -221
- package/dist/index.d.cts +466 -271
- package/dist/index.d.ts +466 -271
- package/dist/index.js +482 -221
- package/package.json +1 -1
- package/src/application-requirements.ts +16 -5
- package/src/applications.generated.ts +349 -171
- package/src/client.test.ts +170 -11
- package/src/client.ts +165 -64
- package/src/modules/p2p.test.ts +25 -2
- package/src/services.generated.ts +4 -2
- package/src/services.test.ts +11 -5
package/src/client.test.ts
CHANGED
|
@@ -17,9 +17,29 @@ 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 CRM_CATALOG = {
|
|
21
|
+
body: {
|
|
22
|
+
data: {
|
|
23
|
+
services: [
|
|
24
|
+
{
|
|
25
|
+
service_id: 'srv_crm',
|
|
26
|
+
app_slug: 'topolo-crm',
|
|
27
|
+
name: 'Topolo CRM',
|
|
28
|
+
base_url: 'https://crm-api.topolo.app',
|
|
29
|
+
surface_kind: 'api',
|
|
30
|
+
status: 'active',
|
|
31
|
+
granted_permissions: ['srv_crm.contacts:read'],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
20
37
|
|
|
21
38
|
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
22
|
-
return calls.filter((call) =>
|
|
39
|
+
return calls.filter((call) =>
|
|
40
|
+
!call.url.includes('/api/services/by-slug/') &&
|
|
41
|
+
!call.url.includes('/api/services/catalog')
|
|
42
|
+
);
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
beforeEach(() => {
|
|
@@ -28,7 +48,7 @@ beforeEach(() => {
|
|
|
28
48
|
|
|
29
49
|
describe('TopoloClient.request', () => {
|
|
30
50
|
it('injects Bearer auth + audit headers for access_token credentials', async () => {
|
|
31
|
-
const { impl, calls } = stubFetch([{ body: { ok: true } }]);
|
|
51
|
+
const { impl, calls } = stubFetch([CRM_CATALOG, { body: { ok: true } }]);
|
|
32
52
|
const client = new TopoloClient({
|
|
33
53
|
credential: { kind: 'access_token', accessToken: 'tok_abc' },
|
|
34
54
|
agent: AGENT,
|
|
@@ -44,7 +64,7 @@ describe('TopoloClient.request', () => {
|
|
|
44
64
|
});
|
|
45
65
|
|
|
46
66
|
it('uses X-Api-Key header for api_key credentials', async () => {
|
|
47
|
-
const { impl, calls } = stubFetch([{ body: { ok: true } }]);
|
|
67
|
+
const { impl, calls } = stubFetch([CRM_CATALOG, { body: { ok: true } }]);
|
|
48
68
|
const client = new TopoloClient({
|
|
49
69
|
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
50
70
|
agent: AGENT,
|
|
@@ -69,7 +89,7 @@ describe('TopoloClient.request', () => {
|
|
|
69
89
|
});
|
|
70
90
|
|
|
71
91
|
it('allows mutating requests with confirm: true', async () => {
|
|
72
|
-
const { impl, calls } = stubFetch([{ body: { id: 'c_1' } }]);
|
|
92
|
+
const { impl, calls } = stubFetch([CRM_CATALOG, { body: { id: 'c_1' } }]);
|
|
73
93
|
const client = new TopoloClient({
|
|
74
94
|
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
75
95
|
agent: AGENT,
|
|
@@ -122,6 +142,7 @@ describe('TopoloClient.request', () => {
|
|
|
122
142
|
|
|
123
143
|
it('maps 403 → TopoloPermissionError with required scopes extracted', async () => {
|
|
124
144
|
const { impl } = stubFetch([
|
|
145
|
+
CRM_CATALOG,
|
|
125
146
|
{ status: 403, body: { error: 'forbidden', required: ['crm.contacts:write'] } },
|
|
126
147
|
]);
|
|
127
148
|
const client = new TopoloClient({
|
|
@@ -135,7 +156,7 @@ describe('TopoloClient.request', () => {
|
|
|
135
156
|
});
|
|
136
157
|
|
|
137
158
|
it('maps non-401/403 failures → TopoloHttpError with status preserved', async () => {
|
|
138
|
-
const { impl } = stubFetch([{ status: 500, body: { error: 'boom' } }]);
|
|
159
|
+
const { impl } = stubFetch([CRM_CATALOG, { status: 500, body: { error: 'boom' } }]);
|
|
139
160
|
const client = new TopoloClient({
|
|
140
161
|
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
141
162
|
agent: AGENT,
|
|
@@ -148,7 +169,7 @@ describe('TopoloClient.request', () => {
|
|
|
148
169
|
});
|
|
149
170
|
|
|
150
171
|
it('appends query params as search string', async () => {
|
|
151
|
-
const { impl, calls } = stubFetch([{ body: { contacts: [] } }]);
|
|
172
|
+
const { impl, calls } = stubFetch([CRM_CATALOG, { body: { contacts: [] } }]);
|
|
152
173
|
const client = new TopoloClient({
|
|
153
174
|
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
154
175
|
agent: AGENT,
|
|
@@ -369,6 +390,7 @@ describe('TopoloClient.listServices', () => {
|
|
|
369
390
|
status: 'active',
|
|
370
391
|
surface_kind: 'application',
|
|
371
392
|
launchable: true,
|
|
393
|
+
granted_permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
|
|
372
394
|
},
|
|
373
395
|
],
|
|
374
396
|
},
|
|
@@ -392,6 +414,7 @@ describe('TopoloClient.listServices', () => {
|
|
|
392
414
|
status: 'active',
|
|
393
415
|
surfaceKind: 'application',
|
|
394
416
|
launchable: true,
|
|
417
|
+
permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
|
|
395
418
|
},
|
|
396
419
|
]);
|
|
397
420
|
expect(calls[0]!.url).toBe(
|
|
@@ -402,7 +425,7 @@ describe('TopoloClient.listServices', () => {
|
|
|
402
425
|
);
|
|
403
426
|
});
|
|
404
427
|
|
|
405
|
-
it('
|
|
428
|
+
it('does not fall back to the generated global snapshot on HTTP failure', async () => {
|
|
406
429
|
const { impl } = stubFetch([{ status: 503, body: { error: 'down' } }]);
|
|
407
430
|
const client = new TopoloClient({
|
|
408
431
|
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
@@ -410,10 +433,146 @@ describe('TopoloClient.listServices', () => {
|
|
|
410
433
|
fetch: impl,
|
|
411
434
|
serviceUrls: { auth: 'https://auth.example.com' },
|
|
412
435
|
});
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
436
|
+
await expect(client.listServices()).rejects.toBeInstanceOf(TopoloHttpError);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('routes bundled service aliases only after the scoped live catalog grants access', async () => {
|
|
440
|
+
const { impl, calls } = stubFetch([
|
|
441
|
+
{
|
|
442
|
+
body: {
|
|
443
|
+
data: {
|
|
444
|
+
services: [
|
|
445
|
+
{
|
|
446
|
+
service_id: 'srv_mail',
|
|
447
|
+
app_slug: 'topolo-mail',
|
|
448
|
+
name: 'Topolo Mail',
|
|
449
|
+
base_url: 'https://mail.topolo.app',
|
|
450
|
+
surface_kind: 'application',
|
|
451
|
+
status: 'active',
|
|
452
|
+
granted_permissions: ['srv_mail.messages:read'],
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
{ body: { ok: true } },
|
|
459
|
+
]);
|
|
460
|
+
const client = new TopoloClient({
|
|
461
|
+
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
462
|
+
agent: AGENT,
|
|
463
|
+
fetch: impl,
|
|
464
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await client.request({ service: 'mail', path: '/api/health' });
|
|
468
|
+
|
|
469
|
+
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
|
|
470
|
+
expect(calls[1]!.url).toBe('https://mail.topolo.app/api/health');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('refuses a bundled static service that is absent from the scoped live catalog', async () => {
|
|
474
|
+
const { impl, calls } = stubFetch([
|
|
475
|
+
{
|
|
476
|
+
body: {
|
|
477
|
+
data: {
|
|
478
|
+
services: [
|
|
479
|
+
{
|
|
480
|
+
service_id: 'srv_crm',
|
|
481
|
+
app_slug: 'topolo-crm',
|
|
482
|
+
name: 'Topolo CRM',
|
|
483
|
+
base_url: 'https://crm-api.topolo.app',
|
|
484
|
+
surface_kind: 'api',
|
|
485
|
+
status: 'active',
|
|
486
|
+
granted_permissions: ['srv_crm.contacts:read'],
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
]);
|
|
493
|
+
const client = new TopoloClient({
|
|
494
|
+
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
495
|
+
agent: AGENT,
|
|
496
|
+
fetch: impl,
|
|
497
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
await expect(client.request({ service: 'mail', path: '/api/health' })).rejects.toMatchObject({
|
|
501
|
+
code: 'unknown_service',
|
|
502
|
+
});
|
|
503
|
+
expect(calls).toHaveLength(1);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('does not treat live browser applications as generic API services', async () => {
|
|
507
|
+
const { impl } = stubFetch([
|
|
508
|
+
{
|
|
509
|
+
body: {
|
|
510
|
+
data: {
|
|
511
|
+
services: [
|
|
512
|
+
{
|
|
513
|
+
service_id: 'srv_future_app',
|
|
514
|
+
app_slug: 'topolo-future-app',
|
|
515
|
+
name: 'Future App',
|
|
516
|
+
base_url: 'https://future-app.topolo.app',
|
|
517
|
+
surface_kind: 'application',
|
|
518
|
+
status: 'active',
|
|
519
|
+
granted_permissions: ['srv_future_app.launch:read'],
|
|
520
|
+
launchable: true,
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
]);
|
|
527
|
+
const client = new TopoloClient({
|
|
528
|
+
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
529
|
+
agent: AGENT,
|
|
530
|
+
fetch: impl,
|
|
531
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
await expect(client.request({ service: 'future-app', path: '/api/health' })).rejects.toMatchObject({
|
|
535
|
+
code: 'unknown_service',
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('caches the live catalog per client for repeated dynamic service calls', async () => {
|
|
540
|
+
const { impl, calls } = stubFetch([
|
|
541
|
+
{
|
|
542
|
+
body: {
|
|
543
|
+
data: {
|
|
544
|
+
services: [
|
|
545
|
+
{
|
|
546
|
+
service_id: 'srv_future',
|
|
547
|
+
app_slug: 'topolo-warehouse',
|
|
548
|
+
name: 'Warehouse',
|
|
549
|
+
base_url: 'https://warehouse.topolo.app',
|
|
550
|
+
surface_kind: 'api',
|
|
551
|
+
status: 'active',
|
|
552
|
+
granted_permissions: ['srv_future.read:all'],
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
{ body: { ok: true } },
|
|
559
|
+
{ body: { ok: true } },
|
|
560
|
+
]);
|
|
561
|
+
const client = new TopoloClient({
|
|
562
|
+
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
563
|
+
agent: AGENT,
|
|
564
|
+
fetch: impl,
|
|
565
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
await client.request({ service: 'warehouse', path: '/api/one' });
|
|
569
|
+
await client.request({ service: 'warehouse', path: '/api/two' });
|
|
570
|
+
|
|
571
|
+
expect(calls.map((call) => call.url)).toEqual([
|
|
572
|
+
'https://auth.example.com/api/services/catalog',
|
|
573
|
+
'https://warehouse.topolo.app/api/one',
|
|
574
|
+
'https://warehouse.topolo.app/api/two',
|
|
575
|
+
]);
|
|
417
576
|
});
|
|
418
577
|
});
|
|
419
578
|
|
package/src/client.ts
CHANGED
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
type AgentIdentity,
|
|
6
6
|
type TopoloCredential,
|
|
7
7
|
} from './auth.js';
|
|
8
|
-
import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
|
|
8
|
+
import { TopoloAuthError, TopoloHttpError, TopoloPermissionError, TopoloSdkError } from './errors.js';
|
|
9
9
|
import {
|
|
10
|
+
DEFAULT_SERVICE_URLS,
|
|
10
11
|
APPLICATIONS,
|
|
11
12
|
resolveServiceUrl,
|
|
12
13
|
type ServiceId,
|
|
@@ -16,9 +17,9 @@ export interface TopoloClientOptions {
|
|
|
16
17
|
credential: TopoloCredential;
|
|
17
18
|
agent: AgentIdentity;
|
|
18
19
|
/** Per-service URL overrides. Useful for staging/dev. */
|
|
19
|
-
serviceUrls?:
|
|
20
|
+
serviceUrls?: Record<string, string>;
|
|
20
21
|
/** Per-service TopoloAuth service id overrides from the owning app/deployment. */
|
|
21
|
-
serviceIds?:
|
|
22
|
+
serviceIds?: Record<string, string>;
|
|
22
23
|
/**
|
|
23
24
|
* When true, mutating helpers (POST/PUT/PATCH/DELETE) require callers to pass
|
|
24
25
|
* `{ confirm: true }`. Defaults to true. Set false only for trusted surfaces.
|
|
@@ -41,7 +42,7 @@ export type TopoloDebugEvent =
|
|
|
41
42
|
| {
|
|
42
43
|
phase: 'request';
|
|
43
44
|
method: string;
|
|
44
|
-
service:
|
|
45
|
+
service: string;
|
|
45
46
|
path: string;
|
|
46
47
|
url: string;
|
|
47
48
|
requestId: string;
|
|
@@ -49,7 +50,7 @@ export type TopoloDebugEvent =
|
|
|
49
50
|
| {
|
|
50
51
|
phase: 'response';
|
|
51
52
|
method: string;
|
|
52
|
-
service:
|
|
53
|
+
service: string;
|
|
53
54
|
path: string;
|
|
54
55
|
url: string;
|
|
55
56
|
requestId: string;
|
|
@@ -59,7 +60,7 @@ export type TopoloDebugEvent =
|
|
|
59
60
|
| {
|
|
60
61
|
phase: 'error';
|
|
61
62
|
method: string;
|
|
62
|
-
service:
|
|
63
|
+
service: string;
|
|
63
64
|
path: string;
|
|
64
65
|
url: string;
|
|
65
66
|
requestId: string;
|
|
@@ -69,7 +70,7 @@ export type TopoloDebugEvent =
|
|
|
69
70
|
};
|
|
70
71
|
|
|
71
72
|
export interface RequestOptions {
|
|
72
|
-
service:
|
|
73
|
+
service: string;
|
|
73
74
|
path: string;
|
|
74
75
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
75
76
|
query?: Record<string, string | number | boolean | undefined>;
|
|
@@ -85,6 +86,7 @@ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
85
86
|
|
|
86
87
|
const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
87
88
|
const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
89
|
+
const CATALOG_CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
88
90
|
|
|
89
91
|
interface ServiceIdCacheEntry {
|
|
90
92
|
value: string | null;
|
|
@@ -105,17 +107,19 @@ export interface TopoloServiceCatalogEntry {
|
|
|
105
107
|
status: string | null;
|
|
106
108
|
surfaceKind: string | null;
|
|
107
109
|
launchable: boolean;
|
|
110
|
+
permissions: string[];
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
export class TopoloClient {
|
|
111
114
|
private readonly credential: TopoloCredential;
|
|
112
115
|
private readonly agent: AgentIdentity;
|
|
113
|
-
private readonly serviceUrls?:
|
|
114
|
-
private readonly serviceIds?:
|
|
116
|
+
private readonly serviceUrls?: Record<string, string>;
|
|
117
|
+
private readonly serviceIds?: Record<string, string>;
|
|
115
118
|
private readonly requireConfirmForWrites: boolean;
|
|
116
119
|
private readonly timeoutMs: number;
|
|
117
120
|
private readonly fetchImpl: typeof fetch;
|
|
118
121
|
private readonly debug?: (event: TopoloDebugEvent) => void;
|
|
122
|
+
private serviceCatalogCache: { entries: TopoloServiceCatalogEntry[]; expiresAt: number } | null = null;
|
|
119
123
|
|
|
120
124
|
/**
|
|
121
125
|
* Process-wide cache for slug → service-id lookups. Keyed by
|
|
@@ -169,7 +173,8 @@ export class TopoloClient {
|
|
|
169
173
|
);
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
const
|
|
176
|
+
const resolvedService = await this.resolveRequestService(opts.service);
|
|
177
|
+
const baseUrl = resolvedService.baseUrl;
|
|
173
178
|
const url = new URL(opts.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
|
|
174
179
|
if (opts.query) {
|
|
175
180
|
for (const [k, v] of Object.entries(opts.query)) {
|
|
@@ -180,7 +185,7 @@ export class TopoloClient {
|
|
|
180
185
|
const headers = new Headers();
|
|
181
186
|
headers.set('Accept', 'application/json');
|
|
182
187
|
if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
|
|
183
|
-
const platformServiceId = resolveConfiguredServiceId(
|
|
188
|
+
const platformServiceId = resolveConfiguredServiceId(resolvedService.serviceKey, this.serviceIds);
|
|
184
189
|
if (platformServiceId) headers.set('X-Service-ID', platformServiceId);
|
|
185
190
|
|
|
186
191
|
applyAuthHeaders(headers, this.credential);
|
|
@@ -199,7 +204,7 @@ export class TopoloClient {
|
|
|
199
204
|
|
|
200
205
|
const urlStr = url.toString();
|
|
201
206
|
const startedAt = Date.now();
|
|
202
|
-
this.emit({ phase: 'request', method, service:
|
|
207
|
+
this.emit({ phase: 'request', method, service: resolvedService.serviceKey, path: opts.path, url: urlStr, requestId });
|
|
203
208
|
|
|
204
209
|
let res: Response;
|
|
205
210
|
try {
|
|
@@ -213,7 +218,7 @@ export class TopoloClient {
|
|
|
213
218
|
this.emit({
|
|
214
219
|
phase: 'error',
|
|
215
220
|
method,
|
|
216
|
-
service:
|
|
221
|
+
service: resolvedService.serviceKey,
|
|
217
222
|
path: opts.path,
|
|
218
223
|
url: urlStr,
|
|
219
224
|
requestId,
|
|
@@ -236,7 +241,7 @@ export class TopoloClient {
|
|
|
236
241
|
this.emit({
|
|
237
242
|
phase: 'error',
|
|
238
243
|
method,
|
|
239
|
-
service:
|
|
244
|
+
service: resolvedService.serviceKey,
|
|
240
245
|
path: opts.path,
|
|
241
246
|
url: urlStr,
|
|
242
247
|
requestId,
|
|
@@ -251,13 +256,13 @@ export class TopoloClient {
|
|
|
251
256
|
extractRequired(parsed),
|
|
252
257
|
);
|
|
253
258
|
}
|
|
254
|
-
throw new TopoloHttpError(
|
|
259
|
+
throw new TopoloHttpError(resolvedService.serviceKey, opts.path, res.status, parsed);
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
this.emit({
|
|
258
263
|
phase: 'response',
|
|
259
264
|
method,
|
|
260
|
-
service:
|
|
265
|
+
service: resolvedService.serviceKey,
|
|
261
266
|
path: opts.path,
|
|
262
267
|
url: urlStr,
|
|
263
268
|
requestId,
|
|
@@ -268,6 +273,37 @@ export class TopoloClient {
|
|
|
268
273
|
return parsed as T;
|
|
269
274
|
}
|
|
270
275
|
|
|
276
|
+
private async resolveRequestService(service: string): Promise<{ serviceKey: string; baseUrl: string }> {
|
|
277
|
+
const serviceKey = normalizeLookupKey(service);
|
|
278
|
+
if (!serviceKey) {
|
|
279
|
+
throw new TopoloSdkError('unknown_service', 'service is required');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (serviceKey === 'auth') {
|
|
283
|
+
const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
|
|
284
|
+
if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
|
|
285
|
+
return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS.auth };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const live = await this.resolveLiveCallableService(serviceKey);
|
|
289
|
+
if (live) {
|
|
290
|
+
const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
|
|
291
|
+
return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw new TopoloSdkError(
|
|
295
|
+
'unknown_service',
|
|
296
|
+
`Unknown Topolo service "${service}". Run service discovery first and pass a callable service id or slug.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async resolveLiveCallableService(serviceKey: string): Promise<{ serviceKey: string; baseUrl: string } | null> {
|
|
301
|
+
const catalog = await this.listServices();
|
|
302
|
+
const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
|
|
303
|
+
if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
|
|
304
|
+
return { serviceKey, baseUrl: match.baseUrl };
|
|
305
|
+
}
|
|
306
|
+
|
|
271
307
|
/**
|
|
272
308
|
* Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
|
|
273
309
|
*
|
|
@@ -339,57 +375,73 @@ export class TopoloClient {
|
|
|
339
375
|
/**
|
|
340
376
|
* List the platform service catalog from the live registry
|
|
341
377
|
* (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
|
|
342
|
-
* caller's credential is attached, so
|
|
343
|
-
*
|
|
344
|
-
* permits; an unauthenticated/agent caller receives `active` only.
|
|
378
|
+
* caller's credential is attached, so the result only includes services the
|
|
379
|
+
* credential's organization and effective permissions can use.
|
|
345
380
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* authoritative; the snapshot is a floor, not a cap.
|
|
381
|
+
* Security boundary: this never falls back to the generated global snapshot.
|
|
382
|
+
* The live Auth catalog is the authority because it is scoped to the caller's
|
|
383
|
+
* organization and effective permissions.
|
|
350
384
|
*/
|
|
351
385
|
async listServices(): Promise<TopoloServiceCatalogEntry[]> {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
: null;
|
|
370
|
-
if (!serviceId) continue;
|
|
371
|
-
entries.push({
|
|
372
|
-
serviceId,
|
|
373
|
-
slug: typeof s.app_slug === 'string' ? s.app_slug : null,
|
|
374
|
-
name: typeof s.name === 'string' ? s.name : null,
|
|
375
|
-
description:
|
|
376
|
-
typeof s.description === 'string' ? s.description : null,
|
|
377
|
-
baseUrl: typeof s.base_url === 'string' ? s.base_url : null,
|
|
378
|
-
status: typeof s.status === 'string' ? s.status : null,
|
|
379
|
-
surfaceKind:
|
|
380
|
-
typeof s.surface_kind === 'string' ? s.surface_kind : null,
|
|
381
|
-
launchable: s.launchable === true,
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
if (entries.length > 0) return entries;
|
|
385
|
-
}
|
|
386
|
-
} catch {
|
|
387
|
-
// Fall through to the generated snapshot.
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
if (this.serviceCatalogCache && this.serviceCatalogCache.expiresAt > now) {
|
|
388
|
+
return this.serviceCatalogCache.entries;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const res = await this.request<
|
|
392
|
+
| { data?: { services?: unknown[] } }
|
|
393
|
+
| { services?: unknown[] }
|
|
394
|
+
>({ service: 'auth', method: 'GET', path: '/api/services/catalog' });
|
|
395
|
+
const raw =
|
|
396
|
+
(res as { data?: { services?: unknown[] } }).data?.services ??
|
|
397
|
+
(res as { services?: unknown[] }).services;
|
|
398
|
+
if (!Array.isArray(raw)) {
|
|
399
|
+
throw new TopoloSdkError(
|
|
400
|
+
'service_catalog_unavailable',
|
|
401
|
+
'TopoloAuth did not return a service catalog for the current credential.',
|
|
402
|
+
);
|
|
388
403
|
}
|
|
389
|
-
|
|
404
|
+
|
|
405
|
+
const entries: TopoloServiceCatalogEntry[] = [];
|
|
406
|
+
for (const item of raw) {
|
|
407
|
+
const s = item as Record<string, unknown>;
|
|
408
|
+
const serviceId =
|
|
409
|
+
typeof s.service_id === 'string'
|
|
410
|
+
? s.service_id
|
|
411
|
+
: typeof s.id === 'string'
|
|
412
|
+
? s.id
|
|
413
|
+
: null;
|
|
414
|
+
if (!serviceId) continue;
|
|
415
|
+
const permissions = Array.isArray(s.granted_permissions)
|
|
416
|
+
? s.granted_permissions
|
|
417
|
+
.filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
|
|
418
|
+
: [];
|
|
419
|
+
entries.push({
|
|
420
|
+
serviceId,
|
|
421
|
+
slug: typeof s.app_slug === 'string' ? s.app_slug : null,
|
|
422
|
+
name: typeof s.name === 'string' ? s.name : null,
|
|
423
|
+
description:
|
|
424
|
+
typeof s.description === 'string' ? s.description : null,
|
|
425
|
+
baseUrl: typeof s.base_url === 'string' ? s.base_url : null,
|
|
426
|
+
status: typeof s.status === 'string' ? s.status : null,
|
|
427
|
+
surfaceKind:
|
|
428
|
+
typeof s.surface_kind === 'string' ? s.surface_kind : null,
|
|
429
|
+
launchable: s.launchable === true,
|
|
430
|
+
permissions,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
this.serviceCatalogCache = {
|
|
434
|
+
entries,
|
|
435
|
+
expiresAt: now + CATALOG_CACHE_TTL_MS,
|
|
436
|
+
};
|
|
437
|
+
return entries;
|
|
390
438
|
}
|
|
391
439
|
|
|
392
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Bundled generated registry for tooling that audits platform packaging, not
|
|
442
|
+
* for deciding what the current credential may use.
|
|
443
|
+
*/
|
|
444
|
+
listBundledServices(): TopoloServiceCatalogEntry[] {
|
|
393
445
|
const apps = Object.values(APPLICATIONS) as ReadonlyArray<{
|
|
394
446
|
id: string;
|
|
395
447
|
name?: string;
|
|
@@ -401,9 +453,10 @@ export class TopoloClient {
|
|
|
401
453
|
name: app.name ?? app.id,
|
|
402
454
|
description: null,
|
|
403
455
|
baseUrl: app.productionUrl ?? null,
|
|
404
|
-
status: '
|
|
456
|
+
status: 'bundled',
|
|
405
457
|
surfaceKind: 'application',
|
|
406
458
|
launchable: Boolean(app.productionUrl),
|
|
459
|
+
permissions: [],
|
|
407
460
|
}));
|
|
408
461
|
}
|
|
409
462
|
|
|
@@ -453,7 +506,7 @@ export class TopoloClient {
|
|
|
453
506
|
|
|
454
507
|
function resolveConfiguredServiceId(
|
|
455
508
|
slug: string,
|
|
456
|
-
serviceIds?:
|
|
509
|
+
serviceIds?: Record<string, string>,
|
|
457
510
|
): string | null {
|
|
458
511
|
for (const serviceKey of serviceKeyCandidates(slug)) {
|
|
459
512
|
const configured = serviceIds?.[serviceKey as ServiceId];
|
|
@@ -467,8 +520,24 @@ function resolveConfiguredServiceId(
|
|
|
467
520
|
return null;
|
|
468
521
|
}
|
|
469
522
|
|
|
523
|
+
function resolveConfiguredServiceUrl(
|
|
524
|
+
slug: string,
|
|
525
|
+
serviceUrls?: Record<string, string>,
|
|
526
|
+
): string | null {
|
|
527
|
+
for (const serviceKey of serviceKeyCandidates(slug)) {
|
|
528
|
+
const configured = serviceUrls?.[serviceKey];
|
|
529
|
+
if (typeof configured === 'string' && configured.trim()) return configured.trim();
|
|
530
|
+
|
|
531
|
+
const envKey = `TOPOLO_SERVICE_URL_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
|
|
532
|
+
const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
|
|
533
|
+
if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
470
539
|
function serviceKeyCandidates(slug: string): string[] {
|
|
471
|
-
const normalized = slug
|
|
540
|
+
const normalized = normalizeLookupKey(slug);
|
|
472
541
|
const withoutTopolo = normalized.startsWith('topolo-')
|
|
473
542
|
? normalized.slice('topolo-'.length)
|
|
474
543
|
: normalized;
|
|
@@ -480,6 +549,38 @@ function serviceKeyCandidates(slug: string): string[] {
|
|
|
480
549
|
])];
|
|
481
550
|
}
|
|
482
551
|
|
|
552
|
+
function normalizeLookupKey(value: string): string {
|
|
553
|
+
return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string): boolean {
|
|
557
|
+
return catalogEntryLookupKeys(entry).includes(needle);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
|
|
561
|
+
const values = [entry.serviceId, entry.slug, entry.name].filter((value): value is string => (
|
|
562
|
+
typeof value === 'string' && value.trim().length > 0
|
|
563
|
+
));
|
|
564
|
+
const keys = new Set<string>();
|
|
565
|
+
for (const value of values) {
|
|
566
|
+
const normalized = normalizeLookupKey(value);
|
|
567
|
+
if (!normalized) continue;
|
|
568
|
+
for (const candidate of serviceKeyCandidates(normalized)) {
|
|
569
|
+
keys.add(candidate);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return [...keys];
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isCallableCatalogEntry(entry: TopoloServiceCatalogEntry): boolean {
|
|
576
|
+
const kind = normalizeLookupKey(entry.surfaceKind || '');
|
|
577
|
+
if (kind === 'api' || kind === 'technical_service' || kind === 'service') return true;
|
|
578
|
+
return Boolean(
|
|
579
|
+
entry.baseUrl &&
|
|
580
|
+
catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key)),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
483
584
|
interface AuthMePayload {
|
|
484
585
|
credentialType?: 'access_token' | 'api_key';
|
|
485
586
|
user: RawUser;
|