@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 +77 -1
- package/dist/index.d.cts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +77 -1
- package/package.json +1 -1
- package/src/client.test.ts +77 -0
- package/src/client.ts +94 -0
- package/src/services.generated.ts +8 -0
- package/src/services.test.ts +12 -3
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
|
|
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
|
|
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
package/src/client.test.ts
CHANGED
|
@@ -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",
|
package/src/services.test.ts
CHANGED
|
@@ -84,11 +84,20 @@ describe('service registry', () => {
|
|
|
84
84
|
expect(DEFAULT_SERVICE_URLS).not.toHaveProperty('web.runtime');
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
it('
|
|
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
|
-
|
|
91
|
-
expect(
|
|
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', () => {
|