@topolo/sdk 0.1.0 → 0.1.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
@@ -225,10 +225,21 @@ var TopoloClient = class {
225
225
  function describeError(body, fallback) {
226
226
  if (body && typeof body === "object") {
227
227
  const maybe = body;
228
- return maybe.message ?? maybe.error ?? fallback;
228
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
229
+ if (resolved) return resolved;
229
230
  }
230
231
  return fallback;
231
232
  }
233
+ function pickString(value) {
234
+ if (typeof value === "string" && value.trim().length > 0) return value;
235
+ if (value && typeof value === "object") {
236
+ const nested = value;
237
+ if (typeof nested.message === "string" && nested.message.trim().length > 0) return nested.message;
238
+ if (typeof nested.description === "string" && nested.description.trim().length > 0)
239
+ return nested.description;
240
+ }
241
+ return void 0;
242
+ }
232
243
  function extractRequired(body) {
233
244
  if (body && typeof body === "object") {
234
245
  const maybe = body;
package/dist/index.js CHANGED
@@ -188,10 +188,21 @@ var TopoloClient = class {
188
188
  function describeError(body, fallback) {
189
189
  if (body && typeof body === "object") {
190
190
  const maybe = body;
191
- return maybe.message ?? maybe.error ?? fallback;
191
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
192
+ if (resolved) return resolved;
192
193
  }
193
194
  return fallback;
194
195
  }
196
+ function pickString(value) {
197
+ if (typeof value === "string" && value.trim().length > 0) return value;
198
+ if (value && typeof value === "object") {
199
+ const nested = value;
200
+ if (typeof nested.message === "string" && nested.message.trim().length > 0) return nested.message;
201
+ if (typeof nested.description === "string" && nested.description.trim().length > 0)
202
+ return nested.description;
203
+ }
204
+ return void 0;
205
+ }
195
206
  function extractRequired(body) {
196
207
  if (body && typeof body === "object") {
197
208
  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.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",
@@ -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,171 @@
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
+ // Silence unused-import warning if the bundler later tree-shakes these
171
+ void TopoloHttpError;
package/src/client.ts CHANGED
@@ -220,12 +220,24 @@ interface RawOrg {
220
220
 
221
221
  function describeError(body: unknown, fallback: string): string {
222
222
  if (body && typeof body === 'object') {
223
- const maybe = body as { error?: string; message?: string };
224
- return maybe.message ?? maybe.error ?? fallback;
223
+ const maybe = body as { error?: unknown; message?: unknown };
224
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
225
+ if (resolved) return resolved;
225
226
  }
226
227
  return fallback;
227
228
  }
228
229
 
230
+ function pickString(value: unknown): string | undefined {
231
+ if (typeof value === 'string' && value.trim().length > 0) return value;
232
+ if (value && typeof value === 'object') {
233
+ const nested = value as { message?: unknown; description?: unknown };
234
+ if (typeof nested.message === 'string' && nested.message.trim().length > 0) return nested.message;
235
+ if (typeof nested.description === 'string' && nested.description.trim().length > 0)
236
+ return nested.description;
237
+ }
238
+ return undefined;
239
+ }
240
+
229
241
  function extractRequired(body: unknown): string[] {
230
242
  if (body && typeof body === 'object') {
231
243
  const maybe = body as { required?: unknown };
@@ -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;