@topolo/sdk 0.4.1 → 0.4.3

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/src/client.ts CHANGED
@@ -107,6 +107,29 @@ export interface TopoloServiceCatalogEntry {
107
107
  status: string | null;
108
108
  surfaceKind: string | null;
109
109
  launchable: boolean;
110
+ permissions: string[];
111
+ }
112
+
113
+ export interface TopoloActionCatalogEntry {
114
+ actionId: string;
115
+ name: string;
116
+ toolName: string | null;
117
+ title: string;
118
+ description: string;
119
+ serviceId: string;
120
+ serviceSlug: string | null;
121
+ serviceName: string | null;
122
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
123
+ path: string;
124
+ permissionName: string;
125
+ requiredPermission: string;
126
+ inputSchema: Record<string, unknown>;
127
+ outputSchema: Record<string, unknown>;
128
+ readOnly: boolean;
129
+ destructive: boolean;
130
+ requiresConfirmation: boolean;
131
+ status: string | null;
132
+ sourceRevision: string | null;
110
133
  }
111
134
 
112
135
  export class TopoloClient {
@@ -119,6 +142,7 @@ export class TopoloClient {
119
142
  private readonly fetchImpl: typeof fetch;
120
143
  private readonly debug?: (event: TopoloDebugEvent) => void;
121
144
  private serviceCatalogCache: { entries: TopoloServiceCatalogEntry[]; expiresAt: number } | null = null;
145
+ private actionCatalogCache: { entries: TopoloActionCatalogEntry[]; expiresAt: number } | null = null;
122
146
 
123
147
  /**
124
148
  * Process-wide cache for slug → service-id lookups. Keyed by
@@ -278,14 +302,17 @@ export class TopoloClient {
278
302
  throw new TopoloSdkError('unknown_service', 'service is required');
279
303
  }
280
304
 
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 };
305
+ if (serviceKey === 'auth') {
306
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
307
+ if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
308
+ return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS.auth };
309
+ }
286
310
 
287
311
  const live = await this.resolveLiveCallableService(serviceKey);
288
- if (live) return live;
312
+ if (live) {
313
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
314
+ return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
315
+ }
289
316
 
290
317
  throw new TopoloSdkError(
291
318
  'unknown_service',
@@ -297,10 +324,7 @@ export class TopoloClient {
297
324
  const catalog = await this.listServices();
298
325
  const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
299
326
  if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
300
- return {
301
- serviceKey: normalizeLookupKey(match.slug || match.name || match.serviceId) || serviceKey,
302
- baseUrl: match.baseUrl,
303
- };
327
+ return { serviceKey, baseUrl: match.baseUrl };
304
328
  }
305
329
 
306
330
  /**
@@ -374,14 +398,12 @@ export class TopoloClient {
374
398
  /**
375
399
  * List the platform service catalog from the live registry
376
400
  * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
377
- * caller's credential is attached, so an authenticated caller in an
378
- * entitled org also receives the `developer_only` services its role
379
- * permits; an unauthenticated/agent caller receives `active` only.
401
+ * caller's credential is attached, so the result only includes services the
402
+ * credential's organization and effective permissions can use.
380
403
  *
381
- * Resilient: on any network/HTTP failure this falls back to the
382
- * build-time generated `APPLICATIONS` snapshot so offline/degraded
383
- * callers still get a usable (if static) catalog. The live result is
384
- * authoritative; the snapshot is a floor, not a cap.
404
+ * Security boundary: this never falls back to the generated global snapshot.
405
+ * The live Auth catalog is the authority because it is scoped to the caller's
406
+ * organization and effective permissions.
385
407
  */
386
408
  async listServices(): Promise<TopoloServiceCatalogEntry[]> {
387
409
  const now = Date.now();
@@ -389,53 +411,133 @@ export class TopoloClient {
389
411
  return this.serviceCatalogCache.entries;
390
412
  }
391
413
 
392
- try {
393
- const res = await this.request<
394
- | { data?: { services?: unknown[] } }
395
- | { services?: unknown[] }
396
- >({ service: 'auth', method: 'GET', path: '/api/services/catalog' });
397
- const raw =
398
- (res as { data?: { services?: unknown[] } }).data?.services ??
399
- (res as { services?: unknown[] }).services;
400
- if (Array.isArray(raw) && raw.length > 0) {
401
- const entries: TopoloServiceCatalogEntry[] = [];
402
- for (const item of raw) {
403
- const s = item as Record<string, unknown>;
404
- const serviceId =
405
- typeof s.service_id === 'string'
406
- ? s.service_id
407
- : typeof s.id === 'string'
408
- ? s.id
409
- : null;
410
- if (!serviceId) continue;
411
- entries.push({
412
- serviceId,
413
- slug: typeof s.app_slug === 'string' ? s.app_slug : null,
414
- name: typeof s.name === 'string' ? s.name : null,
415
- description:
416
- typeof s.description === 'string' ? s.description : null,
417
- baseUrl: typeof s.base_url === 'string' ? s.base_url : null,
418
- status: typeof s.status === 'string' ? s.status : null,
419
- surfaceKind:
420
- typeof s.surface_kind === 'string' ? s.surface_kind : null,
421
- launchable: s.launchable === true,
422
- });
423
- }
424
- if (entries.length > 0) {
425
- this.serviceCatalogCache = {
426
- entries,
427
- expiresAt: now + CATALOG_CACHE_TTL_MS,
428
- };
429
- return entries;
430
- }
431
- }
432
- } catch {
433
- // Fall through to the generated snapshot.
414
+ const res = await this.request<
415
+ | { data?: { services?: unknown[] } }
416
+ | { services?: unknown[] }
417
+ >({ service: 'auth', method: 'GET', path: '/api/services/catalog' });
418
+ const raw =
419
+ (res as { data?: { services?: unknown[] } }).data?.services ??
420
+ (res as { services?: unknown[] }).services;
421
+ if (!Array.isArray(raw)) {
422
+ throw new TopoloSdkError(
423
+ 'service_catalog_unavailable',
424
+ 'TopoloAuth did not return a service catalog for the current credential.',
425
+ );
426
+ }
427
+
428
+ const entries: TopoloServiceCatalogEntry[] = [];
429
+ for (const item of raw) {
430
+ const s = item as Record<string, unknown>;
431
+ const serviceId =
432
+ typeof s.service_id === 'string'
433
+ ? s.service_id
434
+ : typeof s.id === 'string'
435
+ ? s.id
436
+ : null;
437
+ if (!serviceId) continue;
438
+ const permissions = Array.isArray(s.granted_permissions)
439
+ ? s.granted_permissions
440
+ .filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
441
+ : [];
442
+ entries.push({
443
+ serviceId,
444
+ slug: typeof s.app_slug === 'string' ? s.app_slug : null,
445
+ name: typeof s.name === 'string' ? s.name : null,
446
+ description:
447
+ typeof s.description === 'string' ? s.description : null,
448
+ baseUrl: typeof s.base_url === 'string' ? s.base_url : null,
449
+ status: typeof s.status === 'string' ? s.status : null,
450
+ surfaceKind:
451
+ typeof s.surface_kind === 'string' ? s.surface_kind : null,
452
+ launchable: s.launchable === true,
453
+ permissions,
454
+ });
455
+ }
456
+ this.serviceCatalogCache = {
457
+ entries,
458
+ expiresAt: now + CATALOG_CACHE_TTL_MS,
459
+ };
460
+ return entries;
461
+ }
462
+
463
+ /**
464
+ * List callable actions from the live Auth catalog. The response is scoped to
465
+ * the caller's organization and effective permissions; no bundled/static app
466
+ * action list is used as an authorization source.
467
+ */
468
+ async listActions(options: { service?: string } = {}): Promise<TopoloActionCatalogEntry[]> {
469
+ const now = Date.now();
470
+ if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
471
+ return this.actionCatalogCache.entries;
472
+ }
473
+
474
+ const res = await this.request<
475
+ | { data?: { actions?: unknown[] } }
476
+ | { actions?: unknown[] }
477
+ >({
478
+ service: 'auth',
479
+ method: 'GET',
480
+ path: '/api/actions/catalog',
481
+ ...(options.service ? { query: { service: options.service } } : {}),
482
+ });
483
+ const raw =
484
+ (res as { data?: { actions?: unknown[] } }).data?.actions ??
485
+ (res as { actions?: unknown[] }).actions;
486
+ if (!Array.isArray(raw)) {
487
+ throw new TopoloSdkError(
488
+ 'action_catalog_unavailable',
489
+ 'TopoloAuth did not return an action catalog for the current credential.',
490
+ );
491
+ }
492
+
493
+ const entries = raw
494
+ .map((item) => normalizeActionCatalogEntry(item))
495
+ .filter((entry): entry is TopoloActionCatalogEntry => Boolean(entry));
496
+ if (!options.service) {
497
+ this.actionCatalogCache = {
498
+ entries,
499
+ expiresAt: now + CATALOG_CACHE_TTL_MS,
500
+ };
501
+ }
502
+ return entries;
503
+ }
504
+
505
+ async getAction(action: string): Promise<TopoloActionCatalogEntry> {
506
+ const needle = normalizeActionLookupKey(action);
507
+ const actions = await this.listActions();
508
+ const match = actions.find((entry) => actionEntryMatches(entry, needle));
509
+ if (!match) {
510
+ throw new TopoloSdkError(
511
+ 'unknown_action',
512
+ `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`,
513
+ );
434
514
  }
435
- return this.generatedCatalogFallback();
515
+ return match;
436
516
  }
437
517
 
438
- private generatedCatalogFallback(): TopoloServiceCatalogEntry[] {
518
+ async callAction<T = unknown>(
519
+ action: string,
520
+ input: Record<string, unknown> = {},
521
+ options: { confirm?: boolean } = {},
522
+ ): Promise<T> {
523
+ const entry = await this.getAction(action);
524
+ const mapped = mapActionInput(entry, input);
525
+ const service = entry.serviceSlug || entry.serviceId;
526
+ return this.request<T>({
527
+ service,
528
+ method: entry.method,
529
+ path: mapped.path,
530
+ ...(mapped.query ? { query: mapped.query } : {}),
531
+ ...(mapped.body !== undefined ? { body: mapped.body } : {}),
532
+ confirm: options.confirm === true,
533
+ });
534
+ }
535
+
536
+ /**
537
+ * Bundled generated registry for tooling that audits platform packaging, not
538
+ * for deciding what the current credential may use.
539
+ */
540
+ listBundledServices(): TopoloServiceCatalogEntry[] {
439
541
  const apps = Object.values(APPLICATIONS) as ReadonlyArray<{
440
542
  id: string;
441
543
  name?: string;
@@ -447,9 +549,10 @@ export class TopoloClient {
447
549
  name: app.name ?? app.id,
448
550
  description: null,
449
551
  baseUrl: app.productionUrl ?? null,
450
- status: 'active',
552
+ status: 'bundled',
451
553
  surfaceKind: 'application',
452
554
  launchable: Boolean(app.productionUrl),
555
+ permissions: [],
453
556
  }));
454
557
  }
455
558
 
@@ -543,7 +646,7 @@ function serviceKeyCandidates(slug: string): string[] {
543
646
  }
544
647
 
545
648
  function normalizeLookupKey(value: string): string {
546
- return String(value || '').trim().toLowerCase();
649
+ return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
547
650
  }
548
651
 
549
652
  function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string): boolean {
@@ -558,12 +661,8 @@ function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
558
661
  for (const value of values) {
559
662
  const normalized = normalizeLookupKey(value);
560
663
  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, '_'));
664
+ for (const candidate of serviceKeyCandidates(normalized)) {
665
+ keys.add(candidate);
567
666
  }
568
667
  }
