@topolo/sdk 0.3.0 → 0.4.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 +149 -78
- package/dist/index.d.cts +81 -56
- package/dist/index.d.ts +81 -56
- package/dist/index.js +149 -77
- package/package.json +5 -2
- package/src/application-requirements.ts +10 -6
- package/src/applications.generated.ts +61 -40
- package/src/client.test.ts +108 -19
- package/src/client.ts +126 -8
- package/src/index.ts +1 -1
- package/src/modules/p2p.test.ts +27 -11
- package/src/services.generated.ts +2 -32
- package/src/services.test.ts +6 -23
- package/src/services.ts +1 -2
- package/src/widgets.test.ts +7 -7
|
@@ -108,12 +108,12 @@ export const APPLICATION_REQUIREMENTS = [
|
|
|
108
108
|
'Applications must derive identity, organization, scopes, and service entitlement from TopoloAuth rather than inventing parallel auth state.',
|
|
109
109
|
appliesTo: ['browser', 'api', 'tooling', 'agent_surface'],
|
|
110
110
|
evidence: [
|
|
111
|
-
'@topolo/auth-client or @topolo/
|
|
111
|
+
'@topolo/auth-client or @topolo-io/worker-runtime is used where applicable',
|
|
112
112
|
'No public command, tool, or SDK method accepts orgId',
|
|
113
113
|
'Service slug and Auth service registration are documented for protected services',
|
|
114
114
|
],
|
|
115
115
|
implementation: [
|
|
116
|
-
'Use shared auth packages instead of copying token parsing or browser storage flows',
|
|
116
|
+
'Use shared auth/runtime packages instead of copying token parsing, gateway, or browser storage flows',
|
|
117
117
|
'Derive organization from the credential on every request',
|
|
118
118
|
'Route OAuth, refresh, logout, and SSO paths according to the shared Auth contract',
|
|
119
119
|
],
|
|
@@ -264,6 +264,9 @@ export function applicationRequirementScopes(
|
|
|
264
264
|
application: ApplicationCatalogEntry,
|
|
265
265
|
): ApplicationRequirementScope[] {
|
|
266
266
|
const scopes = new Set<ApplicationRequirementScope>(['all']);
|
|
267
|
+
// id is checked by value: tooling ids (cli/mcp) are intentionally not in
|
|
268
|
+
// the deploy catalog union, so compare as string rather than assert overlap.
|
|
269
|
+
const appId: string = application.id;
|
|
267
270
|
const hasBrowserSurface =
|
|
268
271
|
Boolean(application.productionUrl) || application.deployTargets.some((target) => target.kind === 'pages');
|
|
269
272
|
const hasApiSurface =
|
|
@@ -272,10 +275,10 @@ export function applicationRequirementScopes(
|
|
|
272
275
|
|
|
273
276
|
if (hasBrowserSurface) scopes.add('browser');
|
|
274
277
|
if (hasApiSurface) scopes.add('api');
|
|
275
|
-
if (
|
|
278
|
+
if (appId === 'cli' || appId === 'mcp' || appId === 'cloudcontrol') {
|
|
276
279
|
scopes.add('tooling');
|
|
277
280
|
}
|
|
278
|
-
if (
|
|
281
|
+
if (appId === 'cli' || appId === 'mcp' || hasApiSurface) {
|
|
279
282
|
scopes.add('agent_surface');
|
|
280
283
|
}
|
|
281
284
|
|
|
@@ -420,12 +423,13 @@ function evaluateRequirement(
|
|
|
420
423
|
return finding(requirement, 'met', 'CloudControl deploy target metadata is present.', evidence);
|
|
421
424
|
}
|
|
422
425
|
case 'agent-and-operator-surfaces': {
|
|
426
|
+
const appId: string = application.id;
|
|
423
427
|
if (application.services.length > 0) {
|
|
424
428
|
evidence.push(`services=${application.services.join(',')}`);
|
|
425
429
|
return finding(requirement, 'partial', 'SDK service discovery exists; typed SDK/CLI/MCP workflow coverage should be checked as APIs stabilize.', evidence);
|
|
426
430
|
}
|
|
427
|
-
if (
|
|
428
|
-
evidence.push(`tooling=${
|
|
431
|
+
if (appId === 'cli' || appId === 'mcp' || appId === 'cloudcontrol') {
|
|
432
|
+
evidence.push(`tooling=${appId}`);
|
|
429
433
|
return finding(requirement, 'met', 'This application is itself an agent/operator surface.', evidence);
|
|
430
434
|
}
|
|
431
435
|
return finding(requirement, 'missing', 'No SDK service or tooling surface is registered.', evidence);
|
|
@@ -79,6 +79,24 @@ export const APPLICATIONS = {
|
|
|
79
79
|
}
|
|
80
80
|
]
|
|
81
81
|
},
|
|
82
|
+
"backup": {
|
|
83
|
+
"id": "backup",
|
|
84
|
+
"appDir": "TopoloBackup",
|
|
85
|
+
"name": "topolo-backup",
|
|
86
|
+
"packageName": "topolo-backup",
|
|
87
|
+
"productionUrl": null,
|
|
88
|
+
"services": [],
|
|
89
|
+
"deployTargets": [
|
|
90
|
+
{
|
|
91
|
+
"name": "topolo-backup",
|
|
92
|
+
"kind": "worker",
|
|
93
|
+
"apiUrl": null,
|
|
94
|
+
"domains": [],
|
|
95
|
+
"routes": [],
|
|
96
|
+
"serviceId": null
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
},
|
|
82
100
|
"books": {
|
|
83
101
|
"id": "books",
|
|
84
102
|
"appDir": "TopoloBooks",
|
|
@@ -229,15 +247,6 @@ export const APPLICATIONS = {
|
|
|
229
247
|
}
|
|
230
248
|
]
|
|
231
249
|
},
|
|
232
|
-
"cli": {
|
|
233
|
-
"id": "cli",
|
|
234
|
-
"appDir": "TopoloCli",
|
|
235
|
-
"name": "@topolo/cli",
|
|
236
|
-
"packageName": "@topolo/cli",
|
|
237
|
-
"productionUrl": null,
|
|
238
|
-
"services": [],
|
|
239
|
-
"deployTargets": []
|
|
240
|
-
},
|
|
241
250
|
"cloudcontrol": {
|
|
242
251
|
"id": "cloudcontrol",
|
|
243
252
|
"appDir": "TopoloCloudControl",
|
|
@@ -403,24 +412,6 @@ export const APPLICATIONS = {
|
|
|
403
412
|
}
|
|
404
413
|
]
|
|
405
414
|
},
|
|
406
|
-
"d1_backup": {
|
|
407
|
-
"id": "d1_backup",
|
|
408
|
-
"appDir": "TopoloD1Backup",
|
|
409
|
-
"name": "topolo-d1-backup",
|
|
410
|
-
"packageName": "topolo-d1-backup",
|
|
411
|
-
"productionUrl": null,
|
|
412
|
-
"services": [],
|
|
413
|
-
"deployTargets": [
|
|
414
|
-
{
|
|
415
|
-
"name": "topolo-d1-backup",
|
|
416
|
-
"kind": "worker",
|
|
417
|
-
"apiUrl": null,
|
|
418
|
-
"domains": [],
|
|
419
|
-
"routes": [],
|
|
420
|
-
"serviceId": null
|
|
421
|
-
}
|
|
422
|
-
]
|
|
423
|
-
},
|
|
424
415
|
"developers": {
|
|
425
416
|
"id": "developers",
|
|
426
417
|
"appDir": "TopoloDevelopers",
|
|
@@ -763,15 +754,6 @@ export const APPLICATIONS = {
|
|
|
763
754
|
}
|
|
764
755
|
]
|
|
765
756
|
},
|
|
766
|
-
"mcp": {
|
|
767
|
-
"id": "mcp",
|
|
768
|
-
"appDir": "TopoloMCP",
|
|
769
|
-
"name": "@topolo/mcp",
|
|
770
|
-
"packageName": "@topolo/mcp",
|
|
771
|
-
"productionUrl": null,
|
|
772
|
-
"services": [],
|
|
773
|
-
"deployTargets": []
|
|
774
|
-
},
|
|
775
757
|
"mdm": {
|
|
776
758
|
"id": "mdm",
|
|
777
759
|
"appDir": "TopoloMDM",
|
|
@@ -906,7 +888,7 @@ export const APPLICATIONS = {
|
|
|
906
888
|
"one": {
|
|
907
889
|
"id": "one",
|
|
908
890
|
"appDir": "TopoloOne",
|
|
909
|
-
"name": "
|
|
891
|
+
"name": "TopoloOne",
|
|
910
892
|
"packageName": "topolo-oneclick-sso",
|
|
911
893
|
"productionUrl": "https://one.topolo.app",
|
|
912
894
|
"services": [
|
|
@@ -1027,9 +1009,24 @@ export const APPLICATIONS = {
|
|
|
1027
1009
|
"appDir": "TopoloPulse",
|
|
1028
1010
|
"name": "topolo-pulse",
|
|
1029
1011
|
"packageName": "topolo-pulse",
|
|
1030
|
-
"productionUrl":
|
|
1031
|
-
"services": [
|
|
1032
|
-
|
|
1012
|
+
"productionUrl": "https://pulse.topolo.app",
|
|
1013
|
+
"services": [
|
|
1014
|
+
"pulse"
|
|
1015
|
+
],
|
|
1016
|
+
"deployTargets": [
|
|
1017
|
+
{
|
|
1018
|
+
"name": "topolo-pulse",
|
|
1019
|
+
"kind": "worker",
|
|
1020
|
+
"apiUrl": "https://pulse.topolo.app",
|
|
1021
|
+
"domains": [
|
|
1022
|
+
"pulse.topolo.app"
|
|
1023
|
+
],
|
|
1024
|
+
"routes": [
|
|
1025
|
+
"pulse.topolo.app"
|
|
1026
|
+
],
|
|
1027
|
+
"serviceId": "pulse"
|
|
1028
|
+
}
|
|
1029
|
+
]
|
|
1033
1030
|
},
|
|
1034
1031
|
"quro": {
|
|
1035
1032
|
"id": "quro",
|
|
@@ -1212,6 +1209,30 @@ export const APPLICATIONS = {
|
|
|
1212
1209
|
}
|
|
1213
1210
|
]
|
|
1214
1211
|
},
|
|
1212
|
+
"spaces": {
|
|
1213
|
+
"id": "spaces",
|
|
1214
|
+
"appDir": "TopoloSpaces",
|
|
1215
|
+
"name": "topolo-spaces",
|
|
1216
|
+
"packageName": "@topolo/spaces",
|
|
1217
|
+
"productionUrl": "https://spaces.topolo.app",
|
|
1218
|
+
"services": [
|
|
1219
|
+
"spaces"
|
|
1220
|
+
],
|
|
1221
|
+
"deployTargets": [
|
|
1222
|
+
{
|
|
1223
|
+
"name": "topolo-spaces",
|
|
1224
|
+
"kind": "worker",
|
|
1225
|
+
"apiUrl": "https://spaces.topolo.app",
|
|
1226
|
+
"domains": [
|
|
1227
|
+
"spaces.topolo.app"
|
|
1228
|
+
],
|
|
1229
|
+
"routes": [
|
|
1230
|
+
"spaces.topolo.app"
|
|
1231
|
+
],
|
|
1232
|
+
"serviceId": "spaces"
|
|
1233
|
+
}
|
|
1234
|
+
]
|
|
1235
|
+
},
|
|
1215
1236
|
"status": {
|
|
1216
1237
|
"id": "status",
|
|
1217
1238
|
"appDir": "TopoloStatus",
|
package/src/client.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { TopoloClient } from './client.js';
|
|
3
3
|
import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
|
|
4
4
|
|
|
@@ -16,6 +16,15 @@ 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: { service_id: 'service_test' } } };
|
|
20
|
+
|
|
21
|
+
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
22
|
+
return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
TopoloClient.clearServiceIdCache();
|
|
27
|
+
});
|
|
19
28
|
|
|
20
29
|
describe('TopoloClient.request', () => {
|
|
21
30
|
it('injects Bearer auth + audit headers for access_token credentials', async () => {
|
|
@@ -24,10 +33,12 @@ describe('TopoloClient.request', () => {
|
|
|
24
33
|
credential: { kind: 'access_token', accessToken: 'tok_abc' },
|
|
25
34
|
agent: AGENT,
|
|
26
35
|
fetch: impl,
|
|
36
|
+
serviceIds: { auth: 'service_auth' },
|
|
27
37
|
});
|
|
28
38
|
await client.request({ service: 'auth', path: '/api/me' });
|
|
29
|
-
const headers = new Headers(calls[0]!.init.headers);
|
|
39
|
+
const headers = new Headers(requestCalls(calls)[0]!.init.headers);
|
|
30
40
|
expect(headers.get('Authorization')).toBe('Bearer tok_abc');
|
|
41
|
+
expect(headers.get('X-Service-ID')).toBe('service_auth');
|
|
31
42
|
expect(headers.get('X-Topolo-Client')).toBe('topolo-test/0.0.0');
|
|
32
43
|
expect(headers.get('X-Topolo-Request-Id')).toMatch(/.+/);
|
|
33
44
|
});
|
|
@@ -40,7 +51,7 @@ describe('TopoloClient.request', () => {
|
|
|
40
51
|
fetch: impl,
|
|
41
52
|
});
|
|
42
53
|
await client.request({ service: 'crm', path: '/api/contacts' });
|
|
43
|
-
const headers = new Headers(calls[0]!.init.headers);
|
|
54
|
+
const headers = new Headers(requestCalls(calls)[0]!.init.headers);
|
|
44
55
|
expect(headers.get('X-Api-Key')).toBe('dak_xyz');
|
|
45
56
|
expect(headers.get('Authorization')).toBeNull();
|
|
46
57
|
});
|
|
@@ -71,7 +82,7 @@ describe('TopoloClient.request', () => {
|
|
|
71
82
|
body: { email: 'a@b.co' },
|
|
72
83
|
confirm: true,
|
|
73
84
|
});
|
|
74
|
-
expect(calls[0]!.init.method).toBe('POST');
|
|
85
|
+
expect(requestCalls(calls)[0]!.init.method).toBe('POST');
|
|
75
86
|
});
|
|
76
87
|
|
|
77
88
|
it('maps 401 → TopoloAuthError with unwrapped message', async () => {
|
|
@@ -144,7 +155,7 @@ describe('TopoloClient.request', () => {
|
|
|
144
155
|
fetch: impl,
|
|
145
156
|
});
|
|
146
157
|
await client.request({ service: 'crm', path: '/api/contacts', query: { q: 'acme', page: 2 } });
|
|
147
|
-
const u = new URL(calls[0]!.url);
|
|
158
|
+
const u = new URL(requestCalls(calls)[0]!.url);
|
|
148
159
|
expect(u.searchParams.get('q')).toBe('acme');
|
|
149
160
|
expect(u.searchParams.get('page')).toBe('2');
|
|
150
161
|
});
|
|
@@ -169,7 +180,7 @@ describe('TopoloClient.request', () => {
|
|
|
169
180
|
|
|
170
181
|
describe('TopoloClient.debug hook', () => {
|
|
171
182
|
it('fires request → response on a successful call with stable requestId + duration', async () => {
|
|
172
|
-
const { impl } = stubFetch([{ body: { ok: true } }]);
|
|
183
|
+
const { impl } = stubFetch([SERVICE_LOOKUP, { body: { ok: true } }]);
|
|
173
184
|
const events: unknown[] = [];
|
|
174
185
|
const client = new TopoloClient({
|
|
175
186
|
credential: { kind: 'api_key', apiKey: 'x' },
|
|
@@ -262,14 +273,28 @@ describe('TopoloClient.getServiceIdBySlug', () => {
|
|
|
262
273
|
});
|
|
263
274
|
}
|
|
264
275
|
|
|
265
|
-
it('
|
|
276
|
+
it('uses configured service ids without calling Auth', async () => {
|
|
277
|
+
const { impl, calls } = stubFetch([]);
|
|
278
|
+
const client = new TopoloClient({
|
|
279
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
280
|
+
agent: AGENT,
|
|
281
|
+
fetch: impl,
|
|
282
|
+
serviceUrls: { auth: 'https://auth.example.com' } as never,
|
|
283
|
+
serviceIds: { notify: 'service_notify' },
|
|
284
|
+
});
|
|
285
|
+
const id = await client.getServiceIdBySlug('topolo-notify');
|
|
286
|
+
expect(id).toBe('service_notify');
|
|
287
|
+
expect(calls).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('falls back to /api/services/by-slug/:slug for unknown slugs', async () => {
|
|
266
291
|
const { impl, calls } = stubFetch([
|
|
267
|
-
{ body: { success: true, data: { service_id: '
|
|
292
|
+
{ body: { success: true, data: { service_id: 'service_abc123', base_url: 'https://x' } } },
|
|
268
293
|
]);
|
|
269
294
|
const client = freshClient(impl, { auth: 'https://auth.example.com' });
|
|
270
|
-
const id = await client.getServiceIdBySlug('
|
|
271
|
-
expect(id).toBe('
|
|
272
|
-
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/
|
|
295
|
+
const id = await client.getServiceIdBySlug('external-service');
|
|
296
|
+
expect(id).toBe('service_abc123');
|
|
297
|
+
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
|
|
273
298
|
// No auth header — endpoint is public.
|
|
274
299
|
const headers = new Headers(calls[0]!.init.headers);
|
|
275
300
|
expect(headers.get('Authorization')).toBeNull();
|
|
@@ -286,21 +311,21 @@ describe('TopoloClient.getServiceIdBySlug', () => {
|
|
|
286
311
|
|
|
287
312
|
it('caches positive results across calls', async () => {
|
|
288
313
|
const { impl, calls } = stubFetch([
|
|
289
|
-
{ body: { success: true, data: { service_id: '
|
|
314
|
+
{ body: { success: true, data: { service_id: 'service_external' } } },
|
|
290
315
|
]);
|
|
291
316
|
const client = freshClient(impl, { auth: 'https://auth.example.com' });
|
|
292
|
-
expect(await client.getServiceIdBySlug('
|
|
293
|
-
expect(await client.getServiceIdBySlug('
|
|
317
|
+
expect(await client.getServiceIdBySlug('external-service')).toBe('service_external');
|
|
318
|
+
expect(await client.getServiceIdBySlug('external-service')).toBe('service_external');
|
|
294
319
|
expect(calls.length).toBe(1);
|
|
295
320
|
});
|
|
296
321
|
|
|
297
322
|
it('normalizes slug case + whitespace before hitting the network', async () => {
|
|
298
323
|
const { impl, calls } = stubFetch([
|
|
299
|
-
{ body: { success: true, data: { service_id: '
|
|
324
|
+
{ body: { success: true, data: { service_id: 'service_x' } } },
|
|
300
325
|
]);
|
|
301
326
|
const client = freshClient(impl, { auth: 'https://auth.example.com' });
|
|
302
|
-
await client.getServiceIdBySlug('
|
|
303
|
-
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/
|
|
327
|
+
await client.getServiceIdBySlug(' EXTERNAL-SERVICE ');
|
|
328
|
+
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
|
|
304
329
|
});
|
|
305
330
|
|
|
306
331
|
it('returns null on network failure without poisoning cache', async () => {
|
|
@@ -310,7 +335,7 @@ describe('TopoloClient.getServiceIdBySlug', () => {
|
|
|
310
335
|
throws = false;
|
|
311
336
|
throw new Error('ECONNREFUSED');
|
|
312
337
|
}
|
|
313
|
-
return new Response(JSON.stringify({ success: true, data: { service_id: '
|
|
338
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_y' } }), {
|
|
314
339
|
status: 200,
|
|
315
340
|
headers: { 'Content-Type': 'application/json' },
|
|
316
341
|
});
|
|
@@ -324,7 +349,71 @@ describe('TopoloClient.getServiceIdBySlug', () => {
|
|
|
324
349
|
});
|
|
325
350
|
expect(await client.getServiceIdBySlug('flaky')).toBeNull();
|
|
326
351
|
// Second call retries — earlier null was not cached.
|
|
327
|
-
expect(await client.getServiceIdBySlug('flaky')).toBe('
|
|
352
|
+
expect(await client.getServiceIdBySlug('flaky')).toBe('service_y');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('TopoloClient.listServices', () => {
|
|
357
|
+
it('normalizes the live catalog and attaches the credential', async () => {
|
|
358
|
+
const { impl, calls } = stubFetch([
|
|
359
|
+
{
|
|
360
|
+
body: {
|
|
361
|
+
data: {
|
|
362
|
+
services: [
|
|
363
|
+
{
|
|
364
|
+
service_id: 'srv_wRCnLLbtvmVT',
|
|
365
|
+
app_slug: 'topolo-director',
|
|
366
|
+
name: 'Topolo Director',
|
|
367
|
+
description: 'Demo control plane',
|
|
368
|
+
base_url: 'https://director.topolo.app',
|
|
369
|
+
status: 'active',
|
|
370
|
+
surface_kind: 'application',
|
|
371
|
+
launchable: true,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
const client = new TopoloClient({
|
|
379
|
+
credential: { kind: 'access_token', accessToken: 'tok_abc' },
|
|
380
|
+
agent: AGENT,
|
|
381
|
+
fetch: impl,
|
|
382
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
383
|
+
});
|
|
384
|
+
const out = await client.listServices();
|
|
385
|
+
expect(out).toEqual([
|
|
386
|
+
{
|
|
387
|
+
serviceId: 'srv_wRCnLLbtvmVT',
|
|
388
|
+
slug: 'topolo-director',
|
|
389
|
+
name: 'Topolo Director',
|
|
390
|
+
description: 'Demo control plane',
|
|
391
|
+
baseUrl: 'https://director.topolo.app',
|
|
392
|
+
status: 'active',
|
|
393
|
+
surfaceKind: 'application',
|
|
394
|
+
launchable: true,
|
|
395
|
+
},
|
|
396
|
+
]);
|
|
397
|
+
expect(calls[0]!.url).toBe(
|
|
398
|
+
'https://auth.example.com/api/services/catalog',
|
|
399
|
+
);
|
|
400
|
+
expect(new Headers(calls[0]!.init.headers).get('Authorization')).toBe(
|
|
401
|
+
'Bearer tok_abc',
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('falls back to the generated snapshot on HTTP failure', async () => {
|
|
406
|
+
const { impl } = stubFetch([{ status: 503, body: { error: 'down' } }]);
|
|
407
|
+
const client = new TopoloClient({
|
|
408
|
+
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
409
|
+
agent: AGENT,
|
|
410
|
+
fetch: impl,
|
|
411
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
412
|
+
});
|
|
413
|
+
const out = await client.listServices();
|
|
414
|
+
expect(out.length).toBeGreaterThan(10);
|
|
415
|
+
expect(out.some((e) => e.serviceId === 'admin')).toBe(true);
|
|
416
|
+
expect(out.every((e) => typeof e.serviceId === 'string')).toBe(true);
|
|
328
417
|
});
|
|
329
418
|
});
|
|
330
419
|
|
package/src/client.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from './auth.js';
|
|
8
8
|
import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
APPLICATIONS,
|
|
11
11
|
resolveServiceUrl,
|
|
12
12
|
type ServiceId,
|
|
13
13
|
} from './services.js';
|
|
@@ -17,6 +17,8 @@ export interface TopoloClientOptions {
|
|
|
17
17
|
agent: AgentIdentity;
|
|
18
18
|
/** Per-service URL overrides. Useful for staging/dev. */
|
|
19
19
|
serviceUrls?: Partial<Record<ServiceId, string>>;
|
|
20
|
+
/** Per-service TopoloAuth service id overrides from the owning app/deployment. */
|
|
21
|
+
serviceIds?: Partial<Record<ServiceId, string>>;
|
|
20
22
|
/**
|
|
21
23
|
* When true, mutating helpers (POST/PUT/PATCH/DELETE) require callers to pass
|
|
22
24
|
* `{ confirm: true }`. Defaults to true. Set false only for trusted surfaces.
|
|
@@ -89,10 +91,27 @@ interface ServiceIdCacheEntry {
|
|
|
89
91
|
expiresAt: number;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
/**
|
|
95
|
+
* A single entry from the live platform service catalog. Returned by
|
|
96
|
+
* {@link TopoloClient.listServices}. Shape mirrors the public-safe projection
|
|
97
|
+
* from TopoloAuth `GET /api/services/catalog`.
|
|
98
|
+
*/
|
|
99
|
+
export interface TopoloServiceCatalogEntry {
|
|
100
|
+
serviceId: string;
|
|
101
|
+
slug: string | null;
|
|
102
|
+
name: string | null;
|
|
103
|
+
description: string | null;
|
|
104
|
+
baseUrl: string | null;
|
|
105
|
+
status: string | null;
|
|
106
|
+
surfaceKind: string | null;
|
|
107
|
+
launchable: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
export class TopoloClient {
|
|
93
111
|
private readonly credential: TopoloCredential;
|
|
94
112
|
private readonly agent: AgentIdentity;
|
|
95
113
|
private readonly serviceUrls?: Partial<Record<ServiceId, string>>;
|
|
114
|
+
private readonly serviceIds?: Partial<Record<ServiceId, string>>;
|
|
96
115
|
private readonly requireConfirmForWrites: boolean;
|
|
97
116
|
private readonly timeoutMs: number;
|
|
98
117
|
private readonly fetchImpl: typeof fetch;
|
|
@@ -120,6 +139,7 @@ export class TopoloClient {
|
|
|
120
139
|
this.credential = options.credential;
|
|
121
140
|
this.agent = options.agent;
|
|
122
141
|
this.serviceUrls = options.serviceUrls;
|
|
142
|
+
this.serviceIds = options.serviceIds;
|
|
123
143
|
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
124
144
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
125
145
|
this.fetchImpl = options.fetch ?? fetch;
|
|
@@ -160,7 +180,7 @@ export class TopoloClient {
|
|
|
160
180
|
const headers = new Headers();
|
|
161
181
|
headers.set('Accept', 'application/json');
|
|
162
182
|
if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
|
|
163
|
-
const platformServiceId =
|
|
183
|
+
const platformServiceId = resolveConfiguredServiceId(opts.service, this.serviceIds);
|
|
164
184
|
if (platformServiceId) headers.set('X-Service-ID', platformServiceId);
|
|
165
185
|
|
|
166
186
|
applyAuthHeaders(headers, this.credential);
|
|
@@ -249,12 +269,7 @@ export class TopoloClient {
|
|
|
249
269
|
}
|
|
250
270
|
|
|
251
271
|
/**
|
|
252
|
-
* Resolve a service-slug → opaque service-id via TopoloAuth.
|
|
253
|
-
*
|
|
254
|
-
* Replaces the SDK-baked `PLATFORM_SERVICE_IDS` map (deprecated in 0.3.0,
|
|
255
|
-
* removed in 0.4.0). Slug shape accepted: either the canonical Auth slug
|
|
256
|
-
* (e.g. `topolo-notify`) or the SDK short id (`notify`) — Auth's resolver
|
|
257
|
-
* understands both.
|
|
272
|
+
* Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
|
|
258
273
|
*
|
|
259
274
|
* Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
|
|
260
275
|
* results (404) are cached for 60s so a typo doesn't hot-loop the
|
|
@@ -270,6 +285,9 @@ export class TopoloClient {
|
|
|
270
285
|
const normalized = slug.trim().toLowerCase();
|
|
271
286
|
if (!normalized) return null;
|
|
272
287
|
|
|
288
|
+
const configuredServiceId = resolveConfiguredServiceId(normalized, this.serviceIds);
|
|
289
|
+
if (configuredServiceId) return configuredServiceId;
|
|
290
|
+
|
|
273
291
|
const authBase = resolveServiceUrl('auth', this.serviceUrls);
|
|
274
292
|
const cacheKey = `${authBase}|${normalized}`;
|
|
275
293
|
const now = Date.now();
|
|
@@ -318,6 +336,77 @@ export class TopoloClient {
|
|
|
318
336
|
return value;
|
|
319
337
|
}
|
|
320
338
|
|
|
339
|
+
/**
|
|
340
|
+
* List the platform service catalog from the live registry
|
|
341
|
+
* (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
|
|
342
|
+
* caller's credential is attached, so an authenticated caller in an
|
|
343
|
+
* entitled org also receives the `developer_only` services its role
|
|
344
|
+
* permits; an unauthenticated/agent caller receives `active` only.
|
|
345
|
+
*
|
|
346
|
+
* Resilient: on any network/HTTP failure this falls back to the
|
|
347
|
+
* build-time generated `APPLICATIONS` snapshot so offline/degraded
|
|
348
|
+
* callers still get a usable (if static) catalog. The live result is
|
|
349
|
+
* authoritative; the snapshot is a floor, not a cap.
|
|
350
|
+
*/
|
|
351
|
+
async listServices(): Promise<TopoloServiceCatalogEntry[]> {
|
|
352
|
+
try {
|
|
353
|
+
const res = await this.request<
|
|
354
|
+
| { data?: { services?: unknown[] } }
|
|
355
|
+
| { services?: unknown[] }
|
|
356
|
+
>({ service: 'auth', method: 'GET', path: '/api/services/catalog' });
|
|
357
|
+
const raw =
|
|
358
|
+
(res as { data?: { services?: unknown[] } }).data?.services ??
|
|
359
|
+
(res as { services?: unknown[] }).services;
|
|
360
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
361
|
+
const entries: TopoloServiceCatalogEntry[] = [];
|
|
362
|
+
for (const item of raw) {
|
|
363
|
+
const s = item as Record<string, unknown>;
|
|
364
|
+
const serviceId =
|
|
365
|
+
typeof s.service_id === 'string'
|
|
366
|
+
? s.service_id
|
|
367
|
+
: typeof s.id === 'string'
|
|
368
|
+
? s.id
|
|
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.
|
|
388
|
+
}
|
|
389
|
+
return this.generatedCatalogFallback();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private generatedCatalogFallback(): TopoloServiceCatalogEntry[] {
|
|
393
|
+
const apps = Object.values(APPLICATIONS) as ReadonlyArray<{
|
|
394
|
+
id: string;
|
|
395
|
+
name?: string;
|
|
396
|
+
productionUrl?: string | null;
|
|
397
|
+
}>;
|
|
398
|
+
return apps.map((app) => ({
|
|
399
|
+
serviceId: app.id,
|
|
400
|
+
slug: app.id,
|
|
401
|
+
name: app.name ?? app.id,
|
|
402
|
+
description: null,
|
|
403
|
+
baseUrl: app.productionUrl ?? null,
|
|
404
|
+
status: 'active',
|
|
405
|
+
surfaceKind: 'application',
|
|
406
|
+
launchable: Boolean(app.productionUrl),
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
|
|
321
410
|
/**
|
|
322
411
|
* Introspect the current credential. Returns the caller's resolved identity,
|
|
323
412
|
* organization, and permission set. Used by CLI `whoami` and by MCP to gate
|
|
@@ -362,6 +451,35 @@ export class TopoloClient {
|
|
|
362
451
|
}
|
|
363
452
|
}
|
|
364
453
|
|
|
454
|
+
function resolveConfiguredServiceId(
|
|
455
|
+
slug: string,
|
|
456
|
+
serviceIds?: Partial<Record<ServiceId, string>>,
|
|
457
|
+
): string | null {
|
|
458
|
+
for (const serviceKey of serviceKeyCandidates(slug)) {
|
|
459
|
+
const configured = serviceIds?.[serviceKey as ServiceId];
|
|
460
|
+
if (typeof configured === 'string' && configured.trim()) return configured.trim();
|
|
461
|
+
|
|
462
|
+
const envKey = `TOPOLO_SERVICE_ID_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
|
|
463
|
+
const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
|
|
464
|
+
if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function serviceKeyCandidates(slug: string): string[] {
|
|
471
|
+
const normalized = slug.trim().toLowerCase();
|
|
472
|
+
const withoutTopolo = normalized.startsWith('topolo-')
|
|
473
|
+
? normalized.slice('topolo-'.length)
|
|
474
|
+
: normalized;
|
|
475
|
+
return [...new Set([
|
|
476
|
+
normalized,
|
|
477
|
+
normalized.replace(/-/g, '_'),
|
|
478
|
+
withoutTopolo,
|
|
479
|
+
withoutTopolo.replace(/-/g, '_'),
|
|
480
|
+
])];
|
|
481
|
+
}
|
|
482
|
+
|
|
365
483
|
interface AuthMePayload {
|
|
366
484
|
credentialType?: 'access_token' | 'api_key';
|
|
367
485
|
user: RawUser;
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export {
|
|
|
4
4
|
type RequestOptions,
|
|
5
5
|
type CredentialIntrospection,
|
|
6
6
|
type TopoloDebugEvent,
|
|
7
|
+
type TopoloServiceCatalogEntry,
|
|
7
8
|
} from "./client.js";
|
|
8
9
|
export type { TopoloCredential, AgentIdentity } from "./auth.js";
|
|
9
10
|
export {
|
|
@@ -11,7 +12,6 @@ export {
|
|
|
11
12
|
APPLICATIONS,
|
|
12
13
|
APPLICATION_REQUIREMENTS,
|
|
13
14
|
APPLICATION_REQUIREMENTS_VERSION,
|
|
14
|
-
PLATFORM_SERVICE_IDS,
|
|
15
15
|
auditAllApplicationRequirements,
|
|
16
16
|
auditApplicationRequirements,
|
|
17
17
|
applicationRequirementScopes,
|