@topolo/sdk 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -93,18 +93,41 @@ var TopoloHttpError = class extends TopoloSdkError {
93
93
  }
94
94
  };
95
95
 
96
- // src/services.ts
97
- var DEFAULT_SERVICE_URLS = {
96
+ // src/services.generated.ts
97
+ var SERVICES = {
98
+ agent: "https://topolo-agent-worker.topolo.workers.dev",
98
99
  auth: "https://auth.topolo.app",
99
- crm: "https://topolo-crm-worker.topolo.workers.dev"
100
+ bugfix: "https://bugfix.topolo.app",
101
+ bytes: "https://r2-media-manager-worker.topolo.workers.dev",
102
+ chat: "https://chat.topolo.app",
103
+ commerce: "https://topolo-commerce-api.topolo.workers.dev",
104
+ crm: "https://crm-api.topolo.app",
105
+ forecast: "https://forecast-worker-production.topolo.workers.dev",
106
+ learn: "https://topololearn-api.topolo.workers.dev",
107
+ mdm: "https://nodo-mdm-api.topolo.app",
108
+ messaging: "https://messages.topolo.app",
109
+ nexus: "https://topolo-nexus-gateway.topolo.workers.dev",
110
+ one: "https://topoloone-worker.topolo.workers.dev",
111
+ pay: "https://topolo-pay.topolo.workers.dev",
112
+ "quro.api": "https://api.ol0.me",
113
+ "quro.redirect": "https://ol0.me",
114
+ roadmapper: "https://roadmapper-api.topolo.app",
115
+ social_studio: "https://topolo-social-studio-api.topolo.workers.dev",
116
+ socialize: "https://socialize-prod-api.topolo.workers.dev",
117
+ support: "https://support.topolo.app",
118
+ "web.api": "https://web-api.topolo.app",
119
+ "web.runtime": "https://sites.topolo.app"
100
120
  };
101
121
  var PLATFORM_SERVICE_IDS = {
102
122
  crm: "srv_iCwM4jGXcwlj"
103
123
  };
124
+
125
+ // src/services.ts
126
+ var DEFAULT_SERVICE_URLS = SERVICES;
104
127
  function resolveServiceUrl(service, overrides) {
105
128
  const override = overrides?.[service];
106
129
  if (override) return override;
107
- const envKey = `TOPOLO_SERVICE_URL_${service.toUpperCase()}`;
130
+ const envKey = `TOPOLO_SERVICE_URL_${service.replace(/\./g, "_").toUpperCase()}`;
108
131
  const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
109
132
  if (envValue) return envValue;
110
133
  return DEFAULT_SERVICE_URLS[service];
@@ -119,6 +142,7 @@ var TopoloClient = class {
119
142
  requireConfirmForWrites;
120
143
  timeoutMs;
121
144
  fetchImpl;
145
+ debug;
122
146
  constructor(options) {
123
147
  if (!options.credential) throw new TopoloAuthError("credential is required");
124
148
  if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
@@ -128,6 +152,14 @@ var TopoloClient = class {
128
152
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
129
153
  this.timeoutMs = options.timeoutMs ?? 3e4;
130
154
  this.fetchImpl = options.fetch ?? fetch;
155
+ this.debug = options.debug;
156
+ }
157
+ emit(event) {
158
+ if (!this.debug) return;
159
+ try {
160
+ this.debug(event);
161
+ } catch {
162
+ }
131
163
  }
132
164
  /**
133
165
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
@@ -154,27 +186,56 @@ var TopoloClient = class {
154
186
  const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
155
187
  if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
156
188
  applyAuthHeaders(headers, this.credential);
157
- applyAuditHeaders(headers, this.agent, generateRequestId());
189
+ const requestId = generateRequestId();
190
+ applyAuditHeaders(headers, this.agent, requestId);
158
191
  if (opts.headers) {
159
192
  for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
160
193
  }
161
194
  const controller = new AbortController();
162
195
  const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
163
196
  const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
197
+ const urlStr = url.toString();
198
+ const startedAt = Date.now();
199
+ this.emit({ phase: "request", method, service: opts.service, path: opts.path, url: urlStr, requestId });
164
200
  let res;
165
201
  try {
166
- res = await this.fetchImpl(url.toString(), {
202
+ res = await this.fetchImpl(urlStr, {
167
203
  method,
168
204
  headers,
169
205
  body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
170
206
  signal
171
207
  });
208
+ } catch (err) {
209
+ this.emit({
210
+ phase: "error",
211
+ method,
212
+ service: opts.service,
213
+ path: opts.path,
214
+ url: urlStr,
215
+ requestId,
216
+ durationMs: Date.now() - startedAt,
217
+ error: err instanceof Error ? err.message : String(err)
218
+ });
219
+ throw err;
172
220
  } finally {
173
221
  clearTimeout(timeoutId);
174
222
  }
175
223
  const contentType = res.headers.get("Content-Type") ?? "";
176
224
  const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
177
225
  if (!res.ok) {
226
+ const durationMs = Date.now() - startedAt;
227
+ const message = describeError(parsed, `HTTP ${res.status}`);
228
+ this.emit({
229
+ phase: "error",
230
+ method,
231
+ service: opts.service,
232
+ path: opts.path,
233
+ url: urlStr,
234
+ requestId,
235
+ durationMs,
236
+ error: message,
237
+ status: res.status
238
+ });
178
239
  if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
179
240
  if (res.status === 403) {
180
241
  throw new TopoloPermissionError(
@@ -184,6 +245,16 @@ var TopoloClient = class {
184
245
  }
185
246
  throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
186
247
  }
248
+ this.emit({
249
+ phase: "response",
250
+ method,
251
+ service: opts.service,
252
+ path: opts.path,
253
+ url: urlStr,
254
+ requestId,
255
+ status: res.status,
256
+ durationMs: Date.now() - startedAt
257
+ });
187
258
  return parsed;
188
259
  }
189
260
  /**
package/dist/index.d.cts CHANGED
@@ -31,24 +31,50 @@ interface AgentIdentity {
31
31
  agentName?: string;
32
32
  }
33
33
 
34
+ declare const SERVICES: {
35
+ readonly agent: "https://topolo-agent-worker.topolo.workers.dev";
36
+ readonly auth: "https://auth.topolo.app";
37
+ readonly bugfix: "https://bugfix.topolo.app";
38
+ readonly bytes: "https://r2-media-manager-worker.topolo.workers.dev";
39
+ readonly chat: "https://chat.topolo.app";
40
+ readonly commerce: "https://topolo-commerce-api.topolo.workers.dev";
41
+ readonly crm: "https://crm-api.topolo.app";
42
+ readonly forecast: "https://forecast-worker-production.topolo.workers.dev";
43
+ readonly learn: "https://topololearn-api.topolo.workers.dev";
44
+ readonly mdm: "https://nodo-mdm-api.topolo.app";
45
+ readonly messaging: "https://messages.topolo.app";
46
+ readonly nexus: "https://topolo-nexus-gateway.topolo.workers.dev";
47
+ readonly one: "https://topoloone-worker.topolo.workers.dev";
48
+ readonly pay: "https://topolo-pay.topolo.workers.dev";
49
+ readonly "quro.api": "https://api.ol0.me";
50
+ readonly "quro.redirect": "https://ol0.me";
51
+ readonly roadmapper: "https://roadmapper-api.topolo.app";
52
+ readonly social_studio: "https://topolo-social-studio-api.topolo.workers.dev";
53
+ readonly socialize: "https://socialize-prod-api.topolo.workers.dev";
54
+ readonly support: "https://support.topolo.app";
55
+ readonly "web.api": "https://web-api.topolo.app";
56
+ readonly "web.runtime": "https://sites.topolo.app";
57
+ };
58
+ type ServiceId = keyof typeof SERVICES;
59
+ declare const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>>;
60
+
34
61
  /**
35
- * Service registry — base URLs for each Topolo platform service.
62
+ * Service registry — base URLs for every Topolo platform service.
36
63
  *
37
- * Defaults point at production. Any entry can be overridden per-call via
38
- * `TopoloClient` options, or globally via env vars like `TOPOLO_SERVICE_URL_CRM`.
64
+ * Source of truth lives in each app's `topolo.cloudcontrol.json`. The
65
+ * `SERVICES` map is regenerated from those files via
66
+ * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
67
+ *
68
+ * Any entry can be overridden per-call via `TopoloClient` options, or globally
69
+ * via env vars like `TOPOLO_SERVICE_URL_CRM` or `TOPOLO_SERVICE_URL_WEB_API`
70
+ * (dots in service IDs become underscores in env vars).
39
71
  *
40
72
  * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
41
73
  * scoped to the organization embedded in the auth credential (JWT claim or
42
74
  * API-key binding). This is the load-bearing cross-org isolation guarantee.
43
75
  */
44
- type ServiceId = 'auth' | 'crm';
76
+
45
77
  declare const DEFAULT_SERVICE_URLS: Record<ServiceId, string>;
46
- /**
47
- * Service IDs as registered in TopoloAuth's service catalog. These are sent in
48
- * the `X-Service-ID` header when the platform needs to route an API-key
49
- * introspection or permission check.
50
- */
51
- declare const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>>;
52
78
  declare function resolveServiceUrl(service: ServiceId, overrides?: Partial<Record<ServiceId, string>>): string;
53
79
 
54
80
  interface TopoloClientOptions {
@@ -65,7 +91,41 @@ interface TopoloClientOptions {
65
91
  timeoutMs?: number;
66
92
  /** Injected fetch, for testing. Defaults to global fetch. */
67
93
  fetch?: typeof fetch;
94
+ /**
95
+ * Observability hook. Fires once per request lifecycle (`request` at start,
96
+ * then exactly one of `response` or `error`). Safe to leave unset — off by
97
+ * default. Intended for CLI/MCP hosts to surface request-level diagnostics
98
+ * and for consumers building their own logging/tracing.
99
+ */
100
+ debug?: (event: TopoloDebugEvent) => void;
68
101
  }
102
+ type TopoloDebugEvent = {
103
+ phase: 'request';
104
+ method: string;
105
+ service: ServiceId;
106
+ path: string;
107
+ url: string;
108
+ requestId: string;
109
+ } | {
110
+ phase: 'response';
111
+ method: string;
112
+ service: ServiceId;
113
+ path: string;
114
+ url: string;
115
+ requestId: string;
116
+ status: number;
117
+ durationMs: number;
118
+ } | {
119
+ phase: 'error';
120
+ method: string;
121
+ service: ServiceId;
122
+ path: string;
123
+ url: string;
124
+ requestId: string;
125
+ durationMs: number;
126
+ error: string;
127
+ status?: number;
128
+ };
69
129
  interface RequestOptions {
70
130
  service: ServiceId;
71
131
  path: string;
@@ -85,7 +145,9 @@ declare class TopoloClient {
85
145
  private readonly requireConfirmForWrites;
86
146
  private readonly timeoutMs;
87
147
  private readonly fetchImpl;
148
+ private readonly debug?;
88
149
  constructor(options: TopoloClientOptions);
150
+ private emit;
89
151
  /**
90
152
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
91
153
  * for anything a caller would reach for; this stays exported for escape-hatch
@@ -274,4 +336,4 @@ declare function createTopolo(options: TopoloClientOptions): {
274
336
  };
275
337
  type Topolo = ReturnType<typeof createTopolo>;
276
338
 
277
- export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
339
+ export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
package/dist/index.d.ts CHANGED
@@ -31,24 +31,50 @@ interface AgentIdentity {
31
31
  agentName?: string;
32
32
  }
33
33
 
34
+ declare const SERVICES: {
35
+ readonly agent: "https://topolo-agent-worker.topolo.workers.dev";
36
+ readonly auth: "https://auth.topolo.app";
37
+ readonly bugfix: "https://bugfix.topolo.app";
38
+ readonly bytes: "https://r2-media-manager-worker.topolo.workers.dev";
39
+ readonly chat: "https://chat.topolo.app";
40
+ readonly commerce: "https://topolo-commerce-api.topolo.workers.dev";
41
+ readonly crm: "https://crm-api.topolo.app";
42
+ readonly forecast: "https://forecast-worker-production.topolo.workers.dev";
43
+ readonly learn: "https://topololearn-api.topolo.workers.dev";
44
+ readonly mdm: "https://nodo-mdm-api.topolo.app";
45
+ readonly messaging: "https://messages.topolo.app";
46
+ readonly nexus: "https://topolo-nexus-gateway.topolo.workers.dev";
47
+ readonly one: "https://topoloone-worker.topolo.workers.dev";
48
+ readonly pay: "https://topolo-pay.topolo.workers.dev";
49
+ readonly "quro.api": "https://api.ol0.me";
50
+ readonly "quro.redirect": "https://ol0.me";
51
+ readonly roadmapper: "https://roadmapper-api.topolo.app";
52
+ readonly social_studio: "https://topolo-social-studio-api.topolo.workers.dev";
53
+ readonly socialize: "https://socialize-prod-api.topolo.workers.dev";
54
+ readonly support: "https://support.topolo.app";
55
+ readonly "web.api": "https://web-api.topolo.app";
56
+ readonly "web.runtime": "https://sites.topolo.app";
57
+ };
58
+ type ServiceId = keyof typeof SERVICES;
59
+ declare const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>>;
60
+
34
61
  /**
35
- * Service registry — base URLs for each Topolo platform service.
62
+ * Service registry — base URLs for every Topolo platform service.
36
63
  *
37
- * Defaults point at production. Any entry can be overridden per-call via
38
- * `TopoloClient` options, or globally via env vars like `TOPOLO_SERVICE_URL_CRM`.
64
+ * Source of truth lives in each app's `topolo.cloudcontrol.json`. The
65
+ * `SERVICES` map is regenerated from those files via
66
+ * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
67
+ *
68
+ * Any entry can be overridden per-call via `TopoloClient` options, or globally
69
+ * via env vars like `TOPOLO_SERVICE_URL_CRM` or `TOPOLO_SERVICE_URL_WEB_API`
70
+ * (dots in service IDs become underscores in env vars).
39
71
  *
40
72
  * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
41
73
  * scoped to the organization embedded in the auth credential (JWT claim or
42
74
  * API-key binding). This is the load-bearing cross-org isolation guarantee.
43
75
  */
44
- type ServiceId = 'auth' | 'crm';
76
+
45
77
  declare const DEFAULT_SERVICE_URLS: Record<ServiceId, string>;
46
- /**
47
- * Service IDs as registered in TopoloAuth's service catalog. These are sent in
48
- * the `X-Service-ID` header when the platform needs to route an API-key
49
- * introspection or permission check.
50
- */
51
- declare const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>>;
52
78
  declare function resolveServiceUrl(service: ServiceId, overrides?: Partial<Record<ServiceId, string>>): string;
53
79
 
54
80
  interface TopoloClientOptions {
@@ -65,7 +91,41 @@ interface TopoloClientOptions {
65
91
  timeoutMs?: number;
66
92
  /** Injected fetch, for testing. Defaults to global fetch. */
67
93
  fetch?: typeof fetch;
94
+ /**
95
+ * Observability hook. Fires once per request lifecycle (`request` at start,
96
+ * then exactly one of `response` or `error`). Safe to leave unset — off by
97
+ * default. Intended for CLI/MCP hosts to surface request-level diagnostics
98
+ * and for consumers building their own logging/tracing.
99
+ */
100
+ debug?: (event: TopoloDebugEvent) => void;
68
101
  }
102
+ type TopoloDebugEvent = {
103
+ phase: 'request';
104
+ method: string;
105
+ service: ServiceId;
106
+ path: string;
107
+ url: string;
108
+ requestId: string;
109
+ } | {
110
+ phase: 'response';
111
+ method: string;
112
+ service: ServiceId;
113
+ path: string;
114
+ url: string;
115
+ requestId: string;
116
+ status: number;
117
+ durationMs: number;
118
+ } | {
119
+ phase: 'error';
120
+ method: string;
121
+ service: ServiceId;
122
+ path: string;
123
+ url: string;
124
+ requestId: string;
125
+ durationMs: number;
126
+ error: string;
127
+ status?: number;
128
+ };
69
129
  interface RequestOptions {
70
130
  service: ServiceId;
71
131
  path: string;
@@ -85,7 +145,9 @@ declare class TopoloClient {
85
145
  private readonly requireConfirmForWrites;
86
146
  private readonly timeoutMs;
87
147
  private readonly fetchImpl;
148
+ private readonly debug?;
88
149
  constructor(options: TopoloClientOptions);
150
+ private emit;
89
151
  /**
90
152
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
91
153
  * for anything a caller would reach for; this stays exported for escape-hatch
@@ -274,4 +336,4 @@ declare function createTopolo(options: TopoloClientOptions): {
274
336
  };
275
337
  type Topolo = ReturnType<typeof createTopolo>;
276
338
 
277
- export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
339
+ export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
package/dist/index.js CHANGED
@@ -56,18 +56,41 @@ var TopoloHttpError = class extends TopoloSdkError {
56
56
  }
57
57
  };
58
58
 
59
- // src/services.ts
60
- var DEFAULT_SERVICE_URLS = {
59
+ // src/services.generated.ts
60
+ var SERVICES = {
61
+ agent: "https://topolo-agent-worker.topolo.workers.dev",
61
62
  auth: "https://auth.topolo.app",
62
- crm: "https://topolo-crm-worker.topolo.workers.dev"
63
+ bugfix: "https://bugfix.topolo.app",
64
+ bytes: "https://r2-media-manager-worker.topolo.workers.dev",
65
+ chat: "https://chat.topolo.app",
66
+ commerce: "https://topolo-commerce-api.topolo.workers.dev",
67
+ crm: "https://crm-api.topolo.app",
68
+ forecast: "https://forecast-worker-production.topolo.workers.dev",
69
+ learn: "https://topololearn-api.topolo.workers.dev",
70
+ mdm: "https://nodo-mdm-api.topolo.app",
71
+ messaging: "https://messages.topolo.app",
72
+ nexus: "https://topolo-nexus-gateway.topolo.workers.dev",
73
+ one: "https://topoloone-worker.topolo.workers.dev",
74
+ pay: "https://topolo-pay.topolo.workers.dev",
75
+ "quro.api": "https://api.ol0.me",
76
+ "quro.redirect": "https://ol0.me",
77
+ roadmapper: "https://roadmapper-api.topolo.app",
78
+ social_studio: "https://topolo-social-studio-api.topolo.workers.dev",
79
+ socialize: "https://socialize-prod-api.topolo.workers.dev",
80
+ support: "https://support.topolo.app",
81
+ "web.api": "https://web-api.topolo.app",
82
+ "web.runtime": "https://sites.topolo.app"
63
83
  };
64
84
  var PLATFORM_SERVICE_IDS = {
65
85
  crm: "srv_iCwM4jGXcwlj"
66
86
  };
87
+
88
+ // src/services.ts
89
+ var DEFAULT_SERVICE_URLS = SERVICES;
67
90
  function resolveServiceUrl(service, overrides) {
68
91
  const override = overrides?.[service];
69
92
  if (override) return override;
70
- const envKey = `TOPOLO_SERVICE_URL_${service.toUpperCase()}`;
93
+ const envKey = `TOPOLO_SERVICE_URL_${service.replace(/\./g, "_").toUpperCase()}`;
71
94
  const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
72
95
  if (envValue) return envValue;
73
96
  return DEFAULT_SERVICE_URLS[service];
@@ -82,6 +105,7 @@ var TopoloClient = class {
82
105
  requireConfirmForWrites;
83
106
  timeoutMs;
84
107
  fetchImpl;
108
+ debug;
85
109
  constructor(options) {
86
110
  if (!options.credential) throw new TopoloAuthError("credential is required");
87
111
  if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
@@ -91,6 +115,14 @@ var TopoloClient = class {
91
115
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
92
116
  this.timeoutMs = options.timeoutMs ?? 3e4;
93
117
  this.fetchImpl = options.fetch ?? fetch;
118
+ this.debug = options.debug;
119
+ }
120
+ emit(event) {
121
+ if (!this.debug) return;
122
+ try {
123
+ this.debug(event);
124
+ } catch {
125
+ }
94
126
  }
95
127
  /**
96
128
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
@@ -117,27 +149,56 @@ var TopoloClient = class {
117
149
  const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
118
150
  if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
119
151
  applyAuthHeaders(headers, this.credential);
120
- applyAuditHeaders(headers, this.agent, generateRequestId());
152
+ const requestId = generateRequestId();
153
+ applyAuditHeaders(headers, this.agent, requestId);
121
154
  if (opts.headers) {
122
155
  for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
123
156
  }
124
157
  const controller = new AbortController();
125
158
  const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
126
159
  const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
160
+ const urlStr = url.toString();
161
+ const startedAt = Date.now();
162
+ this.emit({ phase: "request", method, service: opts.service, path: opts.path, url: urlStr, requestId });
127
163
  let res;
128
164
  try {
129
- res = await this.fetchImpl(url.toString(), {
165
+ res = await this.fetchImpl(urlStr, {
130
166
  method,
131
167
  headers,
132
168
  body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
133
169
  signal
134
170
  });
171
+ } catch (err) {
172
+ this.emit({
173
+ phase: "error",
174
+ method,
175
+ service: opts.service,
176
+ path: opts.path,
177
+ url: urlStr,
178
+ requestId,
179
+ durationMs: Date.now() - startedAt,
180
+ error: err instanceof Error ? err.message : String(err)
181
+ });
182
+ throw err;
135
183
  } finally {
136
184
  clearTimeout(timeoutId);
137
185
  }
138
186
  const contentType = res.headers.get("Content-Type") ?? "";
139
187
  const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
140
188
  if (!res.ok) {
189
+ const durationMs = Date.now() - startedAt;
190
+ const message = describeError(parsed, `HTTP ${res.status}`);
191
+ this.emit({
192
+ phase: "error",
193
+ method,
194
+ service: opts.service,
195
+ path: opts.path,
196
+ url: urlStr,
197
+ requestId,
198
+ durationMs,
199
+ error: message,
200
+ status: res.status
201
+ });
141
202
  if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
142
203
  if (res.status === 403) {
143
204
  throw new TopoloPermissionError(
@@ -147,6 +208,16 @@ var TopoloClient = class {
147
208
  }
148
209
  throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
149
210
  }
211
+ this.emit({
212
+ phase: "response",
213
+ method,
214
+ service: opts.service,
215
+ path: opts.path,
216
+ url: urlStr,
217
+ requestId,
218
+ status: res.status,
219
+ durationMs: Date.now() - startedAt
220
+ });
150
221
  return parsed;
151
222
  }
152
223
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/sdk",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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",
@@ -18,9 +18,11 @@
18
18
  "src"
19
19
  ],
20
20
  "scripts": {
21
+ "generate:services": "node scripts/generate-services.mjs",
22
+ "prebuild": "node scripts/generate-services.mjs",
21
23
  "build": "tsup src/index.ts --format esm,cjs --dts --clean",
22
24
  "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
23
- "typecheck": "tsc --noEmit",
25
+ "typecheck": "node scripts/generate-services.mjs && tsc --noEmit",
24
26
  "test": "vitest run",
25
27
  "test:watch": "vitest"
26
28
  },
@@ -167,5 +167,89 @@ describe('TopoloClient.request', () => {
167
167
  });
168
168
  });
169
169
 
170
+ describe('TopoloClient.debug hook', () => {
171
+ it('fires request → response on a successful call with stable requestId + duration', async () => {
172
+ const { impl } = stubFetch([{ body: { ok: true } }]);
173
+ const events: unknown[] = [];
174
+ const client = new TopoloClient({
175
+ credential: { kind: 'api_key', apiKey: 'x' },
176
+ agent: AGENT,
177
+ fetch: impl,
178
+ debug: (e) => events.push(e),
179
+ });
180
+ await client.request({ service: 'auth', path: '/api/me' });
181
+ expect(events).toHaveLength(2);
182
+ const typed = events as Array<{
183
+ phase: string;
184
+ method: string;
185
+ service: string;
186
+ path: string;
187
+ url: string;
188
+ requestId: string;
189
+ status?: number;
190
+ durationMs?: number;
191
+ }>;
192
+ const req = typed[0]!;
193
+ const res = typed[1]!;
194
+ expect(req.phase).toBe('request');
195
+ expect(req.method).toBe('GET');
196
+ expect(req.service).toBe('auth');
197
+ expect(req.path).toBe('/api/me');
198
+ expect(req.requestId).toMatch(/.+/);
199
+ expect(res.phase).toBe('response');
200
+ expect(res.status).toBe(200);
201
+ expect(res.requestId).toBe(req.requestId);
202
+ expect(typeof res.durationMs).toBe('number');
203
+ });
204
+
205
+ it('fires request → error on 4xx with the unwrapped message and status', async () => {
206
+ const { impl } = stubFetch([
207
+ { status: 401, body: { error: { code: 'auth_error', message: 'Expired' } } },
208
+ ]);
209
+ const events: Array<{ phase: string; error?: string; status?: number }> = [];
210
+ const client = new TopoloClient({
211
+ credential: { kind: 'api_key', apiKey: 'x' },
212
+ agent: AGENT,
213
+ fetch: impl,
214
+ debug: (e) => events.push(e),
215
+ });
216
+ await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toThrow(TopoloAuthError);
217
+ expect(events.map((e) => e.phase)).toEqual(['request', 'error']);
218
+ expect(events[1]!.status).toBe(401);
219
+ expect(events[1]!.error).toBe('Expired');
220
+ });
221
+
222
+ it('fires request → error on a thrown fetch (network failure)', async () => {
223
+ const impl = (vi.fn(async () => {
224
+ throw new Error('ECONNREFUSED');
225
+ }) as unknown) as typeof fetch;
226
+ const events: Array<{ phase: string; error?: string; status?: number }> = [];
227
+ const client = new TopoloClient({
228
+ credential: { kind: 'api_key', apiKey: 'x' },
229
+ agent: AGENT,
230
+ fetch: impl,
231
+ debug: (e) => events.push(e),
232
+ });
233
+ await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toThrow('ECONNREFUSED');
234
+ expect(events.map((e) => e.phase)).toEqual(['request', 'error']);
235
+ expect(events[1]!.error).toBe('ECONNREFUSED');
236
+ expect(events[1]!.status).toBeUndefined();
237
+ });
238
+
239
+ it('swallows throws from the debug callback without affecting the request path', async () => {
240
+ const { impl } = stubFetch([{ body: { ok: true } }]);
241
+ const client = new TopoloClient({
242
+ credential: { kind: 'api_key', apiKey: 'x' },
243
+ agent: AGENT,
244
+ fetch: impl,
245
+ debug: () => {
246
+ throw new Error('callback blew up');
247
+ },
248
+ });
249
+ // Returns normally despite the callback throwing on both events.
250
+ await expect(client.request({ service: 'auth', path: '/api/me' })).resolves.toBeDefined();
251
+ });
252
+ });
253
+
170
254
  // Silence unused-import warning if the bundler later tree-shakes these
171
255
  void TopoloHttpError;
package/src/client.ts CHANGED
@@ -26,8 +26,46 @@ export interface TopoloClientOptions {
26
26
  timeoutMs?: number;
27
27
  /** Injected fetch, for testing. Defaults to global fetch. */
28
28
  fetch?: typeof fetch;
29
+ /**
30
+ * Observability hook. Fires once per request lifecycle (`request` at start,
31
+ * then exactly one of `response` or `error`). Safe to leave unset — off by
32
+ * default. Intended for CLI/MCP hosts to surface request-level diagnostics
33
+ * and for consumers building their own logging/tracing.
34
+ */
35
+ debug?: (event: TopoloDebugEvent) => void;
29
36
  }
30
37
 
38
+ export type TopoloDebugEvent =
39
+ | {
40
+ phase: 'request';
41
+ method: string;
42
+ service: ServiceId;
43
+ path: string;
44
+ url: string;
45
+ requestId: string;
46
+ }
47
+ | {
48
+ phase: 'response';
49
+ method: string;
50
+ service: ServiceId;
51
+ path: string;
52
+ url: string;
53
+ requestId: string;
54
+ status: number;
55
+ durationMs: number;
56
+ }
57
+ | {
58
+ phase: 'error';
59
+ method: string;
60
+ service: ServiceId;
61
+ path: string;
62
+ url: string;
63
+ requestId: string;
64
+ durationMs: number;
65
+ error: string;
66
+ status?: number;
67
+ };
68
+
31
69
  export interface RequestOptions {
32
70
  service: ServiceId;
33
71
  path: string;
@@ -50,6 +88,7 @@ export class TopoloClient {
50
88
  private readonly requireConfirmForWrites: boolean;
51
89
  private readonly timeoutMs: number;
52
90
  private readonly fetchImpl: typeof fetch;
91
+ private readonly debug?: (event: TopoloDebugEvent) => void;
53
92
 
54
93
  constructor(options: TopoloClientOptions) {
55
94
  if (!options.credential) throw new TopoloAuthError('credential is required');
@@ -60,6 +99,16 @@ export class TopoloClient {
60
99
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
61
100
  this.timeoutMs = options.timeoutMs ?? 30_000;
62
101
  this.fetchImpl = options.fetch ?? fetch;
102
+ this.debug = options.debug;
103
+ }
104
+
105
+ private emit(event: TopoloDebugEvent): void {
106
+ if (!this.debug) return;
107
+ try {
108
+ this.debug(event);
109
+ } catch {
110
+ // Never let a debug consumer crash the request path.
111
+ }
63
112
  }
64
113
 
65
114
  /**
@@ -91,7 +140,8 @@ export class TopoloClient {
91
140
  if (platformServiceId) headers.set('X-Service-ID', platformServiceId);
92
141
 
93
142
  applyAuthHeaders(headers, this.credential);
94
- applyAuditHeaders(headers, this.agent, generateRequestId());
143
+ const requestId = generateRequestId();
144
+ applyAuditHeaders(headers, this.agent, requestId);
95
145
 
96
146
  if (opts.headers) {
97
147
  for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
@@ -103,14 +153,30 @@ export class TopoloClient {
103
153
  ? mergeSignals(opts.signal, controller.signal)
104
154
  : controller.signal;
105
155
 
156
+ const urlStr = url.toString();
157
+ const startedAt = Date.now();
158
+ this.emit({ phase: 'request', method, service: opts.service, path: opts.path, url: urlStr, requestId });
159
+
106
160
  let res: Response;
107
161
  try {
108
- res = await this.fetchImpl(url.toString(), {
162
+ res = await this.fetchImpl(urlStr, {
109
163
  method,
110
164
  headers,
111
165
  body: opts.body !== undefined ? JSON.stringify(opts.body) : null,
112
166
  signal,
113
167
  });
168
+ } catch (err) {
169
+ this.emit({
170
+ phase: 'error',
171
+ method,
172
+ service: opts.service,
173
+ path: opts.path,
174
+ url: urlStr,
175
+ requestId,
176
+ durationMs: Date.now() - startedAt,
177
+ error: err instanceof Error ? err.message : String(err),
178
+ });
179
+ throw err;
114
180
  } finally {
115
181
  clearTimeout(timeoutId);
116
182
  }
@@ -121,6 +187,19 @@ export class TopoloClient {
121
187
  : await res.text().catch(() => null);
122
188
 
123
189
  if (!res.ok) {
190
+ const durationMs = Date.now() - startedAt;
191
+ const message = describeError(parsed, `HTTP ${res.status}`);
192
+ this.emit({
193
+ phase: 'error',
194
+ method,
195
+ service: opts.service,
196
+ path: opts.path,
197
+ url: urlStr,
198
+ requestId,
199
+ durationMs,
200
+ error: message,
201
+ status: res.status,
202
+ });
124
203
  if (res.status === 401) throw new TopoloAuthError(describeError(parsed, 'Unauthorized'));
125
204
  if (res.status === 403) {
126
205
  throw new TopoloPermissionError(
@@ -131,6 +210,17 @@ export class TopoloClient {
131
210
  throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
132
211
  }
133
212
 
213
+ this.emit({
214
+ phase: 'response',
215
+ method,
216
+ service: opts.service,
217
+ path: opts.path,
218
+ url: urlStr,
219
+ requestId,
220
+ status: res.status,
221
+ durationMs: Date.now() - startedAt,
222
+ });
223
+
134
224
  return parsed as T;
135
225
  }
136
226
 
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  type TopoloClientOptions,
4
4
  type RequestOptions,
5
5
  type CredentialIntrospection,
6
+ type TopoloDebugEvent,
6
7
  } from './client.js';
7
8
  export type { TopoloCredential, AgentIdentity } from './auth.js';
8
9
  export {
@@ -0,0 +1,34 @@
1
+ // AUTO-GENERATED by scripts/generate-services.mjs. Do not edit by hand.
2
+ // Source of truth: topolo.cloudcontrol.json in every app repo.
3
+ // Regenerate: npm run generate:services
4
+
5
+ export const SERVICES = {
6
+ agent: "https://topolo-agent-worker.topolo.workers.dev",
7
+ auth: "https://auth.topolo.app",
8
+ bugfix: "https://bugfix.topolo.app",
9
+ bytes: "https://r2-media-manager-worker.topolo.workers.dev",
10
+ chat: "https://chat.topolo.app",
11
+ commerce: "https://topolo-commerce-api.topolo.workers.dev",
12
+ crm: "https://crm-api.topolo.app",
13
+ forecast: "https://forecast-worker-production.topolo.workers.dev",
14
+ learn: "https://topololearn-api.topolo.workers.dev",
15
+ mdm: "https://nodo-mdm-api.topolo.app",
16
+ messaging: "https://messages.topolo.app",
17
+ nexus: "https://topolo-nexus-gateway.topolo.workers.dev",
18
+ one: "https://topoloone-worker.topolo.workers.dev",
19
+ pay: "https://topolo-pay.topolo.workers.dev",
20
+ "quro.api": "https://api.ol0.me",
21
+ "quro.redirect": "https://ol0.me",
22
+ roadmapper: "https://roadmapper-api.topolo.app",
23
+ social_studio: "https://topolo-social-studio-api.topolo.workers.dev",
24
+ socialize: "https://socialize-prod-api.topolo.workers.dev",
25
+ support: "https://support.topolo.app",
26
+ "web.api": "https://web-api.topolo.app",
27
+ "web.runtime": "https://sites.topolo.app",
28
+ } as const;
29
+
30
+ export type ServiceId = keyof typeof SERVICES;
31
+
32
+ export const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>> = {
33
+ crm: "srv_iCwM4jGXcwlj",
34
+ };
package/src/services.ts CHANGED
@@ -1,30 +1,28 @@
1
1
  /**
2
- * Service registry — base URLs for each Topolo platform service.
2
+ * Service registry — base URLs for every Topolo platform service.
3
3
  *
4
- * Defaults point at production. Any entry can be overridden per-call via
5
- * `TopoloClient` options, or globally via env vars like `TOPOLO_SERVICE_URL_CRM`.
4
+ * Source of truth lives in each app's `topolo.cloudcontrol.json`. The
5
+ * `SERVICES` map is regenerated from those files via
6
+ * `scripts/generate-services.mjs`; edit the JSON, not the generated file.
7
+ *
8
+ * Any entry can be overridden per-call via `TopoloClient` options, or globally
9
+ * via env vars like `TOPOLO_SERVICE_URL_CRM` or `TOPOLO_SERVICE_URL_WEB_API`
10
+ * (dots in service IDs become underscores in env vars).
6
11
  *
7
12
  * IMPORTANT: The SDK NEVER accepts `orgId` as a parameter. Every request is
8
13
  * scoped to the organization embedded in the auth credential (JWT claim or
9
14
  * API-key binding). This is the load-bearing cross-org isolation guarantee.
10
15
  */
11
- export type ServiceId =
12
- | 'auth'
13
- | 'crm';
16
+ import {
17
+ SERVICES,
18
+ PLATFORM_SERVICE_IDS,
19
+ type ServiceId,
20
+ } from './services.generated.js';
14
21
 
15
- export const DEFAULT_SERVICE_URLS: Record<ServiceId, string> = {
16
- auth: 'https://auth.topolo.app',
17
- crm: 'https://topolo-crm-worker.topolo.workers.dev',
18
- };
22
+ export { SERVICES, PLATFORM_SERVICE_IDS };
23
+ export type { ServiceId };
19
24
 
20
- /**
21
- * Service IDs as registered in TopoloAuth's service catalog. These are sent in
22
- * the `X-Service-ID` header when the platform needs to route an API-key
23
- * introspection or permission check.
24
- */
25
- export const PLATFORM_SERVICE_IDS: Partial<Record<ServiceId, string>> = {
26
- crm: 'srv_iCwM4jGXcwlj',
27
- };
25
+ export const DEFAULT_SERVICE_URLS: Record<ServiceId, string> = SERVICES;
28
26
 
29
27
  export function resolveServiceUrl(
30
28
  service: ServiceId,
@@ -33,7 +31,7 @@ export function resolveServiceUrl(
33
31
  const override = overrides?.[service];
34
32
  if (override) return override;
35
33
 
36
- const envKey = `TOPOLO_SERVICE_URL_${service.toUpperCase()}`;
34
+ const envKey = `TOPOLO_SERVICE_URL_${service.replace(/\./g, '_').toUpperCase()}`;
37
35
  const envValue = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
38
36
  if (envValue) return envValue;
39
37