@topolo/sdk 0.2.12 → 0.4.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 +1 -1
- package/dist/index.cjs +220 -73
- package/dist/index.d.cts +105 -42
- package/dist/index.d.ts +105 -42
- package/dist/index.js +220 -72
- package/package.json +5 -2
- package/src/application-requirements.ts +10 -6
- package/src/applications.generated.ts +61 -40
- package/src/client.test.ts +172 -6
- package/src/client.ts +214 -2
- package/src/index.ts +1 -1
- package/src/modules/p2p.test.ts +27 -11
- package/src/services.generated.ts +2 -24
- package/src/services.test.ts +5 -13
- package/src/services.ts +1 -2
- package/src/widgets.test.ts +7 -7
package/src/client.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { TopoloClient } from './client.js';
|
|
3
3
|
import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
|
|
4
4
|
|
|
@@ -16,6 +16,15 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown; contentTy
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const AGENT = { clientName: 'topolo-test', clientVersion: '0.0.0' };
|
|
19
|
+
const SERVICE_LOOKUP = { body: { success: true, data: { service_id: 'service_test' } } };
|
|
20
|
+
|
|
21
|
+
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
22
|
+
return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
TopoloClient.clearServiceIdCache();
|
|
27
|
+
});
|
|
19
28
|
|
|
20
29
|
describe('TopoloClient.request', () => {
|
|
21
30
|
it('injects Bearer auth + audit headers for access_token credentials', async () => {
|
|
@@ -24,10 +33,12 @@ describe('TopoloClient.request', () => {
|
|
|
24
33
|
credential: { kind: 'access_token', accessToken: 'tok_abc' },
|
|
25
34
|
agent: AGENT,
|
|
26
35
|
fetch: impl,
|
|
36
|
+
serviceIds: { auth: 'service_auth' },
|
|
27
37
|
});
|
|
28
38
|
await client.request({ service: 'auth', path: '/api/me' });
|
|
29
|
-
const headers = new Headers(calls[0]!.init.headers);
|
|
39
|
+
const headers = new Headers(requestCalls(calls)[0]!.init.headers);
|
|
30
40
|
expect(headers.get('Authorization')).toBe('Bearer tok_abc');
|
|
41
|
+
expect(headers.get('X-Service-ID')).toBe('service_auth');
|
|
31
42
|
expect(headers.get('X-Topolo-Client')).toBe('topolo-test/0.0.0');
|
|
32
43
|
expect(headers.get('X-Topolo-Request-Id')).toMatch(/.+/);
|
|
33
44
|
});
|
|
@@ -40,7 +51,7 @@ describe('TopoloClient.request', () => {
|
|
|
40
51
|
fetch: impl,
|
|
41
52
|
});
|
|
42
53
|
await client.request({ service: 'crm', path: '/api/contacts' });
|
|
43
|
-
const headers = new Headers(calls[0]!.init.headers);
|
|
54
|
+
const headers = new Headers(requestCalls(calls)[0]!.init.headers);
|
|
44
55
|
expect(headers.get('X-Api-Key')).toBe('dak_xyz');
|
|
45
56
|
expect(headers.get('Authorization')).toBeNull();
|
|
46
57
|
});
|
|
@@ -71,7 +82,7 @@ describe('TopoloClient.request', () => {
|
|
|
71
82
|
body: { email: 'a@b.co' },
|
|
72
83
|
confirm: true,
|
|
73
84
|
});
|
|
74
|
-
expect(calls[0]!.init.method).toBe('POST');
|
|
85
|
+
expect(requestCalls(calls)[0]!.init.method).toBe('POST');
|
|
75
86
|
});
|
|
76
87
|
|
|
77
88
|
it('maps 401 → TopoloAuthError with unwrapped message', async () => {
|
|
@@ -144,7 +155,7 @@ describe('TopoloClient.request', () => {
|
|
|
144
155
|
fetch: impl,
|
|
145
156
|
});
|
|
146
157
|
await client.request({ service: 'crm', path: '/api/contacts', query: { q: 'acme', page: 2 } });
|
|
147
|
-
const u = new URL(calls[0]!.url);
|
|
158
|
+
const u = new URL(requestCalls(calls)[0]!.url);
|
|
148
159
|
expect(u.searchParams.get('q')).toBe('acme');
|
|
149
160
|
expect(u.searchParams.get('page')).toBe('2');
|
|
150
161
|
});
|
|
@@ -169,7 +180,7 @@ describe('TopoloClient.request', () => {
|
|
|
169
180
|
|
|
170
181
|
describe('TopoloClient.debug hook', () => {
|
|
171
182
|
it('fires request → response on a successful call with stable requestId + duration', async () => {
|
|
172
|
-
const { impl } = stubFetch([{ body: { ok: true } }]);
|
|
183
|
+
const { impl } = stubFetch([SERVICE_LOOKUP, { body: { ok: true } }]);
|
|
173
184
|
const events: unknown[] = [];
|
|
174
185
|
const client = new TopoloClient({
|
|
175
186
|
credential: { kind: 'api_key', apiKey: 'x' },
|
|
@@ -251,5 +262,160 @@ describe('TopoloClient.debug hook', () => {
|
|
|
251
262
|
});
|
|
252
263
|
});
|
|
253
264
|
|
|
265
|
+
describe('TopoloClient.getServiceIdBySlug', () => {
|
|
266
|
+
function freshClient(impl: typeof fetch, serviceUrls?: Record<string, string>) {
|
|
267
|
+
TopoloClient.clearServiceIdCache();
|
|
268
|
+
return new TopoloClient({
|
|
269
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
270
|
+
agent: AGENT,
|
|
271
|
+
fetch: impl,
|
|
272
|
+
serviceUrls: serviceUrls as never,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
it('uses configured service ids without calling Auth', async () => {
|
|
277
|
+
const { impl, calls } = stubFetch([]);
|
|
278
|
+
const client = new TopoloClient({
|
|
279
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
280
|
+
agent: AGENT,
|
|
281
|
+
fetch: impl,
|
|
282
|
+
serviceUrls: { auth: 'https://auth.example.com' } as never,
|
|
283
|
+
serviceIds: { notify: 'service_notify' },
|
|
284
|
+
});
|
|
285
|
+
const id = await client.getServiceIdBySlug('topolo-notify');
|
|
286
|
+
expect(id).toBe('service_notify');
|
|
287
|
+
expect(calls).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('falls back to /api/services/by-slug/:slug for unknown slugs', async () => {
|
|
291
|
+
const { impl, calls } = stubFetch([
|
|
292
|
+
{ body: { success: true, data: { service_id: 'service_abc123', base_url: 'https://x' } } },
|
|
293
|
+
]);
|
|
294
|
+
const client = freshClient(impl, { auth: 'https://auth.example.com' });
|
|
295
|
+
const id = await client.getServiceIdBySlug('external-service');
|
|
296
|
+
expect(id).toBe('service_abc123');
|
|
297
|
+
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
|
|
298
|
+
// No auth header — endpoint is public.
|
|
299
|
+
const headers = new Headers(calls[0]!.init.headers);
|
|
300
|
+
expect(headers.get('Authorization')).toBeNull();
|
|
301
|
+
expect(headers.get('X-Api-Key')).toBeNull();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('returns null on 404 and caches the negative result', async () => {
|
|
305
|
+
const { impl, calls } = stubFetch([{ status: 404, body: { success: false } }]);
|
|
306
|
+
const client = freshClient(impl, { auth: 'https://auth.example.com' });
|
|
307
|
+
expect(await client.getServiceIdBySlug('nope')).toBeNull();
|
|
308
|
+
expect(await client.getServiceIdBySlug('nope')).toBeNull();
|
|
309
|
+
expect(calls.length).toBe(1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('caches positive results across calls', async () => {
|
|
313
|
+
const { impl, calls } = stubFetch([
|
|
314
|
+
{ body: { success: true, data: { service_id: 'service_external' } } },
|
|
315
|
+
]);
|
|
316
|
+
const client = freshClient(impl, { auth: 'https://auth.example.com' });
|
|
317
|
+
expect(await client.getServiceIdBySlug('external-service')).toBe('service_external');
|
|
318
|
+
expect(await client.getServiceIdBySlug('external-service')).toBe('service_external');
|
|
319
|
+
expect(calls.length).toBe(1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('normalizes slug case + whitespace before hitting the network', async () => {
|
|
323
|
+
const { impl, calls } = stubFetch([
|
|
324
|
+
{ body: { success: true, data: { service_id: 'service_x' } } },
|
|
325
|
+
]);
|
|
326
|
+
const client = freshClient(impl, { auth: 'https://auth.example.com' });
|
|
327
|
+
await client.getServiceIdBySlug(' EXTERNAL-SERVICE ');
|
|
328
|
+
expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/external-service');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('returns null on network failure without poisoning cache', async () => {
|
|
332
|
+
let throws = true;
|
|
333
|
+
const impl = vi.fn(async (url: string) => {
|
|
334
|
+
if (throws) {
|
|
335
|
+
throws = false;
|
|
336
|
+
throw new Error('ECONNREFUSED');
|
|
337
|
+
}
|
|
338
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_y' } }), {
|
|
339
|
+
status: 200,
|
|
340
|
+
headers: { 'Content-Type': 'application/json' },
|
|
341
|
+
});
|
|
342
|
+
}) as unknown as typeof fetch;
|
|
343
|
+
TopoloClient.clearServiceIdCache();
|
|
344
|
+
const client = new TopoloClient({
|
|
345
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
346
|
+
agent: AGENT,
|
|
347
|
+
fetch: impl,
|
|
348
|
+
serviceUrls: { auth: 'https://auth.example.com' } as never,
|
|
349
|
+
});
|
|
350
|
+
expect(await client.getServiceIdBySlug('flaky')).toBeNull();
|
|
351
|
+
// Second call retries — earlier null was not cached.
|
|
352
|
+
expect(await client.getServiceIdBySlug('flaky')).toBe('service_y');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('TopoloClient.listServices', () => {
|
|
357
|
+
it('normalizes the live catalog and attaches the credential', async () => {
|
|
358
|
+
const { impl, calls } = stubFetch([
|
|
359
|
+
{
|
|
360
|
+
body: {
|
|
361
|
+
data: {
|
|
362
|
+
services: [
|
|
363
|
+
{
|
|
364
|
+
service_id: 'srv_wRCnLLbtvmVT',
|
|
365
|
+
app_slug: 'topolo-director',
|
|
366
|
+
name: 'Topolo Director',
|
|
367
|
+
description: 'Demo control plane',
|
|
368
|
+
base_url: 'https://director.topolo.app',
|
|
369
|
+
status: 'active',
|
|
370
|
+
surface_kind: 'application',
|
|
371
|
+
launchable: true,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
const client = new TopoloClient({
|
|
379
|
+
credential: { kind: 'access_token', accessToken: 'tok_abc' },
|
|
380
|
+
agent: AGENT,
|
|
381
|
+
fetch: impl,
|
|
382
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
383
|
+
});
|
|
384
|
+
const out = await client.listServices();
|
|
385
|
+
expect(out).toEqual([
|
|
386
|
+
{
|
|
387
|
+
serviceId: 'srv_wRCnLLbtvmVT',
|
|
388
|
+
slug: 'topolo-director',
|
|
389
|
+
name: 'Topolo Director',
|
|
390
|
+
description: 'Demo control plane',
|
|
391
|
+
baseUrl: 'https://director.topolo.app',
|
|
392
|
+
status: 'active',
|
|
393
|
+
surfaceKind: 'application',
|
|
394
|
+
launchable: true,
|
|
395
|
+
},
|
|
396
|
+
]);
|
|
397
|
+
expect(calls[0]!.url).toBe(
|
|
398
|
+
'https://auth.example.com/api/services/catalog',
|
|
399
|
+
);
|
|
400
|
+
expect(new Headers(calls[0]!.init.headers).get('Authorization')).toBe(
|
|
401
|
+
'Bearer tok_abc',
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('falls back to the generated snapshot on HTTP failure', async () => {
|
|
406
|
+
const { impl } = stubFetch([{ status: 503, body: { error: 'down' } }]);
|
|
407
|
+
const client = new TopoloClient({
|
|
408
|
+
credential: { kind: 'api_key', apiKey: 'dak_xyz' },
|
|
409
|
+
agent: AGENT,
|
|
410
|
+
fetch: impl,
|
|
411
|
+
serviceUrls: { auth: 'https://auth.example.com' },
|
|
412
|
+
});
|
|
413
|
+
const out = await client.listServices();
|
|
414
|
+
expect(out.length).toBeGreaterThan(10);
|
|
415
|
+
expect(out.some((e) => e.serviceId === 'admin')).toBe(true);
|
|
416
|
+
expect(out.every((e) => typeof e.serviceId === 'string')).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
254
420
|
// Silence unused-import warning if the bundler later tree-shakes these
|
|
255
421
|
void TopoloHttpError;
|
package/src/client.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from './auth.js';
|
|
8
8
|
import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
APPLICATIONS,
|
|
11
11
|
resolveServiceUrl,
|
|
12
12
|
type ServiceId,
|
|
13
13
|
} from './services.js';
|
|
@@ -17,6 +17,8 @@ export interface TopoloClientOptions {
|
|
|
17
17
|
agent: AgentIdentity;
|
|
18
18
|
/** Per-service URL overrides. Useful for staging/dev. */
|
|
19
19
|
serviceUrls?: Partial<Record<ServiceId, string>>;
|
|
20
|
+
/** Per-service TopoloAuth service id overrides from the owning app/deployment. */
|
|
21
|
+
serviceIds?: Partial<Record<ServiceId, string>>;
|
|
20
22
|
/**
|
|
21
23
|
* When true, mutating helpers (POST/PUT/PATCH/DELETE) require callers to pass
|
|
22
24
|
* `{ confirm: true }`. Defaults to true. Set false only for trusted surfaces.
|
|
@@ -81,21 +83,63 @@ export interface RequestOptions {
|
|
|
81
83
|
|
|
82
84
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
83
85
|
|
|
86
|
+
const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
87
|
+
const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
|
|
88
|
+
|
|
89
|
+
interface ServiceIdCacheEntry {
|
|
90
|
+
value: string | null;
|
|
91
|
+
expiresAt: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A single entry from the live platform service catalog. Returned by
|
|
96
|
+
* {@link TopoloClient.listServices}. Shape mirrors the public-safe projection
|
|
97
|
+
* from TopoloAuth `GET /api/services/catalog`.
|
|
98
|
+
*/
|
|
99
|
+
export interface TopoloServiceCatalogEntry {
|
|
100
|
+
serviceId: string;
|
|
101
|
+
slug: string | null;
|
|
102
|
+
name: string | null;
|
|
103
|
+
description: string | null;
|
|
104
|
+
baseUrl: string | null;
|
|
105
|
+
status: string | null;
|
|
106
|
+
surfaceKind: string | null;
|
|
107
|
+
launchable: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
export class TopoloClient {
|
|
85
111
|
private readonly credential: TopoloCredential;
|
|
86
112
|
private readonly agent: AgentIdentity;
|
|
87
113
|
private readonly serviceUrls?: Partial<Record<ServiceId, string>>;
|
|
114
|
+
private readonly serviceIds?: Partial<Record<ServiceId, string>>;
|
|
88
115
|
private readonly requireConfirmForWrites: boolean;
|
|
89
116
|
private readonly timeoutMs: number;
|
|
90
117
|
private readonly fetchImpl: typeof fetch;
|
|
91
118
|
private readonly debug?: (event: TopoloDebugEvent) => void;
|
|
92
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Process-wide cache for slug → service-id lookups. Keyed by
|
|
122
|
+
* `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
|
|
123
|
+
* the same Auth share, but staging vs prod don't poison each other.
|
|
124
|
+
* Cleared via `TopoloClient.clearServiceIdCache()` if a test needs it.
|
|
125
|
+
*/
|
|
126
|
+
private static readonly serviceIdCache = new Map<string, ServiceIdCacheEntry>();
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Drop the slug→service-id cache. Test-only — production callers should
|
|
130
|
+
* let the 5-minute TTL expire naturally.
|
|
131
|
+
*/
|
|
132
|
+
static clearServiceIdCache(): void {
|
|
133
|
+
TopoloClient.serviceIdCache.clear();
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
constructor(options: TopoloClientOptions) {
|
|
94
137
|
if (!options.credential) throw new TopoloAuthError('credential is required');
|
|
95
138
|
if (!options.agent?.clientName) throw new TopoloAuthError('agent.clientName is required');
|
|
96
139
|
this.credential = options.credential;
|
|
97
140
|
this.agent = options.agent;
|
|
98
141
|
this.serviceUrls = options.serviceUrls;
|
|
142
|
+
this.serviceIds = options.serviceIds;
|
|
99
143
|
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
100
144
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
101
145
|
this.fetchImpl = options.fetch ?? fetch;
|
|
@@ -136,7 +180,7 @@ export class TopoloClient {
|
|
|
136
180
|
const headers = new Headers();
|
|
137
181
|
headers.set('Accept', 'application/json');
|
|
138
182
|
if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
|
|
139
|
-
const platformServiceId =
|
|
183
|
+
const platformServiceId = resolveConfiguredServiceId(opts.service, this.serviceIds);
|
|
140
184
|
if (platformServiceId) headers.set('X-Service-ID', platformServiceId);
|
|
141
185
|
|
|
142
186
|
applyAuthHeaders(headers, this.credential);
|
|
@@ -224,6 +268,145 @@ export class TopoloClient {
|
|
|
224
268
|
return parsed as T;
|
|
225
269
|
}
|
|
226
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
|
|
273
|
+
*
|
|
274
|
+
* Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
|
|
275
|
+
* results (404) are cached for 60s so a typo doesn't hot-loop the
|
|
276
|
+
* resolver. Returns null when the slug isn't registered — callers decide
|
|
277
|
+
* whether to throw.
|
|
278
|
+
*
|
|
279
|
+
* No credential is sent: the endpoint is public (slugs aren't secret).
|
|
280
|
+
* This keeps the resolver usable during early bootstrap before a caller
|
|
281
|
+
* has acquired an access token.
|
|
282
|
+
*/
|
|
283
|
+
async getServiceIdBySlug(slug: string): Promise<string | null> {
|
|
284
|
+
if (typeof slug !== 'string') return null;
|
|
285
|
+
const normalized = slug.trim().toLowerCase();
|
|
286
|
+
if (!normalized) return null;
|
|
287
|
+
|
|
288
|
+
const configuredServiceId = resolveConfiguredServiceId(normalized, this.serviceIds);
|
|
289
|
+
if (configuredServiceId) return configuredServiceId;
|
|
290
|
+
|
|
291
|
+
const authBase = resolveServiceUrl('auth', this.serviceUrls);
|
|
292
|
+
const cacheKey = `${authBase}|${normalized}`;
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
const cached = TopoloClient.serviceIdCache.get(cacheKey);
|
|
295
|
+
if (cached && cached.expiresAt > now) {
|
|
296
|
+
return cached.value;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const url = new URL(
|
|
300
|
+
`/api/services/by-slug/${encodeURIComponent(normalized)}`,
|
|
301
|
+
authBase.endsWith('/') ? authBase : `${authBase}/`,
|
|
302
|
+
).toString();
|
|
303
|
+
|
|
304
|
+
let res: Response;
|
|
305
|
+
try {
|
|
306
|
+
res = await this.fetchImpl(url, {
|
|
307
|
+
method: 'GET',
|
|
308
|
+
headers: { Accept: 'application/json' },
|
|
309
|
+
});
|
|
310
|
+
} catch {
|
|
311
|
+
// Network-level failure — don't poison cache; let the caller retry.
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (res.status === 404) {
|
|
316
|
+
TopoloClient.serviceIdCache.set(cacheKey, {
|
|
317
|
+
value: null,
|
|
318
|
+
expiresAt: now + NEGATIVE_CACHE_TTL_MS,
|
|
319
|
+
});
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const body = (await res.json().catch(() => null)) as
|
|
328
|
+
| { success?: boolean; data?: { service_id?: unknown } }
|
|
329
|
+
| null;
|
|
330
|
+
const serviceId = body?.data?.service_id;
|
|
331
|
+
const value = typeof serviceId === 'string' && serviceId ? serviceId : null;
|
|
332
|
+
TopoloClient.serviceIdCache.set(cacheKey, {
|
|
333
|
+
value,
|
|
334
|
+
expiresAt: now + POSITIVE_CACHE_TTL_MS,
|
|
335
|
+
});
|
|
336
|
+
return value;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* List the platform service catalog from the live registry
|
|
341
|
+
* (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
|
|
342
|
+
* caller's credential is attached, so an authenticated caller in an
|
|
343
|
+
* entitled org also receives the `developer_only` services its role
|
|
344
|
+
* permits; an unauthenticated/agent caller receives `active` only.
|
|
345
|
+
*
|
|
346
|
+
* Resilient: on any network/HTTP failure this falls back to the
|
|
347
|
+
* build-time generated `APPLICATIONS` snapshot so offline/degraded
|
|
348
|
+
* callers still get a usable (if static) catalog. The live result is
|
|
349
|
+
* authoritative; the snapshot is a floor, not a cap.
|
|
350
|
+
*/
|
|
351
|
+
async listServices(): Promise<TopoloServiceCatalogEntry[]> {
|
|
352
|
+
try {
|
|
353
|
+
const res = await this.request<
|
|
354
|
+
| { data?: { services?: unknown[] } }
|
|
355
|
+
| { services?: unknown[] }
|
|
356
|
+
>({ service: 'auth', method: 'GET', path: '/api/services/catalog' });
|
|
357
|
+
const raw =
|
|
358
|
+
(res as { data?: { services?: unknown[] } }).data?.services ??
|
|
359
|
+
(res as { services?: unknown[] }).services;
|
|
360
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
361
|
+
const entries: TopoloServiceCatalogEntry[] = [];
|
|
362
|
+
for (const item of raw) {
|
|
363
|
+
const s = item as Record<string, unknown>;
|
|
364
|
+
const serviceId =
|
|
365
|
+
typeof s.service_id === 'string'
|
|
366
|
+
? s.service_id
|
|
367
|
+
: typeof s.id === 'string'
|
|
368
|
+
? s.id
|
|
369
|
+
: null;
|
|
370
|
+
if (!serviceId) continue;
|
|
371
|
+
entries.push({
|
|
372
|
+
serviceId,
|
|
373
|
+
slug: typeof s.app_slug === 'string' ? s.app_slug : null,
|
|
374
|
+
name: typeof s.name === 'string' ? s.name : null,
|
|
375
|
+
description:
|
|
376
|
+
typeof s.description === 'string' ? s.description : null,
|
|
377
|
+
baseUrl: typeof s.base_url === 'string' ? s.base_url : null,
|
|
378
|
+
status: typeof s.status === 'string' ? s.status : null,
|
|
379
|
+
surfaceKind:
|
|
380
|
+
typeof s.surface_kind === 'string' ? s.surface_kind : null,
|
|
381
|
+
launchable: s.launchable === true,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (entries.length > 0) return entries;
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
// Fall through to the generated snapshot.
|
|
388
|
+
}
|
|
389
|
+
return this.generatedCatalogFallback();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private generatedCatalogFallback(): TopoloServiceCatalogEntry[] {
|
|
393
|
+
const apps = Object.values(APPLICATIONS) as ReadonlyArray<{
|
|
394
|
+
id: string;
|
|
395
|
+
name?: string;
|
|
396
|
+
productionUrl?: string | null;
|
|
397
|
+
}>;
|
|
398
|
+
return apps.map((app) => ({
|
|
399
|
+
serviceId: app.id,
|
|
400
|
+
slug: app.id,
|
|
401
|
+
name: app.name ?? app.id,
|
|
402
|
+
description: null,
|
|
403
|
+
baseUrl: app.productionUrl ?? null,
|
|
404
|
+
status: 'active',
|
|
405
|
+
surfaceKind: 'application',
|
|
406
|
+
launchable: Boolean(app.productionUrl),
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
|
|
227
410
|
/**
|
|
228
411
|
* Introspect the current credential. Returns the caller's resolved identity,
|
|
229
412
|
* organization, and permission set. Used by CLI `whoami` and by MCP to gate
|
|
@@ -268,6 +451,35 @@ export class TopoloClient {
|
|
|
268
451
|
}
|
|
269
452
|
}
|
|
270
453
|
|
|
454
|
+
function resolveConfiguredServiceId(
|
|
455
|
+
slug: string,
|
|
456
|
+
serviceIds?: Partial<Record<ServiceId, string>>,
|
|
457
|
+
): string | null {
|
|
458
|
+
for (const serviceKey of serviceKeyCandidates(slug)) {
|
|
459
|
+
const configured = serviceIds?.[serviceKey as ServiceId];
|
|
460
|
+
if (typeof configured === 'string' && configured.trim()) return configured.trim();
|
|
461
|
+
|
|
462
|
+
const envKey = `TOPOLO_SERVICE_ID_${serviceKey.replace(/\./g, '_').toUpperCase()}`;
|
|
463
|
+
const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
|
|
464
|
+
if (typeof envValue === 'string' && envValue.trim()) return envValue.trim();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function serviceKeyCandidates(slug: string): string[] {
|
|
471
|
+
const normalized = slug.trim().toLowerCase();
|
|
472
|
+
const withoutTopolo = normalized.startsWith('topolo-')
|
|
473
|
+
? normalized.slice('topolo-'.length)
|
|
474
|
+
: normalized;
|
|
475
|
+
return [...new Set([
|
|
476
|
+
normalized,
|
|
477
|
+
normalized.replace(/-/g, '_'),
|
|
478
|
+
withoutTopolo,
|
|
479
|
+
withoutTopolo.replace(/-/g, '_'),
|
|
480
|
+
])];
|
|
481
|
+
}
|
|
482
|
+
|
|
271
483
|
interface AuthMePayload {
|
|
272
484
|
credentialType?: 'access_token' | 'api_key';
|
|
273
485
|
user: RawUser;
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export {
|
|
|
4
4
|
type RequestOptions,
|
|
5
5
|
type CredentialIntrospection,
|
|
6
6
|
type TopoloDebugEvent,
|
|
7
|
+
type TopoloServiceCatalogEntry,
|
|
7
8
|
} from "./client.js";
|
|
8
9
|
export type { TopoloCredential, AgentIdentity } from "./auth.js";
|
|
9
10
|
export {
|
|
@@ -11,7 +12,6 @@ export {
|
|
|
11
12
|
APPLICATIONS,
|
|
12
13
|
APPLICATION_REQUIREMENTS,
|
|
13
14
|
APPLICATION_REQUIREMENTS_VERSION,
|
|
14
|
-
PLATFORM_SERVICE_IDS,
|
|
15
15
|
auditAllApplicationRequirements,
|
|
16
16
|
auditApplicationRequirements,
|
|
17
17
|
applicationRequirementScopes,
|
package/src/modules/p2p.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { TopoloClient } from '../client.js';
|
|
3
3
|
import { P2PModule } from './p2p.js';
|
|
4
4
|
|
|
@@ -8,6 +8,12 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
8
8
|
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
9
9
|
const impl = vi.fn(async (url: string, init: RequestInit) => {
|
|
10
10
|
calls.push({ url, init });
|
|
11
|
+
if (String(url).includes('/api/services/by-slug/')) {
|
|
12
|
+
return new Response(JSON.stringify({ success: true, data: { service_id: 'service_p2p' } }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
11
17
|
const next = responses.shift() ?? { status: 200, body: { success: true, data: {} } };
|
|
12
18
|
return new Response(JSON.stringify(next.body ?? { success: true, data: {} }), {
|
|
13
19
|
status: next.status ?? 200,
|
|
@@ -17,6 +23,10 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
|
|
|
17
23
|
return { impl: impl as unknown as typeof fetch, calls };
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
|
|
27
|
+
return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
function clientWith(impl: typeof fetch) {
|
|
21
31
|
return new TopoloClient({
|
|
22
32
|
credential: { kind: 'access_token', accessToken: 'tok_test' },
|
|
@@ -26,6 +36,10 @@ function clientWith(impl: typeof fetch) {
|
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
describe('P2PModule.listDirectory', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
TopoloClient.clearServiceIdCache();
|
|
41
|
+
});
|
|
42
|
+
|
|
29
43
|
it('GETs /api/directory/capabilities and unwraps the envelope', async () => {
|
|
30
44
|
const { impl, calls } = stubFetch([
|
|
31
45
|
{
|
|
@@ -37,7 +51,7 @@ describe('P2PModule.listDirectory', () => {
|
|
|
37
51
|
capability_id: 'cap_1',
|
|
38
52
|
publisher_org_id: 'org_a',
|
|
39
53
|
app_id: 'app_x',
|
|
40
|
-
service_id: '
|
|
54
|
+
service_id: 'service_x',
|
|
41
55
|
name: 'Demo',
|
|
42
56
|
description: 'A capability',
|
|
43
57
|
category: 'other',
|
|
@@ -60,10 +74,11 @@ describe('P2PModule.listDirectory', () => {
|
|
|
60
74
|
const capabilities = await p2p.listDirectory({ limit: 10 });
|
|
61
75
|
expect(capabilities).toHaveLength(1);
|
|
62
76
|
expect(capabilities[0]!.capability_id).toBe('cap_1');
|
|
63
|
-
const
|
|
77
|
+
const call = requestCalls(calls)[0]!;
|
|
78
|
+
const url = new URL(call.url);
|
|
64
79
|
expect(url.pathname).toBe('/api/directory/capabilities');
|
|
65
80
|
expect(url.searchParams.get('limit')).toBe('10');
|
|
66
|
-
expect((
|
|
81
|
+
expect((call.init.method ?? 'GET').toUpperCase()).toBe('GET');
|
|
67
82
|
});
|
|
68
83
|
});
|
|
69
84
|
|
|
@@ -78,7 +93,7 @@ describe('P2PModule.publishCapability', () => {
|
|
|
78
93
|
capability_id: 'cap_z',
|
|
79
94
|
publisher_org_id: 'org_p',
|
|
80
95
|
app_id: 'app_x',
|
|
81
|
-
service_id: '
|
|
96
|
+
service_id: 'service_x',
|
|
82
97
|
name: 'Demo',
|
|
83
98
|
description: 'A capability',
|
|
84
99
|
category: 'other',
|
|
@@ -101,7 +116,7 @@ describe('P2PModule.publishCapability', () => {
|
|
|
101
116
|
capability_id: 'cap_z',
|
|
102
117
|
publisher_org_id: 'org_p',
|
|
103
118
|
app_id: 'app_x',
|
|
104
|
-
service_id: '
|
|
119
|
+
service_id: 'service_x',
|
|
105
120
|
name: 'Demo',
|
|
106
121
|
description: 'A capability',
|
|
107
122
|
category: 'other',
|
|
@@ -114,13 +129,14 @@ describe('P2PModule.publishCapability', () => {
|
|
|
114
129
|
});
|
|
115
130
|
expect(capability.capability_id).toBe('cap_z');
|
|
116
131
|
|
|
117
|
-
const
|
|
132
|
+
const call = requestCalls(calls)[0]!;
|
|
133
|
+
const sent = JSON.parse(String(call.init.body));
|
|
118
134
|
expect(sent.publisher_org_id).toBe('org_p');
|
|
119
135
|
expect(sent.acting_for_org_id).toBe('org_p');
|
|
120
136
|
expect(sent.public_directory_visible).toBe(true);
|
|
121
137
|
expect(sent.requires_human_approval).toBe(false);
|
|
122
138
|
expect(sent.trust_level).toBe('standard');
|
|
123
|
-
const url = new URL(
|
|
139
|
+
const url = new URL(call.url);
|
|
124
140
|
expect(url.pathname).toBe('/api/p2p/internal/capabilities/sync');
|
|
125
141
|
expect((calls[0]!.init.method ?? '').toUpperCase()).toBe('POST');
|
|
126
142
|
});
|
|
@@ -134,7 +150,7 @@ describe('P2PModule.publishCapability', () => {
|
|
|
134
150
|
capability_id: 'cap_other',
|
|
135
151
|
publisher_org_id: 'org_target',
|
|
136
152
|
app_id: 'app_x',
|
|
137
|
-
service_id: '
|
|
153
|
+
service_id: 'service_x',
|
|
138
154
|
name: 'Demo',
|
|
139
155
|
description: '...',
|
|
140
156
|
category: 'other',
|
|
@@ -144,7 +160,7 @@ describe('P2PModule.publishCapability', () => {
|
|
|
144
160
|
source_revision: 'rev_1',
|
|
145
161
|
acting_for_org_id: 'org_target',
|
|
146
162
|
});
|
|
147
|
-
const sent = JSON.parse(String(calls[0]!.init.body));
|
|
163
|
+
const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
|
|
148
164
|
expect(sent.acting_for_org_id).toBe('org_target');
|
|
149
165
|
});
|
|
150
166
|
});
|
|
@@ -211,7 +227,7 @@ describe('P2PModule.releaseHold', () => {
|
|
|
211
227
|
const result = await p2p.releaseHold('hold_1', 'seller', { reason: 'arbitration' });
|
|
212
228
|
expect(result.resolution).toBe('seller');
|
|
213
229
|
expect(result.cancel_entries[0]!.entry_id).toBe('led_1');
|
|
214
|
-
const sent = JSON.parse(String(calls[0]!.init.body));
|
|
230
|
+
const sent = JSON.parse(String(requestCalls(calls)[0]!.init.body));
|
|
215
231
|
expect(sent.resolution).toBe('seller');
|
|
216
232
|
expect(sent.reason).toBe('arbitration');
|
|
217
233
|
});
|
|
@@ -36,12 +36,14 @@ export const SERVICES = {
|
|
|
36
36
|
p2p: "https://p2p.topolo.app",
|
|
37
37
|
pay: "https://pay.topolo.app",
|
|
38
38
|
people: "https://people.topolo.app",
|
|
39
|
+
pulse: "https://pulse.topolo.app",
|
|
39
40
|
"quro.api": "https://api.ol0.me",
|
|
40
41
|
"quro.redirect": "https://ol0.me",
|
|
41
42
|
roadmapper: "https://roadmapper-api.topolo.app",
|
|
42
43
|
sign: "https://sign.topolo.app",
|
|
43
44
|
social_studio: "https://studio-api.topolo.app",
|
|
44
45
|
socialize: "https://socialize-api.topolo.app",
|
|
46
|
+
spaces: "https://spaces.topolo.app",
|
|
45
47
|
status: "https://status.topolo.app",
|
|
46
48
|
support: "https://support.topolo.app",
|
|
47
49
|
survey: "https://survey.topolo.app",
|
|
@@ -50,27 +52,3 @@ export const SERVICES = {
|
|
|
50
52
|
} as const;
|
|
51
53
|
|
|
52
54
|
export type ServiceId = keyof typeof SERVICES;
|
|
53
|
-
|
|
54
|
-
export const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>> = {
|
|
55
|
-
books: "srv_0g260hbN8oqv",
|
|
56
|
-
bugfix: "srv_kQ1t8qUSDgNs",
|
|
57
|
-
compose: "srv_aR3ETYmgygmd",
|
|
58
|
-
consent: "srv_8FIGR7Q7zP9r",
|
|
59
|
-
crm: "srv_iCwM4jGXcwlj",
|
|
60
|
-
customer_success: "srv_RWpOxkgZdZbH",
|
|
61
|
-
developers: "srv_NjDq9G9qyyjr",
|
|
62
|
-
director: "srv_wRCnLLbtvmVT",
|
|
63
|
-
flow: "srv_GBCuKkHhnRXv",
|
|
64
|
-
forms: "srv_UE7IcGe80J5o",
|
|
65
|
-
home: "srv_hE6HBldVzjth",
|
|
66
|
-
insights: "srv_DgbBMwWSIhmC",
|
|
67
|
-
inventory: "srv_hmvjbv3ZxDnh",
|
|
68
|
-
localize: "srv_Ze1g9OmXuZjF",
|
|
69
|
-
notify: "srv_9pRdkIjYyKX0",
|
|
70
|
-
p2p: "srv_4BzeLqH0wCve",
|
|
71
|
-
people: "srv_VDMBAonA5fKb",
|
|
72
|
-
sign: "srv_Bd8pRw5ZqRBT",
|
|
73
|
-
status: "srv_topolo_status",
|
|
74
|
-
survey: "srv_RCOi7CJkwf7R",
|
|
75
|
-
voice: "srv_BZANHkshGAK5",
|
|
76
|
-
};
|