@tokenoftrust/cli 2.0.12 → 2.0.14
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/bin/tot.mjs +22 -3
- package/package.json +1 -1
- package/src/activity-log.mjs +2 -1
- package/src/activity.mjs +1 -1
- package/src/commands/clone.mjs +6 -1
- package/src/commands/dev.mjs +12 -2
- package/src/commands/feedback.mjs +18 -8
- package/src/commands/grants.mjs +5 -1
- package/src/commands/link.mjs +9 -1
- package/src/commands/submit.mjs +148 -32
- package/src/commands/sync.mjs +4 -0
- package/src/commands/whoami.mjs +5 -1
- package/src/diagnostics.mjs +224 -0
- package/src/errors.mjs +6 -1
- package/src/mcp.mjs +213 -28
- package/src/oauth.mjs +107 -37
- package/src/open.mjs +18 -3
package/src/oauth.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import http from "node:http";
|
|
|
26
26
|
import crypto from "node:crypto";
|
|
27
27
|
import { setTimeout as delay } from "node:timers/promises";
|
|
28
28
|
import { openBrowser } from "./open.mjs";
|
|
29
|
+
import { CliDiagnosticError, sanitizeDiagnosticText } from "./diagnostics.mjs";
|
|
29
30
|
|
|
30
31
|
const CLIENT_NAME = "Token of Trust CLI (tot)";
|
|
31
32
|
const SCOPE = "mcp offline_access"; // offline_access → the refresh token
|
|
@@ -34,6 +35,87 @@ const SCOPE = "mcp offline_access"; // offline_access → the refresh token
|
|
|
34
35
|
// ephemeral port that the MCP matches port-agnostically.
|
|
35
36
|
const LOOPBACK_REDIRECT = "http://127.0.0.1/callback";
|
|
36
37
|
|
|
38
|
+
function oauthTraceId() {
|
|
39
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function oauthJson(url, init, operation, fetchImpl) {
|
|
43
|
+
const traceId = oauthTraceId();
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await fetchImpl(url, {
|
|
47
|
+
...init,
|
|
48
|
+
headers: { ...(init?.headers || {}), "X-Trace-Id": traceId },
|
|
49
|
+
});
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
throw new CliDiagnosticError(`${operation} failed before a response arrived`, {
|
|
52
|
+
operation, category: "network", message: cause?.message || String(cause), traceId,
|
|
53
|
+
}, { cause });
|
|
54
|
+
}
|
|
55
|
+
const returnedTraceId = res.headers?.get?.("x-trace-id") || traceId;
|
|
56
|
+
const requestId = res.headers?.get?.("x-request-id") || returnedTraceId;
|
|
57
|
+
const contentType = res.headers?.get?.("content-type") || "";
|
|
58
|
+
let text;
|
|
59
|
+
let body;
|
|
60
|
+
let parseError;
|
|
61
|
+
try {
|
|
62
|
+
if (typeof res.text === "function") {
|
|
63
|
+
text = await res.text();
|
|
64
|
+
body = text ? JSON.parse(text) : null;
|
|
65
|
+
} else {
|
|
66
|
+
body = await res.json();
|
|
67
|
+
text = JSON.stringify(body);
|
|
68
|
+
}
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
parseError = cause;
|
|
71
|
+
body = null;
|
|
72
|
+
}
|
|
73
|
+
const responseBytes = text === undefined ? undefined : Buffer.byteLength(text);
|
|
74
|
+
if (res.ok && parseError) {
|
|
75
|
+
throw new CliDiagnosticError(`${operation} returned malformed JSON`, {
|
|
76
|
+
operation, category: "malformed_response", httpStatus: res.status,
|
|
77
|
+
traceId: returnedTraceId, requestId, contentType, responseBytes,
|
|
78
|
+
}, { cause: parseError });
|
|
79
|
+
}
|
|
80
|
+
if (res.ok && body === null) {
|
|
81
|
+
throw new CliDiagnosticError(`${operation} returned an empty response`, {
|
|
82
|
+
operation, category: "empty_response", httpStatus: res.status,
|
|
83
|
+
traceId: returnedTraceId, requestId, contentType, responseBytes,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
body ||= {};
|
|
87
|
+
return {
|
|
88
|
+
res,
|
|
89
|
+
body,
|
|
90
|
+
context: {
|
|
91
|
+
operation,
|
|
92
|
+
httpStatus: res.status,
|
|
93
|
+
statusText: res.statusText,
|
|
94
|
+
serverCode: typeof body?.error === "string" ? body.error : undefined,
|
|
95
|
+
message: sanitizeDiagnosticText(body?.error_description || body?.error || "", 300),
|
|
96
|
+
traceId: returnedTraceId,
|
|
97
|
+
requestId,
|
|
98
|
+
contentType,
|
|
99
|
+
responseBytes: responseBytes ?? 0,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function oauthFailure(message, context) {
|
|
105
|
+
return new CliDiagnosticError(message, {
|
|
106
|
+
...context,
|
|
107
|
+
category: context.serverCode ? "oauth" : `http_${context.httpStatus}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function oauthShapeFailure(message, context) {
|
|
112
|
+
return new CliDiagnosticError(message, {
|
|
113
|
+
...context,
|
|
114
|
+
category: "malformed_response",
|
|
115
|
+
message,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
37
119
|
const b64url = (buf) =>
|
|
38
120
|
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
39
121
|
|
|
@@ -63,11 +145,12 @@ export function randomState() {
|
|
|
63
145
|
export async function discoverMetadata(mcpUrl, fetchImpl = fetch) {
|
|
64
146
|
const root = String(mcpUrl).replace(/\/+$/, "");
|
|
65
147
|
const url = `${root}/.well-known/oauth-authorization-server`;
|
|
66
|
-
const res
|
|
67
|
-
|
|
68
|
-
|
|
148
|
+
const { res, body: meta, context } = await oauthJson(
|
|
149
|
+
url, { headers: { Accept: "application/json" } }, "oauth_metadata", fetchImpl,
|
|
150
|
+
);
|
|
151
|
+
if (!res.ok) throw oauthFailure(`could not read the MCP's OAuth metadata (HTTP ${res.status} at ${url})`, context);
|
|
69
152
|
if (!meta?.authorization_endpoint || !meta?.token_endpoint) {
|
|
70
|
-
throw
|
|
153
|
+
throw oauthShapeFailure("the MCP's OAuth metadata is missing authorization/token endpoints", context);
|
|
71
154
|
}
|
|
72
155
|
return meta;
|
|
73
156
|
}
|
|
@@ -79,7 +162,7 @@ export async function registerClient(
|
|
|
79
162
|
fetchImpl = fetch,
|
|
80
163
|
) {
|
|
81
164
|
if (!registrationEndpoint) throw new Error("the MCP does not advertise a registration endpoint");
|
|
82
|
-
const res = await
|
|
165
|
+
const { res, body, context } = await oauthJson(registrationEndpoint, {
|
|
83
166
|
method: "POST",
|
|
84
167
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
85
168
|
body: JSON.stringify({
|
|
@@ -97,10 +180,9 @@ export async function registerClient(
|
|
|
97
180
|
token_endpoint_auth_method: "none",
|
|
98
181
|
scope: SCOPE,
|
|
99
182
|
}),
|
|
100
|
-
});
|
|
101
|
-
if (!res.ok) throw
|
|
102
|
-
|
|
103
|
-
if (!body?.client_id) throw new Error("client registration returned no client_id");
|
|
183
|
+
}, "oauth_client_registration", fetchImpl);
|
|
184
|
+
if (!res.ok) throw oauthFailure(`client registration failed (HTTP ${res.status})`, context);
|
|
185
|
+
if (!body?.client_id) throw oauthShapeFailure("client registration returned no client_id", context);
|
|
104
186
|
return body.client_id;
|
|
105
187
|
}
|
|
106
188
|
|
|
@@ -121,22 +203,19 @@ export function buildAuthorizeUrl(authorizationEndpoint, {
|
|
|
121
203
|
}
|
|
122
204
|
|
|
123
205
|
async function tokenRequest(tokenEndpoint, params, fetchImpl) {
|
|
124
|
-
const res = await
|
|
206
|
+
const { res, body, context } = await oauthJson(tokenEndpoint, {
|
|
125
207
|
method: "POST",
|
|
126
208
|
headers: {
|
|
127
209
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
128
210
|
Accept: "application/json",
|
|
129
211
|
},
|
|
130
212
|
body: new URLSearchParams(params).toString(),
|
|
131
|
-
});
|
|
132
|
-
const text = await res.text();
|
|
133
|
-
let body;
|
|
134
|
-
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
|
|
213
|
+
}, "oauth_token", fetchImpl);
|
|
135
214
|
if (!res.ok) {
|
|
136
215
|
const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
|
|
137
|
-
throw
|
|
216
|
+
throw oauthFailure(`token request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`, context);
|
|
138
217
|
}
|
|
139
|
-
if (!body.access_token) throw
|
|
218
|
+
if (!body.access_token) throw oauthShapeFailure("token endpoint returned no access_token", context);
|
|
140
219
|
return body;
|
|
141
220
|
}
|
|
142
221
|
|
|
@@ -327,7 +406,7 @@ export function deviceEndpoint(mcpUrl, meta, path) {
|
|
|
327
406
|
* response { device_code, user_fingerprint, interval, expires_in }. Throws a clear,
|
|
328
407
|
* non-stack error on rejection (an expired / already-used handle). */
|
|
329
408
|
export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientId, challenge }, fetchImpl = fetch) {
|
|
330
|
-
const res = await
|
|
409
|
+
const { res, body, context } = await oauthJson(attachEndpoint, {
|
|
331
410
|
method: "POST",
|
|
332
411
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
333
412
|
body: JSON.stringify({
|
|
@@ -336,16 +415,13 @@ export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientI
|
|
|
336
415
|
code_challenge: challenge,
|
|
337
416
|
code_challenge_method: "S256",
|
|
338
417
|
}),
|
|
339
|
-
});
|
|
340
|
-
const text = await res.text();
|
|
341
|
-
let body;
|
|
342
|
-
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
|
|
418
|
+
}, "oauth_rendezvous_attach", fetchImpl);
|
|
343
419
|
if (!res.ok) {
|
|
344
420
|
const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
|
|
345
|
-
throw
|
|
421
|
+
throw oauthFailure(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`, context);
|
|
346
422
|
}
|
|
347
423
|
if (!body.device_code || !body.user_fingerprint) {
|
|
348
|
-
throw
|
|
424
|
+
throw oauthShapeFailure("the attach endpoint returned no device_code/fingerprint", context);
|
|
349
425
|
}
|
|
350
426
|
return body;
|
|
351
427
|
}
|
|
@@ -417,20 +493,17 @@ export async function rendezvousLoginFlow({
|
|
|
417
493
|
* verification_uri_complete?, expires_in, interval}>}
|
|
418
494
|
*/
|
|
419
495
|
export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, scope = SCOPE }, fetchImpl = fetch) {
|
|
420
|
-
const res = await
|
|
496
|
+
const { res, body, context } = await oauthJson(deviceAuthorizationEndpoint, {
|
|
421
497
|
method: "POST",
|
|
422
498
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
423
499
|
body: new URLSearchParams({ client_id: clientId, scope }).toString(),
|
|
424
|
-
});
|
|
425
|
-
const text = await res.text();
|
|
426
|
-
let body;
|
|
427
|
-
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
|
|
500
|
+
}, "oauth_device_authorization", fetchImpl);
|
|
428
501
|
if (!res.ok) {
|
|
429
502
|
const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
|
|
430
|
-
throw
|
|
503
|
+
throw oauthFailure(`device authorization request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`, context);
|
|
431
504
|
}
|
|
432
505
|
if (!body.device_code || !body.user_code) {
|
|
433
|
-
throw
|
|
506
|
+
throw oauthShapeFailure("device authorization response is missing device_code/user_code", context);
|
|
434
507
|
}
|
|
435
508
|
return body;
|
|
436
509
|
}
|
|
@@ -442,7 +515,7 @@ export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, s
|
|
|
442
515
|
* (with `slowDown` set) for those instead of throwing.
|
|
443
516
|
*/
|
|
444
517
|
async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl) {
|
|
445
|
-
const res = await
|
|
518
|
+
const { res, body, context } = await oauthJson(tokenEndpoint, {
|
|
446
519
|
method: "POST",
|
|
447
520
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
448
521
|
body: new URLSearchParams({
|
|
@@ -454,19 +527,16 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifi
|
|
|
454
527
|
// Absent for the plain RFC 8628 device flow (no PKCE) — omitted then.
|
|
455
528
|
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
|
456
529
|
}).toString(),
|
|
457
|
-
});
|
|
458
|
-
const text = await res.text();
|
|
459
|
-
let body;
|
|
460
|
-
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
|
|
530
|
+
}, "oauth_device_token", fetchImpl);
|
|
461
531
|
if (res.ok) {
|
|
462
|
-
if (!body.access_token) throw
|
|
532
|
+
if (!body.access_token) throw oauthShapeFailure("token endpoint returned no access_token", context);
|
|
463
533
|
return { pending: false, token: body };
|
|
464
534
|
}
|
|
465
535
|
if (body.error === "authorization_pending") return { pending: true, slowDown: false };
|
|
466
536
|
if (body.error === "slow_down") return { pending: true, slowDown: true };
|
|
467
537
|
const reason = body.error === "access_denied" ? "was denied" : body.error === "expired_token" ? "code expired" : "failed";
|
|
468
538
|
const detail = body.error_description ? ` — ${body.error_description}` : "";
|
|
469
|
-
throw
|
|
539
|
+
throw oauthFailure(`sign-in ${reason}${detail}`, context);
|
|
470
540
|
}
|
|
471
541
|
|
|
472
542
|
/**
|
package/src/open.mjs
CHANGED
|
@@ -17,19 +17,34 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
17
17
|
* Mirror of scripts/dev/port-check.mjs (separate package — kept dependency-free).
|
|
18
18
|
* @param {number} port @returns {Promise<boolean>}
|
|
19
19
|
*/
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* @param {number} port
|
|
22
|
+
* @param {string | undefined} [host]
|
|
23
|
+
* @param {boolean} [unsupportedIsFree]
|
|
24
|
+
*/
|
|
25
|
+
function canBind(port, host = undefined, unsupportedIsFree = false) {
|
|
21
26
|
return new Promise((resolve) => {
|
|
22
27
|
const srv = net.createServer();
|
|
23
|
-
srv.once("error", () =>
|
|
28
|
+
srv.once("error", (error) => {
|
|
29
|
+
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
30
|
+
const unsupported = code === "EAFNOSUPPORT" || code === "EADDRNOTAVAIL";
|
|
31
|
+
resolve(unsupportedIsFree && unsupported);
|
|
32
|
+
});
|
|
24
33
|
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
25
34
|
try {
|
|
26
|
-
srv.listen(port);
|
|
35
|
+
srv.listen({ port, ...(host ? { host } : {}) });
|
|
27
36
|
} catch {
|
|
28
37
|
resolve(false);
|
|
29
38
|
}
|
|
30
39
|
});
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
export async function isPortFree(port) {
|
|
43
|
+
if (!(await canBind(port))) return false;
|
|
44
|
+
if (!(await canBind(port, "127.0.0.1"))) return false;
|
|
45
|
+
return canBind(port, "::1", true);
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
/**
|
|
34
49
|
* First free port at or after `preferred`. Lets the CLI derive the URL it opens +
|
|
35
50
|
* polls from the SAME port the runner will bind — Vite's strictPort is off, so a
|