@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.
@@ -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/auth-middleware is used where applicable',
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 (application.id === 'cli' || application.id === 'mcp' || application.id === 'cloudcontrol') {
278
+ if (appId === 'cli' || appId === 'mcp' || appId === 'cloudcontrol') {
276
279
  scopes.add('tooling');
277
280
  }
278
- if (application.id === 'cli' || application.id === 'mcp' || hasApiSurface) {
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 (application.id === 'cli' || application.id === 'mcp' || application.id === 'cloudcontrol') {
428
- evidence.push(`tooling=${application.id}`);
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": "topolo-oneclick-sso",
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": null,
1031
- "services": [],
1032
- "deployTargets": []
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",
@@ -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('resolves a known slug via /api/services/by-slug/:slug', async () => {
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: 'srv_abc123', base_url: 'https://x' } } },
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('topolo-notify');
271
- expect(id).toBe('srv_abc123');
272
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/topolo-notify');
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: 'srv_iCwM4jGXcwlj' } } },
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('topolo-crm')).toBe('srv_iCwM4jGXcwlj');
293
- expect(await client.getServiceIdBySlug('topolo-crm')).toBe('srv_iCwM4jGXcwlj');
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: 'srv_x' } } },
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(' TOPOLO-NOTIFY ');
303
- expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/topolo-notify');
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: 'srv_y' } }), {
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('srv_y');
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
- PLATFORM_SERVICE_IDS,
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 = PLATFORM_SERVICE_IDS[opts.service];
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,