569
668
  return [...keys];
@@ -572,7 +671,123 @@ function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
572
671
  function isCallableCatalogEntry(entry: TopoloServiceCatalogEntry): boolean {
573
672
  const kind = normalizeLookupKey(entry.surfaceKind || '');
574
673
  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] ?? ''));
674
+ return Boolean(
675
+ entry.baseUrl &&
676
+ catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key)),
677
+ );
678
+ }
679
+
680
+ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry | null {
681
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
682
+ const a = item as Record<string, unknown>;
683
+ const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
684
+ const name = pickActionString(a.name);
685
+ const serviceId = pickActionString(a.service_id) ?? pickActionString(a.serviceId);
686
+ const method = pickActionString(a.method)?.toUpperCase();
687
+ const path = pickActionString(a.path);
688
+ if (!actionId || !name || !serviceId || !method || !path || !isHttpMethod(method)) {
689
+ return null;
690
+ }
691
+
692
+ return {
693
+ actionId,
694
+ name,
695
+ toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
696
+ title: pickActionString(a.title) ?? name,
697
+ description: pickActionString(a.description) ?? '',
698
+ serviceId,
699
+ serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
700
+ serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
701
+ method,
702
+ path,
703
+ permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? '',
704
+ requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? '',
705
+ inputSchema: plainObject(a.input_schema ?? a.inputSchema),
706
+ outputSchema: plainObject(a.output_schema ?? a.outputSchema),
707
+ readOnly: Boolean(a.read_only ?? a.readOnly),
708
+ destructive: Boolean(a.destructive),
709
+ requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
710
+ status: pickActionString(a.status),
711
+ sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision),
712
+ };
713
+ }
714
+
715
+ function isHttpMethod(value: string): value is TopoloActionCatalogEntry['method'] {
716
+ return value === 'GET' || value === 'POST' || value === 'PUT' || value === 'PATCH' || value === 'DELETE';
717
+ }
718
+
719
+ function pickActionString(value: unknown): string | null {
720
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
721
+ }
722
+
723
+ function plainObject(value: unknown): Record<string, unknown> {
724
+ return value && typeof value === 'object' && !Array.isArray(value)
725
+ ? value as Record<string, unknown>
726
+ : {};
727
+ }
728
+
729
+ function normalizeActionLookupKey(value: string): string {
730
+ return String(value || '').trim().toLowerCase();
731
+ }
732
+
733
+ function actionEntryMatches(entry: TopoloActionCatalogEntry, needle: string): boolean {
734
+ if (!needle) return false;
735
+ return [entry.actionId, entry.name, entry.toolName]
736
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
737
+ .some((value) => value.toLowerCase() === needle);
738
+ }
739
+
740
+ function mapActionInput(
741
+ action: TopoloActionCatalogEntry,
742
+ input: Record<string, unknown>,
743
+ ): { path: string; query?: Record<string, string | number | boolean>; body?: unknown } {
744
+ const remaining: Record<string, unknown> = { ...(input || {}) };
745
+ const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => {
746
+ const value = remaining[key];
747
+ if (value === undefined || value === null || value === '') {
748
+ throw new TopoloSdkError(
749
+ 'invalid_action_input',
750
+ `Action "${action.name}" requires path parameter "${key}".`,
751
+ );
752
+ }
753
+ delete remaining[key];
754
+ return encodeURIComponent(String(value));
755
+ });
756
+
757
+ const explicitQuery = plainObject(remaining.query);
758
+ delete remaining.query;
759
+ const explicitBody = Object.prototype.hasOwnProperty.call(remaining, 'body')
760
+ ? remaining.body
761
+ : undefined;
762
+ delete remaining.body;
763
+
764
+ if (action.method === 'GET') {
765
+ return {
766
+ path,
767
+ query: objectToQuery({ ...remaining, ...explicitQuery }),
768
+ };
769
+ }
770
+
771
+ const query = objectToQuery(explicitQuery);
772
+ const body = explicitBody !== undefined ? explicitBody : remaining;
773
+ return {
774
+ path,
775
+ ...(query ? { query } : {}),
776
+ body,
777
+ };
778
+ }
779
+
780
+ function objectToQuery(value: Record<string, unknown>): Record<string, string | number | boolean> | undefined {
781
+ const query: Record<string, string | number | boolean> = {};
782
+ for (const [key, raw] of Object.entries(value)) {
783
+ if (raw === undefined || raw === null) continue;
784
+ if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
785
+ query[key] = raw;
786
+ } else {
787
+ query[key] = JSON.stringify(raw);
788
+ }
789
+ }
790
+ return Object.keys(query).length > 0 ? query : undefined;
576
791
  }
