@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 +9 -6
- package/dist/index.cjs +59 -57
- package/dist/index.d.cts +11 -8
- package/dist/index.d.ts +11 -8
- package/dist/index.js +59 -57
- package/package.json +1 -1
- package/src/client.test.ts +75 -19
- package/src/client.ts +76 -70
- package/src/modules/p2p.test.ts +25 -2
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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)
|
|
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
|
|
2342
|
-
*
|
|
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
|
-
*
|
|
2346
|
-
*
|
|
2347
|
-
*
|
|
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
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
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
|
-
|
|
2380
|
+
this.serviceCatalogCache = {
|
|
2381
|
+
entries,
|
|
2382
|
+
expiresAt: now + CATALOG_CACHE_TTL_MS
|
|
2383
|
+
};
|
|
2384
|
+
return entries;
|
|
2386
2385
|
}
|
|
2387
|
-
|
|
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: "
|
|
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
|
-
|
|
2479
|
-
|
|
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(
|
|
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
|
|
161
|
-
*
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
|
|
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
|
|
161
|
-
*
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
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)
|
|
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
|
|
2288
|
-
*
|
|
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
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
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
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
-
|
|
2326
|
+
this.serviceCatalogCache = {
|
|
2327
|
+
entries,
|
|
2328
|
+
expiresAt: now + CATALOG_CACHE_TTL_MS
|
|
2329
|
+
};
|
|
2330
|
+
return entries;
|
|
2332
2331
|
}
|
|
2333
|
-
|
|
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: "
|
|
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
|
-
|
|
2425
|
-
|
|
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(
|
|
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
package/src/client.test.ts
CHANGED
|
@@ -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) =>
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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: '
|
|
427
|
-
app_slug: 'topolo-
|
|
428
|
-
name: '
|
|
429
|
-
base_url: 'https://
|
|
430
|
-
surface_kind: '
|
|
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: '
|
|
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://
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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)
|
|
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
|
|
378
|
-
*
|
|
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
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
433
|
+
this.serviceCatalogCache = {
|
|
434
|
+
entries,
|
|
435
|
+
expiresAt: now + CATALOG_CACHE_TTL_MS,
|
|
436
|
+
};
|
|
437
|
+
return entries;
|
|
436
438
|
}
|
|
437
439
|
|
|
438
|
-
|
|
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: '
|
|
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
|
-
|
|
562
|
-
|
|
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(
|
|
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 {
|
package/src/modules/p2p.test.ts
CHANGED
|
@@ -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) =>
|
|
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((
|
|
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 () => {
|