@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 +62 -3
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +62 -3
- package/package.json +6 -3
- package/src/client.test.ts +255 -0
- package/src/client.ts +106 -4
- package/src/index.ts +1 -0
- package/src/oauth.test.ts +113 -0
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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?:
|
|
224
|
-
|
|
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
|
@@ -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;
|