@topolo/sdk 0.4.0 → 0.4.1

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.
@@ -415,6 +415,109 @@ describe('TopoloClient.listServices', () => {
415
415
  expect(out.some((e) => e.serviceId === 'admin')).toBe(true);
416
416
  expect(out.every((e) => typeof e.serviceId === 'string')).toBe(true);
417
417
  });
418
+
419
+ it('resolves future API services from the live catalog during request routing', async () => {
420
+ const { impl, calls } = stubFetch([
421
+ {
422
+ body: {
423
+ data: {
424
+ services: [
425
+ {
426
+ service_id: 'srv_future',
427
+ app_slug: 'topolo-warehouse',
428
+ name: 'Warehouse',
429
+ base_url: 'https://warehouse.topolo.app',
430
+ surface_kind: 'api',
431
+ status: 'active',
432
+ },
433
+ ],
434
+ },
435
+ },
436
+ },
437
+ { body: { ok: true } },
438
+ ]);
439
+ const client = new TopoloClient({
440
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
441
+ agent: AGENT,
442
+ fetch: impl,
443
+ serviceUrls: { auth: 'https://auth.example.com' },
444
+ });
445
+
446
+ await client.request({ service: 'warehouse', path: '/api/health' });
447
+
448
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
449
+ expect(calls[1]!.url).toBe('https://warehouse.topolo.app/api/health');
450
+ });
451
+
452
+ it('does not treat live browser applications as generic API services', async () => {
453
+ const { impl } = stubFetch([
454
+ {
455
+ body: {
456
+ data: {
457
+ services: [
458
+ {
459
+ service_id: 'srv_future_app',
460
+ app_slug: 'topolo-future-app',
461
+ name: 'Future App',
462
+ base_url: 'https://future-app.topolo.app',
463
+ surface_kind: 'application',
464
+ status: 'active',
465
+ launchable: true,
466
+ },
467
+ ],
468
+ },
469
+ },
470
+ },
471
+ ]);
472
+ const client = new TopoloClient({
473
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
474
+ agent: AGENT,
475
+ fetch: impl,
476
+ serviceUrls: { auth: 'https://auth.example.com' },
477
+ });
478
+
479
+ await expect(client.request({ service: 'future-app', path: '/api/health' })).rejects.toMatchObject({
480
+ code: 'unknown_service',
481
+ });
482
+ });
483
+
484
+ it('caches the live catalog per client for repeated dynamic service calls', async () => {
485
+ const { impl, calls } = stubFetch([
486
+ {
487
+ body: {
488
+ data: {
489
+ services: [
490
+ {
491
+ service_id: 'srv_future',
492
+ app_slug: 'topolo-warehouse',
493
+ name: 'Warehouse',
494
+ base_url: 'https://warehouse.topolo.app',
495
+ surface_kind: 'api',
496
+ status: 'active',
497
+ },
498
+ ],
499
+ },
500
+ },
501
+ },
502
+ { body: { ok: true } },
503
+ { body: { ok: true } },
504
+ ]);
505
+ const client = new TopoloClient({
506
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
507
+ agent: AGENT,
508
+ fetch: impl,
509
+ serviceUrls: { auth: 'https://auth.example.com' },
510
+ });
511
+
512
+ await client.request({ service: 'warehouse', path: '/api/one' });
513
+ await client.request({ service: 'warehouse', path: '/api/two' });
514
+
515
+ expect(calls.map((call) => call.url)).toEqual([
516
+ 'https://auth.example.com/api/services/catalog',
517
+ 'https://warehouse.topolo.app/api/one',
518
+ 'https://warehouse.topolo.app/api/two',
519
+ ]);
520
+ });
418
521
  });
419
522
 
