@topolo/sdk 0.1.0 → 0.1.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/dist/index.cjs CHANGED
@@ -119,6 +119,7 @@ var TopoloClient = class {
119
119
  requireConfirmForWrites;
120
120
  timeoutMs;
121
121
  fetchImpl;
122
+ debug;
122
123
  constructor(options) {
123
124
  if (!options.credential) throw new TopoloAuthError("credential is required");
124
125
  if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
@@ -128,6 +129,14 @@ var TopoloClient = class {
128
129
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
129
130
  this.timeoutMs = options.timeoutMs ?? 3e4;
130
131
  this.fetchImpl = options.fetch ?? fetch;
132
+ this.debug = options.debug;
133
+ }
134
+ emit(event) {
135
+ if (!this.debug) return;
136
+ try {
137
+ this.debug(event);
138
+ } catch {
139
+ }
131
140
  }
132
141
  /**
133
142
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
@@ -154,27 +163,56 @@ var TopoloClient = class {
154
163
  const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
155
164
  if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
156
165
  applyAuthHeaders(headers, this.credential);
157
- applyAuditHeaders(headers, this.agent, generateRequestId());
166
+ const requestId = generateRequestId();
167
+ applyAuditHeaders(headers, this.agent, requestId);
158
168
  if (opts.headers) {
159
169
  for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
160
170
  }
161
171
  const controller = new AbortController();
162
172
  const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
163
173
  const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
174
+ const urlStr = url.toString();
175
+ const startedAt = Date.now();
176
+ this.emit({ phase: "request", method, service: opts.service, path: opts.path, url: urlStr, requestId });
164
177
  let res;
165
178
  try {
166
- res = await this.fetchImpl(url.toString(), {
179
+ res = await this.fetchImpl(urlStr, {
167
180
  method,
168
181
  headers,
169
182
  body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
170
183
  signal
171
184
  });
185
+ } catch (err) {
186
+ this.emit({
187
+ phase: "error",
188
+ method,
189
+ service: opts.service,
190
+ path: opts.path,
191
+ url: urlStr,
192
+ requestId,
193
+ durationMs: Date.now() - startedAt,
194
+ error: err instanceof Error ? err.message : String(err)
195
+ });
196
+ throw err;
172
197
  } finally {
173
198
  clearTimeout(timeoutId);
174
199
  }
175
200
  const contentType = res.headers.get("Content-Type") ?? "";
176
201
  const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
177
202
  if (!res.ok) {
203
+ const durationMs = Date.now() - startedAt;
204
+ const message = describeError(parsed, `HTTP ${res.status}`);
205
+ this.emit({
206
+ phase: "error",
207
+ method,
208
+ service: opts.service,
209
+ path: opts.path,
210
+ url: urlStr,
211
+ requestId,
212
+ durationMs,
213
+ error: message,
214
+ status: res.status
215
+ });
178
216
  if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
179
217
  if (res.status === 403) {
180
218
  throw new TopoloPermissionError(
@@ -184,6 +222,16 @@ var TopoloClient = class {
184
222
  }
185
223
  throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
186
224
  }
225
+ this.emit({
226
+ phase: "response",
227
+ method,
228
+ service: opts.service,
229
+ path: opts.path,
230
+ url: urlStr,
231
+ requestId,
232
+ status: res.status,
233
+ durationMs: Date.now() - startedAt
234
+ });
187
235
  return parsed;
188
236
  }
189
237
  /**
@@ -225,10 +273,21 @@ var TopoloClient = class {
225
273
  function describeError(body, fallback) {
226
274
  if (body && typeof body === "object") {
227
275
  const maybe = body;
228
- return maybe.message ?? maybe.error ?? fallback;
276
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
277
+ if (resolved) return resolved;
229
278
  }
230
279
  return fallback;
231
280
  }
281
+ function pickString(value) {
282
+ if (typeof value === "string" && value.trim().length > 0) return value;
283
+ if (value && typeof value === "object") {
284
+ const nested = value;
285
+ if (typeof nested.message === "string" && nested.message.trim().length > 0) return nested.message;
286
+ if (typeof nested.description === "string" && nested.description.trim().length > 0)
287
+ return nested.description;
288
+ }
289
+ return void 0;
290
+ }
232
291
  function extractRequired(body) {
233
292
  if (body && typeof body === "object") {
234
293
  const maybe = body;
package/dist/index.d.cts CHANGED
@@ -65,7 +65,41 @@ interface TopoloClientOptions {
65
65
  timeoutMs?: number;
66
66
  /** Injected fetch, for testing. Defaults to global fetch. */
67
67
  fetch?: typeof fetch;
68
+ /**
69
+ * Observability hook. Fires once per request lifecycle (`request` at start,
70
+ * then exactly one of `response` or `error`). Safe to leave unset — off by
71
+ * default. Intended for CLI/MCP hosts to surface request-level diagnostics
72
+ * and for consumers building their own logging/tracing.
73
+ */
74
+ debug?: (event: TopoloDebugEvent) => void;
68
75
  }
76
+ type TopoloDebugEvent = {
77
+ phase: 'request';
78
+ method: string;
79
+ service: ServiceId;
80
+ path: string;
81
+ url: string;
82
+ requestId: string;
83
+ } | {
84
+ phase: 'response';
85
+ method: string;
86
+ service: ServiceId;
87
+ path: string;
88
+ url: string;
89
+ requestId: string;
90
+ status: number;
91
+ durationMs: number;
92
+ } | {
93
+ phase: 'error';
94
+ method: string;
95
+ service: ServiceId;
96
+ path: string;
97
+ url: string;
98
+ requestId: string;
99
+ durationMs: number;
100
+ error: string;
101
+ status?: number;
102
+ };
69
103
  interface RequestOptions {
70
104
  service: ServiceId;
71
105
  path: string;
@@ -85,7 +119,9 @@ declare class TopoloClient {
85
119
  private readonly requireConfirmForWrites;
86
120
  private readonly timeoutMs;
87
121
  private readonly fetchImpl;
122
+ private readonly debug?;
88
123
  constructor(options: TopoloClientOptions);
124
+ private emit;
89
125
  /**
90
126
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
91
127
  * for anything a caller would reach for; this stays exported for escape-hatch
@@ -274,4 +310,4 @@ declare function createTopolo(options: TopoloClientOptions): {
274
310
  };
275
311
  type Topolo = ReturnType<typeof createTopolo>;
276
312
 
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 };
313
+ 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
@@ -65,7 +65,41 @@ interface TopoloClientOptions {
65
65
  timeoutMs?: number;
66
66
  /** Injected fetch, for testing. Defaults to global fetch. */
67
67
  fetch?: typeof fetch;
68
+ /**
69
+ * Observability hook. Fires once per request lifecycle (`request` at start,
70
+ * then exactly one of `response` or `error`). Safe to leave unset — off by
71
+ * default. Intended for CLI/MCP hosts to surface request-level diagnostics
72
+ * and for consumers building their own logging/tracing.
73
+ */
74
+ debug?: (event: TopoloDebugEvent) => void;
68
75
  }
76
+ type TopoloDebugEvent = {
77
+ phase: 'request';
78
+ method: string;
79
+ service: ServiceId;
80
+ path: string;
81
+ url: string;
82
+ requestId: string;
83
+ } | {
84
+ phase: 'response';
85
+ method: string;
86
+ service: ServiceId;
87
+ path: string;
88
+ url: string;
89
+ requestId: string;
90
+ status: number;
91
+ durationMs: number;
92
+ } | {
93
+ phase: 'error';
94
+ method: string;
95
+ service: ServiceId;
96
+ path: string;
97
+ url: string;
98
+ requestId: string;
99
+ durationMs: number;
100
+ error: string;
101
+ status?: number;
102
+ };
69
103
  interface RequestOptions {
70
104
  service: ServiceId;
71
105
  path: string;
@@ -85,7 +119,9 @@ declare class TopoloClient {
85
119
  private readonly requireConfirmForWrites;
86
120
  private readonly timeoutMs;
87
121
  private readonly fetchImpl;
122
+ private readonly debug?;
88
123
  constructor(options: TopoloClientOptions);
124
+ private emit;
89
125
  /**
90
126
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
91
127
  * for anything a caller would reach for; this stays exported for escape-hatch
@@ -274,4 +310,4 @@ declare function createTopolo(options: TopoloClientOptions): {
274
310
  };
275
311
  type Topolo = ReturnType<typeof createTopolo>;
276
312
 
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 };
313
+ 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
@@ -82,6 +82,7 @@ var TopoloClient = class {
82
82
  requireConfirmForWrites;
83
83
  timeoutMs;
84
84
  fetchImpl;
85
+ debug;
85
86
  constructor(options) {
86
87
  if (!options.credential) throw new TopoloAuthError("credential is required");
87
88
  if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
@@ -91,6 +92,14 @@ var TopoloClient = class {
91
92
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
92
93
  this.timeoutMs = options.timeoutMs ?? 3e4;
93
94
  this.fetchImpl = options.fetch ?? fetch;
95
+ this.debug = options.debug;
96
+ }
97
+ emit(event) {
98
+ if (!this.debug) return;
99
+ try {
100
+ this.debug(event);
101
+ } catch {
102
+ }
94
103
  }
95
104
  /**
96
105
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
@@ -117,27 +126,56 @@ var TopoloClient = class {
117
126
  const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
118
127
  if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
119
128
  applyAuthHeaders(headers, this.credential);
120
- applyAuditHeaders(headers, this.agent, generateRequestId());
129
+ const requestId = generateRequestId();
130
+ applyAuditHeaders(headers, this.agent, requestId);
121
131
  if (opts.headers) {
122
132
  for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
123
133
  }
124
134
  const controller = new AbortController();
125
135
  const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
126
136
  const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
137
+ const urlStr = url.toString();
138
+ const startedAt = Date.now();
139
+ this.emit({ phase: "request", method, service: opts.service, path: opts.path, url: urlStr, requestId });
127
140
  let res;
128
141
  try {
129
- res = await this.fetchImpl(url.toString(), {
142
+ res = await this.fetchImpl(urlStr, {
130
143
  method,
131
144
  headers,
132
145
  body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
133
146
  signal
134
147
  });
148
+ } catch (err) {
149
+ this.emit({
150
+ phase: "error",
151
+ method,
152
+ service: opts.service,
153
+ path: opts.path,
154
+ url: urlStr,
155
+ requestId,
156
+ durationMs: Date.now() - startedAt,
157
+ error: err instanceof Error ? err.message : String(err)
158
+ });
159
+ throw err;
135
160
  } finally {
136
161
  clearTimeout(timeoutId);
137
162
  }
138
163
  const contentType = res.headers.get("Content-Type") ?? "";
139
164
  const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
140
165
  if (!res.ok) {
166
+ const durationMs = Date.now() - startedAt;
167
+ const message = describeError(parsed, `HTTP ${res.status}`);
168
+ this.emit({
169
+ phase: "error",
170
+ method,
171
+ service: opts.service,
172
+ path: opts.path,
173
+ url: urlStr,
174
+ requestId,
175
+ durationMs,
176
+ error: message,
177
+ status: res.status
178
+ });
141
179
  if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
142
180
  if (res.status === 403) {
143
181
  throw new TopoloPermissionError(
@@ -147,6 +185,16 @@ var TopoloClient = class {
147
185
  }
148
186
  throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
149
187
  }
188
+ this.emit({
189
+ phase: "response",
190
+ method,
191
+ service: opts.service,
192
+ path: opts.path,
193
+ url: urlStr,
194
+ requestId,
195
+ status: res.status,
196
+ durationMs: Date.now() - startedAt
197
+ });
150
198
  return parsed;
151
199
  }
152
200
  /**
@@ -188,10 +236,21 @@ var TopoloClient = class {
188
236
  function describeError(body, fallback) {
189
237
  if (body && typeof body === "object") {
190
238
  const maybe = body;
191
- return maybe.message ?? maybe.error ?? fallback;
239
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
240
+ if (resolved) return resolved;
192
241
  }
193
242
  return fallback;
194
243
  }
244
+ function pickString(value) {
245
+ if (typeof value === "string" && value.trim().length > 0) return value;
246
+ if (value && typeof value === "object") {
247
+ const nested = value;
248
+ if (typeof nested.message === "string" && nested.message.trim().length > 0) return nested.message;
249
+ if (typeof nested.description === "string" && nested.description.trim().length > 0)
250
+ return nested.description;
251
+ }
252
+ return void 0;
253
+ }
195
254
  function extractRequired(body) {
196
255
  if (body && typeof body === "object") {
197
256
  const maybe = body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Typed client SDK for the Topolo platform. Used by TopoloCli, TopoloMCP, and third-party agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,12 +20,15 @@
20
20
  "scripts": {
21
21
  "build": "tsup src/index.ts --format esm,cjs --dts --clean",
22
22
  "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
23
- "typecheck": "tsc --noEmit"
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest"
24
26
  },
25
27
  "devDependencies": {
26
28
  "@types/node": "^20.19.39",
27
29
  "tsup": "^8.0.0",
28
- "typescript": "^5.4.0"
30
+ "typescript": "^5.4.0",
31
+ "vitest": "^2.1.0"
29
32
  },
30
33
  "engines": {
31
34
  "node": ">=20"
@@ -0,0 +1,255 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { TopoloClient } from './client.js';
3
+ import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
4
+
5
+ function stubFetch(responses: Array<{ status?: number; body?: unknown; contentType?: string }>) {
6
+ const calls: Array<{ url: string; init: RequestInit }> = [];
7
+ const impl = vi.fn(async (url: string, init: RequestInit) => {
8
+ calls.push({ url, init });
9
+ const next = responses.shift() ?? { status: 200, body: { ok: true } };
10
+ return new Response(JSON.stringify(next.body ?? {}), {
11
+ status: next.status ?? 200,
12
+ headers: { 'Content-Type': next.contentType ?? 'application/json' },
13
+ });
14
+ });
15
+ return { impl: impl as unknown as typeof fetch, calls };
16
+ }
17
+
18
+ const AGENT = { clientName: 'topolo-test', clientVersion: '0.0.0' };
19
+
20
+ describe('TopoloClient.request', () => {
21
+ it('injects Bearer auth + audit headers for access_token credentials', async () => {
22
+ const { impl, calls } = stubFetch([{ body: { ok: true } }]);
23
+ const client = new TopoloClient({
24
+ credential: { kind: 'access_token', accessToken: 'tok_abc' },
25
+ agent: AGENT,
26
+ fetch: impl,
27
+ });
28
+ await client.request({ service: 'auth', path: '/api/me' });
29
+ const headers = new Headers(calls[0]!.init.headers);
30
+ expect(headers.get('Authorization')).toBe('Bearer tok_abc');
31
+ expect(headers.get('X-Topolo-Client')).toBe('topolo-test/0.0.0');
32
+ expect(headers.get('X-Topolo-Request-Id')).toMatch(/.+/);
33
+ });
34
+
35
+ it('uses X-Api-Key header for api_key credentials', async () => {
36
+ const { impl, calls } = stubFetch([{ body: { ok: true } }]);
37
+ const client = new TopoloClient({
38
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
39
+ agent: AGENT,
40
+ fetch: impl,
41
+ });
42
+ await client.request({ service: 'crm', path: '/api/contacts' });
43
+ const headers = new Headers(calls[0]!.init.headers);
44
+ expect(headers.get('X-Api-Key')).toBe('dak_xyz');
45
+ expect(headers.get('Authorization')).toBeNull();
46
+ });
47
+
48
+ it('refuses mutating requests without confirm', async () => {
49
+ const { impl } = stubFetch([]);
50
+ const client = new TopoloClient({
51
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
52
+ agent: AGENT,
53
+ fetch: impl,
54
+ });
55
+ await expect(
56
+ client.request({ service: 'crm', method: 'POST', path: '/api/contacts', body: {} }),
57
+ ).rejects.toBeInstanceOf(TopoloAuthError);
58
+ });
59
+
60
+ it('allows mutating requests with confirm: true', async () => {
61
+ const { impl, calls } = stubFetch([{ body: { id: 'c_1' } }]);
62
+ const client = new TopoloClient({
63
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
64
+ agent: AGENT,
65
+ fetch: impl,
66
+ });
67
+ await client.request({
68
+ service: 'crm',
69
+ method: 'POST',
70
+ path: '/api/contacts',
71
+ body: { email: 'a@b.co' },
72
+ confirm: true,
73
+ });
74
+ expect(calls[0]!.init.method).toBe('POST');
75
+ });
76
+
77
+ it('maps 401 → TopoloAuthError with unwrapped message', async () => {
78
+ const { impl } = stubFetch([
79
+ { status: 401, body: { error: { code: 'invalid_token', message: 'Token expired' } } },
80
+ ]);
81
+ const client = new TopoloClient({
82
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
83
+ agent: AGENT,
84
+ fetch: impl,
85
+ });
86
+ await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toMatchObject({
87
+ name: 'TopoloAuthError',
88
+ code: 'auth_error',
89
+ message: 'Token expired',
90
+ });
91
+ });
92
+
93
+ it('regression: nested error.message object never stringifies to [object Object]', async () => {
94
+ const { impl } = stubFetch([
95
+ { status: 401, body: { error: { code: 'bad_sig', message: 'Bad signature' } } },
96
+ ]);
97
+ const client = new TopoloClient({
98
+ credential: { kind: 'access_token', accessToken: 'bad' },
99
+ agent: AGENT,
100
+ fetch: impl,
101
+ });
102
+ try {
103
+ await client.request({ service: 'auth', path: '/api/me' });
104
+ throw new Error('expected throw');
105
+ } catch (err) {
106
+ expect(err).toBeInstanceOf(TopoloAuthError);
107
+ expect((err as Error).message).not.toBe('[object Object]');
108
+ expect((err as Error).message).toContain('Bad signature');
109
+ }
110
+ });
111
+
112
+ it('maps 403 → TopoloPermissionError with required scopes extracted', async () => {
113
+ const { impl } = stubFetch([
114
+ { status: 403, body: { error: 'forbidden', required: ['crm.contacts:write'] } },
115
+ ]);
116
+ const client = new TopoloClient({
117
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
118
+ agent: AGENT,
119
+ fetch: impl,
120
+ });
121
+ await expect(
122
+ client.request({ service: 'crm', method: 'POST', path: '/api/contacts', body: {}, confirm: true }),
123
+ ).rejects.toBeInstanceOf(TopoloPermissionError);
124
+ });
125
+
126
+ it('maps non-401/403 failures → TopoloHttpError with status preserved', async () => {
127
+ const { impl } = stubFetch([{ status: 500, body: { error: 'boom' } }]);
128
+ const client = new TopoloClient({
129
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
130
+ agent: AGENT,
131
+ fetch: impl,
132
+ });
133
+ await expect(client.request({ service: 'crm', path: '/api/contacts' })).rejects.toMatchObject({
134
+ name: 'TopoloHttpError',
135
+ status: 500,
136
+ });
137
+ });
138
+
139
+ it('appends query params as search string', async () => {
140
+ const { impl, calls } = stubFetch([{ body: { contacts: [] } }]);
141
+ const client = new TopoloClient({
142
+ credential: { kind: 'api_key', apiKey: 'dak_xyz' },
143
+ agent: AGENT,
144
+ fetch: impl,
145
+ });
146
+ await client.request({ service: 'crm', path: '/api/contacts', query: { q: 'acme', page: 2 } });
147
+ const u = new URL(calls[0]!.url);
148
+ expect(u.searchParams.get('q')).toBe('acme');
149
+ expect(u.searchParams.get('page')).toBe('2');
150
+ });
151
+
152
+ it('rejects missing credential or agent.clientName at construction', () => {
153
+ expect(
154
+ () =>
155
+ new TopoloClient({
156
+ credential: undefined as never,
157
+ agent: AGENT,
158
+ }),
159
+ ).toThrow(TopoloAuthError);
160
+ expect(
161
+ () =>
162
+ new TopoloClient({
163
+ credential: { kind: 'api_key', apiKey: 'x' },
164
+ agent: { clientName: '' as unknown as string, clientVersion: '0' },
165
+ }),
166
+ ).toThrow(TopoloAuthError);
167
+ });
168
+ });
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
+
254
+ // Silence unused-import warning if the bundler later tree-shakes these
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
 
@@ -220,12 +310,24 @@ interface RawOrg {
220
310
 
221
311
  function describeError(body: unknown, fallback: string): string {
222
312
  if (body && typeof body === 'object') {
223
- const maybe = body as { error?: string; message?: string };
224
- return maybe.message ?? maybe.error ?? fallback;
313
+ const maybe = body as { error?: unknown; message?: unknown };
314
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
315
+ if (resolved) return resolved;
225
316
  }
226
317
  return fallback;
227
318
  }
228
319
 
320
+ function pickString(value: unknown): string | undefined {
321
+ if (typeof value === 'string' && value.trim().length > 0) return value;
322
+ if (value && typeof value === 'object') {
323
+ const nested = value as { message?: unknown; description?: unknown };
324
+ if (typeof nested.message === 'string' && nested.message.trim().length > 0) return nested.message;
325
+ if (typeof nested.description === 'string' && nested.description.trim().length > 0)
326
+ return nested.description;
327
+ }
328
+ return undefined;
329
+ }
330
+
229
331
  function extractRequired(body: unknown): string[] {
230
332
  if (body && typeof body === 'object') {
231
333
  const maybe = body as { required?: unknown };
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,113 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { TopoloOAuth } from './oauth.js';
3
+ import { TopoloAuthError, TopoloHttpError } from './errors.js';
4
+
5
+ function stubFetch(next: { status: number; body: unknown }) {
6
+ const calls: Array<{ url: string; init: RequestInit }> = [];
7
+ const impl = vi.fn(async (url: string, init: RequestInit) => {
8
+ calls.push({ url, init });
9
+ return new Response(JSON.stringify(next.body ?? {}), {
10
+ status: next.status,
11
+ headers: { 'Content-Type': 'application/json' },
12
+ });
13
+ });
14
+ return { impl: impl as unknown as typeof fetch, calls };
15
+ }
16
+
17
+ describe('TopoloOAuth.pollDeviceToken', () => {
18
+ it('maps authorization_pending response into TopoloAuthError(code=authorization_pending)', async () => {
19
+ const { impl } = stubFetch({
20
+ status: 400,
21
+ body: { error: 'authorization_pending', error_description: 'Pending user approval' },
22
+ });
23
+ const oauth = new TopoloOAuth({ fetch: impl });
24
+ await expect(
25
+ oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
26
+ ).rejects.toMatchObject({
27
+ name: 'TopoloAuthError',
28
+ code: 'authorization_pending',
29
+ });
30
+ });
31
+
32
+ it('maps slow_down into TopoloAuthError(code=slow_down)', async () => {
33
+ const { impl } = stubFetch({
34
+ status: 400,
35
+ body: { error: 'slow_down', error_description: 'Back off' },
36
+ });
37
+ const oauth = new TopoloOAuth({ fetch: impl });
38
+ await expect(
39
+ oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
40
+ ).rejects.toMatchObject({ code: 'slow_down' });
41
+ });
42
+
43
+ it('returns TokenResponse on 200', async () => {
44
+ const { impl } = stubFetch({
45
+ status: 200,
46
+ body: { access_token: 'at', token_type: 'Bearer', expires_in: 3600, refresh_token: 'rt' },
47
+ });
48
+ const oauth = new TopoloOAuth({ fetch: impl });
49
+ const res = await oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' });
50
+ expect(res.access_token).toBe('at');
51
+ expect(res.refresh_token).toBe('rt');
52
+ });
53
+
54
+ it('wraps unknown non-OAuth failures as TopoloHttpError', async () => {
55
+ const { impl } = stubFetch({ status: 500, body: { unexpected: true } });
56
+ const oauth = new TopoloOAuth({ fetch: impl });
57
+ await expect(
58
+ oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
59
+ ).rejects.toBeInstanceOf(TopoloHttpError);
60
+ });
61
+ });
62
+
63
+ describe('TopoloOAuth.refreshToken', () => {
64
+ it('POSTs grant_type=refresh_token with the client_id + refresh_token', async () => {
65
+ const { impl, calls } = stubFetch({
66
+ status: 200,
67
+ body: { access_token: 'new', token_type: 'Bearer', expires_in: 3600, refresh_token: 'new_rt' },
68
+ });
69
+ const oauth = new TopoloOAuth({ fetch: impl });
70
+ const res = await oauth.refreshToken({ clientId: 'doac_x', refreshToken: 'rt_old' });
71
+ expect(res.access_token).toBe('new');
72
+ const body = new URLSearchParams(calls[0]!.init.body as string);
73
+ expect(body.get('grant_type')).toBe('refresh_token');
74
+ expect(body.get('client_id')).toBe('doac_x');
75
+ expect(body.get('refresh_token')).toBe('rt_old');
76
+ });
77
+
78
+ it('surfaces invalid_grant as TopoloAuthError', async () => {
79
+ const { impl } = stubFetch({
80
+ status: 400,
81
+ body: { error: 'invalid_grant', error_description: 'Refresh token revoked' },
82
+ });
83
+ const oauth = new TopoloOAuth({ fetch: impl });
84
+ await expect(
85
+ oauth.refreshToken({ clientId: 'doac_x', refreshToken: 'rt_dead' }),
86
+ ).rejects.toMatchObject({
87
+ name: 'TopoloAuthError',
88
+ code: 'invalid_grant',
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('TopoloOAuth.requestDeviceCode', () => {
94
+ it('joins array scopes with a space', async () => {
95
+ const { impl, calls } = stubFetch({
96
+ status: 200,
97
+ body: {
98
+ device_code: 'dc',
99
+ user_code: 'ABCD-1234',
100
+ verification_uri: 'https://ex/dev',
101
+ expires_in: 600,
102
+ interval: 5,
103
+ },
104
+ });
105
+ const oauth = new TopoloOAuth({ fetch: impl });
106
+ await oauth.requestDeviceCode({ clientId: 'doac_x', scope: ['read', 'write'] });
107
+ const body = new URLSearchParams(calls[0]!.init.body as string);
108
+ expect(body.get('scope')).toBe('read write');
109
+ });
110
+ });
111
+
112
+ // Keep the TopoloAuthError import non-dead for type inference
113
+ void TopoloAuthError;