@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.
@@ -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) => !call.url.includes('/api/services/by-slug/'));
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('falls back to the generated snapshot on HTTP failure', async () => {
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
- 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);
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?: Partial<Record<ServiceId, string>>;
20
+ serviceUrls?: Record<string, string>;
20
21
  /** Per-service TopoloAuth service id overrides from the owning app/deployment. */
21
- serviceIds?: Partial<Record<ServiceId, string>>;
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: ServiceId;
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: ServiceId;
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: ServiceId;
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: ServiceId;
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?: Partial<Record<ServiceId, string>>;
114
- private readonly serviceIds?: Partial<Record<ServiceId, string>>;
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 baseUrl = resolveServiceUrl(opts.service, this.serviceUrls);
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(opts.service, this.serviceIds);
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: opts.service, path: opts.path, url: urlStr, requestId });
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: opts.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: opts.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(opts.service, opts.path, res.status, parsed);
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: opts.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 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.
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
- * 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.
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
- 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.
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
- return this.generatedCatalogFallback();
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
- private generatedCatalogFallback(): TopoloServiceCatalogEntry[] {
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: 'active',
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?: Partial<Record<ServiceId, string>>,
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.trim().toLowerCase();
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;