420
523
  // Silence unused-import warning if the bundler later tree-shakes these
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;
@@ -110,12 +112,13 @@ export interface TopoloServiceCatalogEntry {
110
112
  export class TopoloClient {
111
113
  private readonly credential: TopoloCredential;
112
114
  private readonly agent: AgentIdentity;
113
- private readonly serviceUrls?: Partial<Record<ServiceId, string>>;
114
- private readonly serviceIds?: Partial<Record<ServiceId, string>>;
115
+ private readonly serviceUrls?: Record<string, string>;
116
+ private readonly serviceIds?: Record<string, string>;
115
117
  private readonly requireConfirmForWrites: boolean;
116
118
  private readonly timeoutMs: number;
117
119
  private readonly fetchImpl: typeof fetch;
118
120
  private readonly debug?: (event: TopoloDebugEvent) => void;
121
+ private serviceCatalogCache: { entries: TopoloServiceCatalogEntry[]; expiresAt: number } | null = null;
119
122
 
120
123
  /**
121
124
  * Process-wide cache for slug → service-id lookups. Keyed by
@@ -169,7 +172,8 @@ export class TopoloClient {
169
172
  );
170
173
  }
171
174
 
172
- const baseUrl = resolveServiceUrl(opts.service, this.serviceUrls);
175
+ const resolvedService = await this.resolveRequestService(opts.service);
176
+ const baseUrl = resolvedService.baseUrl;
173
177
  const url = new URL(opts.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
174
178
  if (opts.query) {
175
179
  for (const [k, v] of Object.entries(opts.query)) {
@@ -180,7 +184,7 @@ export class TopoloClient {
180
184
  const headers = new Headers();
181
185
  headers.set('Accept', 'application/json');
182
186
  if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
183
- const platformServiceId = resolveConfiguredServiceId(opts.service, this.serviceIds);
187
+ const platformServiceId = resolveConfiguredServiceId(resolvedService.serviceKey, this.serviceIds);
184
188
  if (platformServiceId) headers.set('X-Service-ID', platformServiceId);
185
189
 
186
190
  applyAuthHeaders(headers, this.credential);
@@ -199,7 +203,7 @@ export class TopoloClient {
199
203
 
200
204
  const urlStr = url.toString();
201
205
  const startedAt = Date.now();
202
- this.emit({ phase: 'request', method, service: opts.service, path: opts.path, url: urlStr, requestId });
206
+ this.emit({ phase: 'request', method, service: resolvedService.serviceKey, path: opts.path, url: urlStr, requestId });
203
207
 
204
208
  let res: Response;
205
209
  try {
@@ -213,7 +217,7 @@ export class TopoloClient {
213
217
  this.emit({
214
218
  phase: 'error',
215
219
  method,
216
- service: opts.service,
220
+ service: resolvedService.serviceKey,
217
221
  path: opts.path,
218
222
  url: urlStr,
219
223
  requestId,
@@ -236,7 +240,7 @@ export class TopoloClient {
236
240
  this.emit({
237
241
  phase: 'error',
238
242
  method,
239
- service: opts.service,
243
+ service: resolvedService.serviceKey,
240
244
  path: opts.path,
241
245
  url: urlStr,
242
246
  requestId,
@@ -251,13 +255,13 @@ export class TopoloClient {
251
255
  extractRequired(parsed),
252
256
  );
253
257
  }
254
- throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
258
+ throw new TopoloHttpError(resolvedService.serviceKey, opts.path, res.status, parsed);
255
259
  }
256
260
 
257
261
  this.emit({
258
262
  phase: 'response',
259
263
  method,
260
- service: opts.service,
264
+ service: resolvedService.serviceKey,
261
265
  path: opts.path,
262
266
  url: urlStr,
263
267
  requestId,
@@ -268,6 +272,37 @@ export class TopoloClient {
268
272
  return parsed as T;
269
273
  }
270
274
 
275
+ private async resolveRequestService(service: string): Promise<{ serviceKey: string; baseUrl: string }> {
276
+ const serviceKey = normalizeLookupKey(service);
277
+ if (!serviceKey) {
278
+ throw new TopoloSdkError('unknown_service', 'service is required');
279
+ }
280
+
281
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
282
+ if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
283
+
284
+ const staticUrl = DEFAULT_SERVICE_URLS[serviceKey as ServiceId];
285
+ if (staticUrl) return { serviceKey, baseUrl: staticUrl };
286
+
287
+ const live = await this.resolveLiveCallableService(serviceKey);
288
+ if (live) return live;
289
+
290
+ throw new TopoloSdkError(
291
+ 'unknown_service',
292
+ `Unknown Topolo service "${service}". Run service discovery first and pass a callable service id or slug.`,
293
+ );
294
+ }
295
+
296
+ private async resolveLiveCallableService(serviceKey: string): Promise<{ serviceKey: string; baseUrl: string } | null> {
297
+ const catalog = await this.listServices();
298
+ const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
299
+ if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
300
+ return {
301
+ serviceKey: normalizeLookupKey(match.slug || match.name || match.serviceId) || serviceKey,
302
+ baseUrl: match.baseUrl,
303
+ };
304
+ }
305
+
271
306
  /**
272
307
  * Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
273
308
  *
@@ -349,6 +384,11 @@ export class TopoloClient {
349
384
  * authoritative; the snapshot is a floor, not a cap.
350
385
  */
351
386
  async listServices(): Promise<TopoloServiceCatalogEntry[]> {
387
+ const now = Date.now();
388
+ if (this.serviceCatalogCache && this.serviceCatalogCache.expiresAt > now) {
389
+ return this.serviceCatalogCache.entries;
390
+ }
391
+
352
392
  try {
353
393
  const res = await this.request<
354
394
  | { data?: { services?: unknown[] } }
@@ -381,7 +421,13 @@ export class TopoloClient {
381
421
  launchable: s.launchable === true,
382
422
  });
383
423
  }
384
- if (entries.length > 0) return entries;
424
+ if (entries.length > 0) {
425
+ this.serviceCatalogCache = {
426
+ entries,
427
+ expiresAt: now + CATALOG_CACHE_TTL_MS,
428
+ };
429
+ return entries;
430
+ }
385
431
  }
386
432
  } catch {
387
433
  // Fall through to the generated snapshot.
@@ -453,7 +499,7 @@ export class TopoloClient {
453
499
 
454
500
  function resolveConfiguredServiceId(
455
501
  slug: string,
456
- serviceIds?: Partial<Record<ServiceId, string>>,
502
+ serviceIds?: Record<string, string>,
457
503
  ): string | null {
458
504
  for (const serviceKey of serviceKeyCandidates(slug)) {
459
505
  const configured = serviceIds?.[serviceKey as ServiceId];
@@ -467,8 +513,24 @@ function resolveConfiguredServiceId(
467
513
  return null;
468
514
  }
469
515
 
516
+ function resolveConfiguredServiceUrl(
517
+ slug: string,
518
+ serviceUrls?: Record<string, string>,
519
+ ): string | null {
520
+ for (const serviceKey of serviceKeyCandidates(slug)) {
521
+ const configured = serviceUrls?.[serviceKey];
522
+ if (typeof configured === 'string' && configured.trim()) return configured.trim();
523
+
524
+ const envKey = `TOPOLO_SERVICE_URL_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
525
+ const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
526
+ if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
527
+ }
528
+
529
+ return null;
530
+ }
531
+
470
532
  function serviceKeyCandidates(slug: string): string[] {
471
- const normalized = slug.trim().toLowerCase();
533
+ const normalized = normalizeLookupKey(slug);
472
534
  const withoutTopolo = normalized.startsWith('topolo-')
473
535
  ? normalized.slice('topolo-'.length)
474
536
  : normalized;
@@ -480,6 +542,39 @@ function serviceKeyCandidates(slug: string): string[] {
480
542
  ])];
481
543
  }
482
544
 
545
+ function normalizeLookupKey(value: string): string {
546
+ return String(value || '').trim().toLowerCase();
547
+ }
548
+
549
+ function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string): boolean {
550
+ return catalogEntryLookupKeys(entry).includes(needle);
551
+ }
552
+
553
+ function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
554
+ const values = [entry.serviceId, entry.slug, entry.name].filter((value): value is string => (
555
+ typeof value === 'string' && value.trim().length > 0
556
+ ));
557
+ const keys = new Set<string>();
558
+ for (const value of values) {
559
+ const normalized = normalizeLookupKey(value);
560
+ if (!normalized) continue;
561
+ keys.add(normalized);
562
+ keys.add(normalized.replace(/-/g, '_'));
563
+ if (normalized.startsWith('topolo-')) {
564
+ const withoutTopolo = normalized.slice('topolo-'.length);
565
+ keys.add(withoutTopolo);
566
+ keys.add(withoutTopolo.replace(/-/g, '_'));
567
+ }
568
+ }
569
+ return [...keys];
570
+ }
571
+
572
+ function isCallableCatalogEntry(entry: TopoloServiceCatalogEntry): boolean {
573
+ const kind = normalizeLookupKey(entry.surfaceKind || '');
574
+ if (kind === 'api' || kind === 'technical_service' || kind === 'service') return true;
575
+ return Boolean(entry.slug && Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, catalogEntryLookupKeys(entry)[0] ?? ''));
576
+ }
577
+
483
578
  interface AuthMePayload {
484
579
  credentialType?: 'access_token' | 'api_key';
485
580
  user: RawUser;
@@ -5,6 +5,7 @@
5
5
  export const SERVICES = {
6
6
  agent: "https://agent-api.topolo.app",
7
7
  auth: "https://auth.topolo.app",
8
+ backup: "https://backup.topolo.app",
8
9
  books: "https://books.topolo.app",
9
10
  bugfix: "https://bugfix.topolo.app",
10
11
  bytes: "https://bytes-api.topolo.app",
@@ -15,7 +16,6 @@ export const SERVICES = {
15
16
  compose: "https://compose.topolo.app",
16
17
  consent: "https://consent.topolo.app",
17
18
  crm: "https://crm-api.topolo.app",
18
- customer_success: "https://success.topolo.app",
19
19
  developers: "https://developers.topolo.app",
20
20
  director: "https://director.topolo.app",
21
21
  feed: "https://feed-api.topolo.app",
@@ -29,9 +29,10 @@ export const SERVICES = {
29
29
  localize: "https://localize.topolo.app",
30
30
  mail: "https://mail.topolo.app",
31
31
  mdm: "https://mdm-api.topolo.app",
32
- messaging: "https://messages.topolo.app",
32
+ messages: "https://messages.topolo.app",
33
33
  nexus: "https://nexus-api.topolo.app",
34
34
  notify: "https://notify.topolo.app",
35
+ observability_watch: "https://observability-watch.topolo.app",
35
36
  one: "https://one.topolo.app",
36
37
  p2p: "https://p2p.topolo.app",
37
38
  pay: "https://pay.topolo.app",
@@ -45,6 +46,7 @@ export const SERVICES = {
45
46
  socialize: "https://socialize-api.topolo.app",
46
47
  spaces: "https://spaces.topolo.app",
47
48
  status: "https://status.topolo.app",
49
+ success: "https://success.topolo.app",
48
50
  support: "https://support.topolo.app",
49
51
  survey: "https://survey.topolo.app",
50
52
  voice: "https://voice.topolo.app",
@@ -21,12 +21,10 @@ const EXPECTED_APPLICATION_IDS = [
21
21
  'calendar',
22
22
  'capacity',
23
23
  'chat',
24
- 'cloudcontrol',
25
24
  'commerce',
26
25
  'compose',
27
26
  'consent',
28
27
  'crm',
29
- 'customer_success',
30
28
  'developers',
31
29
  'director',
32
30
  'docs',
@@ -41,7 +39,7 @@ const EXPECTED_APPLICATION_IDS = [
41
39
  'localize',
42
40
  'mail',
43
41
  'mdm',
44
- 'messaging',
42
+ 'messages',
45
43
  'nexus',
46
44
  'notify',
47
45
  'observability_watch',
@@ -57,6 +55,7 @@ const EXPECTED_APPLICATION_IDS = [
57
55
  'socialize',
58
56
  'spaces',
59
57
  'status',
58
+ 'success',
60
59
  'support',
61
60
  'survey',
62
61
  'voice',
@@ -75,7 +74,7 @@ describe('service registry', () => {
75
74
  web: 'https://web-api.topolo.app',
76
75
  });
77
76
  expect(DEFAULT_SERVICE_URLS).toMatchObject({
78
- customer_success: 'https://success.topolo.app',
77
+ success: 'https://success.topolo.app',
79
78
  forms: 'https://forms.topolo.app',
80
79
  survey: 'https://survey.topolo.app',
81
80
  });
@@ -118,14 +117,21 @@ describe('application catalog', () => {
118
117
  }
119
118
  });
120
119
 
121
- it('treats TopoloWeb runtime as a deploy target, not an agent-callable API service', () => {
120
+ it('treats TopoloWeb runtime as a standalone deploy, not an agent-callable API service', () => {
122
121
  expect(APPLICATIONS.web.services).toEqual(['web']);
123
122
  expect(
124
123
  APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-api')?.serviceId,
125
124
  ).toBe('web');
125
+ // topolo-web-runtime (Astro, sites.topolo.app) deploys from its own wrangler
126
+ // config and is intentionally listed as non-callable, so it can never be an
127
+ // agent-callable service.
126
128
  expect(
127
129
  APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-runtime')?.serviceId,
128
130
  ).toBeNull();
131
+ const callableTargetNames = APPLICATIONS.web.deployTargets
132
+ .filter((target) => target.serviceId)
133
+ .map((target) => target.name);
134
+ expect(callableTargetNames).not.toContain('topolo-web-runtime');
129
135
  });
130
136
 
131
137
  it('treats TopoloFeed analytics as telemetry, not an agent-callable API service', () => {