@topolo/sdk 0.1.0
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/README.md +123 -0
- package/dist/index.cjs +414 -0
- package/dist/index.d.cts +277 -0
- package/dist/index.d.ts +277 -0
- package/dist/index.js +376 -0
- package/package.json +33 -0
- package/src/auth.ts +48 -0
- package/src/client.ts +245 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +49 -0
- package/src/modules/crm.ts +110 -0
- package/src/modules/identity.ts +18 -0
- package/src/oauth.ts +138 -0
- package/src/services.ts +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// src/auth.ts
|
|
2
|
+
function applyAuthHeaders(headers, credential) {
|
|
3
|
+
if (credential.kind === "api_key") {
|
|
4
|
+
headers.set("X-Api-Key", credential.apiKey);
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
headers.set("Authorization", `Bearer ${credential.accessToken}`);
|
|
8
|
+
}
|
|
9
|
+
function applyAuditHeaders(headers, agent, requestId) {
|
|
10
|
+
headers.set("X-Topolo-Client", `${agent.clientName}/${agent.clientVersion}`);
|
|
11
|
+
if (agent.agentName) headers.set("X-Topolo-Agent", agent.agentName);
|
|
12
|
+
headers.set("X-Topolo-Request-Id", requestId);
|
|
13
|
+
}
|
|
14
|
+
function generateRequestId() {
|
|
15
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
16
|
+
return crypto.randomUUID();
|
|
17
|
+
}
|
|
18
|
+
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/errors.ts
|
|
22
|
+
var TopoloSdkError = class extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "TopoloSdkError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var TopoloAuthError = class extends TopoloSdkError {
|
|
31
|
+
constructor(message, code = "auth_error") {
|
|
32
|
+
super(code, message);
|
|
33
|
+
this.name = "TopoloAuthError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var TopoloPermissionError = class extends TopoloSdkError {
|
|
37
|
+
required;
|
|
38
|
+
constructor(message, required) {
|
|
39
|
+
super("permission_denied", message);
|
|
40
|
+
this.name = "TopoloPermissionError";
|
|
41
|
+
this.required = required;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var TopoloHttpError = class extends TopoloSdkError {
|
|
45
|
+
status;
|
|
46
|
+
body;
|
|
47
|
+
service;
|
|
48
|
+
path;
|
|
49
|
+
constructor(service, path, status, body, message) {
|
|
50
|
+
super("http_error", message ?? `HTTP ${status} from ${service}${path}`);
|
|
51
|
+
this.name = "TopoloHttpError";
|
|
52
|
+
this.status = status;
|
|
53
|
+
this.body = body;
|
|
54
|
+
this.service = service;
|
|
55
|
+
this.path = path;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/services.ts
|
|
60
|
+
var DEFAULT_SERVICE_URLS = {
|
|
61
|
+
auth: "https://auth.topolo.app",
|
|
62
|
+
crm: "https://topolo-crm-worker.topolo.workers.dev"
|
|
63
|
+
};
|
|
64
|
+
var PLATFORM_SERVICE_IDS = {
|
|
65
|
+
crm: "srv_iCwM4jGXcwlj"
|
|
66
|
+
};
|
|
67
|
+
function resolveServiceUrl(service, overrides) {
|
|
68
|
+
const override = overrides?.[service];
|
|
69
|
+
if (override) return override;
|
|
70
|
+
const envKey = `TOPOLO_SERVICE_URL_${service.toUpperCase()}`;
|
|
71
|
+
const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
|
|
72
|
+
if (envValue) return envValue;
|
|
73
|
+
return DEFAULT_SERVICE_URLS[service];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/client.ts
|
|
77
|
+
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
78
|
+
var TopoloClient = class {
|
|
79
|
+
credential;
|
|
80
|
+
agent;
|
|
81
|
+
serviceUrls;
|
|
82
|
+
requireConfirmForWrites;
|
|
83
|
+
timeoutMs;
|
|
84
|
+
fetchImpl;
|
|
85
|
+
constructor(options) {
|
|
86
|
+
if (!options.credential) throw new TopoloAuthError("credential is required");
|
|
87
|
+
if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
|
|
88
|
+
this.credential = options.credential;
|
|
89
|
+
this.agent = options.agent;
|
|
90
|
+
this.serviceUrls = options.serviceUrls;
|
|
91
|
+
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
92
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
93
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
|
|
97
|
+
* for anything a caller would reach for; this stays exported for escape-hatch
|
|
98
|
+
* use and for the generic `topolo api` / MCP passthrough tool.
|
|
99
|
+
*/
|
|
100
|
+
async request(opts) {
|
|
101
|
+
const method = opts.method ?? "GET";
|
|
102
|
+
if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
|
|
103
|
+
throw new TopoloAuthError(
|
|
104
|
+
`Mutating ${method} requests require { confirm: true }. This guardrail prevents agents from issuing writes without an explicit human-in-the-loop acknowledgement.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const baseUrl = resolveServiceUrl(opts.service, this.serviceUrls);
|
|
108
|
+
const url = new URL(opts.path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
|
|
109
|
+
if (opts.query) {
|
|
110
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
111
|
+
if (v !== void 0) url.searchParams.set(k, String(v));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const headers = new Headers();
|
|
115
|
+
headers.set("Accept", "application/json");
|
|
116
|
+
if (opts.body !== void 0) headers.set("Content-Type", "application/json");
|
|
117
|
+
const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
|
|
118
|
+
if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
|
|
119
|
+
applyAuthHeaders(headers, this.credential);
|
|
120
|
+
applyAuditHeaders(headers, this.agent, generateRequestId());
|
|
121
|
+
if (opts.headers) {
|
|
122
|
+
for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
|
|
123
|
+
}
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
126
|
+
const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
|
|
127
|
+
let res;
|
|
128
|
+
try {
|
|
129
|
+
res = await this.fetchImpl(url.toString(), {
|
|
130
|
+
method,
|
|
131
|
+
headers,
|
|
132
|
+
body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
|
|
133
|
+
signal
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timeoutId);
|
|
137
|
+
}
|
|
138
|
+
const contentType = res.headers.get("Content-Type") ?? "";
|
|
139
|
+
const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
|
|
142
|
+
if (res.status === 403) {
|
|
143
|
+
throw new TopoloPermissionError(
|
|
144
|
+
describeError(parsed, "Permission denied"),
|
|
145
|
+
extractRequired(parsed)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
|
|
149
|
+
}
|
|
150
|
+
return parsed;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Introspect the current credential. Returns the caller's resolved identity,
|
|
154
|
+
* organization, and permission set. Used by CLI `whoami` and by MCP to gate
|
|
155
|
+
* advertised tools by scope.
|
|
156
|
+
*
|
|
157
|
+
* Both JWT access tokens and platform API keys are resolved via the unified
|
|
158
|
+
* `GET /api/auth/me` endpoint on TopoloAuth, which does not require service
|
|
159
|
+
* credentials. The caller's possession of the credential secret is proof.
|
|
160
|
+
*/
|
|
161
|
+
async introspect() {
|
|
162
|
+
const res = await this.request({
|
|
163
|
+
service: "auth",
|
|
164
|
+
method: "GET",
|
|
165
|
+
path: "/api/auth/me"
|
|
166
|
+
});
|
|
167
|
+
const payload = res.data ?? res;
|
|
168
|
+
const user = payload.user;
|
|
169
|
+
const organization = payload.organization;
|
|
170
|
+
const kind = payload.credentialType === "api_key" ? "api_key" : "access_token";
|
|
171
|
+
return {
|
|
172
|
+
kind,
|
|
173
|
+
user: {
|
|
174
|
+
id: user.id,
|
|
175
|
+
email: user.email,
|
|
176
|
+
name: user.name ?? null,
|
|
177
|
+
role: user.role ?? (kind === "api_key" ? "service" : "member"),
|
|
178
|
+
permissions: payload.permissions ?? user.permissions ?? []
|
|
179
|
+
},
|
|
180
|
+
organization: organization ? {
|
|
181
|
+
id: organization.id,
|
|
182
|
+
slug: organization.slug,
|
|
183
|
+
name: organization.name ?? organization.slug
|
|
184
|
+
} : user.orgId && user.orgSlug ? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug } : null
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
function describeError(body, fallback) {
|
|
189
|
+
if (body && typeof body === "object") {
|
|
190
|
+
const maybe = body;
|
|
191
|
+
return maybe.message ?? maybe.error ?? fallback;
|
|
192
|
+
}
|
|
193
|
+
return fallback;
|
|
194
|
+
}
|
|
195
|
+
function extractRequired(body) {
|
|
196
|
+
if (body && typeof body === "object") {
|
|
197
|
+
const maybe = body;
|
|
198
|
+
if (Array.isArray(maybe.required)) return maybe.required.map(String);
|
|
199
|
+
}
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
function mergeSignals(a, b) {
|
|
203
|
+
if (a.aborted) return a;
|
|
204
|
+
if (b.aborted) return b;
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const onAbort = () => controller.abort();
|
|
207
|
+
a.addEventListener("abort", onAbort, { once: true });
|
|
208
|
+
b.addEventListener("abort", onAbort, { once: true });
|
|
209
|
+
return controller.signal;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/modules/crm.ts
|
|
213
|
+
var CrmModule = class {
|
|
214
|
+
constructor(client) {
|
|
215
|
+
this.client = client;
|
|
216
|
+
}
|
|
217
|
+
client;
|
|
218
|
+
async listContacts(options = {}) {
|
|
219
|
+
const res = await this.client.request({
|
|
220
|
+
service: "crm",
|
|
221
|
+
path: "/api/contacts",
|
|
222
|
+
query: {
|
|
223
|
+
q: options.q,
|
|
224
|
+
page: options.page,
|
|
225
|
+
pageSize: options.pageSize
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
const rows = Array.isArray(res?.contacts) ? res.contacts : Array.isArray(res?.data) ? res.data : [];
|
|
229
|
+
return {
|
|
230
|
+
contacts: rows.map(toSummary),
|
|
231
|
+
total: res?.total ?? res?.pagination?.total ?? rows.length,
|
|
232
|
+
page: res?.page ?? res?.pagination?.page ?? options.page ?? 1,
|
|
233
|
+
pageSize: res?.pageSize ?? res?.pagination?.pageSize ?? options.pageSize ?? rows.length
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async getContact(contactId) {
|
|
237
|
+
if (!contactId) throw new Error("contactId is required");
|
|
238
|
+
const res = await this.client.request({
|
|
239
|
+
service: "crm",
|
|
240
|
+
path: `/api/contacts/${encodeURIComponent(contactId)}`
|
|
241
|
+
});
|
|
242
|
+
const row = res.data ?? res;
|
|
243
|
+
return row ? toSummary(row) : null;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
function toSummary(row) {
|
|
247
|
+
return {
|
|
248
|
+
id: row.id,
|
|
249
|
+
firstName: row.first ?? null,
|
|
250
|
+
lastName: row.last ?? null,
|
|
251
|
+
email: row.email ?? null,
|
|
252
|
+
phone: row.phone ?? null,
|
|
253
|
+
company: row.company ?? null,
|
|
254
|
+
leadStatus: row.lead_status ?? null,
|
|
255
|
+
lifecycleStage: row.lifecycle_stage ?? null,
|
|
256
|
+
updatedAt: row.updated_at ?? null
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/modules/identity.ts
|
|
261
|
+
var IdentityModule = class {
|
|
262
|
+
constructor(client) {
|
|
263
|
+
this.client = client;
|
|
264
|
+
}
|
|
265
|
+
client;
|
|
266
|
+
/** Returns the resolved user, organization, and permission set for the
|
|
267
|
+
* current credential. Equivalent to `TopoloClient.introspect()` — exposed
|
|
268
|
+
* on this module for symmetry with the other domain modules. */
|
|
269
|
+
whoami() {
|
|
270
|
+
return this.client.introspect();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// src/oauth.ts
|
|
275
|
+
var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
276
|
+
var TopoloOAuth = class {
|
|
277
|
+
baseUrl;
|
|
278
|
+
fetchImpl;
|
|
279
|
+
constructor(options = {}) {
|
|
280
|
+
this.baseUrl = resolveServiceUrl("auth", options.serviceUrls);
|
|
281
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* RFC 8628 device authorization grant — starts the flow by asking the server
|
|
285
|
+
* for a device_code + user_code. The caller prints the user_code +
|
|
286
|
+
* verification_uri and then polls `pollDeviceToken` until approval.
|
|
287
|
+
*/
|
|
288
|
+
async requestDeviceCode(params) {
|
|
289
|
+
const body = new URLSearchParams();
|
|
290
|
+
body.set("client_id", params.clientId);
|
|
291
|
+
if (params.scope) {
|
|
292
|
+
body.set("scope", Array.isArray(params.scope) ? params.scope.join(" ") : params.scope);
|
|
293
|
+
}
|
|
294
|
+
return this.#postForm("/api/developer-oauth/device_authorization", body);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Polls the token endpoint once. Returns the token pair on success, or
|
|
298
|
+
* throws a `TopoloAuthError` whose `code` is one of the RFC 8628 poll
|
|
299
|
+
* states: `authorization_pending`, `slow_down`, `access_denied`,
|
|
300
|
+
* `expired_token`. Callers should back off on `slow_down`, wait at least
|
|
301
|
+
* one `interval` on `authorization_pending`, and give up on the rest.
|
|
302
|
+
*/
|
|
303
|
+
async pollDeviceToken(params) {
|
|
304
|
+
const body = new URLSearchParams();
|
|
305
|
+
body.set("grant_type", DEVICE_GRANT);
|
|
306
|
+
body.set("client_id", params.clientId);
|
|
307
|
+
body.set("device_code", params.deviceCode);
|
|
308
|
+
if (params.clientSecret) body.set("client_secret", params.clientSecret);
|
|
309
|
+
return this.#postForm("/api/developer-oauth/token", body);
|
|
310
|
+
}
|
|
311
|
+
/** Exchange an authorization code (with PKCE) for tokens. */
|
|
312
|
+
async exchangeAuthorizationCode(params) {
|
|
313
|
+
const body = new URLSearchParams();
|
|
314
|
+
body.set("grant_type", "authorization_code");
|
|
315
|
+
body.set("client_id", params.clientId);
|
|
316
|
+
body.set("code", params.code);
|
|
317
|
+
body.set("redirect_uri", params.redirectUri);
|
|
318
|
+
if (params.codeVerifier) body.set("code_verifier", params.codeVerifier);
|
|
319
|
+
if (params.clientSecret) body.set("client_secret", params.clientSecret);
|
|
320
|
+
return this.#postForm("/api/developer-oauth/token", body);
|
|
321
|
+
}
|
|
322
|
+
/** Rotate a refresh token for a new token pair. */
|
|
323
|
+
async refreshToken(params) {
|
|
324
|
+
const body = new URLSearchParams();
|
|
325
|
+
body.set("grant_type", "refresh_token");
|
|
326
|
+
body.set("client_id", params.clientId);
|
|
327
|
+
body.set("refresh_token", params.refreshToken);
|
|
328
|
+
if (params.clientSecret) body.set("client_secret", params.clientSecret);
|
|
329
|
+
return this.#postForm("/api/developer-oauth/token", body);
|
|
330
|
+
}
|
|
331
|
+
async #postForm(path, body) {
|
|
332
|
+
const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
|
|
333
|
+
const res = await this.fetchImpl(url.toString(), {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: {
|
|
336
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
337
|
+
Accept: "application/json"
|
|
338
|
+
},
|
|
339
|
+
body: body.toString()
|
|
340
|
+
});
|
|
341
|
+
const contentType = res.headers.get("Content-Type") ?? "";
|
|
342
|
+
const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
|
345
|
+
const err = parsed;
|
|
346
|
+
throw new TopoloAuthError(err.error_description || err.error, err.error);
|
|
347
|
+
}
|
|
348
|
+
throw new TopoloHttpError("auth", path, res.status, parsed);
|
|
349
|
+
}
|
|
350
|
+
return parsed;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/index.ts
|
|
355
|
+
function createTopolo(options) {
|
|
356
|
+
const client = new TopoloClient(options);
|
|
357
|
+
return {
|
|
358
|
+
client,
|
|
359
|
+
identity: new IdentityModule(client),
|
|
360
|
+
crm: new CrmModule(client)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
export {
|
|
364
|
+
CrmModule,
|
|
365
|
+
DEFAULT_SERVICE_URLS,
|
|
366
|
+
IdentityModule,
|
|
367
|
+
PLATFORM_SERVICE_IDS,
|
|
368
|
+
TopoloAuthError,
|
|
369
|
+
TopoloClient,
|
|
370
|
+
TopoloHttpError,
|
|
371
|
+
TopoloOAuth,
|
|
372
|
+
TopoloPermissionError,
|
|
373
|
+
TopoloSdkError,
|
|
374
|
+
createTopolo,
|
|
375
|
+
resolveServiceUrl
|
|
376
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@topolo/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed client SDK for the Topolo platform. Used by TopoloCli, TopoloMCP, and third-party agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
22
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20.19.39",
|
|
27
|
+
"tsup": "^8.0.0",
|
|
28
|
+
"typescript": "^5.4.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth credential types used by the SDK.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 supports two modes:
|
|
5
|
+
* - `api_key`: a single platform API key (service-scoped at issuance).
|
|
6
|
+
* Preferred for long-lived agent installs.
|
|
7
|
+
* - `access_token`: a short-lived JWT from the interactive login flow.
|
|
8
|
+
* Used by TopoloCli after `topolo auth login`.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 will add OAuth access tokens issued by the authorization-code +
|
|
11
|
+
* PKCE flow in TopoloAuth. Those will plug in as a third variant.
|
|
12
|
+
*/
|
|
13
|
+
export type TopoloCredential =
|
|
14
|
+
| { kind: 'api_key'; apiKey: string }
|
|
15
|
+
| { kind: 'access_token'; accessToken: string; refreshToken?: string; expiresAt?: number };
|
|
16
|
+
|
|
17
|
+
export interface AgentIdentity {
|
|
18
|
+
/** Display name of the agent or tool making requests. Included in audit headers. */
|
|
19
|
+
clientName: string;
|
|
20
|
+
/** Semver of the client. Included in audit headers. */
|
|
21
|
+
clientVersion: string;
|
|
22
|
+
/**
|
|
23
|
+
* Optional agent-assigned label (e.g. "claude-code", "codex-cli") used for
|
|
24
|
+
* multi-tenant audit attribution when the client is used by another agent.
|
|
25
|
+
*/
|
|
26
|
+
agentName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyAuthHeaders(headers: Headers, credential: TopoloCredential): void {
|
|
30
|
+
if (credential.kind === 'api_key') {
|
|
31
|
+
headers.set('X-Api-Key', credential.apiKey);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
headers.set('Authorization', `Bearer ${credential.accessToken}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function applyAuditHeaders(headers: Headers, agent: AgentIdentity, requestId: string): void {
|
|
38
|
+
headers.set('X-Topolo-Client', `${agent.clientName}/${agent.clientVersion}`);
|
|
39
|
+
if (agent.agentName) headers.set('X-Topolo-Agent', agent.agentName);
|
|
40
|
+
headers.set('X-Topolo-Request-Id', requestId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generateRequestId(): string {
|
|
44
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
45
|
+
return crypto.randomUUID();
|
|
46
|
+
}
|
|
47
|
+
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
48
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyAuditHeaders,
|
|
3
|
+
applyAuthHeaders,
|
|
4
|
+
generateRequestId,
|
|
5
|
+
type AgentIdentity,
|
|
6
|
+
type TopoloCredential,
|
|
7
|
+
} from './auth.js';
|
|
8
|
+
import { TopoloAuthError, TopoloHttpError, TopoloPermissionError } from './errors.js';
|
|
9
|
+
import {
|
|
10
|
+
PLATFORM_SERVICE_IDS,
|
|
11
|
+
resolveServiceUrl,
|
|
12
|
+
type ServiceId,
|
|
13
|
+
} from './services.js';
|
|
14
|
+
|
|
15
|
+
export interface TopoloClientOptions {
|
|
16
|
+
credential: TopoloCredential;
|
|
17
|
+
agent: AgentIdentity;
|
|
18
|
+
/** Per-service URL overrides. Useful for staging/dev. */
|
|
19
|
+
serviceUrls?: Partial<Record<ServiceId, string>>;
|
|
20
|
+
/**
|
|
21
|
+
* When true, mutating helpers (POST/PUT/PATCH/DELETE) require callers to pass
|
|
22
|
+
* `{ confirm: true }`. Defaults to true. Set false only for trusted surfaces.
|
|
23
|
+
*/
|
|
24
|
+
requireConfirmForWrites?: boolean;
|
|
25
|
+
/** HTTP request timeout (ms). Default 30s. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Injected fetch, for testing. Defaults to global fetch. */
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RequestOptions {
|
|
32
|
+
service: ServiceId;
|
|
33
|
+
path: string;
|
|
34
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
35
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
36
|
+
body?: unknown;
|
|
37
|
+
/** Explicit write-acknowledgement for mutating calls. */
|
|
38
|
+
confirm?: boolean;
|
|
39
|
+
/** Extra headers appended after auth/audit headers. */
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
45
|
+
|
|
46
|
+
export class TopoloClient {
|
|
47
|
+
private readonly credential: TopoloCredential;
|
|
48
|
+
private readonly agent: AgentIdentity;
|
|
49
|
+
private readonly serviceUrls?: Partial<Record<ServiceId, string>>;
|
|
50
|
+
private readonly requireConfirmForWrites: boolean;
|
|
51
|
+
private readonly timeoutMs: number;
|
|
52
|
+
private readonly fetchImpl: typeof fetch;
|
|
53
|
+
|
|
54
|
+
constructor(options: TopoloClientOptions) {
|
|
55
|
+
if (!options.credential) throw new TopoloAuthError('credential is required');
|
|
56
|
+
if (!options.agent?.clientName) throw new TopoloAuthError('agent.clientName is required');
|
|
57
|
+
this.credential = options.credential;
|
|
58
|
+
this.agent = options.agent;
|
|
59
|
+
this.serviceUrls = options.serviceUrls;
|
|
60
|
+
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
61
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
62
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
|
|
67
|
+
* for anything a caller would reach for; this stays exported for escape-hatch
|
|
68
|
+
* use and for the generic `topolo api` / MCP passthrough tool.
|
|
69
|
+
*/
|
|
70
|
+
async request<T = unknown>(opts: RequestOptions): Promise<T> {
|
|
71
|
+
const method = opts.method ?? 'GET';
|
|
72
|
+
if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
|
|
73
|
+
throw new TopoloAuthError(
|
|
74
|
+
`Mutating ${method} requests require { confirm: true }. This guardrail prevents ` +
|
|
75
|
+
`agents from issuing writes without an explicit human-in-the-loop acknowledgement.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const baseUrl = resolveServiceUrl(opts.service, this.serviceUrls);
|
|
80
|
+
const url = new URL(opts.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
|
|
81
|
+
if (opts.query) {
|
|
82
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
83
|
+
if (v !== undefined) url.searchParams.set(k, String(v));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const headers = new Headers();
|
|
88
|
+
headers.set('Accept', 'application/json');
|
|
89
|
+
if (opts.body !== undefined) headers.set('Content-Type', 'application/json');
|
|
90
|
+
const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
|
|
91
|
+
if (platformServiceId) headers.set('X-Service-ID', platformServiceId);
|
|
92
|
+
|
|
93
|
+
applyAuthHeaders(headers, this.credential);
|
|
94
|
+
applyAuditHeaders(headers, this.agent, generateRequestId());
|
|
95
|
+
|
|
96
|
+
if (opts.headers) {
|
|
97
|
+
for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
102
|
+
const signal = opts.signal
|
|
103
|
+
? mergeSignals(opts.signal, controller.signal)
|
|
104
|
+
: controller.signal;
|
|
105
|
+
|
|
106
|
+
let res: Response;
|
|
107
|
+
try {
|
|
108
|
+
res = await this.fetchImpl(url.toString(), {
|
|
109
|
+
method,
|
|
110
|
+
headers,
|
|
111
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : null,
|
|
112
|
+
signal,
|
|
113
|
+
});
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timeoutId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const contentType = res.headers.get('Content-Type') ?? '';
|
|
119
|
+
const parsed: unknown = contentType.includes('application/json')
|
|
120
|
+
? await res.json().catch(() => null)
|
|
121
|
+
: await res.text().catch(() => null);
|
|
122
|
+
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
if (res.status === 401) throw new TopoloAuthError(describeError(parsed, 'Unauthorized'));
|
|
125
|
+
if (res.status === 403) {
|
|
126
|
+
throw new TopoloPermissionError(
|
|
127
|
+
describeError(parsed, 'Permission denied'),
|
|
128
|
+
extractRequired(parsed),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return parsed as T;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Introspect the current credential. Returns the caller's resolved identity,
|
|
139
|
+
* organization, and permission set. Used by CLI `whoami` and by MCP to gate
|
|
140
|
+
* advertised tools by scope.
|
|
141
|
+
*
|
|
142
|
+
* Both JWT access tokens and platform API keys are resolved via the unified
|
|
143
|
+
* `GET /api/auth/me` endpoint on TopoloAuth, which does not require service
|
|
144
|
+
* credentials. The caller's possession of the credential secret is proof.
|
|
145
|
+
*/
|
|
146
|
+
async introspect(): Promise<CredentialIntrospection> {
|
|
147
|
+
const res = await this.request<{ data: AuthMePayload } | AuthMePayload>({
|
|
148
|
+
service: 'auth',
|
|
149
|
+
method: 'GET',
|
|
150
|
+
path: '/api/auth/me',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const payload: AuthMePayload = (res as { data?: AuthMePayload }).data ?? (res as AuthMePayload);
|
|
154
|
+
const user = payload.user;
|
|
155
|
+
const organization = payload.organization;
|
|
156
|
+
const kind: CredentialIntrospection['kind'] =
|
|
157
|
+
payload.credentialType === 'api_key' ? 'api_key' : 'access_token';
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
kind,
|
|
161
|
+
user: {
|
|
162
|
+
id: user.id,
|
|
163
|
+
email: user.email,
|
|
164
|
+
name: user.name ?? null,
|
|
165
|
+
role: user.role ?? (kind === 'api_key' ? 'service' : 'member'),
|
|
166
|
+
permissions: payload.permissions ?? user.permissions ?? [],
|
|
167
|
+
},
|
|
168
|
+
organization: organization
|
|
169
|
+
? {
|
|
170
|
+
id: organization.id,
|
|
171
|
+
slug: organization.slug,
|
|
172
|
+
name: organization.name ?? organization.slug,
|
|
173
|
+
}
|
|
174
|
+
: user.orgId && user.orgSlug
|
|
175
|
+
? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug }
|
|
176
|
+
: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface AuthMePayload {
|
|
182
|
+
credentialType?: 'access_token' | 'api_key';
|
|
183
|
+
user: RawUser;
|
|
184
|
+
organization?: RawOrg | null;
|
|
185
|
+
permissions?: string[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface CredentialIntrospection {
|
|
189
|
+
kind: 'api_key' | 'access_token';
|
|
190
|
+
user: {
|
|
191
|
+
id: string;
|
|
192
|
+
email: string;
|
|
193
|
+
name: string | null;
|
|
194
|
+
role: string;
|
|
195
|
+
permissions: string[];
|
|
196
|
+
};
|
|
197
|
+
organization: {
|
|
198
|
+
id: string;
|
|
199
|
+
slug: string;
|
|
200
|
+
name: string;
|
|
201
|
+
} | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface RawUser {
|
|
205
|
+
id: string;
|
|
206
|
+
email: string;
|
|
207
|
+
name?: string;
|
|
208
|
+
role?: string;
|
|
209
|
+
permissions?: string[];
|
|
210
|
+
orgId?: string;
|
|
211
|
+
orgSlug?: string;
|
|
212
|
+
organization?: { id: string; slug: string; name: string } | null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface RawOrg {
|
|
216
|
+
id: string;
|
|
217
|
+
slug: string;
|
|
218
|
+
name?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function describeError(body: unknown, fallback: string): string {
|
|
222
|
+
if (body && typeof body === 'object') {
|
|
223
|
+
const maybe = body as { error?: string; message?: string };
|
|
224
|
+
return maybe.message ?? maybe.error ?? fallback;
|
|
225
|
+
}
|
|
226
|
+
return fallback;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function extractRequired(body: unknown): string[] {
|
|
230
|
+
if (body && typeof body === 'object') {
|
|
231
|
+
const maybe = body as { required?: unknown };
|
|
232
|
+
if (Array.isArray(maybe.required)) return maybe.required.map(String);
|
|
233
|
+
}
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function mergeSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
|
|
238
|
+
if (a.aborted) return a;
|
|
239
|
+
if (b.aborted) return b;
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
const onAbort = () => controller.abort();
|
|
242
|
+
a.addEventListener('abort', onAbort, { once: true });
|
|
243
|
+
b.addEventListener('abort', onAbort, { once: true });
|
|
244
|
+
return controller.signal;
|
|
245
|
+
}
|