@topolo/sdk 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts DELETED
@@ -1,1246 +0,0 @@
1
- import {
2
- applyAuditHeaders,
3
- applyAuthHeaders,
4
- generateRequestId,
5
- type AgentIdentity,
6
- type TopoloCredential,
7
- } from './auth.js';
8
- import {
9
- TopoloAuthError,
10
- TopoloConfirmationRequiredError,
11
- TopoloHttpError,
12
- TopoloPermissionError,
13
- TopoloSdkError,
14
- } from './errors.js';
15
- import {
16
- DEFAULT_APP_URLS,
17
- APPLICATIONS,
18
- resolveCatalogServiceUrl,
19
- resolveServiceUrl,
20
- type AppApiId,
21
- } from './services.js';
22
-
23
- export interface TopoloClientOptions {
24
- credential: TopoloCredential;
25
- agent: AgentIdentity;
26
- /** Per-service URL overrides. Useful for staging/dev. */
27
- serviceUrls?: Record<string, string>;
28
- /** Per-service TopoloAuth app id overrides from the owning app/deployment. */
29
- appIds?: Record<string, string>;
30
- /**
31
- * When true, mutating helpers (POST/PUT/PATCH/DELETE) require callers to pass
32
- * `{ confirm: true }`. Defaults to true. Set false only for trusted surfaces.
33
- */
34
- requireConfirmForWrites?: boolean;
35
- /** HTTP request timeout (ms). Default 30s. */
36
- timeoutMs?: number;
37
- /** Injected fetch, for testing. Defaults to global fetch. */
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>;
45
- /**
46
- * Observability hook. Fires once per request lifecycle (`request` at start,
47
- * then exactly one of `response` or `error`). Safe to leave unset — off by
48
- * default. Intended for CLI/MCP hosts to surface request-level diagnostics
49
- * and for consumers building their own logging/tracing.
50
- */
51
- debug?: (event: TopoloDebugEvent) => void;
52
- }
53
-
54
- export type TopoloDebugEvent =
55
- | {
56
- phase: 'request';
57
- method: string;
58
- service: string;
59
- path: string;
60
- url: string;
61
- requestId: string;
62
- }
63
- | {
64
- phase: 'response';
65
- method: string;
66
- service: string;
67
- path: string;
68
- url: string;
69
- requestId: string;
70
- status: number;
71
- durationMs: number;
72
- }
73
- | {
74
- phase: 'error';
75
- method: string;
76
- service: string;
77
- path: string;
78
- url: string;
79
- requestId: string;
80
- durationMs: number;
81
- error: string;
82
- status?: number;
83
- };
84
-
85
- export interface RequestOptions {
86
- service: string;
87
- /** Catalog-resolved origin for an action target. */
88
- baseUrl?: string;
89
- path: string;
90
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
91
- query?: Record<string, string | number | boolean | undefined>;
92
- body?: unknown;
93
- /** Workspace-boundary resource context for resource-scoped API keys. */
94
- resource?: {
95
- resourceType: string;
96
- resourceId: string;
97
- };
98
- /** Explicit write-acknowledgement for mutating calls. */
99
- confirm?: boolean;
100
- /** Extra headers appended after auth/audit headers. */
101
- headers?: Record<string, string>;
102
- signal?: AbortSignal;
103
- }
104
-
105
- const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
106
- const TOPOLO_ACTION_ID_HEADER = 'X-Topolo-Action-Id';
107
- const TOPOLO_ACTION_NAME_HEADER = 'X-Topolo-Action-Name';
108
- const TOPOLO_RESOURCE_TYPE_HEADER = 'X-Topolo-Resource-Type';
109
- const TOPOLO_RESOURCE_ID_HEADER = 'X-Topolo-Resource-ID';
110
-
111
- const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
112
- const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
113
-
114
- interface AppIdCacheEntry {
115
- value: string | null;
116
- expiresAt: number;
117
- }
118
-
119
- /**
120
- * A single entry from the live platform service catalog. Returned by
121
- * {@link TopoloClient.listServices}. Shape mirrors the public-safe projection
122
- * from TopoloAuth `GET /api/services/catalog`.
123
- */
124
- export interface TopoloServiceCatalogEntry {
125
- appId: string;
126
- slug: string | null;
127
- name: string | null;
128
- description: string | null;
129
- apiBaseUrl?: string | null;
130
- launchUrl?: string | null;
131
- status: string | null;
132
- surfaceKind: string | null;
133
- launchable: boolean;
134
- permissions: string[];
135
- apiKeyResource: TopoloApiKeyResourceMetadata | null;
136
- apiKeyResources: TopoloApiKeyResourceMetadata[];
137
- }
138
-
139
- export interface TopoloApiKeyResourceMetadata {
140
- resourceType: string;
141
- label: string;
142
- emptyLabel: string;
143
- resourceConcept: 'workspace';
144
- canonicalResourceType: 'workspace';
145
- defaultEntitlement: 'enterprise';
146
- exceptionPolicy: 'app_or_organization';
147
- }
148
-
149
- export interface TopoloActionCatalogEntry {
150
- actionId: string;
151
- target: string | null;
152
- apiBaseUrl: string | null;
153
- name: string;
154
- toolName: string | null;
155
- title: string;
156
- description: string;
157
- appId: string;
158
- appSlug: string | null;
159
- appName: string | null;
160
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
161
- path: string;
162
- permissionName: string;
163
- requiredPermission: string;
164
- /** Static catalog permission or target-enforced input-dependent authorization. */
165
- authorizationMode?: 'static' | 'runtime';
166
- inputSchema: Record<string, unknown>;
167
- outputSchema: Record<string, unknown>;
168
- readOnly: boolean;
169
- destructive: boolean;
170
- requiresConfirmation: boolean;
171
- /** Agent access tier. null/absent when the catalog does not specify one (consumers derive a default). */
172
- agentAccess?: 'auto' | 'confirm' | 'off' | null;
173
- status: string | null;
174
- sourceRevision: string | null;
175
- }
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
-
198
- export class TopoloClient {
199
- private credential: TopoloCredential;
200
- private readonly agent: AgentIdentity;
201
- private readonly serviceUrls?: Record<string, string>;
202
- private readonly appIds?: Record<string, string>;
203
- private readonly requireConfirmForWrites: boolean;
204
- private readonly timeoutMs: number;
205
- private readonly fetchImpl: typeof fetch;
206
- private readonly refreshCredential?: () => Promise<TopoloCredential | null>;
207
- private refreshInFlight: Promise<TopoloCredential | null> | null = null;
208
- private readonly debug?: (event: TopoloDebugEvent) => void;
209
-
210
- /**
211
- * Process-wide cache for slug → app-id lookups. Keyed by
212
- * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
213
- * the same Auth share, but staging vs prod don't poison each other.
214
- * Cleared via `TopoloClient.clearAppIdCache()` if a test needs it.
215
- */
216
- private static readonly appIdCache = new Map<string, AppIdCacheEntry>();
217
-
218
- /**
219
- * Drop the slug→app-id cache. Test-only — production callers should
220
- * let the 5-minute TTL expire naturally.
221
- */
222
- static clearAppIdCache(): void {
223
- TopoloClient.appIdCache.clear();
224
- }
225
-
226
- constructor(options: TopoloClientOptions) {
227
- if (!options.credential) throw new TopoloAuthError('credential is required');
228
- if (!options.agent?.clientName) throw new TopoloAuthError('agent.clientName is required');
229
- this.credential = options.credential;
230
- this.agent = options.agent;
231
- this.serviceUrls = options.serviceUrls;
232
- this.appIds = options.appIds;
233
- this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
234
- this.timeoutMs = options.timeoutMs ?? 30_000;
235
- this.fetchImpl = options.fetch ?? fetch;
236
- this.refreshCredential = options.refreshCredential;
237
- this.debug = options.debug;
238
- }
239
-
240
- private emit(event: TopoloDebugEvent): void {
241
- if (!this.debug) return;
242
- try {
243
- this.debug(event);
244
- } catch {
245
- // Never let a debug consumer crash the request path.
246
- }
247
- }
248
-
249
- /**
250
- * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
251
- * for anything a caller would reach for; this stays exported for escape-hatch
252
- * use and for the generic `topolo api` / MCP passthrough tool.
253
- */
254
- async request<T = unknown>(opts: RequestOptions): Promise<T> {
255
- const method = opts.method ?? 'GET';
256
- if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
257
- throw new TopoloConfirmationRequiredError(
258
- `Mutating ${method} requests require { confirm: true }. This guardrail prevents ` +
259
- `agents from issuing writes without an explicit human-in-the-loop acknowledgement.`,
260
- );
261
- }
262
-
263
- const resolvedService = opts.baseUrl
264
- ? { serviceKey: normalizeLookupKey(opts.service), baseUrl: requireHttpOrigin(opts.baseUrl) }
265
- : await this.resolveRequestService(opts.service);
266
- const baseUrl = resolvedService.baseUrl;
267
- const url = new URL(opts.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
268
- if (opts.query) {
269
- for (const [k, v] of Object.entries(opts.query)) {
270
- if (v !== undefined) url.searchParams.set(k, String(v));
271
- }
272
- }
273
-
274
- const headers = new Headers();
275
- headers.set('Accept', 'application/json');
276
- if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
277
- const platformAppId = resolveConfiguredAppId(resolvedService.serviceKey, this.appIds);
278
- if (platformAppId) headers.set('X-App-ID', platformAppId);
279
-
280
- applyAuthHeaders(headers, this.credential);
281
- const requestId = generateRequestId();
282
- applyAuditHeaders(headers, this.agent, requestId);
283
-
284
- if (opts.headers) {
285
- for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
286
- }
287
- if (opts.resource) {
288
- const resourceType = opts.resource.resourceType.trim();
289
- const resourceId = opts.resource.resourceId.trim();
290
- if (!resourceType || !resourceId) {
291
- throw new TopoloSdkError(
292
- 'invalid_resource_context',
293
- 'Both resource.resourceType and resource.resourceId are required when resource context is provided.',
294
- );
295
- }
296
- headers.set(TOPOLO_RESOURCE_TYPE_HEADER, resourceType);
297
- headers.set(TOPOLO_RESOURCE_ID_HEADER, resourceId);
298
- }
299
-
300
- const urlStr = url.toString();
301
- const startedAt = Date.now();
302
- this.emit({ phase: 'request', method, service: resolvedService.serviceKey, path: opts.path, url: urlStr, requestId });
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
-
322
- let res: Response;
323
- try {
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
- }
338
- } catch (err) {
339
- this.emit({
340
- phase: 'error',
341
- method,
342
- service: resolvedService.serviceKey,
343
- path: opts.path,
344
- url: urlStr,
345
- requestId,
346
- durationMs: Date.now() - startedAt,
347
- error: err instanceof Error ? err.message : String(err),
348
- });
349
- throw err;
350
- }
351
-
352
- const contentType = res.headers.get('Content-Type') ?? '';
353
- const parsed: unknown = contentType.includes('application/json')
354
- ? await res.json().catch(() => null)
355
- : await res.text().catch(() => null);
356
-
357
- if (!res.ok) {
358
- const durationMs = Date.now() - startedAt;
359
- const message = describeError(parsed, `HTTP ${res.status}`);
360
- this.emit({
361
- phase: 'error',
362
- method,
363
- service: resolvedService.serviceKey,
364
- path: opts.path,
365
- url: urlStr,
366
- requestId,
367
- durationMs,
368
- error: message,
369
- status: res.status,
370
- });
371
- if (res.status === 401) throw new TopoloAuthError(describeError(parsed, 'Unauthorized'));
372
- if (res.status === 403) {
373
- throw new TopoloPermissionError(
374
- describeError(parsed, 'Permission denied'),
375
- extractRequired(parsed),
376
- );
377
- }
378
- throw new TopoloHttpError(resolvedService.serviceKey, opts.path, res.status, parsed);
379
- }
380
-
381
- this.emit({
382
- phase: 'response',
383
- method,
384
- service: resolvedService.serviceKey,
385
- path: opts.path,
386
- url: urlStr,
387
- requestId,
388
- status: res.status,
389
- durationMs: Date.now() - startedAt,
390
- });
391
-
392
- return parsed as T;
393
- }
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
-
412
- private async resolveRequestService(service: string): Promise<{ serviceKey: string; baseUrl: string }> {
413
- const serviceKey = normalizeLookupKey(service);
414
- if (!serviceKey) {
415
- throw new TopoloSdkError('unknown_service', 'service is required');
416
- }
417
-
418
- // auth + developers are DIRECT services resolved without a catalog lookup:
419
- // developers serves the catalog itself, so resolving it via the catalog would
420
- // recurse infinitely.
421
- if (serviceKey === 'auth' || serviceKey === 'developers') {
422
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
423
- if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
424
- // The catalog host (developers) defaults to the SAME environment as auth.
425
- // Apps configure only `auth` per-env; derive developers from it (auth.X ->
426
- // developers.X) so the catalog cutover needs no per-app config change.
427
- if (serviceKey === 'developers') {
428
- const derived = deriveDevelopersFromAuth(resolveConfiguredServiceUrl('auth', this.serviceUrls));
429
- if (derived) return { serviceKey, baseUrl: derived };
430
- }
431
- return { serviceKey, baseUrl: DEFAULT_APP_URLS[serviceKey as 'auth' | 'developers'] };
432
- }
433
-
434
- const live = await this.resolveLiveCallableService(serviceKey);
435
- if (live) {
436
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
437
- return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
438
- }
439
-
440
- throw new TopoloSdkError(
441
- 'unknown_service',
442
- `Unknown Topolo app API "${service}". Run app discovery first and pass a callable app id or slug.`,
443
- );
444
- }
445
-
446
- private async resolveLiveCallableService(serviceKey: string): Promise<{ serviceKey: string; baseUrl: string } | null> {
447
- const match = await this.getService(serviceKey).catch(() => null);
448
- if (!match || !catalogEntryMatches(match, serviceKey)) return null;
449
- const baseUrl = resolveCatalogServiceUrl(match, {
450
- serviceKey,
451
- overrides: this.serviceUrls,
452
- });
453
- return baseUrl ? { serviceKey, baseUrl } : null;
454
- }
455
-
456
- /**
457
- * Resolve a service-slug → opaque app-id via caller config or TopoloAuth.
458
- *
459
- * Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
460
- * results (404) are cached for 60s so a typo doesn't hot-loop the
461
- * resolver. Returns null when the slug isn't registered — callers decide
462
- * whether to throw.
463
- *
464
- * No credential is sent: the endpoint is public (slugs aren't secret).
465
- * This keeps the resolver usable during early bootstrap before a caller
466
- * has acquired an access token.
467
- */
468
- async getAppIdBySlug(slug: string): Promise<string | null> {
469
- if (typeof slug !== 'string') return null;
470
- const normalized = slug.trim().toLowerCase();
471
- if (!normalized) return null;
472
-
473
- const configuredAppId = resolveConfiguredAppId(normalized, this.appIds);
474
- if (configuredAppId) return configuredAppId;
475
-
476
- // app-catalog greenfield: by-slug resolver lives on the Catalog (developers).
477
- const authBase = resolveServiceUrl('developers', this.serviceUrls);
478
- const cacheKey = `${authBase}|${normalized}`;
479
- const now = Date.now();
480
- const cached = TopoloClient.appIdCache.get(cacheKey);
481
- if (cached && cached.expiresAt > now) {
482
- return cached.value;
483
- }
484
-
485
- const url = new URL(
486
- `/api/services/by-slug/${encodeURIComponent(normalized)}`,
487
- authBase.endsWith('/') ? authBase : `${authBase}/`,
488
- ).toString();
489
-
490
- let res: Response;
491
- try {
492
- res = await this.fetchImpl(url, {
493
- method: 'GET',
494
- headers: { Accept: 'application/json' },
495
- });
496
- } catch {
497
- // Network-level failure — don't poison cache; let the caller retry.
498
- return null;
499
- }
500
-
501
- if (res.status === 404) {
502
- TopoloClient.appIdCache.set(cacheKey, {
503
- value: null,
504
- expiresAt: now + NEGATIVE_CACHE_TTL_MS,
505
- });
506
- return null;
507
- }
508
-
509
- if (!res.ok) {
510
- return null;
511
- }
512
-
513
- const body = (await res.json().catch(() => null)) as
514
- | { success?: boolean; data?: { app_id?: unknown } }
515
- | null;
516
- const appId = body?.data?.app_id;
517
- const value = typeof appId === 'string' && appId ? appId : null;
518
- TopoloClient.appIdCache.set(cacheKey, {
519
- value,
520
- expiresAt: now + POSITIVE_CACHE_TTL_MS,
521
- });
522
- return value;
523
- }
524
-
525
- /**
526
- * List the platform service catalog from the live registry
527
- * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
528
- * caller's credential is attached, so the result only includes services the
529
- * credential's organization and effective permissions can use.
530
- *
531
- * Security boundary: this never falls back to the generated global snapshot.
532
- * The live Auth catalog is the authority because it is scoped to the caller's
533
- * organization and effective permissions.
534
- */
535
- async searchServices(options: TopoloCatalogSearchOptions = {}): Promise<TopoloServiceCatalogPage> {
536
- // app-catalog greenfield: the catalog moved to the Catalog (developers)
537
- // service. Auth no longer owns the registry; it only provides scoping, which
538
- // the Catalog composes server-side. Consumers pass the per-env developers URL.
539
- const res = await this.request<
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
- });
548
- const raw =
549
- (res as { data?: { services?: unknown[] } }).data?.services ??
550
- (res as { services?: unknown[] }).services;
551
- if (!Array.isArray(raw)) {
552
- throw new TopoloSdkError(
553
- 'service_catalog_unavailable',
554
- 'TopoloAuth did not return a service catalog for the current credential.',
555
- );
556
- }
557
-
558
- const entries: TopoloServiceCatalogEntry[] = [];
559
- for (const item of raw) {
560
- if (!isRecord(item)) continue;
561
- const s = item;
562
- const appId =
563
- typeof s.app_id === 'string'
564
- ? s.app_id
565
- : typeof s.id === 'string'
566
- ? s.id
567
- : null;
568
- if (!appId) continue;
569
- const permissions = Array.isArray(s.granted_permissions)
570
- ? s.granted_permissions
571
- .filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
572
- : [];
573
- const catalogApiKeyResources = normalizeApiKeyResourceMetadataList(
574
- s.api_key_resources ?? s.apiKeyResources,
575
- );
576
- const apiKeyResource =
577
- normalizeApiKeyResourceMetadata(s.api_key_resource ?? s.apiKeyResource) ??
578
- catalogApiKeyResources[0] ??
579
- null;
580
- const apiKeyResources = catalogApiKeyResources.length > 0
581
- ? catalogApiKeyResources
582
- : apiKeyResource
583
- ? [apiKeyResource]
584
- : [];
585
- entries.push({
586
- appId,
587
- slug: typeof s.app_slug === 'string' ? s.app_slug : null,
588
- name: typeof s.name === 'string' ? s.name : null,
589
- description:
590
- typeof s.description === 'string' ? s.description : null,
591
- apiBaseUrl:
592
- typeof s.api_base_url === 'string'
593
- ? s.api_base_url
594
- : typeof s.apiBaseUrl === 'string'
595
- ? s.apiBaseUrl
596
- : null,
597
- launchUrl:
598
- typeof s.launch_url === 'string'
599
- ? s.launch_url
600
- : typeof s.launchUrl === 'string'
601
- ? s.launchUrl
602
- : null,
603
- status: typeof s.status === 'string' ? s.status : null,
604
- surfaceKind:
605
- typeof s.surface_kind === 'string' ? s.surface_kind : null,
606
- launchable: s.launchable === true,
607
- permissions,
608
- apiKeyResource,
609
- apiKeyResources,
610
- });
611
- }
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;
642
- }
643
-
644
- /**
645
- * List callable actions from the live registry-canonical catalog (definitions
646
- * from the Catalog registry, scoping from Auth). The response is scoped to the
647
- * caller's organization and effective permissions; no bundled/static app
648
- * action list is used as an authorization source.
649
- */
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');
655
- }
656
- const res = await this.request<
657
- | { data?: { actions?: unknown[]; page?: unknown } }
658
- | { actions?: unknown[]; page?: unknown }
659
- >({
660
- service: 'developers',
661
- method: 'GET',
662
- path: '/api/actions/catalog',
663
- query: { service: options.service, ...catalogSearchQuery(options) },
664
- });
665
- const raw =
666
- (res as { data?: { actions?: unknown[] } }).data?.actions ??
667
- (res as { actions?: unknown[] }).actions;
668
- if (!Array.isArray(raw)) {
669
- throw new TopoloSdkError(
670
- 'action_catalog_unavailable',
671
- 'TopoloAuth did not return an action catalog for the current credential.',
672
- );
673
- }
674
-
675
- const entries = raw
676
- .map((item) => normalizeActionCatalogEntry(item))
677
- .filter((entry): entry is TopoloActionCatalogEntry => Boolean(entry));
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');
690
- }
691
- return (await this.searchActions(options)).actions;
692
- }
693
-
694
- async getAction(action: string): Promise<TopoloActionCatalogEntry> {
695
- const actionId = action.trim();
696
- if (!actionId) {
697
- throw new TopoloSdkError(
698
- 'unknown_action',
699
- 'action id is required',
700
- );
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}".`);
715
- return match;
716
- }
717
-
718
- async callAction<T = unknown>(
719
- action: string,
720
- input: Record<string, unknown> = {},
721
- options: {
722
- confirm?: boolean;
723
- resource?: { resourceType: string; resourceId: string };
724
- } = {},
725
- ): Promise<T> {
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
- }
740
- const mapped = mapActionInput(entry, input);
741
- const service = entry.appSlug || entry.appId;
742
- return this.request<T>({
743
- service,
744
- ...(entry.apiBaseUrl ? { baseUrl: entry.apiBaseUrl } : {}),
745
- method: entry.method,
746
- path: mapped.path,
747
- ...(mapped.query ? { query: mapped.query } : {}),
748
- ...(mapped.body !== undefined ? { body: mapped.body } : {}),
749
- ...(options.resource ? { resource: options.resource } : {}),
750
- headers: {
751
- [TOPOLO_ACTION_ID_HEADER]: entry.actionId,
752
- [TOPOLO_ACTION_NAME_HEADER]: entry.name,
753
- },
754
- confirm: options.confirm === true,
755
- });
756
- }
757
-
758
- /**
759
- * Bundled generated registry for tooling that audits platform packaging, not
760
- * for deciding what the current credential may use.
761
- */
762
- listBundledServices(): TopoloServiceCatalogEntry[] {
763
- const apps = Object.values(APPLICATIONS) as ReadonlyArray<{
764
- id: string;
765
- name?: string;
766
- productionUrl?: string | null;
767
- }>;
768
- return apps.map((app) => ({
769
- appId: app.id,
770
- slug: app.id,
771
- name: app.name ?? app.id,
772
- description: null,
773
- apiBaseUrl: null,
774
- launchUrl: app.productionUrl ?? null,
775
- status: 'bundled',
776
- surfaceKind: 'application',
777
- launchable: Boolean(app.productionUrl),
778
- permissions: [],
779
- apiKeyResource: null,
780
- apiKeyResources: [],
781
- }));
782
- }
783
-
784
- /**
785
- * Introspect the current credential. Returns the caller's resolved identity,
786
- * organization, and permission set. Used by CLI `whoami` and by MCP to gate
787
- * advertised tools by scope.
788
- *
789
- * Both JWT access tokens and platform API keys are resolved via the unified
790
- * `GET /api/auth/me` endpoint on TopoloAuth, which does not require service
791
- * credentials. The caller's possession of the credential secret is proof.
792
- */
793
- async introspect(): Promise<CredentialIntrospection> {
794
- const res = await this.request<{ data: AuthMePayload } | AuthMePayload>({
795
- service: 'auth',
796
- method: 'GET',
797
- path: '/api/auth/me',
798
- });
799
-
800
- const payload: AuthMePayload = (res as { data?: AuthMePayload }).data ?? (res as AuthMePayload);
801
- const user = payload.user;
802
- const organization = payload.organization;
803
- const kind: CredentialIntrospection['kind'] =
804
- payload.credentialType === 'api_key' ? 'api_key' : 'access_token';
805
-
806
- return {
807
- kind,
808
- user: {
809
- id: user.id,
810
- email: user.email,
811
- name: user.name ?? null,
812
- role: user.role ?? (kind === 'api_key' ? 'service' : 'member'),
813
- permissions: payload.permissions ?? user.permissions ?? [],
814
- },
815
- organization: organization
816
- ? {
817
- id: organization.id,
818
- slug: organization.slug,
819
- name: organization.name ?? organization.slug,
820
- }
821
- : user.orgId && user.orgSlug
822
- ? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug }
823
- : null,
824
- };
825
- }
826
- }
827
-
828
- function resolveConfiguredAppId(
829
- slug: string,
830
- appIds?: Record<string, string>,
831
- ): string | null {
832
- for (const serviceKey of serviceKeyCandidates(slug)) {
833
- const configured = appIds?.[serviceKey as AppApiId];
834
- if (typeof configured === 'string' && configured.trim()) return configured.trim();
835
-
836
- const envKey = `TOPOLO_APP_ID_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
837
- const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
838
- if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
839
- }
840
-
841
- return null;
842
- }
843
-
844
- function normalizeApiKeyResourceMetadata(input: unknown): TopoloApiKeyResourceMetadata | null {
845
- if (!isRecord(input)) return null;
846
- const resourceType = readString(input.resourceType ?? input.resource_type);
847
- const label = readString(input.label);
848
- const emptyLabel = readString(input.emptyLabel ?? input.empty_label);
849
- const resourceConcept = readString(input.resourceConcept ?? input.resource_concept);
850
- const canonicalResourceType = readString(input.canonicalResourceType ?? input.canonical_resource_type);
851
- const defaultEntitlement = readString(input.defaultEntitlement ?? input.default_entitlement);
852
- const exceptionPolicy = readString(input.exceptionPolicy ?? input.exception_policy);
853
-
854
- if (
855
- !resourceType ||
856
- !label ||
857
- !emptyLabel ||
858
- resourceConcept !== 'workspace' ||
859
- canonicalResourceType !== 'workspace' ||
860
- defaultEntitlement !== 'enterprise' ||
861
- exceptionPolicy !== 'app_or_organization'
862
- ) {
863
- return null;
864
- }
865
-
866
- return {
867
- resourceType,
868
- label,
869
- emptyLabel,
870
- resourceConcept,
871
- canonicalResourceType,
872
- defaultEntitlement,
873
- exceptionPolicy,
874
- };
875
- }
876
-
877
- function normalizeApiKeyResourceMetadataList(input: unknown): TopoloApiKeyResourceMetadata[] {
878
- if (!Array.isArray(input)) return [];
879
- const resources: TopoloApiKeyResourceMetadata[] = [];
880
- const seenTypes = new Set<string>();
881
- for (const item of input) {
882
- const resource = normalizeApiKeyResourceMetadata(item);
883
- if (!resource || seenTypes.has(resource.resourceType)) continue;
884
- seenTypes.add(resource.resourceType);
885
- resources.push(resource);
886
- }
887
- return resources;
888
- }
889
-
890
- function readString(value: unknown): string {
891
- return typeof value === 'string' ? value.trim() : '';
892
- }
893
-
894
- function isRecord(value: unknown): value is Record<string, unknown> {
895
- return typeof value === 'object' && value !== null && !Array.isArray(value);
896
- }
897
-
898
- function resolveConfiguredServiceUrl(
899
- slug: string,
900
- serviceUrls?: Record<string, string>,
901
- ): string | null {
902
- for (const serviceKey of serviceKeyCandidates(slug)) {
903
- const configured = serviceUrls?.[serviceKey];
904
- if (typeof configured === 'string' && configured.trim()) return configured.trim();
905
-
906
- const envKey = `TOPOLO_SERVICE_URL_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
907
- const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
908
- if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
909
- }
910
-
911
- return null;
912
- }
913
-
914
- function serviceKeyCandidates(slug: string): string[] {
915
- const normalized = normalizeLookupKey(slug);
916
- const withoutTopolo = normalized.startsWith('topolo-')
917
- ? normalized.slice('topolo-'.length)
918
- : normalized;
919
- return [...new Set([
920
- normalized,
921
- normalized.replace(/-/g, '_'),
922
- withoutTopolo,
923
- withoutTopolo.replace(/-/g, '_'),
924
- ])];
925
- }
926
-
927
- function normalizeLookupKey(value: string): string {
928
- return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
929
- }
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
-
944
- /**
945
- * Derive the Catalog (developers) base URL from a configured auth base URL by
946
- * swapping the leading `auth` host label for `developers` (auth.stg.topolo.us ->
947
- * developers.stg.topolo.us). Returns null if the auth URL is absent or doesn't
948
- * start with the `auth.` label, so the caller falls back to the bundled default.
949
- */
950
- function deriveDevelopersFromAuth(authUrl: string | null | undefined): string | null {
951
- if (!authUrl) return null;
952
- try {
953
- const url = new URL(authUrl);
954
- if (!/^auth\./i.test(url.hostname)) return null;
955
- url.hostname = url.hostname.replace(/^auth\./i, 'developers.');
956
- return url.origin;
957
- } catch {
958
- return null;
959
- }
960
- }
961
-
962
- function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string): boolean {
963
- return catalogEntryLookupKeys(entry).includes(needle);
964
- }
965
-
966
- function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
967
- const values = [entry.appId, entry.slug, entry.name].filter((value): value is string => (
968
- typeof value === 'string' && value.trim().length > 0
969
- ));
970
- const keys = new Set<string>();
971
- for (const value of values) {
972
- const normalized = normalizeLookupKey(value);
973
- if (!normalized) continue;
974
- for (const candidate of serviceKeyCandidates(normalized)) {
975
- keys.add(candidate);
976
- }
977
- }
978
- return [...keys];
979
- }
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
-
1045
- function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry | null {
1046
- if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
1047
- const a = item as Record<string, unknown>;
1048
- const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
1049
- const name = pickActionString(a.name);
1050
- const appId = pickActionString(a.app_id) ?? pickActionString(a.appId);
1051
- const method = pickActionString(a.method)?.toUpperCase();
1052
- const path = pickActionString(a.path);
1053
- if (!actionId || !name || !appId || !method || !path || !isHttpMethod(method)) {
1054
- return null;
1055
- }
1056
-
1057
- return {
1058
- actionId,
1059
- target: pickActionString(a.target_key) ?? pickActionString(a.target) ?? null,
1060
- apiBaseUrl: pickActionString(a.api_base_url) ?? pickActionString(a.apiBaseUrl) ?? null,
1061
- name,
1062
- toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
1063
- title: pickActionString(a.title) ?? name,
1064
- description: pickActionString(a.description) ?? '',
1065
- appId,
1066
- appSlug: pickActionString(a.app_slug) ?? pickActionString(a.appSlug) ?? null,
1067
- appName: pickActionString(a.app_name) ?? pickActionString(a.appName) ?? null,
1068
- method,
1069
- path,
1070
- permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? '',
1071
- requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? '',
1072
- authorizationMode: pickAuthorizationMode(a.authorization_mode ?? a.authorizationMode),
1073
- inputSchema: plainObject(a.input_schema ?? a.inputSchema),
1074
- outputSchema: plainObject(a.output_schema ?? a.outputSchema),
1075
- readOnly: Boolean(a.read_only ?? a.readOnly),
1076
- destructive: Boolean(a.destructive),
1077
- requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
1078
- agentAccess: pickAgentAccess(a.agent_access ?? a.agentAccess),
1079
- status: pickActionString(a.status),
1080
- sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision),
1081
- };
1082
- }
1083
-
1084
- function isHttpMethod(value: string): value is TopoloActionCatalogEntry['method'] {
1085
- return value === 'GET' || value === 'POST' || value === 'PUT' || value === 'PATCH' || value === 'DELETE';
1086
- }
1087
-
1088
- function pickActionString(value: unknown): string | null {
1089
- return typeof value === 'string' && value.trim() ? value.trim() : null;
1090
- }
1091
-
1092
- function pickAgentAccess(value: unknown): 'auto' | 'confirm' | 'off' | null {
1093
- return value === 'auto' || value === 'confirm' || value === 'off' ? value : null;
1094
- }
1095
-
1096
- function pickAuthorizationMode(value: unknown): 'static' | 'runtime' {
1097
- return value === 'runtime' ? 'runtime' : 'static';
1098
- }
1099
-
1100
- function plainObject(value: unknown): Record<string, unknown> {
1101
- return value && typeof value === 'object' && !Array.isArray(value)
1102
- ? value as Record<string, unknown>
1103
- : {};
1104
- }
1105
-
1106
- function normalizeActionLookupKey(value: string): string {
1107
- return String(value || '').trim().toLowerCase();
1108
- }
1109
-
1110
- function actionEntryMatches(entry: TopoloActionCatalogEntry, needle: string): boolean {
1111
- if (!needle) return false;
1112
- return [entry.actionId, entry.name, entry.toolName]
1113
- .filter((value): value is string => typeof value === 'string' && value.length > 0)
1114
- .some((value) => value.toLowerCase() === needle);
1115
- }
1116
-
1117
- function mapActionInput(
1118
- action: TopoloActionCatalogEntry,
1119
- input: Record<string, unknown>,
1120
- ): { path: string; query?: Record<string, string | number | boolean>; body?: unknown } {
1121
- const remaining: Record<string, unknown> = { ...(input || {}) };
1122
- const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => {
1123
- const value = remaining[key];
1124
- if (value === undefined || value === null || value === '') {
1125
- throw new TopoloSdkError(
1126
- 'invalid_action_input',
1127
- `Action "${action.name}" requires path parameter "${key}".`,
1128
- );
1129
- }
1130
- delete remaining[key];
1131
- return encodeURIComponent(String(value));
1132
- });
1133
-
1134
- const explicitQuery = plainObject(remaining.query);
1135
- delete remaining.query;
1136
- const explicitBody = Object.prototype.hasOwnProperty.call(remaining, 'body')
1137
- ? remaining.body
1138
- : undefined;
1139
- delete remaining.body;
1140
-
1141
- if (action.method === 'GET') {
1142
- return {
1143
- path,
1144
- query: objectToQuery({ ...remaining, ...explicitQuery }),
1145
- };
1146
- }
1147
-
1148
- const query = objectToQuery(explicitQuery);
1149
- const body = explicitBody !== undefined ? explicitBody : remaining;
1150
- return {
1151
- path,
1152
- ...(query ? { query } : {}),
1153
- body,
1154
- };
1155
- }
1156
-
1157
- function objectToQuery(value: Record<string, unknown>): Record<string, string | number | boolean> | undefined {
1158
- const query: Record<string, string | number | boolean> = {};
1159
- for (const [key, raw] of Object.entries(value)) {
1160
- if (raw === undefined || raw === null) continue;
1161
- if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
1162
- query[key] = raw;
1163
- } else {
1164
- query[key] = JSON.stringify(raw);
1165
- }
1166
- }
1167
- return Object.keys(query).length > 0 ? query : undefined;
1168
- }
1169
-
1170
- interface AuthMePayload {
1171
- credentialType?: 'access_token' | 'api_key';
1172
- user: RawUser;
1173
- organization?: RawOrg | null;
1174
- permissions?: string[];
1175
- }
1176
-
1177
- export interface CredentialIntrospection {
1178
- kind: 'api_key' | 'access_token';
1179
- user: {
1180
- id: string;
1181
- email: string;
1182
- name: string | null;
1183
- role: string;
1184
- permissions: string[];
1185
- };
1186
- organization: {
1187
- id: string;
1188
- slug: string;
1189
- name: string;
1190
- } | null;
1191
- }
1192
-
1193
- interface RawUser {
1194
- id: string;
1195
- email: string;
1196
- name?: string;
1197
- role?: string;
1198
- permissions?: string[];
1199
- orgId?: string;
1200
- orgSlug?: string;
1201
- organization?: { id: string; slug: string; name: string } | null;
1202
- }
1203
-
1204
- interface RawOrg {
1205
- id: string;
1206
- slug: string;
1207
- name?: string;
1208
- }
1209
-
1210
- function describeError(body: unknown, fallback: string): string {
1211
- if (body && typeof body === 'object') {
1212
- const maybe = body as { error?: unknown; message?: unknown };
1213
- const resolved = pickString(maybe.message) ?? pickString(maybe.error);
1214
- if (resolved) return resolved;
1215
- }
1216
- return fallback;
1217
- }
1218
-
1219
- function pickString(value: unknown): string | undefined {
1220
- if (typeof value === 'string' && value.trim().length > 0) return value;
1221
- if (value && typeof value === 'object') {
1222
- const nested = value as { message?: unknown; description?: unknown };
1223
- if (typeof nested.message === 'string' && nested.message.trim().length > 0) return nested.message;
1224
- if (typeof nested.description === 'string' && nested.description.trim().length > 0)
1225
- return nested.description;
1226
- }
1227
- return undefined;
1228
- }
1229
-
1230
- function extractRequired(body: unknown): string[] {
1231
- if (body && typeof body === 'object') {
1232
- const maybe = body as { required?: unknown };
1233
- if (Array.isArray(maybe.required)) return maybe.required.map(String);
1234
- }
1235
- return [];
1236
- }
1237
-
1238
- function mergeSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
1239
- if (a.aborted) return a;
1240
- if (b.aborted) return b;
1241
- const controller = new AbortController();
1242
- const onAbort = () => controller.abort();
1243
- a.addEventListener('abort', onAbort, { once: true });
1244
- b.addEventListener('abort', onAbort, { once: true });
1245
- return controller.signal;
1246
- }