@topolo/sdk 0.4.1 → 0.4.2

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 CHANGED
@@ -89,12 +89,15 @@ PlatformApplications metadata, including Worker apps that do not expose a
89
89
  callable service. `DEFAULT_SERVICE_URLS` is the bundled callable API service
90
90
  snapshot generated from `topolo.cloudcontrol.json`.
91
91
 
92
- For agent-scale fleets, the bundle is a floor, not a cap. `TopoloClient.request`
93
- accepts bundled service IDs and live Auth-catalog service IDs/slugs. When a
94
- service is not in the bundled snapshot, the client resolves the selected
95
- environment's live catalog and only routes entries marked as API/technical
96
- service surfaces. Browser/runtime surfaces are not treated as generic API
97
- targets.
92
+ For agent-scale fleets, the bundle is packaging metadata, not an authorization
93
+ source. `TopoloClient.listServices()` always calls the live TopoloAuth catalog
94
+ with the current credential and returns only services that credential can use in
95
+ its organization, including the granted permissions. `TopoloClient.request()`
96
+ uses that credential-scoped catalog before routing any non-Auth service, so a
97
+ bundled service ID is only a convenient alias after Auth confirms access.
98
+
99
+ `listBundledServices()` is available for packaging/audit tooling that needs the
100
+ generated snapshot. Do not use it to decide what an end user or agent may call.
98
101
 
99
102
  `APPLICATION_REQUIREMENTS` is the versioned app-build contract agents should
100
103
  read before creating or expanding a Topolo app. `requirementsForApplication()`
package/dist/index.cjs CHANGED
@@ -2258,12 +2258,16 @@ var TopoloClient = class _TopoloClient {
2258
2258
  if (!serviceKey) {
2259
2259
  throw new TopoloSdkError("unknown_service", "service is required");
2260
2260
  }
2261
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2262
- if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
2263
- const staticUrl = DEFAULT_SERVICE_URLS[serviceKey];
2264
- if (staticUrl) return { serviceKey, baseUrl: staticUrl };
2261
+ if (serviceKey === "auth") {
2262
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2263
+ if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
2264
+ return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS.auth };
2265
+ }
2265
2266
  const live = await this.resolveLiveCallableService(serviceKey);
