@topolo/sdk 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts DELETED
@@ -1,1025 +0,0 @@
1
- import {
2
- applyAuditHeaders,
3
- applyAuthHeaders,
4
- generateRequestId,
5
- type AgentIdentity,
6
- type TopoloCredential,
7
- } from './auth.js';
8
- import { TopoloAuthError, TopoloHttpError, TopoloPermissionError, TopoloSdkError } from './errors.js';
9
- import {
10
- DEFAULT_APP_URLS,
11
- APPLICATIONS,
12
- resolveCatalogServiceUrl,
13
- resolveServiceUrl,
14
- type AppApiId,
15
- } from './services.js';
16
-
17
- export interface TopoloClientOptions {
18
- credential: TopoloCredential;
19
- agent: AgentIdentity;
20
- /** Per-service URL overrides. Useful for staging/dev. */
21
- serviceUrls?: Record<string, string>;
22
- /** Per-service TopoloAuth app id overrides from the owning app/deployment. */
23
- appIds?: Record<string, string>;
24
- /**
25
- * When true, mutating helpers (POST/PUT/PATCH/DELETE) require callers to pass
26
- * `{ confirm: true }`. Defaults to true. Set false only for trusted surfaces.
27
- */
28
- requireConfirmForWrites?: boolean;
29
- /** HTTP request timeout (ms). Default 30s. */
30
- timeoutMs?: number;
31
- /** Injected fetch, for testing. Defaults to global fetch. */
32
- fetch?: typeof fetch;
33
- /**
34
- * Observability hook. Fires once per request lifecycle (`request` at start,
35
- * then exactly one of `response` or `error`). Safe to leave unset — off by
36
- * default. Intended for CLI/MCP hosts to surface request-level diagnostics
37
- * and for consumers building their own logging/tracing.
38
- */
39
- debug?: (event: TopoloDebugEvent) => void;
40
- }
41
-
42
- export type TopoloDebugEvent =
43
- | {
44
- phase: 'request';
45
- method: string;
46
- service: string;
47
- path: string;
48
- url: string;
49
- requestId: string;
50
- }
51
- | {
52
- phase: 'response';
53
- method: string;
54
- service: string;
55
- path: string;
56
- url: string;
57
- requestId: string;
58
- status: number;
59
- durationMs: number;
60
- }
61
- | {
62
- phase: 'error';
63
- method: string;
64
- service: string;
65
- path: string;
66
- url: string;
67
- requestId: string;
68
- durationMs: number;
69
- error: string;
70
- status?: number;
71
- };
72
-
73
- export interface RequestOptions {
74
- service: string;
75
- path: string;
76
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
77
- query?: Record<string, string | number | boolean | undefined>;
78
- body?: unknown;
79
- /** Workspace-boundary resource context for resource-scoped API keys. */
80
- resource?: {
81
- resourceType: string;
82
- resourceId: string;
83
- };
84
- /** Explicit write-acknowledgement for mutating calls. */
85
- confirm?: boolean;
86
- /** Extra headers appended after auth/audit headers. */
87
- headers?: Record<string, string>;
88
- signal?: AbortSignal;
89
- }
90
-
91
- const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
92
- const TOPOLO_ACTION_ID_HEADER = 'X-Topolo-Action-Id';
93
- const TOPOLO_ACTION_NAME_HEADER = 'X-Topolo-Action-Name';
94
- const TOPOLO_RESOURCE_TYPE_HEADER = 'X-Topolo-Resource-Type';
95
- const TOPOLO_RESOURCE_ID_HEADER = 'X-Topolo-Resource-ID';
96
-
97
- const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
98
- const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
99
- const CATALOG_CACHE_TTL_MS = 60 * 1000; // 1 minute
100
-
101
- interface AppIdCacheEntry {
102
- value: string | null;
103
- expiresAt: number;
104
- }
105
-
106
- /**
107
- * A single entry from the live platform service catalog. Returned by
108
- * {@link TopoloClient.listServices}. Shape mirrors the public-safe projection
109
- * from TopoloAuth `GET /api/services/catalog`.
110
- */
111
- export interface TopoloServiceCatalogEntry {
112
- appId: string;
113
- slug: string | null;
114
- name: string | null;
115
- description: string | null;
116
- apiBaseUrl?: string | null;
117
- launchUrl?: string | null;
118
- status: string | null;
119
- surfaceKind: string | null;
120
- launchable: boolean;
121
- permissions: string[];
122
- apiKeyResource: TopoloApiKeyResourceMetadata | null;
123
- apiKeyResources: TopoloApiKeyResourceMetadata[];
124
- }
125
-
126
- export interface TopoloApiKeyResourceMetadata {
127
- resourceType: string;
128
- label: string;
129
- emptyLabel: string;
130
- resourceConcept: 'workspace';
131
- canonicalResourceType: 'workspace';
132
- defaultEntitlement: 'enterprise';
133
- exceptionPolicy: 'app_or_organization';
134
- }
135
-
136
- export interface TopoloActionCatalogEntry {
137
- actionId: string;
138
- name: string;
139
- toolName: string | null;
140
- title: string;
141
- description: string;
142
- appId: string;
143
- appSlug: string | null;
144
- appName: string | null;
145
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
146
- path: string;
147
- permissionName: string;
148
- requiredPermission: string;
149
- inputSchema: Record<string, unknown>;
150
- outputSchema: Record<string, unknown>;
151
- readOnly: boolean;
152
- destructive: boolean;
153
- requiresConfirmation: boolean;
154
- /** Agent access tier. null/absent when the catalog does not specify one (consumers derive a default). */
155
- agentAccess?: 'auto' | 'confirm' | 'off' | null;
156
- status: string | null;
157
- sourceRevision: string | null;
158
- }
159
-
160
- export class TopoloClient {
161
- private readonly credential: TopoloCredential;
162
- private readonly agent: AgentIdentity;
163
- private readonly serviceUrls?: Record<string, string>;
164
- private readonly appIds?: Record<string, string>;
165
- private readonly requireConfirmForWrites: boolean;
166
- private readonly timeoutMs: number;
167
- private readonly fetchImpl: typeof fetch;
168
- 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
-
172
- /**
173
- * Process-wide cache for slug → app-id lookups. Keyed by
174
- * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
175
- * the same Auth share, but staging vs prod don't poison each other.
176
- * Cleared via `TopoloClient.clearAppIdCache()` if a test needs it.
177
- */
178
- private static readonly appIdCache = new Map<string, AppIdCacheEntry>();
179
-
180
- /**
181
- * Drop the slug→app-id cache. Test-only — production callers should
182
- * let the 5-minute TTL expire naturally.
183
- */
184
- static clearAppIdCache(): void {
185
- TopoloClient.appIdCache.clear();
186
- }
187
-
188
- constructor(options: TopoloClientOptions) {
189
- if (!options.credential) throw new TopoloAuthError('credential is required');
190
- if (!options.agent?.clientName) throw new TopoloAuthError('agent.clientName is required');
191
- this.credential = options.credential;
192
- this.agent = options.agent;
193
- this.serviceUrls = options.serviceUrls;
194
- this.appIds = options.appIds;
195
- this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
196
- this.timeoutMs = options.timeoutMs ?? 30_000;
197
- this.fetchImpl = options.fetch ?? fetch;
198
- this.debug = options.debug;
199
- }
200
-
201
- private emit(event: TopoloDebugEvent): void {
202
- if (!this.debug) return;
203
- try {
204
- this.debug(event);
205
- } catch {
206
- // Never let a debug consumer crash the request path.
207
- }
208
- }
209
-
210
- /**
211
- * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
212
- * for anything a caller would reach for; this stays exported for escape-hatch
213
- * use and for the generic `topolo api` / MCP passthrough tool.
214
- */
215
- async request<T = unknown>(opts: RequestOptions): Promise<T> {
216
- const method = opts.method ?? 'GET';
217
- if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
218
- throw new TopoloAuthError(
219
- `Mutating ${method} requests require { confirm: true }. This guardrail prevents ` +
220
- `agents from issuing writes without an explicit human-in-the-loop acknowledgement.`,
221
- );
222
- }
223
-
224
- const resolvedService = await this.resolveRequestService(opts.service);
225
- const baseUrl = resolvedService.baseUrl;
226
- const url = new URL(opts.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
227
- if (opts.query) {
228
- for (const [k, v] of Object.entries(opts.query)) {
229
- if (v !== undefined) url.searchParams.set(k, String(v));
230
- }
231
- }
232
-
233
- const headers = new Headers();
234
- headers.set('Accept', 'application/json');
235
- if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
236
- const platformAppId = resolveConfiguredAppId(resolvedService.serviceKey, this.appIds);
237
- if (platformAppId) headers.set('X-App-ID', platformAppId);
238
-
239
- applyAuthHeaders(headers, this.credential);
240
- const requestId = generateRequestId();
241
- applyAuditHeaders(headers, this.agent, requestId);
242
-
243
- if (opts.headers) {
244
- for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
245
- }
246
- if (opts.resource) {
247
- const resourceType = opts.resource.resourceType.trim();
248
- const resourceId = opts.resource.resourceId.trim();
249
- if (!resourceType || !resourceId) {
250
- throw new TopoloSdkError(
251
- 'invalid_resource_context',
252
- 'Both resource.resourceType and resource.resourceId are required when resource context is provided.',
253
- );
254
- }
255
- headers.set(TOPOLO_RESOURCE_TYPE_HEADER, resourceType);
256
- headers.set(TOPOLO_RESOURCE_ID_HEADER, resourceId);
257
- }
258
-
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
- const urlStr = url.toString();
266
- const startedAt = Date.now();
267
- this.emit({ phase: 'request', method, service: resolvedService.serviceKey, path: opts.path, url: urlStr, requestId });
268
-
269
- let res: Response;
270
- try {
271
- res = await this.fetchImpl(urlStr, {
272
- method,
273
- headers,
274
- body: opts.body !== undefined ? JSON.stringify(opts.body) : null,
275
- signal,
276
- });
277
- } catch (err) {
278
- this.emit({
279
- phase: 'error',
280
- method,
281
- service: resolvedService.serviceKey,
282
- path: opts.path,
283
- url: urlStr,
284
- requestId,
285
- durationMs: Date.now() - startedAt,
286
- error: err instanceof Error ? err.message : String(err),
287
- });
288
- throw err;
289
- } finally {
290
- clearTimeout(timeoutId);
291
- }
292
-
293
- const contentType = res.headers.get('Content-Type') ?? '';
294
- const parsed: unknown = contentType.includes('application/json')
295
- ? await res.json().catch(() => null)
296
- : await res.text().catch(() => null);
297
-
298
- if (!res.ok) {
299
- const durationMs = Date.now() - startedAt;
300
- const message = describeError(parsed, `HTTP ${res.status}`);
301
- this.emit({
302
- phase: 'error',
303
- method,
304
- service: resolvedService.serviceKey,
305
- path: opts.path,
306
- url: urlStr,
307
- requestId,
308
- durationMs,
309
- error: message,
310
- status: res.status,
311
- });
312
- if (res.status === 401) throw new TopoloAuthError(describeError(parsed, 'Unauthorized'));
313
- if (res.status === 403) {
314
- throw new TopoloPermissionError(
315
- describeError(parsed, 'Permission denied'),
316
- extractRequired(parsed),
317
- );
318
- }
319
- throw new TopoloHttpError(resolvedService.serviceKey, opts.path, res.status, parsed);
320
- }
321
-
322
- this.emit({
323
- phase: 'response',
324
- method,
325
- service: resolvedService.serviceKey,
326
- path: opts.path,
327
- url: urlStr,
328
- requestId,
329
- status: res.status,
330
- durationMs: Date.now() - startedAt,
331
- });
332
-
333
- return parsed as T;
334
- }
335
-
336
- private async resolveRequestService(service: string): Promise<{ serviceKey: string; baseUrl: string }> {
337
- const serviceKey = normalizeLookupKey(service);
338
- if (!serviceKey) {
339
- throw new TopoloSdkError('unknown_service', 'service is required');
340
- }
341
-
342
- // auth + developers are DIRECT services resolved without a catalog lookup:
343
- // developers serves the catalog itself, so resolving it via the catalog would
344
- // recurse infinitely.
345
- if (serviceKey === 'auth' || serviceKey === 'developers') {
346
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
347
- if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
348
- // The catalog host (developers) defaults to the SAME environment as auth.
349
- // Apps configure only `auth` per-env; derive developers from it (auth.X ->
350
- // developers.X) so the catalog cutover needs no per-app config change.
351
- if (serviceKey === 'developers') {
352
- const derived = deriveDevelopersFromAuth(resolveConfiguredServiceUrl('auth', this.serviceUrls));
353
- if (derived) return { serviceKey, baseUrl: derived };
354
- }
355
- return { serviceKey, baseUrl: DEFAULT_APP_URLS[serviceKey as 'auth' | 'developers'] };
356
- }
357
-
358
- const live = await this.resolveLiveCallableService(serviceKey);
359
- if (live) {
360
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
361
- return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
362
- }
363
-
364
- throw new TopoloSdkError(
365
- 'unknown_service',
366
- `Unknown Topolo app API "${service}". Run app discovery first and pass a callable app id or slug.`,
367
- );
368
- }
369
-
370
- 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;
374
- const baseUrl = resolveCatalogServiceUrl(match, {
375
- serviceKey,
376
- overrides: this.serviceUrls,
377
- });
378
- return baseUrl ? { serviceKey, baseUrl } : null;
379
- }
380
-
381
- /**
382
- * Resolve a service-slug → opaque app-id via caller config or TopoloAuth.
383
- *
384
- * Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
385
- * results (404) are cached for 60s so a typo doesn't hot-loop the
386
- * resolver. Returns null when the slug isn't registered — callers decide
387
- * whether to throw.
388
- *
389
- * No credential is sent: the endpoint is public (slugs aren't secret).
390
- * This keeps the resolver usable during early bootstrap before a caller
391
- * has acquired an access token.
392
- */
393
- async getAppIdBySlug(slug: string): Promise<string | null> {
394
- if (typeof slug !== 'string') return null;
395
- const normalized = slug.trim().toLowerCase();
396
- if (!normalized) return null;
397
-
398
- const configuredAppId = resolveConfiguredAppId(normalized, this.appIds);
399
- if (configuredAppId) return configuredAppId;
400
-
401
- // app-catalog greenfield: by-slug resolver lives on the Catalog (developers).
402
- const authBase = resolveServiceUrl('developers', this.serviceUrls);
403
- const cacheKey = `${authBase}|${normalized}`;
404
- const now = Date.now();
405
- const cached = TopoloClient.appIdCache.get(cacheKey);
406
- if (cached && cached.expiresAt > now) {
407
- return cached.value;
408
- }
409
-
410
- const url = new URL(
411
- `/api/services/by-slug/${encodeURIComponent(normalized)}`,
412
- authBase.endsWith('/') ? authBase : `${authBase}/`,
413
- ).toString();
414
-
415
- let res: Response;
416
- try {
417
- res = await this.fetchImpl(url, {
418
- method: 'GET',
419
- headers: { Accept: 'application/json' },
420
- });
421
- } catch {
422
- // Network-level failure — don't poison cache; let the caller retry.
423
- return null;
424
- }
425
-
426
- if (res.status === 404) {
427
- TopoloClient.appIdCache.set(cacheKey, {
428
- value: null,
429
- expiresAt: now + NEGATIVE_CACHE_TTL_MS,
430
- });
431
- return null;
432
- }
433
-
434
- if (!res.ok) {
435
- return null;
436
- }
437
-
438
- const body = (await res.json().catch(() => null)) as
439
- | { success?: boolean; data?: { app_id?: unknown } }
440
- | null;
441
- const appId = body?.data?.app_id;
442
- const value = typeof appId === 'string' && appId ? appId : null;
443
- TopoloClient.appIdCache.set(cacheKey, {
444
- value,
445
- expiresAt: now + POSITIVE_CACHE_TTL_MS,
446
- });
447
- return value;
448
- }
449
-
450
- /**
451
- * List the platform service catalog from the live registry
452
- * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
453
- * caller's credential is attached, so the result only includes services the
454
- * credential's organization and effective permissions can use.
455
- *
456
- * Security boundary: this never falls back to the generated global snapshot.
457
- * The live Auth catalog is the authority because it is scoped to the caller's
458
- * organization and effective permissions.
459
- */
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
-
466
- // app-catalog greenfield: the catalog moved to the Catalog (developers)
467
- // service. Auth no longer owns the registry; it only provides scoping, which
468
- // the Catalog composes server-side. Consumers pass the per-env developers URL.
469
- const res = await this.request<
470
- | { data?: { services?: unknown[] } }
471
- | { services?: unknown[] }
472
- >({ service: 'developers', method: 'GET', path: '/api/services/catalog' });
473
- const raw =
474
- (res as { data?: { services?: unknown[] } }).data?.services ??
475
- (res as { services?: unknown[] }).services;
476
- if (!Array.isArray(raw)) {
477
- throw new TopoloSdkError(
478
- 'service_catalog_unavailable',
479
- 'TopoloAuth did not return a service catalog for the current credential.',
480
- );
481
- }
482
-
483
- const entries: TopoloServiceCatalogEntry[] = [];
484
- for (const item of raw) {
485
- const s = item as Record<string, unknown>;
486
- const appId =
487
- typeof s.app_id === 'string'
488
- ? s.app_id
489
- : typeof s.id === 'string'
490
- ? s.id
491
- : null;
492
- if (!appId) continue;
493
- const permissions = Array.isArray(s.granted_permissions)
494
- ? s.granted_permissions
495
- .filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
496
- : [];
497
- const catalogApiKeyResources = normalizeApiKeyResourceMetadataList(
498
- s.api_key_resources ?? s.apiKeyResources,
499
- );
500
- const apiKeyResource =
501
- normalizeApiKeyResourceMetadata(s.api_key_resource ?? s.apiKeyResource) ??
502
- catalogApiKeyResources[0] ??
503
- null;
504
- const apiKeyResources = catalogApiKeyResources.length > 0
505
- ? catalogApiKeyResources
506
- : apiKeyResource
507
- ? [apiKeyResource]
508
- : [];
509
- entries.push({
510
- appId,
511
- slug: typeof s.app_slug === 'string' ? s.app_slug : null,
512
- name: typeof s.name === 'string' ? s.name : null,
513
- description:
514
- typeof s.description === 'string' ? s.description : null,
515
- apiBaseUrl:
516
- typeof s.api_base_url === 'string'
517
- ? s.api_base_url
518
- : typeof s.apiBaseUrl === 'string'
519
- ? s.apiBaseUrl
520
- : null,
521
- launchUrl:
522
- typeof s.launch_url === 'string'
523
- ? s.launch_url
524
- : typeof s.launchUrl === 'string'
525
- ? s.launchUrl
526
- : null,
527
- status: typeof s.status === 'string' ? s.status : null,
528
- surfaceKind:
529
- typeof s.surface_kind === 'string' ? s.surface_kind : null,
530
- launchable: s.launchable === true,
531
- permissions,
532
- apiKeyResource,
533
- apiKeyResources,
534
- });
535
- }
536
- this.serviceCatalogCache = {
537
- entries,
538
- expiresAt: now + CATALOG_CACHE_TTL_MS,
539
- };
540
- return entries;
541
- }
542
-
543
- /**
544
- * List callable actions from the live registry-canonical catalog (definitions
545
- * from the Catalog registry, scoping from Auth). The response is scoped to the
546
- * caller's organization and effective permissions; no bundled/static app
547
- * action list is used as an authorization source.
548
- */
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;
553
- }
554
-
555
- const res = await this.request<
556
- | { data?: { actions?: unknown[] } }
557
- | { actions?: unknown[] }
558
- >({
559
- service: 'developers',
560
- method: 'GET',
561
- path: '/api/actions/catalog',
562
- ...(options.service ? { query: { service: options.service } } : {}),
563
- });
564
- const raw =
565
- (res as { data?: { actions?: unknown[] } }).data?.actions ??
566
- (res as { actions?: unknown[] }).actions;
567
- if (!Array.isArray(raw)) {
568
- throw new TopoloSdkError(
569
- 'action_catalog_unavailable',
570
- 'TopoloAuth did not return an action catalog for the current credential.',
571
- );
572
- }
573
-
574
- const entries = raw
575
- .map((item) => normalizeActionCatalogEntry(item))
576
- .filter((entry): entry is TopoloActionCatalogEntry => Boolean(entry));
577
- if (!options.service) {
578
- this.actionCatalogCache = {
579
- entries,
580
- expiresAt: now + CATALOG_CACHE_TTL_MS,
581
- };
582
- }
583
- return entries;
584
- }
585
-
586
- 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) {
591
- throw new TopoloSdkError(
592
- 'unknown_action',
593
- `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`,
594
- );
595
- }
596
- return match;
597
- }
598
-
599
- async callAction<T = unknown>(
600
- action: string,
601
- input: Record<string, unknown> = {},
602
- options: { confirm?: boolean } = {},
603
- ): Promise<T> {
604
- const entry = await this.getAction(action);
605
- const mapped = mapActionInput(entry, input);
606
- const service = entry.appSlug || entry.appId;
607
- return this.request<T>({
608
- service,
609
- method: entry.method,
610
- path: mapped.path,
611
- ...(mapped.query ? { query: mapped.query } : {}),
612
- ...(mapped.body !== undefined ? { body: mapped.body } : {}),
613
- headers: {
614
- [TOPOLO_ACTION_ID_HEADER]: entry.actionId,
615
- [TOPOLO_ACTION_NAME_HEADER]: entry.name,
616
- },
617
- confirm: options.confirm === true,
618
- });
619
- }
620
-
621
- /**
622
- * Bundled generated registry for tooling that audits platform packaging, not
623
- * for deciding what the current credential may use.
624
- */
625
- listBundledServices(): TopoloServiceCatalogEntry[] {
626
- const apps = Object.values(APPLICATIONS) as ReadonlyArray<{
627
- id: string;
628
- name?: string;
629
- productionUrl?: string | null;
630
- }>;
631
- return apps.map((app) => ({
632
- appId: app.id,
633
- slug: app.id,
634
- name: app.name ?? app.id,
635
- description: null,
636
- apiBaseUrl: null,
637
- launchUrl: app.productionUrl ?? null,
638
- status: 'bundled',
639
- surfaceKind: 'application',
640
- launchable: Boolean(app.productionUrl),
641
- permissions: [],
642
- apiKeyResource: null,
643
- apiKeyResources: [],
644
- }));
645
- }
646
-
647
- /**
648
- * Introspect the current credential. Returns the caller's resolved identity,
649
- * organization, and permission set. Used by CLI `whoami` and by MCP to gate
650
- * advertised tools by scope.
651
- *
652
- * Both JWT access tokens and platform API keys are resolved via the unified
653
- * `GET /api/auth/me` endpoint on TopoloAuth, which does not require service
654
- * credentials. The caller's possession of the credential secret is proof.
655
- */
656
- async introspect(): Promise<CredentialIntrospection> {
657
- const res = await this.request<{ data: AuthMePayload } | AuthMePayload>({
658
- service: 'auth',
659
- method: 'GET',
660
- path: '/api/auth/me',
661
- });
662
-
663
- const payload: AuthMePayload = (res as { data?: AuthMePayload }).data ?? (res as AuthMePayload);
664
- const user = payload.user;
665
- const organization = payload.organization;
666
- const kind: CredentialIntrospection['kind'] =
667
- payload.credentialType === 'api_key' ? 'api_key' : 'access_token';
668
-
669
- return {
670
- kind,
671
- user: {
672
- id: user.id,
673
- email: user.email,
674
- name: user.name ?? null,
675
- role: user.role ?? (kind === 'api_key' ? 'service' : 'member'),
676
- permissions: payload.permissions ?? user.permissions ?? [],
677
- },
678
- organization: organization
679
- ? {
680
- id: organization.id,
681
- slug: organization.slug,
682
- name: organization.name ?? organization.slug,
683
- }
684
- : user.orgId && user.orgSlug
685
- ? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug }
686
- : null,
687
- };
688
- }
689
- }
690
-
691
- function resolveConfiguredAppId(
692
- slug: string,
693
- appIds?: Record<string, string>,
694
- ): string | null {
695
- for (const serviceKey of serviceKeyCandidates(slug)) {
696
- const configured = appIds?.[serviceKey as AppApiId];
697
- if (typeof configured === 'string' && configured.trim()) return configured.trim();
698
-
699
- const envKey = `TOPOLO_APP_ID_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
700
- const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
701
- if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
702
- }
703
-
704
- return null;
705
- }
706
-
707
- function normalizeApiKeyResourceMetadata(input: unknown): TopoloApiKeyResourceMetadata | null {
708
- if (!isRecord(input)) return null;
709
- const resourceType = readString(input.resourceType ?? input.resource_type);
710
- const label = readString(input.label);
711
- const emptyLabel = readString(input.emptyLabel ?? input.empty_label);
712
- const resourceConcept = readString(input.resourceConcept ?? input.resource_concept);
713
- const canonicalResourceType = readString(input.canonicalResourceType ?? input.canonical_resource_type);
714
- const defaultEntitlement = readString(input.defaultEntitlement ?? input.default_entitlement);
715
- const exceptionPolicy = readString(input.exceptionPolicy ?? input.exception_policy);
716
-
717
- if (
718
- !resourceType ||
719
- !label ||
720
- !emptyLabel ||
721
- resourceConcept !== 'workspace' ||
722
- canonicalResourceType !== 'workspace' ||
723
- defaultEntitlement !== 'enterprise' ||
724
- exceptionPolicy !== 'app_or_organization'
725
- ) {
726
- return null;
727
- }
728
-
729
- return {
730
- resourceType,
731
- label,
732
- emptyLabel,
733
- resourceConcept,
734
- canonicalResourceType,
735
- defaultEntitlement,
736
- exceptionPolicy,
737
- };
738
- }
739
-
740
- function normalizeApiKeyResourceMetadataList(input: unknown): TopoloApiKeyResourceMetadata[] {
741
- if (!Array.isArray(input)) return [];
742
- const resources: TopoloApiKeyResourceMetadata[] = [];
743
- const seenTypes = new Set<string>();
744
- for (const item of input) {
745
- const resource = normalizeApiKeyResourceMetadata(item);
746
- if (!resource || seenTypes.has(resource.resourceType)) continue;
747
- seenTypes.add(resource.resourceType);
748
- resources.push(resource);
749
- }
750
- return resources;
751
- }
752
-
753
- function readString(value: unknown): string {
754
- return typeof value === 'string' ? value.trim() : '';
755
- }
756
-
757
- function isRecord(value: unknown): value is Record<string, unknown> {
758
- return typeof value === 'object' && value !== null && !Array.isArray(value);
759
- }
760
-
761
- function resolveConfiguredServiceUrl(
762
- slug: string,
763
- serviceUrls?: Record<string, string>,
764
- ): string | null {
765
- for (const serviceKey of serviceKeyCandidates(slug)) {
766
- const configured = serviceUrls?.[serviceKey];
767
- if (typeof configured === 'string' && configured.trim()) return configured.trim();
768
-
769
- const envKey = `TOPOLO_SERVICE_URL_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
770
- const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
771
- if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
772
- }
773
-
774
- return null;
775
- }
776
-
777
- function serviceKeyCandidates(slug: string): string[] {
778
- const normalized = normalizeLookupKey(slug);
779
- const withoutTopolo = normalized.startsWith('topolo-')
780
- ? normalized.slice('topolo-'.length)
781
- : normalized;
782
- return [...new Set([
783
- normalized,
784
- normalized.replace(/-/g, '_'),
785
- withoutTopolo,
786
- withoutTopolo.replace(/-/g, '_'),
787
- ])];
788
- }
789
-
790
- function normalizeLookupKey(value: string): string {
791
- return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
792
- }
793
-
794
- /**
795
- * Derive the Catalog (developers) base URL from a configured auth base URL by
796
- * swapping the leading `auth` host label for `developers` (auth.stg.topolo.us ->
797
- * developers.stg.topolo.us). Returns null if the auth URL is absent or doesn't
798
- * start with the `auth.` label, so the caller falls back to the bundled default.
799
- */
800
- function deriveDevelopersFromAuth(authUrl: string | null | undefined): string | null {
801
- if (!authUrl) return null;
802
- try {
803
- const url = new URL(authUrl);
804
- if (!/^auth\./i.test(url.hostname)) return null;
805
- url.hostname = url.hostname.replace(/^auth\./i, 'developers.');
806
- return url.origin;
807
- } catch {
808
- return null;
809
- }
810
- }
811
-
812
- function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string): boolean {
813
- return catalogEntryLookupKeys(entry).includes(needle);
814
- }
815
-
816
- function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
817
- const values = [entry.appId, entry.slug, entry.name].filter((value): value is string => (
818
- typeof value === 'string' && value.trim().length > 0
819
- ));
820
- const keys = new Set<string>();
821
- for (const value of values) {
822
- const normalized = normalizeLookupKey(value);
823
- if (!normalized) continue;
824
- for (const candidate of serviceKeyCandidates(normalized)) {
825
- keys.add(candidate);
826
- }
827
- }
828
- return [...keys];
829
- }
830
-
831
- function normalizeActionCatalogEntry(item: unknown): TopoloActionCatalogEntry | null {
832
- if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
833
- const a = item as Record<string, unknown>;
834
- const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
835
- const name = pickActionString(a.name);
836
- const appId = pickActionString(a.app_id) ?? pickActionString(a.appId);
837
- const method = pickActionString(a.method)?.toUpperCase();
838
- const path = pickActionString(a.path);
839
- if (!actionId || !name || !appId || !method || !path || !isHttpMethod(method)) {
840
- return null;
841
- }
842
-
843
- return {
844
- actionId,
845
- name,
846
- toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
847
- title: pickActionString(a.title) ?? name,
848
- description: pickActionString(a.description) ?? '',
849
- appId,
850
- appSlug: pickActionString(a.app_slug) ?? pickActionString(a.appSlug) ?? null,
851
- appName: pickActionString(a.app_name) ?? pickActionString(a.appName) ?? null,
852
- method,
853
- path,
854
- permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? '',
855
- requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? '',
856
- inputSchema: plainObject(a.input_schema ?? a.inputSchema),
857
- outputSchema: plainObject(a.output_schema ?? a.outputSchema),
858
- readOnly: Boolean(a.read_only ?? a.readOnly),
859
- destructive: Boolean(a.destructive),
860
- requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
861
- agentAccess: pickAgentAccess(a.agent_access ?? a.agentAccess),
862
- status: pickActionString(a.status),
863
- sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision),
864
- };
865
- }
866
-
867
- function isHttpMethod(value: string): value is TopoloActionCatalogEntry['method'] {
868
- return value === 'GET' || value === 'POST' || value === 'PUT' || value === 'PATCH' || value === 'DELETE';
869
- }
870
-
871
- function pickActionString(value: unknown): string | null {
872
- return typeof value === 'string' && value.trim() ? value.trim() : null;
873
- }
874
-
875
- function pickAgentAccess(value: unknown): 'auto' | 'confirm' | 'off' | null {
876
- return value === 'auto' || value === 'confirm' || value === 'off' ? value : null;
877
- }
878
-
879
- function plainObject(value: unknown): Record<string, unknown> {
880
- return value && typeof value === 'object' && !Array.isArray(value)
881
- ? value as Record<string, unknown>
882
- : {};
883
- }
884
-
885
- function normalizeActionLookupKey(value: string): string {
886
- return String(value || '').trim().toLowerCase();
887
- }
888
-
889
- function actionEntryMatches(entry: TopoloActionCatalogEntry, needle: string): boolean {
890
- if (!needle) return false;
891
- return [entry.actionId, entry.name, entry.toolName]
892
- .filter((value): value is string => typeof value === 'string' && value.length > 0)
893
- .some((value) => value.toLowerCase() === needle);
894
- }
895
-
896
- function mapActionInput(
897
- action: TopoloActionCatalogEntry,
898
- input: Record<string, unknown>,
899
- ): { path: string; query?: Record<string, string | number | boolean>; body?: unknown } {
900
- const remaining: Record<string, unknown> = { ...(input || {}) };
901
- const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => {
902
- const value = remaining[key];
903
- if (value === undefined || value === null || value === '') {
904
- throw new TopoloSdkError(
905
- 'invalid_action_input',
906
- `Action "${action.name}" requires path parameter "${key}".`,
907
- );
908
- }
909
- delete remaining[key];
910
- return encodeURIComponent(String(value));
911
- });
912
-
913
- const explicitQuery = plainObject(remaining.query);
914
- delete remaining.query;
915
- const explicitBody = Object.prototype.hasOwnProperty.call(remaining, 'body')
916
- ? remaining.body
917
- : undefined;
918
- delete remaining.body;
919
-
920
- if (action.method === 'GET') {
921
- return {
922
- path,
923
- query: objectToQuery({ ...remaining, ...explicitQuery }),
924
- };
925
- }
926
-
927
- const query = objectToQuery(explicitQuery);
928
- const body = explicitBody !== undefined ? explicitBody : remaining;
929
- return {
930
- path,
931
- ...(query ? { query } : {}),
932
- body,
933
- };
934
- }
935
-
936
- function objectToQuery(value: Record<string, unknown>): Record<string, string | number | boolean> | undefined {
937
- const query: Record<string, string | number | boolean> = {};
938
- for (const [key, raw] of Object.entries(value)) {
939
- if (raw === undefined || raw === null) continue;
940
- if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
941
- query[key] = raw;
942
- } else {
943
- query[key] = JSON.stringify(raw);
944
- }
945
- }
946
- return Object.keys(query).length > 0 ? query : undefined;
947
- }
948
-
949
- interface AuthMePayload {
950
- credentialType?: 'access_token' | 'api_key';
951
- user: RawUser;
952
- organization?: RawOrg | null;
953
- permissions?: string[];
954
- }
955
-
956
- export interface CredentialIntrospection {
957
- kind: 'api_key' | 'access_token';
958
- user: {
959
- id: string;
960
- email: string;
961
- name: string | null;
962
- role: string;
963
- permissions: string[];
964
- };
965
- organization: {
966
- id: string;
967
- slug: string;
968
- name: string;
969
- } | null;
970
- }
971
-
972
- interface RawUser {
973
- id: string;
974
- email: string;
975
- name?: string;
976
- role?: string;
977
- permissions?: string[];
978
- orgId?: string;
979
- orgSlug?: string;
980
- organization?: { id: string; slug: string; name: string } | null;
981
- }
982
-
983
- interface RawOrg {
984
- id: string;
985
- slug: string;
986
- name?: string;
987
- }
988
-
989
- function describeError(body: unknown, fallback: string): string {
990
- if (body && typeof body === 'object') {
991
- const maybe = body as { error?: unknown; message?: unknown };
992
- const resolved = pickString(maybe.message) ?? pickString(maybe.error);
993
- if (resolved) return resolved;
994
- }
995
- return fallback;
996
- }
997
-
998
- function pickString(value: unknown): string | undefined {
999
- if (typeof value === 'string' && value.trim().length > 0) return value;
1000
- if (value && typeof value === 'object') {
1001
- const nested = value as { message?: unknown; description?: unknown };
1002
- if (typeof nested.message === 'string' && nested.message.trim().length > 0) return nested.message;
1003
- if (typeof nested.description === 'string' && nested.description.trim().length > 0)
1004
- return nested.description;
1005
- }
1006
- return undefined;
1007
- }
1008
-
1009
- function extractRequired(body: unknown): string[] {
1010
- if (body && typeof body === 'object') {
1011
- const maybe = body as { required?: unknown };
1012
- if (Array.isArray(maybe.required)) return maybe.required.map(String);
1013
- }
1014
- return [];
1015
- }
1016
-
1017
- function mergeSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
1018
- if (a.aborted) return a;
1019
- if (b.aborted) return b;
1020
- const controller = new AbortController();
1021
- const onAbort = () => controller.abort();
1022
- a.addEventListener('abort', onAbort, { once: true });
1023
- b.addEventListener('abort', onAbort, { once: true });
1024
- return controller.signal;
1025
- }