@topolo/sdk 0.6.0 → 0.8.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/src/client.ts CHANGED
@@ -5,7 +5,13 @@ import {
5
5
  type AgentIdentity,
6
6
  type TopoloCredential,
7
7
  } from './auth.js';
8
- import { TopoloAuthError, TopoloHttpError, TopoloPermissionError, TopoloSdkError } from './errors.js';
8
+ import {
9
+ TopoloAuthError,
10
+ TopoloConfirmationRequiredError,
11
+ TopoloHttpError,
12
+ TopoloPermissionError,
13
+ TopoloSdkError,
14
+ } from './errors.js';
9
15
  import {
10
16
  DEFAULT_APP_URLS,
11
17
  APPLICATIONS,
@@ -30,6 +36,12 @@ export interface TopoloClientOptions {
30
36
  timeoutMs?: number;
31
37
  /** Injected fetch, for testing. Defaults to global fetch. */
32
38
  fetch?: typeof fetch;
39
+ /**
40
+ * Refresh a rejected access-token credential. When supplied, the client
41
+ * retries one 401 response with the returned credential. Concurrent 401s
42
+ * share one refresh operation. API keys are never refreshed.
43
+ */
44
+ refreshCredential?: () => Promise<TopoloCredential | null>;
33
45
  /**
34
46
  * Observability hook. Fires once per request lifecycle (`request` at start,
35
47
  * then exactly one of `response` or `error`). Safe to leave unset — off by
@@ -72,6 +84,8 @@ export type TopoloDebugEvent =
72
84
 
73
85
  export interface RequestOptions {
74
86
  service: string;
87
+ /** Catalog-resolved origin for an action target. */
88
+ baseUrl?: string;
75
89
  path: string;
76
90
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
77
91
  query?: Record<string, string | number | boolean | undefined>;
@@ -96,7 +110,6 @@ const TOPOLO_RESOURCE_ID_HEADER = 'X-Topolo-Resource-ID';
96
110
 
97
111
  const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
98
112
  const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
99
- const CATALOG_CACHE_TTL_MS = 60 * 1000; // 1 minute
100
113
 
101
114
  interface AppIdCacheEntry {
102
115
  value: string | null;
@@ -135,6 +148,8 @@ export interface TopoloApiKeyResourceMetadata {
135
148
 
136
149
  export interface TopoloActionCatalogEntry {
137
150
  actionId: string;
151
+ target: string | null;
152
+ apiBaseUrl: string | null;
138
153
  name: string;
139
154
  toolName: string | null;
140
155
  title: string;
@@ -146,6 +161,8 @@ export interface TopoloActionCatalogEntry {
146
161
  path: string;
147
162
  permissionName: string;
148
163
  requiredPermission: string;
164
+ /** Static catalog permission or target-enforced input-dependent authorization. */
165
+ authorizationMode?: 'static' | 'runtime';
149
166
  inputSchema: Record<string, unknown>;
150
167
  outputSchema: Record<string, unknown>;
151
168
  readOnly: boolean;
@@ -157,17 +174,38 @@ export interface TopoloActionCatalogEntry {
157
174
  sourceRevision: string | null;
158
175
  }
159
176
 
177
+ export interface TopoloCatalogPage {
178
+ nextCursor: string | null;
179
+ hasMore: boolean;
180
+ }
181
+
182
+ export interface TopoloServiceCatalogPage {
183
+ services: TopoloServiceCatalogEntry[];
184
+ page: TopoloCatalogPage;
185
+ }
186
+
187
+ export interface TopoloActionCatalogPage {
188
+ actions: TopoloActionCatalogEntry[];
189
+ page: TopoloCatalogPage;
190
+ }
191
+
192
+ export interface TopoloCatalogSearchOptions {
193
+ query?: string;
194
+ cursor?: string;
195
+ limit?: number;
196
+ }
197
+
160
198
  export class TopoloClient {
161
- private readonly credential: TopoloCredential;
199
+ private credential: TopoloCredential;
162
200
  private readonly agent: AgentIdentity;
163
201
  private readonly serviceUrls?: Record<string, string>;
164
202
  private readonly appIds?: Record<string, string>;
165
203
  private readonly requireConfirmForWrites: boolean;
166
204
  private readonly timeoutMs: number;
167
205
  private readonly fetchImpl: typeof fetch;
206
+ private readonly refreshCredential?: () => Promise<TopoloCredential | null>;
207
+ private refreshInFlight: Promise<TopoloCredential | null> | null = null;
168
208
  private readonly debug?: (event: TopoloDebugEvent) => void;
169
- private serviceCatalogCache: { entries: TopoloServiceCatalogEntry[]; expiresAt: number } | null = null;
170
- private actionCatalogCache: { entries: TopoloActionCatalogEntry[]; expiresAt: number } | null = null;
171
209
 
172
210
  /**
173
211
  * Process-wide cache for slug → app-id lookups. Keyed by
@@ -195,6 +233,7 @@ export class TopoloClient {
195
233
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
196
234
  this.timeoutMs = options.timeoutMs ?? 30_000;
197
235
  this.fetchImpl = options.fetch ?? fetch;
236
+ this.refreshCredential = options.refreshCredential;
198
237
  this.debug = options.debug;
199
238
  }
200
239
 
@@ -215,13 +254,15 @@ export class TopoloClient {
215
254
  async request<T = unknown>(opts: RequestOptions): Promise<T> {
216
255
  const method = opts.method ?? 'GET';
217
256
  if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
218
- throw new TopoloAuthError(
257
+ throw new TopoloConfirmationRequiredError(
219
258
  `Mutating ${method} requests require { confirm: true }. This guardrail prevents ` +
220
259
  `agents from issuing writes without an explicit human-in-the-loop acknowledgement.`,
221
260
  );
222
261
  }
223
262
 
224
- const resolvedService = await this.resolveRequestService(opts.service);
263
+ const resolvedService = opts.baseUrl
264
+ ? { serviceKey: normalizeLookupKey(opts.service), baseUrl: requireHttpOrigin(opts.baseUrl) }
265
+ : await this.resolveRequestService(opts.service);
225
266
  const baseUrl = resolvedService.baseUrl;
226
267
  const url = new URL(opts.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
227
268
  if (opts.query) {
@@ -256,24 +297,44 @@ export class TopoloClient {
256
297
  headers.set(TOPOLO_RESOURCE_ID_HEADER, resourceId);
257
298
  }
258
299
 
259
- const controller = new AbortController();
260
- const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
261
- const signal = opts.signal
262
- ? mergeSignals(opts.signal, controller.signal)
263
- : controller.signal;
264
-
265
300
  const urlStr = url.toString();
266
301
  const startedAt = Date.now();
267
302
  this.emit({ phase: 'request', method, service: resolvedService.serviceKey, path: opts.path, url: urlStr, requestId });
268
303
 
304
+ const fetchOnce = async (): Promise<Response> => {
305
+ const controller = new AbortController();
306
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
307
+ const signal = opts.signal
308
+ ? mergeSignals(opts.signal, controller.signal)
309
+ : controller.signal;
310
+ try {
311
+ return await this.fetchImpl(urlStr, {
312
+ method,
313
+ headers: new Headers(headers),
314
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : null,
315
+ signal,
316
+ });
317
+ } finally {
318
+ clearTimeout(timeoutId);
319
+ }
320
+ };
321
+
269
322
  let res: Response;
270
323
  try {
271
- res = await this.fetchImpl(urlStr, {
272
- method,
273
- headers,
274
- body: opts.body !== undefined ? JSON.stringify(opts.body) : null,
275
- signal,
276
- });
324
+ const attemptedCredential = this.credential;
325
+ res = await fetchOnce();
326
+ if (
327
+ res.status === 401
328
+ && attemptedCredential.kind === 'access_token'
329
+ && this.refreshCredential
330
+ ) {
331
+ await res.body?.cancel().catch(() => undefined);
332
+ const refreshed = await this.refreshRejectedAccessToken(attemptedCredential.accessToken);
333
+ if (refreshed) {
334
+ applyAuthHeaders(headers, refreshed);
335
+ res = await fetchOnce();
336
+ }
337
+ }
277
338
  } catch (err) {
278
339
  this.emit({
279
340
  phase: 'error',
@@ -286,8 +347,6 @@ export class TopoloClient {
286
347
  error: err instanceof Error ? err.message : String(err),
287
348
  });
288
349
  throw err;
289
- } finally {
290
- clearTimeout(timeoutId);
291
350
  }
292
351
 
293
352
  const contentType = res.headers.get('Content-Type') ?? '';
@@ -333,6 +392,23 @@ export class TopoloClient {
333
392
  return parsed as T;
334
393
  }
335
394
 
395
+ private async refreshRejectedAccessToken(attemptedAccessToken: string): Promise<TopoloCredential | null> {
396
+ if (this.credential.kind === 'access_token' && this.credential.accessToken !== attemptedAccessToken) {
397
+ return this.credential;
398
+ }
399
+ if (!this.refreshCredential) return null;
400
+ this.refreshInFlight ??= this.refreshCredential()
401
+ .then((credential) => {
402
+ if (credential) this.credential = credential;
403
+ return credential;
404
+ })
405
+ .catch(() => null)
406
+ .finally(() => {
407
+ this.refreshInFlight = null;
408
+ });
409
+ return this.refreshInFlight;
410
+ }
411
+
336
412
  private async resolveRequestService(service: string): Promise<{ serviceKey: string; baseUrl: string }> {
337
413
  const serviceKey = normalizeLookupKey(service);
338
414
  if (!serviceKey) {
@@ -368,9 +444,8 @@ export class TopoloClient {
368
444
  }
369
445
 
370
446
  private async resolveLiveCallableService(serviceKey: string): Promise<{ serviceKey: string; baseUrl: string } | null> {
371
- const catalog = await this.listServices();
372
- const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
373
- if (!match) return null;
447
+ const match = await this.getService(serviceKey).catch(() => null);
448
+ if (!match || !catalogEntryMatches(match, serviceKey)) return null;
374
449
  const baseUrl = resolveCatalogServiceUrl(match, {
375
450
  serviceKey,
376
451
  overrides: this.serviceUrls,
@@ -457,19 +532,19 @@ export class TopoloClient {
457
532
  * The live Auth catalog is the authority because it is scoped to the caller's
458
533
  * organization and effective permissions.
459
534
  */
460
- async listServices(): Promise<TopoloServiceCatalogEntry[]> {
461
- const now = Date.now();
462
- if (this.serviceCatalogCache && this.serviceCatalogCache.expiresAt > now) {
463
- return this.serviceCatalogCache.entries;
464
- }
465
-
535
+ async searchServices(options: TopoloCatalogSearchOptions = {}): Promise<TopoloServiceCatalogPage> {
466
536
  // app-catalog greenfield: the catalog moved to the Catalog (developers)
467
537
  // service. Auth no longer owns the registry; it only provides scoping, which
468
538
  // the Catalog composes server-side. Consumers pass the per-env developers URL.
469
539
  const res = await this.request<
470
- | { data?: { services?: unknown[] } }
471
- | { services?: unknown[] }
472
- >({ service: 'developers', method: 'GET', path: '/api/services/catalog' });
540
+ | { data?: { services?: unknown[]; page?: unknown } }
541
+ | { services?: unknown[]; page?: unknown }
542
+ >({
543
+ service: 'developers',
544
+ method: 'GET',
545
+ path: '/api/services/catalog',
546
+ query: catalogSearchQuery(options),
547
+ });
473
548
  const raw =
474
549
  (res as { data?: { services?: unknown[] } }).data?.services ??
475
550
  (res as { services?: unknown[] }).services;
@@ -482,7 +557,8 @@ export class TopoloClient {
482
557
 
483
558
  const entries: TopoloServiceCatalogEntry[] = [];
484
559
  for (const item of raw) {
485
- const s = item as Record<string, unknown>;
560
+ if (!isRecord(item)) continue;
561
+ const s = item;
486
562
  const appId =
487
563
  typeof s.app_id === 'string'
488
564
  ? s.app_id
@@ -533,11 +609,36 @@ export class TopoloClient {
533
609
  apiKeyResources,
534
610
  });
535
611
  }
536
- this.serviceCatalogCache = {
537
- entries,
538
- expiresAt: now + CATALOG_CACHE_TTL_MS,
539
- };
540
- return entries;
612
+ const page = normalizeCatalogPage(
613
+ (res as { data?: { page?: unknown } }).data?.page ?? (res as { page?: unknown }).page,
614
+ );
615
+ return { services: entries, page };
616
+ }
617
+
618
+ /** @deprecated Prefer searchServices() so callers retain the continuation cursor. */
619
+ async listServices(options: TopoloCatalogSearchOptions = {}): Promise<TopoloServiceCatalogEntry[]> {
620
+ return (await this.searchServices(options)).services;
621
+ }
622
+
623
+ async getService(application: string): Promise<TopoloServiceCatalogEntry> {
624
+ const needle = application.trim();
625
+ if (!needle) throw new TopoloSdkError('unknown_service', 'application is required');
626
+ const res = await this.request<
627
+ | { data?: { application?: unknown } }
628
+ | { application?: unknown }
629
+ >({
630
+ service: 'developers',
631
+ method: 'GET',
632
+ path: '/api/services/catalog',
633
+ query: { application: needle },
634
+ });
635
+ const raw = (res as { data?: { application?: unknown } }).data?.application
636
+ ?? (res as { application?: unknown }).application;
637
+ const entry = normalizeServiceCatalogEntry(raw);
638
+ if (!entry) {
639
+ throw new TopoloSdkError('unknown_service', `Unknown Topolo application "${application}".`);
640
+ }
641
+ return entry;
541
642
  }
542
643
 
543
644
  /**
@@ -546,20 +647,20 @@ export class TopoloClient {
546
647
  * caller's organization and effective permissions; no bundled/static app
547
648
  * action list is used as an authorization source.
548
649
  */
549
- async listActions(options: { service?: string } = {}): Promise<TopoloActionCatalogEntry[]> {
550
- const now = Date.now();
551
- if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
552
- return this.actionCatalogCache.entries;
650
+ async searchActions(
651
+ options: TopoloCatalogSearchOptions & { service: string },
652
+ ): Promise<TopoloActionCatalogPage> {
653
+ if (!options.service?.trim()) {
654
+ throw new TopoloSdkError('service_required', 'service is required for bounded action discovery');
553
655
  }
554
-
555
656
  const res = await this.request<
556
- | { data?: { actions?: unknown[] } }
557
- | { actions?: unknown[] }
657
+ | { data?: { actions?: unknown[]; page?: unknown } }
658
+ | { actions?: unknown[]; page?: unknown }
558
659
  >({
559
660
  service: 'developers',
560
661
  method: 'GET',
561
662
  path: '/api/actions/catalog',
562
- ...(options.service ? { query: { service: options.service } } : {}),
663
+ query: { service: options.service, ...catalogSearchQuery(options) },
563
664
  });
564
665
  const raw =
565
666
  (res as { data?: { actions?: unknown[] } }).data?.actions ??
@@ -574,42 +675,78 @@ export class TopoloClient {
574
675
  const entries = raw
575
676
  .map((item) => normalizeActionCatalogEntry(item))
576
677
  .filter((entry): entry is TopoloActionCatalogEntry => Boolean(entry));
577
- if (!options.service) {
578
- this.actionCatalogCache = {
579
- entries,
580
- expiresAt: now + CATALOG_CACHE_TTL_MS,
581
- };
678
+ return {
679
+ actions: entries,
680
+ page: normalizeCatalogPage(
681
+ (res as { data?: { page?: unknown } }).data?.page ?? (res as { page?: unknown }).page,
682
+ ),
683
+ };
684
+ }
685
+
686
+ /** @deprecated Prefer searchActions() so callers retain the continuation cursor. */
687
+ async listActions(options?: TopoloCatalogSearchOptions & { service: string }): Promise<TopoloActionCatalogEntry[]> {
688
+ if (!options) {
689
+ throw new TopoloSdkError('service_required', 'service is required for bounded action discovery');
582
690
  }
583
- return entries;
691
+ return (await this.searchActions(options)).actions;
584
692
  }
585
693
 
586
694
  async getAction(action: string): Promise<TopoloActionCatalogEntry> {
587
- const needle = normalizeActionLookupKey(action);
588
- const actions = await this.listActions();
589
- const match = actions.find((entry) => actionEntryMatches(entry, needle));
590
- if (!match) {
695
+ const actionId = action.trim();
696
+ if (!actionId) {
591
697
  throw new TopoloSdkError(
592
698
  'unknown_action',
593
- `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`,
699
+ 'action id is required',
594
700
  );
595
701
  }
702
+ const res = await this.request<
703
+ | { data?: { action?: unknown } }
704
+ | { action?: unknown }
705
+ >({
706
+ service: 'developers',
707
+ method: 'GET',
708
+ path: '/api/actions/catalog',
709
+ query: { actionId },
710
+ });
711
+ const raw = (res as { data?: { action?: unknown } }).data?.action
712
+ ?? (res as { action?: unknown }).action;
713
+ const match = normalizeActionCatalogEntry(raw);
714
+ if (!match) throw new TopoloSdkError('unknown_action', `Unknown Topolo action "${action}".`);
596
715
  return match;
597
716
  }
598
717
 
599
718
  async callAction<T = unknown>(
600
719
  action: string,
601
720
  input: Record<string, unknown> = {},
602
- options: { confirm?: boolean } = {},
721
+ options: {
722
+ confirm?: boolean;
723
+ resource?: { resourceType: string; resourceId: string };
724
+ } = {},
603
725
  ): Promise<T> {
604
726
  const entry = await this.getAction(action);
727
+ const agentAccess = entry.agentAccess
728
+ ?? (entry.destructive ? 'off' : entry.readOnly && !entry.requiresConfirmation ? 'auto' : 'confirm');
729
+ if (agentAccess === 'off') {
730
+ throw new TopoloPermissionError(
731
+ `Topolo action "${entry.actionId}" is not available for agent invocation.`,
732
+ entry.requiredPermission ? [entry.requiredPermission] : [],
733
+ );
734
+ }
735
+ if ((agentAccess === 'confirm' || entry.requiresConfirmation || entry.destructive) && options.confirm !== true) {
736
+ throw new TopoloConfirmationRequiredError(
737
+ `Topolo action "${entry.actionId}" requires { confirm: true } before invocation.`,
738
+ );
739
+ }
605
740
  const mapped = mapActionInput(entry, input);
606
741
  const service = entry.appSlug || entry.appId;
607
742
  return this.request<T>({
608
743
  service,
744
+ ...(entry.apiBaseUrl ? { baseUrl: entry.apiBaseUrl } : {}),
609
745
  method: entry.method,
610
746
  path: mapped.path,
611
747
  ...(mapped.query ? { query: mapped.query } : {}),
612
748
  ...(mapped.body !== undefined ? { body: mapped.body } : {}),
749
+ ...(options.resource ? { resource: options.resource } : {}),
613
750
  headers: {
614
751
  [TOPOLO_ACTION_ID_HEADER]: entry.actionId,
615
752
  [TOPOLO_ACTION_NAME_HEADER]: entry.name,
@@ -791,6 +928,19 @@ function normalizeLookupKey(value: string): string {
791
928
  return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
792
929
  }
793
930
 
931
+ function requireHttpOrigin(value: string): string {
932
+ let url: URL;
933
+ try {
934
+ url = new URL(value);
935
+ } catch {
936
+ throw new TopoloSdkError('invalid_action_target', `Invalid action API origin "${value}".`);
937
+ }
938
+ if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) {
939
+ throw new TopoloSdkError('invalid_action_target', `Invalid action API origin "${value}".`);
940
+ }
941
+ return url.origin;
942
+ }
943
+
794
944
  /**
795
945
  * Derive the Catalog (developers) base URL from a configured auth base URL by
796
946
  * swapping the leading `auth` host label for `developers` (auth.stg.topolo.us ->
@@ -828,6 +978,70 @@ function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
828
978
  return [...keys];
829
979
  }
830
980
 
981
+ function catalogSearchQuery(options: TopoloCatalogSearchOptions): Record<string, string | number> {
982
+ const query: Record<string, string | number> = {};
983
+ if (typeof options.query === 'string' && options.query.trim()) query.query = options.query.trim();
984
+ if (typeof options.cursor === 'string' && options.cursor.trim()) query.cursor = options.cursor.trim();
985
+ if (typeof options.limit === 'number' && Number.isFinite(options.limit)) query.limit = Math.trunc(options.limit);
986
+ return query;
987
+ }
988
+
989
+ function normalizeCatalogPage(value: unknown): TopoloCatalogPage {
990
+ if (!isRecord(value)) return { nextCursor: null, hasMore: false };
991
+ return {
992
+ nextCursor: typeof value.nextCursor === 'string' && value.nextCursor ? value.nextCursor : null,
993
+ hasMore: value.hasMore === true,
994
+ };
995
+ }
996
+
997
+ function normalizeServiceCatalogEntry(item: unknown): TopoloServiceCatalogEntry | null {
998
+ if (!isRecord(item)) return null;
999
+ const appId = typeof item.app_id === 'string'
1000
+ ? item.app_id
1001
+ : typeof item.id === 'string'
1002
+ ? item.id
1003
+ : null;
1004
+ if (!appId) return null;
1005
+ const permissions = Array.isArray(item.granted_permissions)
1006
+ ? item.granted_permissions.filter(
1007
+ (permission): permission is string => typeof permission === 'string' && permission.trim().length > 0,
1008
+ )
1009
+ : [];
1010
+ const catalogApiKeyResources = normalizeApiKeyResourceMetadataList(
1011
+ item.api_key_resources ?? item.apiKeyResources,
1012
+ );
1013
+ const apiKeyResource = normalizeApiKeyResourceMetadata(item.api_key_resource ?? item.apiKeyResource)
1014
+ ?? catalogApiKeyResources[0]
1015
+ ?? null;
1016
+ const apiKeyResources = catalogApiKeyResources.length > 0
1017
+ ? catalogApiKeyResources
1018
+ : apiKeyResource
1019
+ ? [apiKeyResource]
1020
+ : [];
1021
+ return {
1022
+ appId,
1023
+ slug: typeof item.app_slug === 'string' ? item.app_slug : null,
1024
+ name: typeof item.name === 'string' ? item.name : null,
1025
+ description: typeof item.description === 'string' ? item.description : null,
1026
+ apiBaseUrl: typeof item.api_base_url === 'string'
1027
+ ? item.api_base_url
1028
+ : typeof item.apiBaseUrl === 'string'
1029
+ ? item.apiBaseUrl
1030
+ : null,
1031
+ launchUrl: typeof item.launch_url === 'string'
1032
+ ? item.launch_url
1033
+ : typeof item.launchUrl === 'string'
1034
+ ? item.launchUrl
1035
+ : null,
1036
+ status: typeof item.status === 'string' ? item.status : null,
1037
+ surfaceKind: typeof item.surface_kind === 'string' ? item.surface_kind : null,
1038
+ launchable: item.launchable === true,
1039
+ permissions,
1040
+ apiKeyResource,
1041
+ apiKeyResources,
1042
+ };
1043
+ }
1044
+
831
1045
  function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry | null {
832
1046
  if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
833
1047
  const a = item as Record<string, unknown>;
@@ -842,6 +1056,8 @@ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry |
842
1056
 
843
1057
  return {
844
1058
  actionId,
1059
+ target: pickActionString(a.target_key) ?? pickActionString(a.target) ?? null,
1060
+ apiBaseUrl: pickActionString(a.api_base_url) ?? pickActionString(a.apiBaseUrl) ?? null,
845
1061
  name,
846
1062
  toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
847
1063
  title: pickActionString(a.title) ?? name,
@@ -853,6 +1069,7 @@ function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry |
853
1069
  path,
854
1070
  permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? '',
855
1071
  requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? '',
1072
+ authorizationMode: pickAuthorizationMode(a.authorization_mode ?? a.authorizationMode),
856
1073
  inputSchema: plainObject(a.input_schema ?? a.inputSchema),
857
1074
  outputSchema: plainObject(a.output_schema ?? a.outputSchema),
858
1075
  readOnly: Boolean(a.read_only ?? a.readOnly),
@@ -876,6 +1093,10 @@ function pickAgentAccess(value: unknown): 'auto' | 'confirm' | 'off' | null {
876
1093
  return value === 'auto' || value === 'confirm' || value === 'off' ? value : null;
877
1094
  }
878
1095
 
1096
+ function pickAuthorizationMode(value: unknown): 'static' | 'runtime' {
1097
+ return value === 'runtime' ? 'runtime' : 'static';
1098
+ }
1099
+
879
1100
  function plainObject(value: unknown): Record<string, unknown> {
880
1101
  return value && typeof value === 'object' && !Array.isArray(value)
881
1102
  ? value as Record<string, unknown>
package/src/errors.ts CHANGED
@@ -23,6 +23,13 @@ export class TopoloPermissionError extends TopoloSdkError {
23
23
  }
24
24
  }
25
25
 
26
+ export class TopoloConfirmationRequiredError extends TopoloSdkError {
27
+ constructor(message: string) {
28
+ super('confirmation_required', message);
29
+ this.name = 'TopoloConfirmationRequiredError';
30
+ }
31
+ }
32
+
26
33
  export class TopoloHttpError extends TopoloSdkError {
27
34
  readonly status: number;
28
35
  readonly body: unknown;
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  export {
38
38
  TopoloSdkError,
39
39
  TopoloAuthError,
40
+ TopoloConfirmationRequiredError,
40
41
  TopoloPermissionError,
41
42
  TopoloHttpError,
42
43
  } from "./errors.js";
@@ -18,8 +18,7 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
18
18
  return new Response(
19
19
  JSON.stringify({
20
20
  data: {
21
- services: [
22
- {
21
+ application: {
23
22
  app_id: 'app_crm',
24
23
  app_slug: 'topolo-crm',
25
24
  name: 'Topolo CRM',
@@ -29,7 +28,6 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
29
28
  status: 'active',
30
29
  granted_permissions: ['app_crm.contacts:read'],
31
30
  },
32
- ],
33
31
  },
34
32
  }),
35
33
  { status: 200, headers: { 'Content-Type': 'application/json' } },
@@ -17,8 +17,7 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
17
17
  if (String(url).includes('/api/services/catalog')) {
18
18
  return new Response(JSON.stringify({
19
19
  data: {
20
- services: [
21
- {
20
+ application: {
22
21
  app_id: 'app_p2p',
23
22
  app_slug: 'topolo-p2p',
24
23
  name: 'Topolo P2P',
@@ -28,7 +27,6 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
28
27
  status: 'active',
29
28
  granted_permissions: ['app_p2p.directory:read'],
30
29
  },
31
- ],
32
30
  },
33
31
  }), {
34
32
  status: 200,