577
792
 
578
793
  interface AuthMePayload {
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  type CredentialIntrospection,
6
6
  type TopoloDebugEvent,
7
7
  type TopoloServiceCatalogEntry,
8
+ type TopoloActionCatalogEntry,
8
9
  } from "./client.js";
9
10
  export type { TopoloCredential, AgentIdentity } from "./auth.js";
10
11
  export {
@@ -14,6 +14,26 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
14
14
  headers: { 'Content-Type': 'application/json' },
15
15
  });
16
16
  }
17
+ if (String(url).includes('/api/services/catalog')) {
18
+ return new Response(JSON.stringify({
19
+ data: {
20
+ services: [
21
+ {
22
+ service_id: 'srv_p2p',
23
+ app_slug: 'topolo-p2p',
24
+ name: 'Topolo P2P',
25
+ base_url: 'https://p2p.topolo.app',
26
+ surface_kind: 'api',
27
+ status: 'active',
28
+ granted_permissions: ['srv_p2p.directory:read'],
29
+ },
30
+ ],
31
+ },
32
+ }), {
33
+ status: 200,
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
17
37
  const next = responses.shift() ?? { status: 200, body: { success: true, data: {} } };
18
38
  return new Response(JSON.stringify(next.body ?? { success: true, data: {} }), {
19
39
  status: next.status ?? 200,
@@ -24,7 +44,10 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
24
44
  }
25
45
 
26
46
  function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
27
- return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
47
+ return calls.filter((call) =>
48
+ !call.url.includes('/api/services/by-slug/') &&
49
+ !call.url.includes('/api/services/catalog')
50
+ );
28
51
  }
29
52
 
30
53
  function clientWith(impl: typeof fetch) {
@@ -138,7 +161,7 @@ describe('P2PModule.publishCapability', () => {
138
161
  expect(sent.trust_level).toBe('standard');
139
162
  const url = new URL(call.url);
140
163
  expect(url.pathname).toBe('/api/p2p/internal/capabilities/sync');
141
- expect((calls[0]!.init.method ?? '').toUpperCase()).toBe('POST');
164
+ expect((call.init.method ?? '').toUpperCase()).toBe('POST');
142
165
  });
143
166
 
144
167
  it('honours an explicit acting_for_org_id when the platform key is publishing for another org', async () => {
package/src/services.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
7
7
  *
8
8
  * Any entry can be overridden per-call via `TopoloClient` options, or globally
9
- * via env vars like `TOPOLO_SERVICE_URL_CRM` or `TOPOLO_SERVICE_URL_WEB_API`
9
+ * via env vars like `TOPOLO_SERVICE_URL_MAIL` or `TOPOLO_SERVICE_URL_WEB_API`
10
10
  * (dots in service IDs become underscores in env vars).
11
11
  *
12
12
  * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is