@topolo/sdk 0.2.12 → 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.
package/dist/index.cjs CHANGED
@@ -1925,7 +1925,9 @@ function resolveServiceUrl(service, overrides) {
1925
1925
 
1926
1926
  // src/client.ts
1927
1927
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
1928
- var TopoloClient = class {
1928
+ var POSITIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
1929
+ var NEGATIVE_CACHE_TTL_MS = 60 * 1e3;
1930
+ var TopoloClient = class _TopoloClient {
1929
1931
  credential;
1930
1932
  agent;
1931
1933
  serviceUrls;
@@ -1933,6 +1935,20 @@ var TopoloClient = class {
1933
1935
  timeoutMs;
1934
1936
  fetchImpl;
1935
1937
  debug;
1938
+ /**
1939
+ * Process-wide cache for slug → service-id lookups. Keyed by
1940
+ * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
1941
+ * the same Auth share, but staging vs prod don't poison each other.
1942
+ * Cleared via `TopoloClient.clearServiceIdCache()` if a test needs it.
1943
+ */
1944
+ static serviceIdCache = /* @__PURE__ */ new Map();
1945
+ /**
1946
+ * Drop the slug→service-id cache. Test-only — production callers should
1947
+ * let the 5-minute TTL expire naturally.
1948
+ */
1949
+ static clearServiceIdCache() {
1950
+ _TopoloClient.serviceIdCache.clear();
1951
+ }
1936
1952
  constructor(options) {
1937
1953
  if (!options.credential) throw new TopoloAuthError("credential is required");
1938
1954
  if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
@@ -2047,6 +2063,66 @@ var TopoloClient = class {
2047
2063
  });
2048
2064
  return parsed;
2049
2065
  }
2066
+ /**
2067
+ * Resolve a service-slug → opaque service-id via TopoloAuth.
2068
+ *
2069
+ * Replaces the SDK-baked `PLATFORM_SERVICE_IDS` map (deprecated in 0.3.0,
2070
+ * removed in 0.4.0). Slug shape accepted: either the canonical Auth slug
2071
+ * (e.g. `topolo-notify`) or the SDK short id (`notify`) — Auth's resolver
2072
+ * understands both.
2073
+ *
2074
+ * Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
2075
+ * results (404) are cached for 60s so a typo doesn't hot-loop the
2076
+ * resolver. Returns null when the slug isn't registered — callers decide
2077
+ * whether to throw.
2078
+ *
2079
+ * No credential is sent: the endpoint is public (slugs aren't secret).
2080
+ * This keeps the resolver usable during early bootstrap before a caller
2081
+ * has acquired an access token.
2082
+ */
2083
+ async getServiceIdBySlug(slug) {
2084
+ if (typeof slug !== "string") return null;
2085
+ const normalized = slug.trim().toLowerCase();
2086
+ if (!normalized) return null;
2087
+ const authBase = resolveServiceUrl("auth", this.serviceUrls);
2088
+ const cacheKey = `${authBase}|${normalized}`;
2089
+ const now = Date.now();
2090
+ const cached = _TopoloClient.serviceIdCache.get(cacheKey);
2091
+ if (cached && cached.expiresAt > now) {
2092
+ return cached.value;
2093
+ }
2094
+ const url = new URL(
2095
+ `/api/services/by-slug/${encodeURIComponent(normalized)}`,
2096
+ authBase.endsWith("/") ? authBase : `${authBase}/`
2097
+ ).toString();
2098
+ let res;
2099
+ try {
2100
+ res = await this.fetchImpl(url, {
2101
+ method: "GET",
2102
+ headers: { Accept: "application/json" }
2103
+ });
2104
+ } catch {
2105
+ return null;
2106
+ }
2107
+ if (res.status === 404) {
2108
+ _TopoloClient.serviceIdCache.set(cacheKey, {
2109
+ value: null,
2110
+ expiresAt: now + NEGATIVE_CACHE_TTL_MS
2111
+ });
2112
+ return null;
2113
+ }
2114
+ if (!res.ok) {
2115
+ return null;
2116
+ }
2117
+ const body = await res.json().catch(() => null);
2118
+ const serviceId = body?.data?.service_id;
2119
+ const value = typeof serviceId === "string" && serviceId ? serviceId : null;
2120
+ _TopoloClient.serviceIdCache.set(cacheKey, {
2121
+ value,
2122
+ expiresAt: now + POSITIVE_CACHE_TTL_MS
2123
+ });
2124
+ return value;
2125
+ }
2050
2126
  /**
2051
2127
  * Introspect the current credential. Returns the caller's resolved identity,
2052
2128
  * organization, and permission set. Used by CLI `whoami` and by MCP to gate
package/dist/index.d.cts CHANGED
@@ -78,6 +78,14 @@ declare const SERVICES: {
78
78
  readonly web: "https://web-api.topolo.app";
79
79
  };
80
80
  type ServiceId = keyof typeof SERVICES;
81
+ /**
82
+ * @deprecated since 0.3.0 — opaque service ids are no longer baked into the
83
+ * SDK. Use `TopoloClient.getServiceIdBySlug(slug)` which hits
84
+ * `GET /api/services/by-slug/:slug` on TopoloAuth at runtime. This map is
85
+ * still populated as a build-time snapshot for backward compatibility and
86
+ * will be removed in 0.4.0. Stale entries are expected — do not rely on
87
+ * presence/absence for catalog membership.
88
+ */
81
89
  declare const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>>;
82
90
 
83
91
  declare const APPLICATIONS: {
@@ -1254,6 +1262,18 @@ declare class TopoloClient {
1254
1262
  private readonly timeoutMs;
1255
1263
  private readonly fetchImpl;
1256
1264
  private readonly debug?;
1265
+ /**
1266
+ * Process-wide cache for slug → service-id lookups. Keyed by
1267
+ * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
1268
+ * the same Auth share, but staging vs prod don't poison each other.
1269
+ * Cleared via `TopoloClient.clearServiceIdCache()` if a test needs it.
1270
+ */
1271
+ private static readonly serviceIdCache;
1272
+ /**
1273
+ * Drop the slug→service-id cache. Test-only — production callers should
1274
+ * let the 5-minute TTL expire naturally.
1275
+ */
1276
+ static clearServiceIdCache(): void;
1257
1277
  constructor(options: TopoloClientOptions);
1258
1278
  private emit;
1259
1279
  /**
@@ -1262,6 +1282,24 @@ declare class TopoloClient {
1262
1282
  * use and for the generic `topolo api` / MCP passthrough tool.
1263
1283
  */
1264
1284
  request<T = unknown>(opts: RequestOptions): Promise<T>;
1285
+ /**
1286
+ * Resolve a service-slug → opaque service-id via TopoloAuth.
1287
+ *
1288
+ * Replaces the SDK-baked `PLATFORM_SERVICE_IDS` map (deprecated in 0.3.0,
1289
+ * removed in 0.4.0). Slug shape accepted: either the canonical Auth slug
1290
+ * (e.g. `topolo-notify`) or the SDK short id (`notify`) — Auth's resolver
1291
+ * understands both.
1292
+ *
1293
+ * Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
1294
+ * results (404) are cached for 60s so a typo doesn't hot-loop the
1295
+ * resolver. Returns null when the slug isn't registered — callers decide
1296
+ * whether to throw.
1297
+ *
1298
+ * No credential is sent: the endpoint is public (slugs aren't secret).
1299
+ * This keeps the resolver usable during early bootstrap before a caller
1300
+ * has acquired an access token.
1301
+ */
1302
+ getServiceIdBySlug(slug: string): Promise<string | null>;
1265
1303
  /**
1266
1304
  * Introspect the current credential. Returns the caller's resolved identity,
1267
1305
  * organization, and permission set. Used by CLI `whoami` and by MCP to gate
package/dist/index.d.ts CHANGED
@@ -78,6 +78,14 @@ declare const SERVICES: {
78
78
  readonly web: "https://web-api.topolo.app";
79
79
  };
80
80
  type ServiceId = keyof typeof SERVICES;
81
+ /**
82
+ * @deprecated since 0.3.0 — opaque service ids are no longer baked into the
83
+ * SDK. Use `TopoloClient.getServiceIdBySlug(slug)` which hits
84
+ * `GET /api/services/by-slug/:slug` on TopoloAuth at runtime. This map is
85
+ * still populated as a build-time snapshot for backward compatibility and
86
+ * will be removed in 0.4.0. Stale entries are expected — do not rely on
87
+ * presence/absence for catalog membership.
88
+ */
81
89
  declare const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>>;
82
90
 
83
91
  declare const APPLICATIONS: {
@@ -1254,6 +1262,18 @@ declare class TopoloClient {
1254
1262
  private readonly timeoutMs;
1255
1263
  private readonly fetchImpl;
1256
1264
  private readonly debug?;
1265
+ /**
1266
+ * Process-wide cache for slug → service-id lookups. Keyed by
1267
+ * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
1268
+ * the same Auth share, but staging vs prod don't poison each other.
1269
+ * Cleared via `TopoloClient.clearServiceIdCache()` if a test needs it.
1270
+ */
1271
+ private static readonly serviceIdCache;
1272
+ /**
1273
+ * Drop the slug→service-id cache. Test-only — production callers should
1274
+ * let the 5-minute TTL expire naturally.
1275
+ */
1276
+ static clearServiceIdCache(): void;
1257
1277
  constructor(options: TopoloClientOptions);
1258
1278
  private emit;
1259
1279
  /**
@@ -1262,6 +1282,24 @@ declare class TopoloClient {
1262
1282
  * use and for the generic `topolo api` / MCP passthrough tool.
1263
1283
  */
1264
1284
  request<T = unknown>(opts: RequestOptions): Promise<T>;
1285
+ /**
1286
+ * Resolve a service-slug → opaque service-id via TopoloAuth.
1287
+ *
1288
+ * Replaces the SDK-baked `PLATFORM_SERVICE_IDS` map (deprecated in 0.3.0,
1289
+ * removed in 0.4.0). Slug shape accepted: either the canonical Auth slug
1290
+ * (e.g. `topolo-notify`) or the SDK short id (`notify`) — Auth's resolver
1291
+ * understands both.
1292
+ *
1293
+ * Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
1294
+ * results (404) are cached for 60s so a typo doesn't hot-loop the
1295
+ * resolver. Returns null when the slug isn't registered — callers decide
1296
+ * whether to throw.
1297
+ *
1298
+ * No credential is sent: the endpoint is public (slugs aren't secret).
1299
+ * This keeps the resolver usable during early bootstrap before a caller
1300
+ * has acquired an access token.
1301
+ */
1302
+ getServiceIdBySlug(slug: string): Promise<string | null>;
1265
1303
  /**
1266
1304
  * Introspect the current credential. Returns the caller's resolved identity,
1267
1305
  * organization, and permission set. Used by CLI `whoami` and by MCP to gate
package/dist/index.js CHANGED
@@ -1870,7 +1870,9 @@ function resolveServiceUrl(service, overrides) {
1870
1870
 
1871
1871
  // src/client.ts
1872
1872
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
1873
- var TopoloClient = class {
1873
+ var POSITIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
1874
+ var NEGATIVE_CACHE_TTL_MS = 60 * 1e3;
1875
+ var TopoloClient = class _TopoloClient {
1874
1876
  credential;
1875
1877
  agent;
1876
1878
  serviceUrls;
@@ -1878,6 +1880,20 @@ var TopoloClient = class {
1878
1880
  timeoutMs;
1879
1881
  fetchImpl;
1880
1882
  debug;
1883
+ /**
1884
+ * Process-wide cache for slug → service-id lookups. Keyed by
1885
+ * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
1886
+ * the same Auth share, but staging vs prod don't poison each other.
1887
+ * Cleared via `TopoloClient.clearServiceIdCache()` if a test needs it.
1888
+ */
1889
+ static serviceIdCache = /* @__PURE__ */ new Map();
1890
+ /**
1891
+ * Drop the slug→service-id cache. Test-only — production callers should
1892
+ * let the 5-minute TTL expire naturally.
1893
+ */
1894
+ static clearServiceIdCache() {
1895
+ _TopoloClient.serviceIdCache.clear();
1896
+ }
1881
1897
  constructor(options) {
1882
1898
  if (!options.credential) throw new TopoloAuthError("credential is required");
1883
1899
  if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
@@ -1992,6 +2008,66 @@ var TopoloClient = class {
1992
2008
  });
1993
2009
  return parsed;
1994
2010
  }
2011
+ /**
2012
+ * Resolve a service-slug → opaque service-id via TopoloAuth.
2013
+ *
2014
+ * Replaces the SDK-baked `PLATFORM_SERVICE_IDS` map (deprecated in 0.3.0,
2015
+ * removed in 0.4.0). Slug shape accepted: either the canonical Auth slug
2016
+ * (e.g. `topolo-notify`) or the SDK short id (`notify`) — Auth's resolver
2017
+ * understands both.
2018
+ *
2019
+ * Cache: 5 minutes per process per `(slug, auth_base_url)`. Negative
2020
+ * results (404) are cached for 60s so a typo doesn't hot-loop the
2021
+ * resolver. Returns null when the slug isn't registered — callers decide
2022
+ * whether to throw.
2023
+ *
2024
+ * No credential is sent: the endpoint is public (slugs aren't secret).
2025
+ * This keeps the resolver usable during early bootstrap before a caller
2026
+ * has acquired an access token.
2027
+ */
2028
+ async getServiceIdBySlug(slug) {
2029
+ if (typeof slug !== "string") return null;
2030
+ const normalized = slug.trim().toLowerCase();
2031
+ if (!normalized) return null;
2032
+ const authBase = resolveServiceUrl("auth", this.serviceUrls);
2033
+ const cacheKey = `${authBase}|${normalized}`;
2034
+ const now = Date.now();
2035
+ const cached = _TopoloClient.serviceIdCache.get(cacheKey);
2036
+ if (cached && cached.expiresAt > now) {
2037
+ return cached.value;
2038
+ }
2039
+ const url = new URL(
2040
+ `/api/services/by-slug/${encodeURIComponent(normalized)}`,
2041
+ authBase.endsWith("/") ? authBase : `${authBase}/`
2042
+ ).toString();
2043
+ let res;
2044
+ try {
2045
+ res = await this.fetchImpl(url, {
2046
+ method: "GET",
2047
+ headers: { Accept: "application/json" }
2048
+ });
2049
+ } catch {
2050
+ return null;
2051
+ }
2052
+ if (res.status === 404) {
2053
+ _TopoloClient.serviceIdCache.set(cacheKey, {
2054
+ value: null,
2055
+ expiresAt: now + NEGATIVE_CACHE_TTL_MS
2056
+ });
2057
+ return null;
2058
+ }
2059
+ if (!res.ok) {
2060
+ return null;
2061
+ }
2062
+ const body = await res.json().catch(() => null);
2063
+ const serviceId = body?.data?.service_id;
2064
+ const value = typeof serviceId === "string" && serviceId ? serviceId : null;
2065
+ _TopoloClient.serviceIdCache.set(cacheKey, {
2066
+ value,
2067
+ expiresAt: now + POSITIVE_CACHE_TTL_MS
2068
+ });
2069
+ return value;
2070
+ }
1995
2071
  /**
1996
2072
  * Introspect the current credential. Returns the caller's resolved identity,
1997
2073
  * organization, and permission set. Used by CLI `whoami` and by MCP to gate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/sdk",
3
- "version": "0.2.12",
3
+ "version": "0.3.0",
4
4
  "description": "Typed client SDK for the Topolo platform. Used by TopoloCli, TopoloMCP, and third-party agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
@@ -51,6 +51,14 @@ export const SERVICES = {
51
51
 
52
52
  export type ServiceId = keyof typeof SERVICES;
53
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
+ */
54
62
  export const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>> = {
55
63
  books: "srv_0g260hbN8oqv",
56
64
  bugfix: "srv_kQ1t8qUSDgNs",
@@ -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', () => {