@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11
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 +12 -9
- package/bin/tot.mjs +51 -11
- package/package.json +2 -2
- package/src/candidate-state.mjs +137 -0
- package/src/commands/{checkout.mjs → clone.mjs} +129 -28
- package/src/commands/dev.mjs +110 -15
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/grants.mjs +8 -3
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +19 -12
- package/src/commands/pr.mjs +214 -0
- package/src/commands/preview.mjs +71 -0
- package/src/commands/ship.mjs +667 -0
- package/src/commands/start.mjs +34 -14
- package/src/commands/submit.mjs +458 -41
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/oauth.mjs +92 -42
- package/src/obstacle-beacon.cjs +1 -1
package/src/oauth.mjs
CHANGED
|
@@ -84,8 +84,15 @@ export async function registerClient(
|
|
|
84
84
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
85
85
|
body: JSON.stringify({
|
|
86
86
|
client_name: CLIENT_NAME,
|
|
87
|
+
// Include the device-code grant: the browserless rendezvous + B3 device flows
|
|
88
|
+
// redeem their device_code at /oauth/token with this grant, so a client
|
|
89
|
+
// registered WITHOUT it gets "unauthorized_client: grant_type is invalid".
|
|
90
|
+
grant_types: [
|
|
91
|
+
"authorization_code",
|
|
92
|
+
"refresh_token",
|
|
93
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
94
|
+
],
|
|
87
95
|
redirect_uris: [redirectUri],
|
|
88
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
89
96
|
response_types: ["code"],
|
|
90
97
|
token_endpoint_auth_method: "none",
|
|
91
98
|
scope: SCOPE,
|
|
@@ -285,41 +292,50 @@ export async function loginFlow({
|
|
|
285
292
|
}
|
|
286
293
|
}
|
|
287
294
|
|
|
288
|
-
// ──
|
|
295
|
+
// ── Browserless CLI sign-in — RFC 8628 RENDEZVOUS (`tot login --code <handle>`) ──
|
|
289
296
|
//
|
|
290
|
-
// The invited developer pastes the
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// the
|
|
294
|
-
//
|
|
295
|
-
//
|
|
297
|
+
// The invited developer pastes the NON-SECRET rendezvous handle their cockpit
|
|
298
|
+
// rendered. Unlike the retired bearer-redeem (a secret received elsewhere, replayed
|
|
299
|
+
// once), the terminal generates its OWN PKCE key here, ATTACHES only the public
|
|
300
|
+
// challenge to the pending rendezvous, prints a human-verifiable fingerprint, and
|
|
301
|
+
// polls the standard token endpoint (device_code grant + PKCE) until the developer
|
|
302
|
+
// approves that fingerprint in the cockpit. Same credentials shape as
|
|
303
|
+
// loginFlow/deviceLoginFlow, same dynamically-registered client_id, so later silent
|
|
304
|
+
// refreshes go through the standard token endpoint like any other session.
|
|
296
305
|
//
|
|
297
|
-
// Wire contract (CLI → MCP):
|
|
298
|
-
// POST
|
|
299
|
-
// {
|
|
300
|
-
//
|
|
301
|
-
//
|
|
306
|
+
// Wire contract (CLI → MCP), all off the MCP origin (from the AS token endpoint):
|
|
307
|
+
// POST /oauth/device/attach (application/json)
|
|
308
|
+
// { rendezvous_code, client_id, code_challenge, code_challenge_method: "S256" }
|
|
309
|
+
// → 200 { device_code, user_fingerprint, interval, expires_in }
|
|
310
|
+
// → 400 { error, error_description } (invalid_grant | invalid_client)
|
|
311
|
+
// POST /oauth/token (form) grant_type=…:device_code, device_code, code_verifier,
|
|
312
|
+
// client_id → RFC 8628 polling until approved/denied/expired.
|
|
302
313
|
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
export function redeemCodeEndpoint(mcpUrl, meta) {
|
|
314
|
+
// Device-bound by construction: the pasted handle attaches only a PUBLIC PKCE
|
|
315
|
+
// challenge (intercepting it grants nothing — an interceptor's terminal shows a
|
|
316
|
+
// DIFFERENT fingerprint the developer won't approve), and only the terminal holding
|
|
317
|
+
// the matching verifier can redeem the device_code at /oauth/token.
|
|
318
|
+
|
|
319
|
+
/** An MCP device/rendezvous endpoint — the MCP origin (from the AS token endpoint)
|
|
320
|
+
* + a fixed path. Kept beside the flow so the paths live in exactly one place. */
|
|
321
|
+
export function deviceEndpoint(mcpUrl, meta, path) {
|
|
312
322
|
const origin = meta?.token_endpoint ? new URL(meta.token_endpoint) : new URL(mcpUrl);
|
|
313
|
-
return new URL(
|
|
323
|
+
return new URL(path, origin).toString();
|
|
314
324
|
}
|
|
315
325
|
|
|
316
|
-
/**
|
|
317
|
-
* response
|
|
318
|
-
|
|
319
|
-
|
|
326
|
+
/** Attach the terminal's PKCE challenge to a pending rendezvous. Returns the raw
|
|
327
|
+
* response { device_code, user_fingerprint, interval, expires_in }. Throws a clear,
|
|
328
|
+
* non-stack error on rejection (an expired / already-used handle). */
|
|
329
|
+
export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientId, challenge }, fetchImpl = fetch) {
|
|
330
|
+
const res = await fetchImpl(attachEndpoint, {
|
|
320
331
|
method: "POST",
|
|
321
332
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
322
|
-
body: JSON.stringify({
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
rendezvous_code: rendezvousCode,
|
|
335
|
+
client_id: clientId,
|
|
336
|
+
code_challenge: challenge,
|
|
337
|
+
code_challenge_method: "S256",
|
|
338
|
+
}),
|
|
323
339
|
});
|
|
324
340
|
const text = await res.text();
|
|
325
341
|
let body;
|
|
@@ -328,31 +344,61 @@ export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope =
|
|
|
328
344
|
const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
|
|
329
345
|
throw new Error(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
|
|
330
346
|
}
|
|
331
|
-
if (!body.
|
|
347
|
+
if (!body.device_code || !body.user_fingerprint) {
|
|
348
|
+
throw new Error("the attach endpoint returned no device_code/fingerprint");
|
|
349
|
+
}
|
|
332
350
|
return body;
|
|
333
351
|
}
|
|
334
352
|
|
|
335
353
|
/**
|
|
336
|
-
* Run the full browserless
|
|
337
|
-
* — the invite
|
|
338
|
-
* shape
|
|
339
|
-
*
|
|
354
|
+
* Run the full browserless rendezvous sign-in and return a persistable credentials
|
|
355
|
+
* record — the invite sibling of loginFlow()/deviceLoginFlow(), same credentials
|
|
356
|
+
* shape + dynamically-registered client_id (reused when the caller cached one for
|
|
357
|
+
* this MCP). The terminal generates its own PKCE key, attaches, surfaces the
|
|
358
|
+
* fingerprint via `log` for the developer to confirm in the cockpit, then polls the
|
|
359
|
+
* token endpoint until approved. Injectable (`fetchImpl`, `log`, `sleep`, `now`) so
|
|
360
|
+
* it's testable with no network and no real waiting.
|
|
340
361
|
* @returns {Promise<object>} credentials to hand to writeCredentials()
|
|
341
362
|
*/
|
|
342
|
-
export async function
|
|
363
|
+
export async function rendezvousLoginFlow({
|
|
343
364
|
mcpUrl,
|
|
344
|
-
clientId,
|
|
345
365
|
code,
|
|
346
366
|
fetchImpl = fetch,
|
|
367
|
+
log = () => {},
|
|
368
|
+
sleep = delay,
|
|
347
369
|
now = () => Date.now(),
|
|
348
370
|
}) {
|
|
349
371
|
const meta = await discoverMetadata(mcpUrl, fetchImpl);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
372
|
+
// Always register a FRESH client for a rendezvous sign-in — do NOT reuse a cached
|
|
373
|
+
// clientId. A client cached by an older CLI build was registered without the
|
|
374
|
+
// device-code grant, so /oauth/token would reject the device grant with
|
|
375
|
+
// "unauthorized_client". Registration is cheap and a rendezvous has no consent
|
|
376
|
+
// screen to re-trigger (unlike the loopback/device flows, which reuse the cache).
|
|
377
|
+
const resolvedClientId = await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl);
|
|
378
|
+
const { verifier, challenge } = generatePkce();
|
|
379
|
+
|
|
380
|
+
const attach = await attachRendezvous(
|
|
381
|
+
deviceEndpoint(mcpUrl, meta, "/oauth/device/attach"),
|
|
382
|
+
{ rendezvousCode: code, clientId: resolvedClientId, challenge },
|
|
354
383
|
fetchImpl,
|
|
355
384
|
);
|
|
385
|
+
|
|
386
|
+
log("");
|
|
387
|
+
log(` Confirm this code in your browser to finish signing in: ${attach.user_fingerprint}`);
|
|
388
|
+
log(" Waiting for you to approve it in Token of Trust …");
|
|
389
|
+
|
|
390
|
+
const token = await pollDeviceToken(
|
|
391
|
+
meta.token_endpoint,
|
|
392
|
+
{
|
|
393
|
+
deviceCode: attach.device_code,
|
|
394
|
+
clientId: resolvedClientId,
|
|
395
|
+
codeVerifier: verifier,
|
|
396
|
+
intervalSec: attach.interval,
|
|
397
|
+
expiresInSec: attach.expires_in,
|
|
398
|
+
},
|
|
399
|
+
fetchImpl,
|
|
400
|
+
{ sleep, now },
|
|
401
|
+
);
|
|
356
402
|
return credentialsFromToken({
|
|
357
403
|
mcpUrl,
|
|
358
404
|
clientId: resolvedClientId,
|
|
@@ -395,7 +441,7 @@ export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, s
|
|
|
395
441
|
* normal "keep waiting" responses — so this resolves `{ pending: true }`
|
|
396
442
|
* (with `slowDown` set) for those instead of throwing.
|
|
397
443
|
*/
|
|
398
|
-
async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl) {
|
|
444
|
+
async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl) {
|
|
399
445
|
const res = await fetchImpl(tokenEndpoint, {
|
|
400
446
|
method: "POST",
|
|
401
447
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
@@ -403,6 +449,10 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
|
|
|
403
449
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
404
450
|
device_code: deviceCode,
|
|
405
451
|
client_id: clientId,
|
|
452
|
+
// The rendezvous flow binds the grant to the terminal's PKCE key: the
|
|
453
|
+
// verifier proves this is the same terminal that attached the challenge.
|
|
454
|
+
// Absent for the plain RFC 8628 device flow (no PKCE) — omitted then.
|
|
455
|
+
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
|
406
456
|
}).toString(),
|
|
407
457
|
});
|
|
408
458
|
const text = await res.text();
|
|
@@ -428,7 +478,7 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
|
|
|
428
478
|
*/
|
|
429
479
|
export async function pollDeviceToken(
|
|
430
480
|
tokenEndpoint,
|
|
431
|
-
{ deviceCode, clientId, intervalSec, expiresInSec },
|
|
481
|
+
{ deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
|
|
432
482
|
fetchImpl = fetch,
|
|
433
483
|
{ sleep = delay, now = () => Date.now() } = {},
|
|
434
484
|
) {
|
|
@@ -437,7 +487,7 @@ export async function pollDeviceToken(
|
|
|
437
487
|
for (;;) {
|
|
438
488
|
await sleep(intervalMs);
|
|
439
489
|
if (now() >= deadline) throw new Error("the device code expired before it was approved");
|
|
440
|
-
const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl);
|
|
490
|
+
const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl);
|
|
441
491
|
if (!r.pending) return r.token;
|
|
442
492
|
if (r.slowDown) intervalMs += 5000;
|
|
443
493
|
}
|
package/src/obstacle-beacon.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The obstacle beacon — a fire-and-forget POST that tells the hosted cockpit a
|
|
3
|
-
* `tot start` / `tot
|
|
3
|
+
* `tot start` / `tot clone` failed, so it can show the exact fix in the bridge
|
|
4
4
|
* strip (obstacle lane, server side already shipped). Best-effort telemetry that
|
|
5
5
|
* rides ALONGSIDE the house-style `✗ … → next:` error; it must NEVER change,
|
|
6
6
|
* delay past its timeout, or fail that error path.
|