@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.
@@ -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
- PLATFORM_SERVICE_IDS,
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 = PLATFORM_SERVICE_IDS[opts.service];
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,
@@ -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: 'srv_x',
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 url = new URL(calls[0]!.url);
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((calls[0]!.init.method ?? 'GET').toUpperCase()).toBe('GET');
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: 'srv_x',
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: 'srv_x',
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 sent = JSON.parse(String(calls[0]!.init.body));
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(calls[0]!.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: 'srv_x',
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
- };