@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/README.md +11 -3
- package/dist/index.cjs +187 -53
- package/dist/index.d.cts +52 -8
- package/dist/index.d.ts +52 -8
- package/dist/index.js +186 -53
- package/package.json +1 -2
- package/src/applications.generated.ts +1 -0
- package/src/client.test.ts +294 -31
- package/src/client.ts +280 -59
- package/src/errors.ts +7 -0
- package/src/index.ts +1 -0
- package/src/modules/crm.test.ts +1 -3
- package/src/modules/p2p.test.ts +1 -3
- package/src/node/credential-store.test.ts +337 -0
- package/src/node/credential-store.ts +86 -5
- package/dist/node/credential-store.cjs +0 -440
- package/dist/node/credential-store.d.cts +0 -81
- package/dist/node/credential-store.d.ts +0 -81
- package/dist/node/credential-store.js +0 -399
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
372
|
-
|
|
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
|
|
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
|
-
>({
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
};
|
|
540
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
691
|
+
return (await this.searchActions(options)).actions;
|
|
584
692
|
}
|
|
585
693
|
|
|
586
694
|
async getAction(action: string): Promise<TopoloActionCatalogEntry> {
|
|
587
|
-
const
|
|
588
|
-
|
|
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
|
-
|
|
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: {
|
|
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
package/src/modules/crm.test.ts
CHANGED
|
@@ -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
|
-
|
|
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' } },
|
package/src/modules/p2p.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|