@topolo/sdk 0.2.11 → 0.3.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.
@@ -6,13 +6,13 @@ export const APPLICATIONS = {
6
6
  "admin": {
7
7
  "id": "admin",
8
8
  "appDir": "TopoloAdmin",
9
- "name": "topoloadmin",
9
+ "name": "topolo-admin",
10
10
  "packageName": "topolo-admin",
11
11
  "productionUrl": "https://admin.topolo.app",
12
12
  "services": [],
13
13
  "deployTargets": [
14
14
  {
15
- "name": "topoloadmin",
15
+ "name": "topolo-admin",
16
16
  "kind": "pages",
17
17
  "apiUrl": null,
18
18
  "domains": [
@@ -35,7 +35,7 @@ export const APPLICATIONS = {
35
35
  "deployTargets": [
36
36
  {
37
37
  "name": "topolo-agent",
38
- "kind": "pages",
38
+ "kind": "worker",
39
39
  "apiUrl": null,
40
40
  "domains": [
41
41
  "agent.topolo.app"
@@ -369,7 +369,7 @@ export const APPLICATIONS = {
369
369
  },
370
370
  {
371
371
  "name": "topolo-crm-app",
372
- "kind": "pages",
372
+ "kind": "worker",
373
373
  "apiUrl": null,
374
374
  "domains": [
375
375
  "crm.topolo.app"
@@ -567,7 +567,7 @@ export const APPLICATIONS = {
567
567
  "deployTargets": [
568
568
  {
569
569
  "name": "topolo-forecast",
570
- "kind": "pages",
570
+ "kind": "worker",
571
571
  "apiUrl": null,
572
572
  "domains": [
573
573
  "forecast.topolo.app"
@@ -695,7 +695,7 @@ export const APPLICATIONS = {
695
695
  "deployTargets": [
696
696
  {
697
697
  "name": "topolo-learn",
698
- "kind": "pages",
698
+ "kind": "worker",
699
699
  "apiUrl": null,
700
700
  "domains": [
701
701
  "learn.topolo.app"
@@ -751,7 +751,7 @@ export const APPLICATIONS = {
751
751
  "deployTargets": [
752
752
  {
753
753
  "name": "topolo-mail",
754
- "kind": "workers",
754
+ "kind": "worker",
755
755
  "apiUrl": "https://mail.topolo.app",
756
756
  "domains": [
757
757
  "mail.topolo.app"
@@ -796,7 +796,7 @@ export const APPLICATIONS = {
796
796
  },
797
797
  {
798
798
  "name": "topolo-mdm-console",
799
- "kind": "pages",
799
+ "kind": "worker",
800
800
  "apiUrl": null,
801
801
  "domains": [
802
802
  "mdm.topolo.app"
@@ -820,7 +820,7 @@ export const APPLICATIONS = {
820
820
  "deployTargets": [
821
821
  {
822
822
  "name": "topolo-messaging",
823
- "kind": "workers",
823
+ "kind": "worker",
824
824
  "apiUrl": "https://messages.topolo.app",
825
825
  "domains": [
826
826
  "messages.topolo.app"
@@ -830,7 +830,7 @@ export const APPLICATIONS = {
830
830
  },
831
831
  {
832
832
  "name": "topolo-messaging-dispatch",
833
- "kind": "workers",
833
+ "kind": "worker",
834
834
  "apiUrl": null,
835
835
  "domains": [],
836
836
  "routes": [],
@@ -850,7 +850,7 @@ export const APPLICATIONS = {
850
850
  "deployTargets": [
851
851
  {
852
852
  "name": "topolo-nexus-dashboard",
853
- "kind": "pages",
853
+ "kind": "worker",
854
854
  "apiUrl": null,
855
855
  "domains": [
856
856
  "nexus.topolo.app"
@@ -910,20 +910,9 @@ export const APPLICATIONS = {
910
910
  "packageName": "topolo-oneclick-sso",
911
911
  "productionUrl": "https://one.topolo.app",
912
912
  "services": [
913
- "one",
914
- "one.ai"
913
+ "one"
915
914
  ],
916
915
  "deployTargets": [
917
- {
918
- "name": "ai-chat-rag",
919
- "kind": "worker",
920
- "apiUrl": "https://ai.one.topolo.app",
921
- "domains": [
922
- "ai.one.topolo.app"
923
- ],
924
- "routes": [],
925
- "serviceId": "one.ai"
926
- },
927
916
  {
928
917
  "name": "topolo-one",
929
918
  "kind": "pages",
@@ -1077,7 +1066,7 @@ export const APPLICATIONS = {
1077
1066
  },
1078
1067
  {
1079
1068
  "name": "quro-ui",
1080
- "kind": "pages",
1069
+ "kind": "worker",
1081
1070
  "apiUrl": null,
1082
1071
  "domains": [
1083
1072
  "quro.topolo.app"
@@ -1099,7 +1088,7 @@ export const APPLICATIONS = {
1099
1088
  "deployTargets": [
1100
1089
  {
1101
1090
  "name": "topolo-roadmapper",
1102
- "kind": "pages",
1091
+ "kind": "worker",
1103
1092
  "apiUrl": null,
1104
1093
  "domains": [
1105
1094
  "roadmapper.topolo.app"
@@ -1155,7 +1144,7 @@ export const APPLICATIONS = {
1155
1144
  "deployTargets": [
1156
1145
  {
1157
1146
  "name": "topolo-social-studio",
1158
- "kind": "pages",
1147
+ "kind": "worker",
1159
1148
  "apiUrl": null,
1160
1149
  "domains": [
1161
1150
  "studio.topolo.app"
@@ -1259,7 +1248,7 @@ export const APPLICATIONS = {
1259
1248
  "deployTargets": [
1260
1249
  {
1261
1250
  "name": "topolo-support",
1262
- "kind": "workers",
1251
+ "kind": "worker",
1263
1252
  "apiUrl": "https://support.topolo.app",
1264
1253
  "domains": [
1265
1254
  "support.topolo.app"
@@ -1351,7 +1340,7 @@ export const APPLICATIONS = {
1351
1340
  },
1352
1341
  {
1353
1342
  "name": "topolo-web-studio",
1354
- "kind": "pages",
1343
+ "kind": "worker",
1355
1344
  "apiUrl": null,
1356
1345
  "domains": [
1357
1346
  "web.topolo.app"
@@ -251,5 +251,82 @@ describe('TopoloClient.debug hook', () => {
251
251
  });
252
252
  });
253
253
 
254
+ describe('TopoloClient.getServiceIdBySlug', () => {
255
+ function freshClient(impl: typeof fetch, serviceUrls?: Record<string, string>) {
256
+ TopoloClient.clearServiceIdCache();
257
+ return new TopoloClient({
258
+ credential: { kind: 'api_key', apiKey: 'x' },
259
+ agent: AGENT,
260
+ fetch: impl,
261
+ serviceUrls: serviceUrls as never,
262
+ });
263
+ }
264
+
265
+ it('resolves a known slug via /api/services/by-slug/:slug', async () => {
266
+ const { impl, calls } = stubFetch([
267
+ { body: { success: true, data: { service_id: 'srv_abc123', base_url: 'https://x' } } },
268
+ ]);
269
+ const client = freshClient(impl, { auth: 'https://auth.example.com' });
270
+ const id = await client.getServiceIdBySlug('topolo-notify');
271
+ expect(id).toBe('srv_abc123');
272
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/topolo-notify');
273
+ // No auth header — endpoint is public.
274
+ const headers = new Headers(calls[0]!.init.headers);
275
+ expect(headers.get('Authorization')).toBeNull();
276
+ expect(headers.get('X-Api-Key')).toBeNull();
277
+ });
278
+
279
+ it('returns null on 404 and caches the negative result', async () => {
280
+ const { impl, calls } = stubFetch([{ status: 404, body: { success: false } }]);
281
+ const client = freshClient(impl, { auth: 'https://auth.example.com' });
282
+ expect(await client.getServiceIdBySlug('nope')).toBeNull();
283
+ expect(await client.getServiceIdBySlug('nope')).toBeNull();
284
+ expect(calls.length).toBe(1);
285
+ });
286
+
287
+ it('caches positive results across calls', async () => {
288
+ const { impl, calls } = stubFetch([
289
+ { body: { success: true, data: { service_id: 'srv_iCwM4jGXcwlj' } } },
290
+ ]);
291
+ const client = freshClient(impl, { auth: 'https://auth.example.com' });
292
+ expect(await client.getServiceIdBySlug('topolo-crm')).toBe('srv_iCwM4jGXcwlj');
293
+ expect(await client.getServiceIdBySlug('topolo-crm')).toBe('srv_iCwM4jGXcwlj');
294
+ expect(calls.length).toBe(1);
295
+ });
296
+
297
+ it('normalizes slug case + whitespace before hitting the network', async () => {
298
+ const { impl, calls } = stubFetch([
299
+ { body: { success: true, data: { service_id: 'srv_x' } } },
300
+ ]);
301
+ const client = freshClient(impl, { auth: 'https://auth.example.com' });
302
+ await client.getServiceIdBySlug(' TOPOLO-NOTIFY ');
303
+ expect(calls[0]!.url).toBe('https://auth.example.com/api/services/by-slug/topolo-notify');
304
+ });
305
+
306
+ it('returns null on network failure without poisoning cache', async () => {
307
+ let throws = true;
308
+ const impl = vi.fn(async (url: string) => {
309
+ if (throws) {
310
+ throws = false;
311
+ throw new Error('ECONNREFUSED');
312
+ }
313
+ return new Response(JSON.stringify({ success: true, data: { service_id: 'srv_y' } }), {
314
+ status: 200,
315
+ headers: { 'Content-Type': 'application/json' },
316
+ });
317
+ }) as unknown as typeof fetch;
318
+ TopoloClient.clearServiceIdCache();
319
+ const client = new TopoloClient({
320
+ credential: { kind: 'api_key', apiKey: 'x' },
321
+ agent: AGENT,
322
+ fetch: impl,
323
+ serviceUrls: { auth: 'https://auth.example.com' } as never,
324
+ });
325
+ expect(await client.getServiceIdBySlug('flaky')).toBeNull();
326
+ // Second call retries — earlier null was not cached.
327
+ expect(await client.getServiceIdBySlug('flaky')).toBe('srv_y');
328
+ });
329
+ });
330
+
254
331
  // Silence unused-import warning if the bundler later tree-shakes these
255
332
  void TopoloHttpError;
package/src/client.ts CHANGED
@@ -81,6 +81,14 @@ export interface RequestOptions {
81
81
 
82
82
  const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
83
83
 
84
+ const POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
85
+ const NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 minute
86
+
87
+ interface ServiceIdCacheEntry {
88
+ value: string | null;
89
+ expiresAt: number;
90
+ }
91
+
84
92
  export class TopoloClient {
85
93
  private readonly credential: TopoloCredential;
86
94
  private readonly agent: AgentIdentity;
@@ -90,6 +98,22 @@ export class TopoloClient {
90
98
  private readonly fetchImpl: typeof fetch;
91
99
  private readonly debug?: (event: TopoloDebugEvent) => void;
92
100
 
101
+ /**
102
+ * Process-wide cache for slug → service-id lookups. Keyed by
103
+ * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
104
+ * the same Auth share, but staging vs prod don't poison each other.
105
+ * Cleared via `TopoloClient.clearServiceIdCache()` if a test needs it.
106
+ */
107
+ private static readonly serviceIdCache = new Map<string, ServiceIdCacheEntry>();
108
+
109
+ /**
110
+ * Drop the slug→service-id cache. Test-only — production callers should
111
+ * let the 5-minute TTL expire naturally.
112
+ */
113
+ static clearServiceIdCache(): void {
114
+ TopoloClient.serviceIdCache.clear();
115
+ }
116
+
93
117
  constructor(options: TopoloClientOptions) {
94
118
  if (!options.credential) throw new TopoloAuthError('credential is required');
95
119
  if (!options.agent?.clientName) throw new TopoloAuthError('agent.clientName is required');
@@ -224,6 +248,76 @@ export class TopoloClient {
224
248
  return parsed as T;
225
249
  }
226
250
 
251
+ /**
252
+ * Resolve a service-slug → opaque service-id via TopoloAuth.
253
+ *
254
+ * Replaces the SDK-baked `PLATFORM_SERVICE_IDS` map (deprecated in 0.3.0,
255
+ * removed in 0.4.0). Slug shape accepted: either the canonical Auth slug
256
+ * (e.g. `topolo-notify`) or the SDK short id (`notify`) — Auth's resolver
257
+ * understands both.
258
+ *
259
+ * Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
260
+ * results (404) are cached for 60s so a typo doesn't hot-loop the
261
+ * resolver. Returns null when the slug isn't registered — callers decide
262
+ * whether to throw.
263
+ *
264
+ * No credential is sent: the endpoint is public (slugs aren't secret).
265
+ * This keeps the resolver usable during early bootstrap before a caller
266
+ * has acquired an access token.
267
+ */
268
+ async getServiceIdBySlug(slug: string): Promise<string | null> {
269
+ if (typeof slug !== 'string') return null;
270
+ const normalized = slug.trim().toLowerCase();
271
+ if (!normalized) return null;
272
+
273
+ const authBase = resolveServiceUrl('auth', this.serviceUrls);
274
+ const cacheKey = `${authBase}|${normalized}`;
275
+ const now = Date.now();
276
+ const cached = TopoloClient.serviceIdCache.get(cacheKey);
277
+ if (cached && cached.expiresAt > now) {
278
+ return cached.value;
279
+ }
280
+
281
+ const url = new URL(
282
+ `/api/services/by-slug/${encodeURIComponent(normalized)}`,
283
+ authBase.endsWith('/') ? authBase : `${authBase}/`,
284
+ ).toString();
285
+
286
+ let res: Response;
287
+ try {
288
+ res = await this.fetchImpl(url, {
289
+ method: 'GET',
290
+ headers: { Accept: 'application/json' },
291
+ });
292
+ } catch {
293
+ // Network-level failure — don't poison cache; let the caller retry.
294
+ return null;
295
+ }
296
+
297
+ if (res.status === 404) {
298
+ TopoloClient.serviceIdCache.set(cacheKey, {
299
+ value: null,
300
+ expiresAt: now + NEGATIVE_CACHE_TTL_MS,
301
+ });
302
+ return null;
303
+ }
304
+
305
+ if (!res.ok) {
306
+ return null;
307
+ }
308
+
309
+ const body = (await res.json().catch(() => null)) as
310
+ | { success?: boolean; data?: { service_id?: unknown } }
311
+ | null;
312
+ const serviceId = body?.data?.service_id;
313
+ const value = typeof serviceId === 'string' && serviceId ? serviceId : null;
314
+ TopoloClient.serviceIdCache.set(cacheKey, {
315
+ value,
316
+ expiresAt: now + POSITIVE_CACHE_TTL_MS,
317
+ });
318
+ return value;
319
+ }
320
+
227
321
  /**
228
322
  * Introspect the current credential. Returns the caller's resolved identity,
229
323
  * organization, and permission set. Used by CLI `whoami` and by MCP to gate
@@ -33,7 +33,6 @@ export const SERVICES = {
33
33
  nexus: "https://nexus-api.topolo.app",
34
34
  notify: "https://notify.topolo.app",
35
35
  one: "https://one.topolo.app",
36
- "one.ai": "https://ai.one.topolo.app",
37
36
  p2p: "https://p2p.topolo.app",
38
37
  pay: "https://pay.topolo.app",
39
38
  people: "https://people.topolo.app",
@@ -52,26 +51,34 @@ export const SERVICES = {
52
51
 
53
52
  export type ServiceId = keyof typeof SERVICES;
54
53
 
54
+ /**
55
+ * @deprecated since 0.3.0 — opaque service ids are no longer baked into the
56
+ * SDK. Use `TopoloClient.getServiceIdBySlug(slug)` which hits
57
+ * `GET /api/services/by-slug/:slug` on TopoloAuth at runtime. This map is
58
+ * still populated as a build-time snapshot for backward compatibility and
59
+ * will be removed in 0.4.0. Stale entries are expected — do not rely on
60
+ * presence/absence for catalog membership.
61
+ */
55
62
  export const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>> = {
56
- books: "srv_topolo_books",
57
- bugfix: "srv_topolo_bugfix",
58
- compose: "srv_topolo_compose",
59
- consent: "srv_topolo_consent",
63
+ books: "srv_0g260hbN8oqv",
64
+ bugfix: "srv_kQ1t8qUSDgNs",
65
+ compose: "srv_aR3ETYmgygmd",
66
+ consent: "srv_8FIGR7Q7zP9r",
60
67
  crm: "srv_iCwM4jGXcwlj",
61
- customer_success: "srv_topolo_success",
62
- developers: "svc_topolo_developers",
63
- director: "srv_topolo_director",
64
- flow: "srv_topolo_flow",
65
- forms: "srv_topolo_forms",
66
- home: "srv_topolo_home",
67
- insights: "srv_topolo_insights",
68
- inventory: "srv_topolo_inventory",
69
- localize: "srv_topolo_localize",
70
- notify: "svc_topolo_notify",
71
- p2p: "srv_topolo_p2p",
72
- people: "srv_topolo_people",
73
- sign: "srv_topolo_sign",
68
+ customer_success: "srv_RWpOxkgZdZbH",
69
+ developers: "srv_NjDq9G9qyyjr",
70
+ director: "srv_wRCnLLbtvmVT",
71
+ flow: "srv_GBCuKkHhnRXv",
72
+ forms: "srv_UE7IcGe80J5o",
73
+ home: "srv_hE6HBldVzjth",
74
+ insights: "srv_DgbBMwWSIhmC",
75
+ inventory: "srv_hmvjbv3ZxDnh",
76
+ localize: "srv_Ze1g9OmXuZjF",
77
+ notify: "srv_9pRdkIjYyKX0",
78
+ p2p: "srv_4BzeLqH0wCve",
79
+ people: "srv_VDMBAonA5fKb",
80
+ sign: "srv_Bd8pRw5ZqRBT",
74
81
  status: "srv_topolo_status",
75
- survey: "srv_topolo_survey",
76
- voice: "srv_topolo_voice",
82
+ survey: "srv_RCOi7CJkwf7R",
83
+ voice: "srv_BZANHkshGAK5",
77
84
  };
@@ -84,11 +84,20 @@ describe('service registry', () => {
84
84
  expect(DEFAULT_SERVICE_URLS).not.toHaveProperty('web.runtime');
85
85
  });
86
86
 
87
- it('infers platform Auth service ids from scaffolded app metadata', async () => {
87
+ it('still ships PLATFORM_SERVICE_IDS as a deprecated build-time snapshot', async () => {
88
+ // Deprecated in 0.3.0, removed in 0.4.0. Source of truth has moved to
89
+ // TopoloAuth's GET /api/services/by-slug/:slug — see
90
+ // TopoloClient.getServiceIdBySlug. We only sanity-check that the snapshot
91
+ // is non-empty and that the values look like opaque service ids; specific
92
+ // ids are no longer load-bearing.
88
93
  const { PLATFORM_SERVICE_IDS } = await import('./services.js');
89
94
 
90
- expect(PLATFORM_SERVICE_IDS.books).toBe('srv_topolo_books');
91
- expect(PLATFORM_SERVICE_IDS.customer_success).toBe('srv_topolo_success');
95
+ const entries = Object.entries(PLATFORM_SERVICE_IDS);
96
+ expect(entries.length).toBeGreaterThan(0);
97
+ for (const [, id] of entries) {
98
+ expect(typeof id).toBe('string');
99
+ expect(id).toMatch(/^srv_/);
100
+ }
92
101
  });
93
102
 
94
103
  it('maps every callable service back to an application catalog entry', () => {