@topolo/sdk 0.4.10 → 0.6.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.
@@ -0,0 +1,399 @@
1
+ // src/node/credential-store.ts
2
+ import { mkdir, readFile, writeFile, chmod } from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import { homedir, platform } from "os";
5
+ import { dirname, join } from "path";
6
+
7
+ // src/errors.ts
8
+ var TopoloSdkError = class extends Error {
9
+ code;
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.name = "TopoloSdkError";
13
+ this.code = code;
14
+ }
15
+ };
16
+ var TopoloAuthError = class extends TopoloSdkError {
17
+ constructor(message, code = "auth_error") {
18
+ super(code, message);
19
+ this.name = "TopoloAuthError";
20
+ }
21
+ };
22
+ var TopoloHttpError = class extends TopoloSdkError {
23
+ status;
24
+ body;
25
+ service;
26
+ path;
27
+ constructor(service, path, status, body, message) {
28
+ super("http_error", message ?? `HTTP ${status} from ${service}${path}`);
29
+ this.name = "TopoloHttpError";
30
+ this.status = status;
31
+ this.body = body;
32
+ this.service = service;
33
+ this.path = path;
34
+ }
35
+ };
36
+
37
+ // src/services.generated.ts
38
+ var APP_API_URLS = {
39
+ agent: "https://agent.topolo.app",
40
+ auth: "https://auth.topolo.app",
41
+ backup: "https://backup.topolo.app",
42
+ books: "https://books.topolo.app",
43
+ bugfix: "https://bugfix.topolo.app",
44
+ bytes: "https://bytes.topolo.app",
45
+ calendar: "https://calendar.topolo.app",
46
+ capacity: "https://capacity.topolo.app",
47
+ chat: "https://chat.topolo.app",
48
+ commerce: "https://commerce.topolo.app",
49
+ compose: "https://compose.topolo.app",
50
+ consent: "https://consent.topolo.app",
51
+ crm: "https://crm.topolo.app",
52
+ developers: "https://developers.topolo.app",
53
+ director: "https://director.topolo.app",
54
+ feed: "https://feed.topolo.app",
55
+ flow: "https://flow.topolo.app",
56
+ forecast: "https://forecast.topolo.app",
57
+ forms: "https://forms.topolo.app",
58
+ home: "https://home.topolo.app",
59
+ insights: "https://insights.topolo.app",
60
+ inventory: "https://inventory.topolo.app",
61
+ learn: "https://learn.topolo.app",
62
+ localize: "https://localize.topolo.app",
63
+ mail: "https://mail.topolo.app",
64
+ mdm: "https://mdm.topolo.app",
65
+ messages: "https://messages.topolo.app",
66
+ nexus: "https://nexus.topolo.app",
67
+ notify: "https://notify.topolo.app",
68
+ observability_watch: "https://observability-watch.topolo.app",
69
+ one: "https://www.topolo.app",
70
+ p2p: "https://p2p.topolo.app",
71
+ pay: "https://pay.topolo.app",
72
+ people: "https://people.topolo.app",
73
+ "quro.api": "https://api.ol0.me",
74
+ "quro.redirect": "https://ol0.me",
75
+ roadmapper: "https://roadmapper.topolo.app",
76
+ sign: "https://sign.topolo.app",
77
+ social_studio: "https://studio.topolo.app",
78
+ socialize: "https://socialize.topolo.app",
79
+ spaces: "https://spaces.topolo.app",
80
+ status: "https://status.topolo.app",
81
+ success: "https://success.topolo.app",
82
+ support: "https://support.topolo.app",
83
+ survey: "https://survey.topolo.app",
84
+ voice: "https://voice.topolo.app",
85
+ web: "https://web.topolo.app"
86
+ };
87
+
88
+ // src/services.ts
89
+ var DEFAULT_APP_URLS = APP_API_URLS;
90
+ function resolveServiceUrl(service, overrides) {
91
+ const override = resolveServiceUrlOverride([service], overrides);
92
+ if (override) return override;
93
+ return DEFAULT_APP_URLS[service];
94
+ }
95
+ function resolveServiceUrlOverride(keys, overrides) {
96
+ for (const key of keys) {
97
+ const direct = overrides?.[key];
98
+ if (direct) return direct;
99
+ const envStyleKey = key.replace(/\./g, "_");
100
+ const envStyle = overrides?.[envStyleKey];
101
+ if (envStyle) return envStyle;
102
+ }
103
+ for (const key of keys) {
104
+ const envKey = `TOPOLO_APP_URL_${key.replace(/\./g, "_").toUpperCase()}`;
105
+ const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
106
+ if (envValue) return envValue;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ // src/oauth.ts
112
+ var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
113
+ var TopoloOAuth = class {
114
+ baseUrl;
115
+ fetchImpl;
116
+ constructor(options = {}) {
117
+ this.baseUrl = resolveServiceUrl("auth", options.serviceUrls);
118
+ this.fetchImpl = options.fetch ?? fetch;
119
+ }
120
+ /**
121
+ * RFC 8628 device authorization grant — starts the flow by asking the server
122
+ * for a device_code + user_code. The caller prints the user_code +
123
+ * verification_uri and then polls `pollDeviceToken` until approval.
124
+ */
125
+ async requestDeviceCode(params) {
126
+ const body = new URLSearchParams();
127
+ body.set("client_id", params.clientId);
128
+ if (params.scope) {
129
+ body.set("scope", Array.isArray(params.scope) ? params.scope.join(" ") : params.scope);
130
+ }
131
+ return this.#postForm("/api/developer-oauth/device_authorization", body);
132
+ }
133
+ /**
134
+ * Polls the token endpoint once. Returns the token pair on success, or
135
+ * throws a `TopoloAuthError` whose `code` is one of the RFC 8628 poll
136
+ * states: `authorization_pending`, `slow_down`, `access_denied`,
137
+ * `expired_token`. Callers should back off on `slow_down`, wait at least
138
+ * one `interval` on `authorization_pending`, and give up on the rest.
139
+ */
140
+ async pollDeviceToken(params) {
141
+ const body = new URLSearchParams();
142
+ body.set("grant_type", DEVICE_GRANT);
143
+ body.set("client_id", params.clientId);
144
+ body.set("device_code", params.deviceCode);
145
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
146
+ return this.#postForm("/api/developer-oauth/token", body);
147
+ }
148
+ /** Exchange an authorization code (with PKCE) for tokens. */
149
+ async exchangeAuthorizationCode(params) {
150
+ const body = new URLSearchParams();
151
+ body.set("grant_type", "authorization_code");
152
+ body.set("client_id", params.clientId);
153
+ body.set("code", params.code);
154
+ body.set("redirect_uri", params.redirectUri);
155
+ if (params.codeVerifier) body.set("code_verifier", params.codeVerifier);
156
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
157
+ return this.#postForm("/api/developer-oauth/token", body);
158
+ }
159
+ /** Rotate a refresh token for a new token pair. */
160
+ async refreshToken(params) {
161
+ const body = new URLSearchParams();
162
+ body.set("grant_type", "refresh_token");
163
+ body.set("client_id", params.clientId);
164
+ body.set("refresh_token", params.refreshToken);
165
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
166
+ return this.#postForm("/api/developer-oauth/token", body);
167
+ }
168
+ async #postForm(path, body) {
169
+ const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
170
+ const res = await this.fetchImpl(url.toString(), {
171
+ method: "POST",
172
+ headers: {
173
+ "Content-Type": "application/x-www-form-urlencoded",
174
+ Accept: "application/json"
175
+ },
176
+ body: body.toString()
177
+ });
178
+ const contentType = res.headers.get("Content-Type") ?? "";
179
+ const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
180
+ if (!res.ok) {
181
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
182
+ const err = parsed;
183
+ throw new TopoloAuthError(err.error_description || err.error, err.error);
184
+ }
185
+ throw new TopoloHttpError("auth", path, res.status, parsed);
186
+ }
187
+ return parsed;
188
+ }
189
+ };
190
+
191
+ // src/node/credential-store.ts
192
+ var TOPOLO_ENVIRONMENTS = ["production", "staging", "development", "local"];
193
+ var ENVIRONMENT_DEFAULT_SERVICE_URLS = {
194
+ production: {
195
+ developers: "https://developers.topolo.app"
196
+ },
197
+ staging: {
198
+ auth: "https://auth.stg.topolo.us",
199
+ developers: "https://developers.stg.topolo.us",
200
+ seed: "https://seed.stg.topolo.us"
201
+ },
202
+ development: {
203
+ auth: "https://auth.topolo.dev",
204
+ developers: "https://developers.topolo.dev"
205
+ },
206
+ local: {}
207
+ };
208
+ function configPath() {
209
+ const override = process.env.TOPOLO_CONFIG_PATH;
210
+ if (override) return override;
211
+ const base = process.env.XDG_CONFIG_HOME || (platform() === "win32" ? join(homedir(), "AppData", "Roaming") : join(homedir(), ".config"));
212
+ return join(base, "topolo", "config.json");
213
+ }
214
+ async function readConfig() {
215
+ const path = configPath();
216
+ if (!existsSync(path)) return {};
217
+ try {
218
+ const raw = await readFile(path, "utf8");
219
+ return JSON.parse(raw);
220
+ } catch {
221
+ return {};
222
+ }
223
+ }
224
+ async function writeConfig(config) {
225
+ const path = configPath();
226
+ await mkdir(dirname(path), { recursive: true });
227
+ await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8");
228
+ if (platform() !== "win32") {
229
+ try {
230
+ await chmod(path, 384);
231
+ } catch {
232
+ }
233
+ }
234
+ }
235
+ function normalizeEnvironment(value) {
236
+ const normalized = (value ?? "production").trim().toLowerCase();
237
+ if (normalized === "" || normalized === "production" || normalized === "prod") {
238
+ return "production";
239
+ }
240
+ if (normalized === "staging" || normalized === "stage" || normalized === "stg") {
241
+ return "staging";
242
+ }
243
+ if (normalized === "development" || normalized === "dev") {
244
+ return "development";
245
+ }
246
+ if (normalized === "local" || normalized === "localhost") {
247
+ return "local";
248
+ }
249
+ throw new Error(
250
+ `Unknown Topolo environment "${value}". Expected production, staging, development, or local.`
251
+ );
252
+ }
253
+ function selectedEnvironment() {
254
+ return normalizeEnvironment(process.env.TOPOLO_ENV);
255
+ }
256
+ function envVarPrefix(environment) {
257
+ if (environment === "production") return "TOPOLO_PRODUCTION";
258
+ if (environment === "staging") return "TOPOLO_STAGING";
259
+ if (environment === "development") return "TOPOLO_DEVELOPMENT";
260
+ return "TOPOLO_LOCAL";
261
+ }
262
+ function profileForEnvironment(config, environment = selectedEnvironment()) {
263
+ const profile = config.profiles?.[environment] ?? {};
264
+ if (environment === "production") {
265
+ return {
266
+ credential: config.credential,
267
+ oauthClientId: config.oauthClientId,
268
+ ...profile,
269
+ serviceUrls: {
270
+ ...config.serviceUrls ?? {},
271
+ ...profile.serviceUrls ?? {}
272
+ },
273
+ agentName: profile.agentName ?? config.agentName
274
+ };
275
+ }
276
+ return profile;
277
+ }
278
+ function writeProfileConfig(config, environment, profile) {
279
+ const profiles = { ...config.profiles ?? {} };
280
+ profiles[environment] = {
281
+ ...profiles[environment] ?? {},
282
+ ...profile
283
+ };
284
+ return { ...config, profiles };
285
+ }
286
+ function clearProfileCredential(config, environment) {
287
+ const next = { ...config };
288
+ if (environment === "production") {
289
+ delete next.credential;
290
+ delete next.oauthClientId;
291
+ }
292
+ const profiles = { ...config.profiles ?? {} };
293
+ const profile = { ...profiles[environment] ?? {} };
294
+ delete profile.credential;
295
+ delete profile.oauthClientId;
296
+ profiles[environment] = profile;
297
+ return { ...next, profiles };
298
+ }
299
+ function credentialSourceFromConfig(config, environment = selectedEnvironment()) {
300
+ const prefix = envVarPrefix(environment);
301
+ if (process.env[`${prefix}_API_KEY`]) return `env:${prefix}_API_KEY`;
302
+ if (process.env[`${prefix}_ACCESS_TOKEN`]) return `env:${prefix}_ACCESS_TOKEN`;
303
+ if (process.env.TOPOLO_API_KEY) return "env:TOPOLO_API_KEY";
304
+ if (process.env.TOPOLO_ACCESS_TOKEN) return "env:TOPOLO_ACCESS_TOKEN";
305
+ return profileForEnvironment(config, environment).credential ? "config-file" : "none";
306
+ }
307
+ async function resolveCredential(environment = selectedEnvironment()) {
308
+ const prefix = envVarPrefix(environment);
309
+ const apiKey = process.env[`${prefix}_API_KEY`] ?? process.env.TOPOLO_API_KEY;
310
+ if (apiKey) return { kind: "api_key", apiKey };
311
+ const accessToken = process.env[`${prefix}_ACCESS_TOKEN`] ?? process.env.TOPOLO_ACCESS_TOKEN;
312
+ if (accessToken) return { kind: "access_token", accessToken };
313
+ const config = await readConfig();
314
+ return profileForEnvironment(config, environment).credential ?? null;
315
+ }
316
+ var REFRESH_SKEW_SECONDS = 60;
317
+ async function refreshCredentialIfNeeded(environment = selectedEnvironment()) {
318
+ const prefix = envVarPrefix(environment);
319
+ if (process.env[`${prefix}_API_KEY`] || process.env[`${prefix}_ACCESS_TOKEN`] || process.env.TOPOLO_API_KEY || process.env.TOPOLO_ACCESS_TOKEN) {
320
+ return resolveCredential(environment);
321
+ }
322
+ const config = await readConfig();
323
+ const profile = profileForEnvironment(config, environment);
324
+ const cred = profile.credential;
325
+ if (!cred || cred.kind !== "access_token") return cred ?? null;
326
+ if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
327
+ const now = Math.floor(Date.now() / 1e3);
328
+ if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
329
+ try {
330
+ const oauth = new TopoloOAuth({
331
+ serviceUrls: resolveServiceUrls(profile.serviceUrls, environment)
332
+ });
333
+ const tokens = await oauth.refreshToken({
334
+ clientId: profile.oauthClientId,
335
+ refreshToken: cred.refreshToken
336
+ });
337
+ const refreshed = {
338
+ kind: "access_token",
339
+ accessToken: tokens.access_token,
340
+ ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: cred.refreshToken },
341
+ expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
342
+ };
343
+ await writeConfig(
344
+ environment === "production" && !config.profiles?.production ? { ...config, credential: refreshed } : writeProfileConfig(config, environment, { credential: refreshed })
345
+ );
346
+ return refreshed;
347
+ } catch {
348
+ return cred;
349
+ }
350
+ }
351
+ function resolveAgentName(configAgentName) {
352
+ return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
353
+ }
354
+ function resolveServiceUrls(configUrls, environment = selectedEnvironment()) {
355
+ const merged = {
356
+ ...ENVIRONMENT_DEFAULT_SERVICE_URLS[environment],
357
+ ...configUrls ?? {}
358
+ };
359
+ const prefix = `${envVarPrefix(environment)}_SERVICE_URL_`;
360
+ for (const [k, v] of Object.entries(process.env)) {
361
+ if (k.startsWith(prefix) && v) {
362
+ merged[k.slice(prefix.length).toLowerCase()] = v;
363
+ }
364
+ }
365
+ for (const [k, v] of Object.entries(process.env)) {
366
+ if (k.startsWith("TOPOLO_SERVICE_URL_") && v) {
367
+ merged[k.slice("TOPOLO_SERVICE_URL_".length).toLowerCase()] = v;
368
+ }
369
+ }
370
+ return Object.keys(merged).length > 0 ? merged : void 0;
371
+ }
372
+ function resolvedProfileConfig(config, environment = selectedEnvironment()) {
373
+ const profile = profileForEnvironment(config, environment);
374
+ return {
375
+ credential: profile.credential,
376
+ oauthClientId: profile.oauthClientId,
377
+ agentName: profile.agentName,
378
+ serviceUrls: resolveServiceUrls(profile.serviceUrls, environment)
379
+ };
380
+ }
381
+ export {
382
+ ENVIRONMENT_DEFAULT_SERVICE_URLS,
383
+ TOPOLO_ENVIRONMENTS,
384
+ clearProfileCredential,
385
+ configPath,
386
+ credentialSourceFromConfig,
387
+ envVarPrefix,
388
+ normalizeEnvironment,
389
+ profileForEnvironment,
390
+ readConfig,
391
+ refreshCredentialIfNeeded,
392
+ resolveAgentName,
393
+ resolveCredential,
394
+ resolveServiceUrls,
395
+ resolvedProfileConfig,
396
+ selectedEnvironment,
397
+ writeConfig,
398
+ writeProfileConfig
399
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/sdk",
3
- "version": "0.4.10",
3
+ "version": "0.6.0",
4
4
  "description": "Typed client SDK for the Topolo platform. Used by TopoloCli, TopoloMCP, and third-party agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,6 +11,11 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js",
13
13
  "require": "./dist/index.cjs"
14
+ },
15
+ "./node": {
16
+ "types": "./dist/node/credential-store.d.ts",
17
+ "import": "./dist/node/credential-store.js",
18
+ "require": "./dist/node/credential-store.cjs"
14
19
  }
