@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 +12 -1
- package/dist/index.js +12 -1
- package/package.json +6 -3
- package/src/client.test.ts +171 -0
- package/src/client.ts +14 -2
- package/src/oauth.test.ts +113 -0
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
|
-
|
|
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
|
-
|
|
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.
|
|
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?:
|
|
224
|
-
|
|
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;
|