2266
- if (live) return live;
2267
+ if (live) {
2268
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2269
+ return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
2270
+ }
2267
2271
  throw new TopoloSdkError(
2268
2272
  "unknown_service",
2269
2273
  `Unknown Topolo service "${service}". Run service discovery first and pass a callable service id or slug.`
@@ -2273,10 +2277,7 @@ var TopoloClient = class _TopoloClient {
2273
2277
  const catalog = await this.listServices();
2274
2278
  const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
2275
2279
  if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
2276
- return {
2277
- serviceKey: normalizeLookupKey(match.slug || match.name || match.serviceId) || serviceKey,
2278
- baseUrl: match.baseUrl
2279
- };
2280
+ return { serviceKey, baseUrl: match.baseUrl };
2280
2281
  }
2281
2282
  /**
2282
2283
  * Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
@@ -2338,53 +2339,55 @@ var TopoloClient = class _TopoloClient {
2338
2339
  /**
2339
2340
  * List the platform service catalog from the live registry
2340
2341
  * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
2341
- * caller's credential is attached, so an authenticated caller in an
2342
- * entitled org also receives the `developer_only` services its role
2343
- * permits; an unauthenticated/agent caller receives `active` only.
2342
+ * caller's credential is attached, so the result only includes services the
2343
+ * credential's organization and effective permissions can use.
2344
2344
  *
2345
- * Resilient: on any network/HTTP failure this falls back to the
2346
- * build-time generated `APPLICATIONS` snapshot so offline/degraded
2347
- * callers still get a usable (if static) catalog. The live result is
2348
- * authoritative; the snapshot is a floor, not a cap.
2345
+ * Security boundary: this never falls back to the generated global snapshot.
2346
+ * The live Auth catalog is the authority because it is scoped to the caller's
2347
+ * organization and effective permissions.
2349
2348
  */
2350
2349
  async listServices() {
2351
2350
  const now = Date.now();
2352
2351
  if (this.serviceCatalogCache && this.serviceCatalogCache.expiresAt > now) {
2353
2352
  return this.serviceCatalogCache.entries;
2354
2353
  }
2355
- try {
2356
- const res = await this.request({ service: "auth", method: "GET", path: "/api/services/catalog" });
2357
- const raw = res.data?.services ?? res.services;
2358
- if (Array.isArray(raw) && raw.length > 0) {
2359
- const entries = [];
2360
- for (const item of raw) {
2361
- const s = item;
2362
- const serviceId = typeof s.service_id === "string" ? s.service_id : typeof s.id === "string" ? s.id : null;
2363
- if (!serviceId) continue;
2364
- entries.push({
2365
- serviceId,
2366
- slug: typeof s.app_slug === "string" ? s.app_slug : null,
2367
- name: typeof s.name === "string" ? s.name : null,
2368
- description: typeof s.description === "string" ? s.description : null,
2369
- baseUrl: typeof s.base_url === "string" ? s.base_url : null,
2370
- status: typeof s.status === "string" ? s.status : null,
2371
- surfaceKind: typeof s.surface_kind === "string" ? s.surface_kind : null,
2372
- launchable: s.launchable === true
2373
- });
2374
- }
2375
- if (entries.length > 0) {
2376
- this.serviceCatalogCache = {
2377
- entries,
2378
- expiresAt: now + CATALOG_CACHE_TTL_MS
2379
- };
2380
- return entries;
2381
- }
2382
- }
2383
- } catch {
2354
+ const res = await this.request({ service: "auth", method: "GET", path: "/api/services/catalog" });
2355
+ const raw = res.data?.services ?? res.services;
2356
+ if (!Array.isArray(raw)) {
2357
+ throw new TopoloSdkError(
2358
+ "service_catalog_unavailable",
2359
+ "TopoloAuth did not return a service catalog for the current credential."
2360
+ );
2361
+ }
2362
+ const entries = [];
2363
+ for (const item of raw) {
2364
+ const s = item;
2365
+ const serviceId = typeof s.service_id === "string" ? s.service_id : typeof s.id === "string" ? s.id : null;
2366
+ if (!serviceId) continue;
2367
+ const permissions = Array.isArray(s.granted_permissions) ? s.granted_permissions.filter((permission) => typeof permission === "string" && permission.trim().length > 0) : [];
2368
+ entries.push({
2369
+ serviceId,
2370
+ slug: typeof s.app_slug === "string" ? s.app_slug : null,
2371
+ name: typeof s.name === "string" ? s.name : null,
2372
+ description: typeof s.description === "string" ? s.description : null,
2373
+ baseUrl: typeof s.base_url === "string" ? s.base_url : null,
2374
+ status: typeof s.status === "string" ? s.status : null,
2375
+ surfaceKind: typeof s.surface_kind === "string" ? s.surface_kind : null,
2376
+ launchable: s.launchable === true,
2377
+ permissions
2378
+ });
2384
2379
  }
2385
- return this.generatedCatalogFallback();
2380
+ this.serviceCatalogCache = {
2381
+ entries,
2382
+ expiresAt: now + CATALOG_CACHE_TTL_MS
2383
+ };
2384
+ return entries;
2386
2385
  }
2387
- generatedCatalogFallback() {
2386
+ /**
2387
+ * Bundled generated registry for tooling that audits platform packaging, not
2388
+ * for deciding what the current credential may use.
2389
+ */
2390
+ listBundledServices() {
2388
2391
  const apps = Object.values(APPLICATIONS);
2389
2392
  return apps.map((app) => ({
2390
2393
  serviceId: app.id,
@@ -2392,9 +2395,10 @@ var TopoloClient = class _TopoloClient {
2392
2395
  name: app.name ?? app.id,
2393
2396
  description: null,
2394
2397
  baseUrl: app.productionUrl ?? null,
2395
- status: "active",
2398
+ status: "bundled",
2396
2399
  surfaceKind: "application",
2397
- launchable: Boolean(app.productionUrl)
2400
+ launchable: Boolean(app.productionUrl),
2401
+ permissions: []
2398
2402
  }));
2399
2403
  }
2400
2404
  /**
@@ -2464,7 +2468,7 @@ function serviceKeyCandidates(slug) {
2464
2468
  ])];
2465
2469
  }
2466
2470
  function normalizeLookupKey(value) {
2467
- return String(value || "").trim().toLowerCase();
2471
+ return String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
2468
2472
  }
2469
2473
  function catalogEntryMatches(entry, needle) {
2470
2474
  return catalogEntryLookupKeys(entry).includes(needle);
@@ -2475,12 +2479,8 @@ function catalogEntryLookupKeys(entry) {
2475
2479
  for (const value of values) {
2476
2480
  const normalized = normalizeLookupKey(value);
2477
2481
  if (!normalized) continue;
2478
- keys.add(normalized);
2479
- keys.add(normalized.replace(/-/g, "_"));
2480
- if (normalized.startsWith("topolo-")) {
2481
- const withoutTopolo = normalized.slice("topolo-".length);
2482
- keys.add(withoutTopolo);
2483
- keys.add(withoutTopolo.replace(/-/g, "_"));
2482
+ for (const candidate of serviceKeyCandidates(normalized)) {
2483
+ keys.add(candidate);
2484
2484
  }
2485
2485
  }
2486
2486
  return [...keys];
@@ -2488,7 +2488,9 @@ function catalogEntryLookupKeys(entry) {
2488
2488
  function isCallableCatalogEntry(entry) {
2489
2489
  const kind = normalizeLookupKey(entry.surfaceKind || "");
2490
2490
  if (kind === "api" || kind === "technical_service" || kind === "service") return true;
2491
- return Boolean(entry.slug && Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, catalogEntryLookupKeys(entry)[0] ?? ""));
2491
+ return Boolean(
2492
+ entry.baseUrl && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2493
+ );
2492
2494
  }
2493
2495
  function describeError(body, fallback) {
2494
2496
  if (body && typeof body === "object") {
package/dist/index.d.cts CHANGED
@@ -108,6 +108,7 @@ interface TopoloServiceCatalogEntry {
108
108
  status: string | null;
109
109
  surfaceKind: string | null;
110
110
  launchable: boolean;
111
+ permissions: string[];
111
112
  }
112
113
  declare class TopoloClient {
113
114
  private readonly credential;
@@ -157,17 +158,19 @@ declare class TopoloClient {
157
158
  /**
158
159
  * List the platform service catalog from the live registry
159
160
  * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
160
- * caller's credential is attached, so an authenticated caller in an
161
- * entitled org also receives the `developer_only` services its role
162
- * permits; an unauthenticated/agent caller receives `active` only.
161
+ * caller's credential is attached, so the result only includes services the
162
+ * credential's organization and effective permissions can use.
163
163
  *
164
- * Resilient: on any network/HTTP failure this falls back to the
165
- * build-time generated `APPLICATIONS` snapshot so offline/degraded
166
- * callers still get a usable (if static) catalog. The live result is
167
- * authoritative; the snapshot is a floor, not a cap.
164
+ * Security boundary: this never falls back to the generated global snapshot.
165
+ * The live Auth catalog is the authority because it is scoped to the caller's
166
+ * organization and effective permissions.
168
167
  */
169
168
  listServices(): Promise<TopoloServiceCatalogEntry[]>;
170
- private generatedCatalogFallback;
169
+ /**
170
+ * Bundled generated registry for tooling that audits platform packaging, not
171
+ * for deciding what the current credential may use.
172
+ */
173
+ listBundledServices(): TopoloServiceCatalogEntry[];
171
174
  /**
172
175
  * Introspect the current credential. Returns the caller's resolved identity,
173
176
  * organization, and permission set. Used by CLI `whoami` and by MCP to gate
package/dist/index.d.ts CHANGED
@@ -108,6 +108,7 @@ interface TopoloServiceCatalogEntry {
108
108
  status: string | null;
109
109
  surfaceKind: string | null;
110
110
  launchable: boolean;
111
+ permissions: string[];
111
112
  }
112
113
  declare class TopoloClient {
113
114
  private readonly credential;
@@ -157,17 +158,19 @@ declare class TopoloClient {
157
158
  /**
158
159
  * List the platform service catalog from the live registry
159
160
  * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
160
- * caller's credential is attached, so an authenticated caller in an
161
- * entitled org also receives the `developer_only` services its role
162
- * permits; an unauthenticated/agent caller receives `active` only.
161
+ * caller's credential is attached, so the result only includes services the
162
+ * credential's organization and effective permissions can use.
163
163
  *
164
- * Resilient: on any network/HTTP failure this falls back to the
165
- * build-time generated `APPLICATIONS` snapshot so offline/degraded
166
- * callers still get a usable (if static) catalog. The live result is
167
- * authoritative; the snapshot is a floor, not a cap.
164
+ * Security boundary: this never falls back to the generated global snapshot.
165
+ * The live Auth catalog is the authority because it is scoped to the caller's
166
+ * organization and effective permissions.
168
167
  */
169
168
  listServices(): Promise<TopoloServiceCatalogEntry[]>;
170
- private generatedCatalogFallback;
169
+ /**
170
+ * Bundled generated registry for tooling that audits platform packaging, not
171
+ * for deciding what the current credential may use.
172
+ */
173
+ listBundledServices(): TopoloServiceCatalogEntry[];
171
174
  /**
172
175
  * Introspect the current credential. Returns the caller's resolved identity,
173
176
  * organization, and permission set. Used by CLI `whoami` and by MCP to gate
package/dist/index.js CHANGED
@@ -2204,12 +2204,16 @@ var TopoloClient = class _TopoloClient {
2204
2204
  if (!serviceKey) {
2205
2205
  throw new TopoloSdkError("unknown_service", "service is required");
2206
2206
  }
2207
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2208
- if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
2209
- const staticUrl = DEFAULT_SERVICE_URLS[serviceKey];
2210
- if (staticUrl) return { serviceKey, baseUrl: staticUrl };
2207
+ if (serviceKey === "auth") {
2208
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2209
+ if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
2210
+ return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS.auth };
2211
+ }
2211
2212
  const live = await this.resolveLiveCallableService(serviceKey);
2212
- if (live) return live;
2213
+ if (live) {
2214
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2215
+ return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
2216
+ }
2213
2217
  throw new TopoloSdkError(
2214
2218
  "unknown_service",
2215
2219
  `Unknown Topolo service "${service}". Run service discovery first and pass a callable service id or slug.`
@@ -2219,10 +2223,7 @@ var TopoloClient = class _TopoloClient {
2219
2223
  const catalog = await this.listServices();
2220
2224
  const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
2221
2225
  if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
2222
- return {
2223
- serviceKey: normalizeLookupKey(match.slug || match.name || match.serviceId) || serviceKey,
2224
- baseUrl: match.baseUrl
2225
- };
2226
+ return { serviceKey, baseUrl: match.baseUrl };
2226
2227
  }
2227
2228
  /**
2228
2229
  * Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
@@ -2284,53 +2285,55 @@ var TopoloClient = class _TopoloClient {
2284
2285
  /**
2285
2286
  * List the platform service catalog from the live registry
2286
2287
  * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
2287
- * caller's credential is attached, so an authenticated caller in an
2288
- * entitled org also receives the `developer_only` services its role
2289
- * permits; an unauthenticated/agent caller receives `active` only.
2288
+ * caller's credential is attached, so the result only includes services the
2289
+ * credential's organization and effective permissions can use.
2290
2290
  *
2291
- * Resilient: on any network/HTTP failure this falls back to the
2292
- * build-time generated `APPLICATIONS` snapshot so offline/degraded
2293
- * callers still get a usable (if static) catalog. The live result is
2294
- * authoritative; the snapshot is a floor, not a cap.
2291
+ * Security boundary: this never falls back to the generated global snapshot.
2292
+ * The live Auth catalog is the authority because it is scoped to the caller's
2293
+ * organization and effective permissions.
2295
2294
  */
2296
2295
  async listServices() {
2297
2296
  const now = Date.now();
2298
2297
  if (this.serviceCatalogCache && this.serviceCatalogCache.expiresAt > now) {
2299
2298
  return this.serviceCatalogCache.entries;
2300
2299
  }
2301
- try {
2302
- const res = await this.request({ service: "auth", method: "GET", path: "/api/services/catalog" });
2303
- const raw = res.data?.services ?? res.services;
2304
- if (Array.isArray(raw) && raw.length > 0) {
2305
- const entries = [];
2306
- for (const item of raw) {
2307
- const s = item;
2308
- const serviceId = typeof s.service_id === "string" ? s.service_id : typeof s.id === "string" ? s.id : null;
2309
- if (!serviceId) continue;
2310
- entries.push({
2311
- serviceId,
2312
- slug: typeof s.app_slug === "string" ? s.app_slug : null,
2313
- name: typeof s.name === "string" ? s.name : null,
2314
- description: typeof s.description === "string" ? s.description : null,
2315
- baseUrl: typeof s.base_url === "string" ? s.base_url : null,
2316
- status: typeof s.status === "string" ? s.status : null,
2317
- surfaceKind: typeof s.surface_kind === "string" ? s.surface_kind : null,
2318
- launchable: s.launchable === true
2319
- });
2320
- }
2321
- if (entries.length > 0) {
2322
- this.serviceCatalogCache = {
2323
- entries,
2324
- expiresAt: now + CATALOG_CACHE_TTL_MS
2325
- };
2326
- return entries;
2327
- }
2328
- }
2329
- } catch {
2300
+ const res = await this.request({ service: "auth", method: "GET", path: "/api/services/catalog" });
2301
+ const raw = res.data?.services ?? res.services;
2302
+ if (!Array.isArray(raw)) {
2303
+ throw new TopoloSdkError(
2304
+ "service_catalog_unavailable",
2305
+ "TopoloAuth did not return a service catalog for the current credential."
2306
+ );
2307
+ }
2308
+ const entries = [];
2309
+ for (const item of raw) {
2310
+ const s = item;
2311
+ const serviceId = typeof s.service_id === "string" ? s.service_id : typeof s.id === "string" ? s.id : null;
2312
+ if (!serviceId) continue;
2313
+ const permissions = Array.isArray(s.granted_permissions) ? s.granted_permissions.filter((permission) => typeof permission === "string" && permission.trim().length > 0) : [];
2314
+ entries.push({
2315
+ serviceId,
2316
+ slug: typeof s.app_slug === "string" ? s.app_slug : null,
2317
+ name: typeof s.name === "string" ? s.name : null,
2318
+ description: typeof s.description === "string" ? s.description : null,
2319
+ baseUrl: typeof s.base_url === "string" ? s.base_url : null,
2320
+ status: typeof s.status === "string" ? s.status : null,
2321
+ surfaceKind: typeof s.surface_kind === "string" ? s.surface_kind : null,
2322
+ launchable: s.launchable === true,
2323
+ permissions
2324
+ });
2330
2325
  }
2331
- return this.generatedCatalogFallback();
2326
+ this.serviceCatalogCache = {
2327
+ entries,
2328
+ expiresAt: now + CATALOG_CACHE_TTL_MS
2329
+ };
2330
+ return entries;
2332
2331
  }
2333
- generatedCatalogFallback() {
2332
+ /**
2333
+ * Bundled generated registry for tooling that audits platform packaging, not
2334
+ * for deciding what the current credential may use.
2335
+ */
2336
+ listBundledServices() {
2334
2337
  const apps = Object.values(APPLICATIONS);
2335
2338
  return apps.map((app) => ({
2336
2339
  serviceId: app.id,
@@ -2338,9 +2341,10 @@ var TopoloClient = class _TopoloClient {
2338
2341
  name: app.name ?? app.id,
2339
2342
  description: null,
2340
2343
  baseUrl: app.productionUrl ?? null,
2341
- status: "active",
2344
+ status: "bundled",
2342
2345
  surfaceKind: "application",
2343
- launchable: Boolean(app.productionUrl)
2346
+ launchable: Boolean(app.productionUrl),
2347
+ permissions: []
2344
2348
  }));
2345
2349
  }
2346
2350
  /**
@@ -2410,7 +2414,7 @@ function serviceKeyCandidates(slug) {
2410
2414
  ])];
2411
2415
  }
2412
2416
  function normalizeLookupKey(value) {
2413
- return String(value || "").trim().toLowerCase();
2417
+ return String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
2414
2418
  }
2415
2419
  function catalogEntryMatches(entry, needle) {
2416
2420
  return catalogEntryLookupKeys(entry).includes(needle);
@@ -2421,12 +2425,8 @@ function catalogEntryLookupKeys(entry) {
2421
2425
  for (const value of values) {
2422
2426
  const normalized = normalizeLookupKey(value);
2423
2427
  if (!normalized) continue;
2424
- keys.add(normalized);
2425
- keys.add(normalized.replace(/-/g, "_"));
2426
- if (normalized.startsWith("topolo-")) {
2427
- const withoutTopolo = normalized.slice("topolo-".length);
2428
- keys.add(withoutTopolo);
2429
- keys.add(withoutTopolo.replace(/-/g, "_"));
2428
+ for (const candidate of serviceKeyCandidates(normalized)) {
2429
+ keys.add(candidate);
2430
2430
  }
2431
2431
  }
2432
2432
  return [...keys];
@@ -2434,7 +2434,9 @@ function catalogEntryLookupKeys(entry) {
2434
2434
  function isCallableCatalogEntry(entry) {
2435
2435
  const kind = normalizeLookupKey(entry.surfaceKind || "");
2436
2436
  if (kind === "api" || kind === "technical_service" || kind === "service") return true;
2437
- return Boolean(entry.slug && Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, catalogEntryLookupKeys(entry)[0] ?? ""));
2437
+ return Boolean(
2438
+ entry.baseUrl && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2439
+ );
2438
2440
  }
2439
2441
  function describeError(body, fallback) {
2440
2442
  if (body && typeof body === "object") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/sdk",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",
@@ -17,9 +17,29 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown; contentTy
17
17
 
18
18
  const AGENT = { clientName: 'topolo-test', clientVersion: '0.0.0' };
19
19
  const SERVICE_LOOKUP = { body: { success: true, data: { service_id: 'service_test' } } };
20
+ const CRM_CATALOG = {
21
+ body: {
22
+ data: {
23
+ services: [
24
+ {
25
+ service_id: 'srv_crm',
26
+ app_slug: 'topolo-crm',
27
+ name: 'Topolo CRM',
28
+ base_url: 'https://crm-api.topolo.app',
29
+ surface_kind: 'api',
30
+ status: 'active',
31
+ granted_permissions: ['srv_crm.contacts:read'],
32
+ },
33
+ ],
34
+ },
35
+ },
36
+ };
20
37
 
21
38
  function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
22
- return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
39
+ return calls.filter((call) =>
40
+ !call.url.includes('/api/services/by-slug/') &&
41
+ !call.url.includes('/api/services/catalog')
42
+ );
23
43
  }
24
44
 
25
45
  beforeEach(() => {
@@ -28,7 +48,7 @@ beforeEach(() => {
28
48
 
29
49
  describe('TopoloClient.request', () => {
30
50
  it('injects Bearer auth + audit headers for access_token credentials', async () => {
31
- const { impl, calls } = stubFetch([{ body: { ok: true } }]);
51
+ const { impl, calls } = stubFetch([CRM_CATALOG, { body: { ok: true } }]);
32
52
  const client = new TopoloClient({
33
53
  credential: { kind: 'access_token', accessToken: 'tok_abc' },
34
54
  agent: AGENT,
@@ -44,7 +64,7 @@ describe('TopoloClient.request', () => {
44
64
  });
45
65
 
46
66
  it('uses X-Api-Key header for api_key credentials', async () => {
47
- const { impl, calls } = stubFetch([{ body: { ok: true } }]);
67
+ const { impl, calls } = stubFetch([CRM_CATALOG, { body: { ok: true } }]);
48
68
  const client = new TopoloClient({
49
69
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
50
70
  agent: AGENT,
@@ -69,7 +89,7 @@ describe('TopoloClient.request', () => {
69
89
  });
70
90
 
71
91
  it('allows mutating requests with confirm: true', async () => {
72
- const { impl, calls } = stubFetch([{ body: { id: 'c_1' } }]);
92
+ const { impl, calls } = stubFetch([CRM_CATALOG, { body: { id: 'c_1' } }]);
73
93
  const client = new TopoloClient({
74
94
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
75
95
  agent: AGENT,
@@ -122,6 +142,7 @@ describe('TopoloClient.request', () => {
122
142
 
123
143
  it('maps 403 → TopoloPermissionError with required scopes extracted', async () => {
124
144
  const { impl } = stubFetch([
145
+ CRM_CATALOG,
125
146
  { status: 403, body: { error: 'forbidden', required: ['crm.contacts:write'] } },
126
147
  ]);
127
148
  const client = new TopoloClient({
@@ -135,7 +156,7 @@ describe('TopoloClient.request', () => {
135
156
  });
136
157
 
137
158
  it('maps non-401/403 failures → TopoloHttpError with status preserved', async () => {
138
- const { impl } = stubFetch([{ status: 500, body: { error: 'boom' } }]);
159
+ const { impl } = stubFetch([CRM_CATALOG, { status: 500, body: { error: 'boom' } }]);
139
160
  const client = new TopoloClient({
140
161
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
141
162
  agent: AGENT,
@@ -148,7 +169,7 @@ describe('TopoloClient.request', () => {
148
169
  });
149
170
 
150
171
  it('appends query params as search string', async () => {
151
- const { impl, calls } = stubFetch([{ body: { contacts: [] } }]);
172
+ const { impl, calls } = stubFetch([CRM_CATALOG, { body: { contacts: [] } }]);
152
173
  const client = new TopoloClient({
153
174
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
154
175
  agent: AGENT,
@@ -369,6 +390,7 @@ describe('TopoloClient.listServices', () => {
369
390
  status: 'active',
370
391
  surface_kind: 'application',
371
392
  launchable: true,
393
+ granted_permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
372
394
  },
373
395
  ],
374
396
  },
@@ -392,6 +414,7 @@ describe('TopoloClient.listServices', () => {
392
414
  status: 'active',
393
415
  surfaceKind: 'application',
394
416
  launchable: true,
417
+ permissions: ['srv_wRCnLLbtvmVT.dashboard:read'],
395
418
  },
396
419
  ]);
397
420
  expect(calls[0]!.url).toBe(
@@ -402,7 +425,7 @@ describe('TopoloClient.listServices', () => {
402
425
  );
403
426
  });
404
427
 
405
- it('falls back to the generated snapshot on HTTP failure', async () => {
428
+ it('does not fall back to the generated global snapshot on HTTP failure', async () => {
406
429
  const { impl } = stubFetch([{ status: 503, body: { error: 'down' } }]);
407
430
  const client = new TopoloClient({
408
431
  credential: { kind: 'api_key', apiKey: 'dak_xyz' },
@@ -410,25 +433,23 @@ describe('TopoloClient.listServices', () => {
410
433
  fetch: impl,
411
434
  serviceUrls: { auth: 'https://auth.example.com' },
412
435
  });
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);
436
+ await expect(client.listServices()).rejects.toBeInstanceOf(TopoloHttpError);
417
437
  });
418
438
 
419
- it('resolves future API services from the live catalog during request routing', async () => {
439
+ it('routes bundled service aliases only after the scoped live catalog grants access', async () => {
420
440
  const { impl, calls } = stubFetch([
421
441
  {
422
442
  body: {
423
443
  data: {
424
444
  services: [
425
445
  {
426
- service_id: 'srv_future',
427
- app_slug: 'topolo-warehouse',
428
- name: 'Warehouse',
429
- base_url: 'https://warehouse.topolo.app',
430
- surface_kind: 'api',
446
+ service_id: 'srv_mail',
447
+ app_slug: 'topolo-mail',
448
+ name: 'Topolo Mail',
449
+ base_url: 'https://mail.topolo.app',
450
+ surface_kind: 'application',
431
451
  status: 'active',
452
+ granted_permissions: ['srv_mail.messages:read'],
432
453
  },
433
454
  ],
434
455
  },
@@ -443,10 +464,43 @@ describe('TopoloClient.listServices', () => {
443
464
  serviceUrls: { auth: 'https://auth.example.com' },
444
465
  });
445
466
 
446
- await client.request({ service: 'warehouse', path: '/api/health' });
467
+ await client.request({ service: 'mail', path: '/api/health' });
447
468
 
448
469
  expect(calls[0]!.url).toBe('https://auth.example.com/api/services/catalog');
449
- expect(calls[1]!.url).toBe('https://warehouse.topolo.app/api/health');
470
+ expect(calls[1]!.url).toBe('https://mail.topolo.app/api/health');
471
+ });
472
+
473
+ it('refuses a bundled static service that is absent from the scoped live catalog', async () => {
474
+ const { impl, calls } = stubFetch([
475
+ {
476
+ body: {
477
+ data: {
478
+ services: [
479
+ {
480
+ service_id: 'srv_crm',
481
+ app_slug: 'topolo-crm',
482
+ name: 'Topolo CRM',
483
+ base_url: 'https://crm-api.topolo.app',
484
+ surface_kind: 'api',
485
+ status: 'active',
486
+ granted_permissions: ['srv_crm.contacts:read'],
487
+ },
488
+ ],
489
+ },
490
+ },
491
+ },
492
+ ]);
493
+ const client = new TopoloClient({
494
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
495
+ agent: AGENT,
496
+ fetch: impl,
497
+ serviceUrls: { auth: 'https://auth.example.com' },
498
+ });
499
+
500
+ await expect(client.request({ service: 'mail', path: '/api/health' })).rejects.toMatchObject({
501
+ code: 'unknown_service',
502
+ });
503
+ expect(calls).toHaveLength(1);
450
504
  });
451
505
 
452
506
  it('does not treat live browser applications as generic API services', async () => {
@@ -462,6 +516,7 @@ describe('TopoloClient.listServices', () => {
462
516
  base_url: 'https://future-app.topolo.app',
463
517
  surface_kind: 'application',
464
518
  status: 'active',
519
+ granted_permissions: ['srv_future_app.launch:read'],
465
520
  launchable: true,
466
521
  },
467
522
  ],
@@ -494,6 +549,7 @@ describe('TopoloClient.listServices', () => {
494
549
  base_url: 'https://warehouse.topolo.app',
495
550
  surface_kind: 'api',
496
551
  status: 'active',
552
+ granted_permissions: ['srv_future.read:all'],
497
553
  },
498
554
  ],
499
555
  },
package/src/client.ts CHANGED
@@ -107,6 +107,7 @@ export interface TopoloServiceCatalogEntry {
107
107
  status: string | null;
108
108
  surfaceKind: string | null;
109
109
  launchable: boolean;
110
+ permissions: string[];
110
111
  }
111
112
 
112
113
  export class TopoloClient {
@@ -278,14 +279,17 @@ export class TopoloClient {
278
279
  throw new TopoloSdkError('unknown_service', 'service is required');
279
280
  }
280
281
 
281
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
282
- if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
283
-
284
- const staticUrl = DEFAULT_SERVICE_URLS[serviceKey as ServiceId];
285
- if (staticUrl) return { serviceKey, baseUrl: staticUrl };
282
+ if (serviceKey === 'auth') {
283
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
284
+ if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
285
+ return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS.auth };
286
+ }
286
287
 
287
288
  const live = await this.resolveLiveCallableService(serviceKey);
288
- if (live) return live;
289
+ if (live) {
290
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
291
+ return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
292
+ }
289
293
 
290
294
  throw new TopoloSdkError(
291
295
  'unknown_service',
@@ -297,10 +301,7 @@ export class TopoloClient {
297
301
  const catalog = await this.listServices();
298
302
  const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
299
303
  if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
300
- return {
301
- serviceKey: normalizeLookupKey(match.slug || match.name || match.serviceId) || serviceKey,
302
- baseUrl: match.baseUrl,
303
- };
304
+ return { serviceKey, baseUrl: match.baseUrl };
304
305
  }
305
306
 
306
307
  /**
@@ -374,14 +375,12 @@ export class TopoloClient {
374
375
  /**
375
376
  * List the platform service catalog from the live registry
376
377
  * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
377
- * caller's credential is attached, so an authenticated caller in an
378
- * entitled org also receives the `developer_only` services its role
379
- * permits; an unauthenticated/agent caller receives `active` only.
378
+ * caller's credential is attached, so the result only includes services the
379
+ * credential's organization and effective permissions can use.
380
380
  *
381
- * Resilient: on any network/HTTP failure this falls back to the
382
- * build-time generated `APPLICATIONS` snapshot so offline/degraded
383
- * callers still get a usable (if static) catalog. The live result is
384
- * authoritative; the snapshot is a floor, not a cap.
381
+ * Security boundary: this never falls back to the generated global snapshot.
382
+ * The live Auth catalog is the authority because it is scoped to the caller's
383
+ * organization and effective permissions.
385
384
  */
386
385
  async listServices(): Promise<TopoloServiceCatalogEntry[]> {
387
386
  const now = Date.now();
@@ -389,53 +388,60 @@ export class TopoloClient {
389
388
  return this.serviceCatalogCache.entries;
390
389
  }
391
390
 
392
- try {
393
- const res = await this.request<
394
- | { data?: { services?: unknown[] } }
395
- | { services?: unknown[] }
396
- >({ service: 'auth', method: 'GET', path: '/api/services/catalog' });
397
- const raw =
398
- (res as { data?: { services?: unknown[] } }).data?.services ??
399
- (res as { services?: unknown[] }).services;
400
- if (Array.isArray(raw) && raw.length > 0) {
401
- const entries: TopoloServiceCatalogEntry[] = [];
402
- for (const item of raw) {
403
- const s = item as Record<string, unknown>;
404
- const serviceId =
405
- typeof s.service_id === 'string'
406
- ? s.service_id
407
- : typeof s.id === 'string'
408
- ? s.id
409
- : null;
410
- if (!serviceId) continue;
411
- entries.push({
412
- serviceId,
413
- slug: typeof s.app_slug === 'string' ? s.app_slug : null,
414
- name: typeof s.name === 'string' ? s.name : null,
415
- description:
416
- typeof s.description === 'string' ? s.description : null,
417
- baseUrl: typeof s.base_url === 'string' ? s.base_url : null,
418
- status: typeof s.status === 'string' ? s.status : null,
419
- surfaceKind:
420
- typeof s.surface_kind === 'string' ? s.surface_kind : null,
421
- launchable: s.launchable === true,
422
- });
423
- }
424
- if (entries.length > 0) {
425
- this.serviceCatalogCache = {
426
- entries,
427
- expiresAt: now + CATALOG_CACHE_TTL_MS,
428
- };
429
- return entries;
430
- }
431
- }
432
- } catch {
433
- // Fall through to the generated snapshot.
391
+ const res = await this.request<
392
+ | { data?: { services?: unknown[] } }
393
+ | { services?: unknown[] }
394
+ >({ service: 'auth', method: 'GET', path: '/api/services/catalog' });
395
+ const raw =
396
+ (res as { data?: { services?: unknown[] } }).data?.services ??
397
+ (res as { services?: unknown[] }).services;
398
+ if (!Array.isArray(raw)) {
399
+ throw new TopoloSdkError(
400
+ 'service_catalog_unavailable',
401
+ 'TopoloAuth did not return a service catalog for the current credential.',
402
+ );
403
+ }
404
+
405
+ const entries: TopoloServiceCatalogEntry[] = [];
406
+ for (const item of raw) {
407
+ const s = item as Record<string, unknown>;
408
+ const serviceId =
409
+ typeof s.service_id === 'string'
410
+ ? s.service_id
411
+ : typeof s.id === 'string'
412
+ ? s.id
413
+ : null;
414
+ if (!serviceId) continue;
415
+ const permissions = Array.isArray(s.granted_permissions)
416
+ ? s.granted_permissions
417
+ .filter((permission): permission is string => typeof permission === 'string' && permission.trim().length > 0)
418
+ : [];
419
+ entries.push({
420
+ serviceId,
421
+ slug: typeof s.app_slug === 'string' ? s.app_slug : null,
422
+ name: typeof s.name === 'string' ? s.name : null,
423
+ description:
424
+ typeof s.description === 'string' ? s.description : null,
425
+ baseUrl: typeof s.base_url === 'string' ? s.base_url : null,
426
+ status: typeof s.status === 'string' ? s.status : null,
427
+ surfaceKind:
428
+ typeof s.surface_kind === 'string' ? s.surface_kind : null,
429
+ launchable: s.launchable === true,
430
+ permissions,
431
+ });
434
432
  }
435
- return this.generatedCatalogFallback();
433
+ this.serviceCatalogCache = {
434
+ entries,
435
+ expiresAt: now + CATALOG_CACHE_TTL_MS,
436
+ };
437
+ return entries;
436
438
  }
437
439
 
438
- private generatedCatalogFallback(): TopoloServiceCatalogEntry[] {
440
+ /**
441
+ * Bundled generated registry for tooling that audits platform packaging, not
442
+ * for deciding what the current credential may use.
443
+ */
444
+ listBundledServices(): TopoloServiceCatalogEntry[] {
439
445
  const apps = Object.values(APPLICATIONS) as ReadonlyArray<{
440
446
  id: string;
441
447
  name?: string;
@@ -447,9 +453,10 @@ export class TopoloClient {
447
453
  name: app.name ?? app.id,
448
454
  description: null,
449
455
  baseUrl: app.productionUrl ?? null,
450
- status: 'active',
456
+ status: 'bundled',
451
457
  surfaceKind: 'application',
452
458
  launchable: Boolean(app.productionUrl),
459
+ permissions: [],
453
460
  }));
454
461
  }
455
462
 
@@ -543,7 +550,7 @@ function serviceKeyCandidates(slug: string): string[] {
543
550
  }
544
551
 
545
552
  function normalizeLookupKey(value: string): string {
546
- return String(value || '').trim().toLowerCase();
553
+ return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
547
554
  }
548
555
 
549
556
  function catalogEntryMatches(entry: TopoloServiceCatalogEntry, needle: string): boolean {
@@ -558,12 +565,8 @@ function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
558
565
  for (const value of values) {
559
566
  const normalized = normalizeLookupKey(value);
560
567
  if (!normalized) continue;
561
- keys.add(normalized);
562
- keys.add(normalized.replace(/-/g, '_'));
563
- if (normalized.startsWith('topolo-')) {
564
- const withoutTopolo = normalized.slice('topolo-'.length);
565
- keys.add(withoutTopolo);
566
- keys.add(withoutTopolo.replace(/-/g, '_'));
568
+ for (const candidate of serviceKeyCandidates(normalized)) {
569
+ keys.add(candidate);
567
570
  }
568
571
  }
569
572
  return [...keys];
@@ -572,7 +575,10 @@ function catalogEntryLookupKeys(entry: TopoloServiceCatalogEntry): string[] {
572
575
  function isCallableCatalogEntry(entry: TopoloServiceCatalogEntry): boolean {
573
576
  const kind = normalizeLookupKey(entry.surfaceKind || '');
574
577
  if (kind === 'api' || kind === 'technical_service' || kind === 'service') return true;
575
- return Boolean(entry.slug && Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, catalogEntryLookupKeys(entry)[0] ?? ''));
578
+ return Boolean(
579
+ entry.baseUrl &&
580
+ catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key)),
581
+ );
576
582
  }
577
583
 
578
584
  interface AuthMePayload {
@@ -14,6 +14,26 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
14
14
  headers: { 'Content-Type': 'application/json' },
15
15
  });
16
16
  }
17
+ if (String(url).includes('/api/services/catalog')) {
18
+ return new Response(JSON.stringify({
19
+ data: {
20
+ services: [
21
+ {
22
+ service_id: 'srv_p2p',
23
+ app_slug: 'topolo-p2p',
24
+ name: 'Topolo P2P',
25
+ base_url: 'https://p2p.topolo.app',
26
+ surface_kind: 'api',
27
+ status: 'active',
28
+ granted_permissions: ['srv_p2p.directory:read'],
29
+ },
30
+ ],
31
+ },
32
+ }), {
33
+ status: 200,
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
17
37
  const next = responses.shift() ?? { status: 200, body: { success: true, data: {} } };
18
38
  return new Response(JSON.stringify(next.body ?? { success: true, data: {} }), {
19
39
  status: next.status ?? 200,
@@ -24,7 +44,10 @@ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
24
44
  }
25
45
 
26
46
  function requestCalls(calls: Array<{ url: string; init: RequestInit }>) {
27
- return calls.filter((call) => !call.url.includes('/api/services/by-slug/'));
47
+ return calls.filter((call) =>
48
+ !call.url.includes('/api/services/by-slug/') &&
49
+ !call.url.includes('/api/services/catalog')
50
+ );
28
51
  }
29
52
 
30
53
  function clientWith(impl: typeof fetch) {
@@ -138,7 +161,7 @@ describe('P2PModule.publishCapability', () => {
138
161
  expect(sent.trust_level).toBe('standard');
139
162
  const url = new URL(call.url);
140
163
  expect(url.pathname).toBe('/api/p2p/internal/capabilities/sync');
141
- expect((calls[0]!.init.method ?? '').toUpperCase()).toBe('POST');
164
+ expect((call.init.method ?? '').toUpperCase()).toBe('POST');
142
165
  });
143
166
 
144
167
  it('honours an explicit acting_for_org_id when the platform key is publishing for another org', async () => {