15
20
  },
16
21
  "files": [
@@ -21,7 +26,7 @@
21
26
  "generate:services": "node scripts/generate-services.mjs",
22
27
  "check:generated": "node scripts/check-generated.mjs",
23
28
  "prebuild": "node scripts/generate-services.mjs",
24
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
29
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean && tsup src/node/credential-store.ts --format esm,cjs --dts --out-dir dist/node",
25
30
  "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
26
31
  "typecheck": "node scripts/generate-services.mjs && tsc --noEmit",
27
32
  "test": "vitest run",
@@ -105,12 +105,12 @@ export const APPLICATION_REQUIREMENTS = [
105
105
  id: 'shared-auth-boundary',
106
106
  title: 'Use the shared Topolo auth boundary',
107
107
  summary:
108
- 'Applications must derive identity, organization, scopes, and service entitlement from TopoloAuth rather than inventing parallel auth state.',
108
+ 'Applications must derive identity, organization, scopes, and app entitlement from TopoloAuth rather than inventing parallel auth state.',
109
109
  appliesTo: ['browser', 'api', 'tooling', 'agent_surface'],
110
110
  evidence: [
111
111
  '@topolo/auth-client or @topolo-io/worker-runtime is used where applicable',
112
112
  'No public command, tool, or SDK method accepts orgId',
113
- 'Service slug and Auth service registration are documented for protected services',
113
+ 'App slug and Auth app registration are documented for protected APIs',
114
114
  ],
115
115
  implementation: [
116
116
  'Use shared auth/runtime packages instead of copying token parsing, gateway, or browser storage flows',
@@ -144,21 +144,21 @@ export const APPLICATION_REQUIREMENTS = [
144
144
  ],
145
145
  },
146
146
  {
147
- id: 'service-registration-and-scopes',
148
- title: 'Register protected APIs as platform services',
147
+ id: 'app-registration-and-scopes',
148
+ title: 'Register protected APIs as platform apps',
149
149
  summary:
150
- 'Callable app APIs need a stable service ID, service slug, permissions, role bundles, and API-key scopes before agents or users rely on them.',
150
+ 'Callable app APIs need a stable app ID, app slug, permissions, role bundles, and API-key scopes before agents or users rely on them.',
151
151
  appliesTo: ['api'],
152
152
  evidence: [
153
- 'Auth service catalog contains the service ID and slug',
154
- 'Permissions, role bundles, and API-key scopes exist for the service',
155
- 'SDK, CLI, and MCP service discovery include the callable production API',
156
- 'CloudControl production HTTP targets classify agent reachability with sdk_service_id or agent_callable:false',
153
+ 'Auth app catalog contains the app ID and slug',
154
+ 'Permissions, role bundles, and API-key scopes exist for the app',
155
+ 'SDK, CLI, and MCP app discovery include the callable production API',
156
+ 'CloudControl production HTTP targets classify agent reachability with sdk_app_id or agent_callable:false',
157
157
  ],
158
158
  implementation: [
159
- 'Add production Worker api_url metadata and sdk_service_id for every agent-callable API target',
159
+ 'Add production Worker api_url metadata and sdk_app_id for every agent-callable API target',
160
160
  'Mark browser, documentation, and runtime-only Worker targets with agent_callable:false',
161
- 'Expose stable service IDs through generated DEFAULT_SERVICE_URLS and live catalog discovery',
161
+ 'Expose stable app IDs through generated DEFAULT_APP_URLS and live catalog discovery',
162
162
  'Keep typed SDK/CLI/MCP surfaces in sync when an API contract stabilizes',
163
163
  ],
164
164
  },
@@ -281,12 +281,12 @@ export function applicationRequirementScopes(
281
281
  // the generated kind union to "worker" (no "pages" literal remains).
282
282
  application.deployTargets.some((target) => String(target.kind) === 'pages');
283
283
  // An API surface means an agent-callable service — keyed on a resolved
284
- // serviceId, NOT a bare apiUrl. After the Pages->Worker migration every
284
+ // appId, NOT a bare apiUrl. After the Pages->Worker migration every
285
285
  // frontend shell carries an apiUrl (its own serving domain), so apiUrl is no
286
- // longer a signal for "exposes a callable API"; serviceId is.
286
+ // longer a signal for "exposes a callable API"; appId is.
287
287
  const hasApiSurface =
288
288
  application.services.length > 0 ||
289
- application.deployTargets.some((target) => Boolean(target.serviceId));
289
+ application.deployTargets.some((target) => Boolean(target.appId));
290
290
 
291
291
  if (hasBrowserSurface) scopes.add('browser');
292
292
  if (hasApiSurface) scopes.add('api');
@@ -410,7 +410,7 @@ function evaluateRequirement(
410
410
  'Catalog metadata cannot verify shared shell, launcher, and account menu adoption.',
411
411
  evidence,
412
412
  );
413
- case 'service-registration-and-scopes': {
413
+ case 'app-registration-and-scopes': {
414
414
  if (application.services.length > 0) {
415
415
  evidence.push(`services=${application.services.join(',')}`);
416
416
  return finding(
@@ -420,7 +420,7 @@ function evaluateRequirement(
420
420
  evidence,
421
421
  );
422
422
  }
423
- return finding(requirement, 'missing', 'No SDK service ID is registered for this callable API surface.', evidence);
423
+ return finding(requirement, 'missing', 'No SDK app API id is registered for this callable API surface.', evidence);
424
424
  }
425
425
  case 'organization-scoped-data':
426
426
  return finding(