@tokenoftrust/cli 2.4.9 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.4.9",
3
+ "version": "2.4.11",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
package/src/auth.mjs CHANGED
@@ -136,7 +136,7 @@ export function warnLegacyOperatorEnv(env = process.env, warn = (m) => console.e
136
136
  * @param {(import("./mcp.mjs").createMcpClient extends (...a:any)=>infer R ? R : any)|null} client
137
137
  * @param {NodeJS.ProcessEnv} env
138
138
  * @param {{ fetchImpl?: typeof fetch, now?: number }} [deps] injectable for tests
139
- * @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null }>}
139
+ * @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null, mcpUrl?: string|null }>}
140
140
  */
141
141
  export async function resolveDeveloperSession(client, env, deps = {}) {
142
142
  const { fetchImpl = fetch, now = Date.now() } = deps;
@@ -204,6 +204,10 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
204
204
  appDomain: null,
205
205
  token: creds.accessToken,
206
206
  email: emailFromJwt(creds.accessToken),
207
+ // The MCP this developer is signed in to — so a caller can tell the human
208
+ // which MCP their OWN session is against when a store reports it trusts a
209
+ // different one (the tier-mismatch case: signed in to qa, store trusts prod).
210
+ mcpUrl: creds.mcpUrl || null,
207
211
  };
208
212
  }
209
213
 
@@ -228,7 +232,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
228
232
  *
229
233
  * @param {ReturnType<import("./mcp.mjs").createMcpClient>} client
230
234
  * @param {{ env?: NodeJS.ProcessEnv, initialize?: () => Promise<any>, prefer?: string }} [opts]
231
- * @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null }>}
235
+ * @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null, mcpUrl?: string|null }>}
232
236
  */
233
237
  export async function establishSession(client, opts = {}) {
234
238
  const env = opts.env || process.env;
@@ -36,6 +36,7 @@ import { fail } from "../errors.mjs";
36
36
  import { planForAction, printPlanAndConfirm } from "../plan.mjs";
37
37
  import { startProgress } from "../progress.mjs";
38
38
  import { openBrowser } from "../open.mjs";
39
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
39
40
  // Reuse `tot ship`'s operator-secret precedence + live-url derivation verbatim so
40
41
  // ship + hotfix speak ONE operator-auth contract.
41
42
  import { resolveOperatorSecret, liveUrlFor } from "./ship.mjs";
@@ -245,18 +246,27 @@ export async function runHotfix(
245
246
  return 1;
246
247
  }
247
248
  const planData = await readJsonSafe(planRes);
248
- if (!planRes.ok && planData?.ok !== false) {
249
+ if (planRes.status === 401 || planRes.status === 403) {
250
+ // The admission rule's own refusal (body shape `{ ok:false, error,
251
+ // admission:{ missing } }`) — name exactly what's missing instead of a
252
+ // generic "check the operator secret" when the server named one.
249
253
  const msg = planData?.error || `HTTP ${planRes.status}`;
254
+ const missing = missingGrantsOf(planData);
250
255
  console.error(
251
256
  fail(
252
257
  `the hotfix plan was refused: ${msg}`,
253
- planRes.status === 401 || planRes.status === 403
254
- ? "check the operator secret and that it's authorised for this tenant"
255
- : "check --tenant / --url / --pr, then re-run",
258
+ missing.length > 0 ? refusalHint(planRes.status, missing) : "check the operator secret and that it's authorised for this tenant",
256
259
  ),
257
260
  );
258
261
  return 1;
259
262
  }
263
+ if (!planRes.ok && planData?.ok !== false) {
264
+ // A transport-level refusal the endpoint didn't explain in its own shape
265
+ // (tenant-resolution/network) — surface it honestly.
266
+ const msg = planData?.error || `HTTP ${planRes.status}`;
267
+ console.error(fail(`the hotfix plan was refused: ${msg}`, "check --tenant / --url / --pr, then re-run"));
268
+ return 1;
269
+ }
260
270
  const plan = normalizeHotfixPlan(planData);
261
271
  if (!plan.ok) {
262
272
  console.error(fail(plan.message, hotfixNextStep(plan.reason)));
@@ -41,6 +41,7 @@ import { repoNameFromRemote, currentBranch } from "./submit.mjs";
41
41
  // `tot pr list` reuses `tot ship --pr`'s operator-secret transport helpers verbatim
42
42
  // (same env precedence, same queue normalisation) so the two speak one wire, not two.
43
43
  import { resolveOperatorSecret, normalizeChangesQueue } from "./ship.mjs";
44
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
44
45
  import {
45
46
  defaultCandidateStatePath,
46
47
  readActiveChangeId,
@@ -280,11 +281,14 @@ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }
280
281
  }
281
282
  if (!res.ok) {
282
283
  const msg = data?.error || `HTTP ${res.status}`;
284
+ const missing = missingGrantsOf(data);
283
285
  console.error(
284
286
  fail(
285
287
  `the candidate queue refused the request: ${msg}`,
286
288
  res.status === 401 || res.status === 403
287
- ? "check the operator secret and that it's authorised for this tenant"
289
+ ? missing.length > 0
290
+ ? refusalHint(res.status, missing)
291
+ : "check the operator secret and that it's authorised for this tenant"
288
292
  : "check --tenant / --url, then re-run",
289
293
  ),
290
294
  );
@@ -14,6 +14,7 @@
14
14
  * Treat the token like a password: anyone holding it sees the unpublished site.
15
15
  */
16
16
  import { resolveViewerTransport } from "../viewer-session.mjs";
17
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
17
18
 
18
19
  const MAX_TTL_SECONDS = 24 * 3600;
19
20
 
@@ -107,19 +108,19 @@ export async function run(argv, ctx, deps = {}) {
107
108
  return 1;
108
109
  }
109
110
 
111
+ let data = null;
112
+ try {
113
+ data = await res.json();
114
+ } catch {
115
+ /* non-JSON error body — this endpoint answers 401/403 as plain text */
116
+ }
110
117
  if (res.status === 401 || res.status === 403) {
111
118
  err(
112
119
  `✗ your account can't view ${tenant}'s preview.\n\n` +
113
- " → next: ask the store owner for developer access (or check `tot whoami`), then re-run.",
120
+ ` → next: ${refusalHint(res.status, missingGrantsOf(data))}.`,
114
121
  );
115
122
  return 1;
116
123
  }
117
- let data = null;
118
- try {
119
- data = await res.json();
120
- } catch {
121
- /* non-JSON error body */
122
- }
123
124
  if (!res.ok || typeof data?.token !== "string") {
124
125
  err(`✗ the store didn't mint a token (HTTP ${res.status}).\n\n → next: retry shortly; if it persists, report it with \`tot feedback\`.`);
125
126
  return 1;
@@ -62,6 +62,7 @@ import { emitActivity } from "../activity.mjs";
62
62
  // `tot preview doctor` run.
63
63
  import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
64
64
  import { resolveViewerTransport } from "../viewer-session.mjs";
65
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
65
66
 
66
67
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
67
68
 
@@ -76,6 +77,8 @@ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live
76
77
  accept this revision's overridable quality findings,
77
78
  record the required reason, then continue publishing
78
79
  tot ship --no-open don't open the live URL in your browser
80
+ tot ship --verbose print each request's URL, HTTP status, and the
81
+ server's error code — never a token or cookie
79
82
 
80
83
  Authentication: use your own \`tot login\` session if you own the store, hold
81
84
  a live ship-on-behalf grant, or — while the store is unpaid — were invited to
@@ -108,6 +111,7 @@ export function parseShipArgs(argv) {
108
111
  url: null,
109
112
  secret: null,
110
113
  acceptQualityReason: null,
114
+ verbose: false,
111
115
  help: false,
112
116
  };
113
117
  for (let i = 0; i < argv.length; i++) {
@@ -118,6 +122,7 @@ export function parseShipArgs(argv) {
118
122
  else if (t === "--url") a.url = argv[++i];
119
123
  else if (t === "--secret") a.secret = argv[++i];
120
124
  else if (t === "--accept-quality-findings") a.acceptQualityReason = argv[++i];
125
+ else if (t === "--verbose") a.verbose = true;
121
126
  else if (t === "--help" || t === "-h") a.help = true;
122
127
  }
123
128
  return a;
@@ -423,9 +428,9 @@ async function readJsonSafe(res) {
423
428
  *
424
429
  * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
425
430
  * yes?:boolean, noOpen?:boolean, acceptQualityReason?:string|null,
426
- * env?:NodeJS.ProcessEnv }} params
431
+ * verbose?:boolean, env?:NodeJS.ProcessEnv }} params
427
432
  * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
428
- * openUrl?:(u:string)=>boolean, progress?:boolean,
433
+ * openUrl?:(u:string)=>boolean, progress?:boolean, log?:(s:string)=>void,
429
434
  * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
430
435
  * @returns {Promise<number>} process exit code
431
436
  */
@@ -437,6 +442,7 @@ export async function runShip(
437
442
  yes = false,
438
443
  noOpen = false,
439
444
  acceptQualityReason = null,
445
+ verbose = false,
440
446
  env = process.env,
441
447
  },
442
448
  deps = {},
@@ -444,6 +450,9 @@ export async function runShip(
444
450
  const fetchImpl = deps.fetch || globalThis.fetch;
445
451
  const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
446
452
  const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
453
+ // --verbose: request URL + HTTP status + the server's error code only — NEVER
454
+ // a token, secret, or cookie value.
455
+ const vlog = verbose ? (deps.log || ((s) => console.error(`[verbose] ${s}`))) : () => {};
447
456
  let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
448
457
  let authHeaders;
449
458
  if (secret) {
@@ -453,7 +462,7 @@ export async function runShip(
453
462
  "x-tot-capability": "ship-on-behalf",
454
463
  };
455
464
  } else {
456
- const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
465
+ const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl, verbose, log: vlog });
457
466
  if (!viewer.ok) {
458
467
  console.error(fail(viewer.message, viewer.hint));
459
468
  return 2;
@@ -472,6 +481,7 @@ export async function runShip(
472
481
 
473
482
  // 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
474
483
  // rollback target + paywall verdict. Zero side effects.
484
+ vlog(`GET ${base}/api/changes/ship`);
475
485
  let planRes;
476
486
  try {
477
487
  planRes = await fetchImpl(`${base}/api/changes/ship`, { method: "GET", headers: authHeaders });
@@ -481,21 +491,35 @@ export async function runShip(
481
491
  );
482
492
  return 1;
483
493
  }
494
+ vlog(`ship plan → HTTP ${planRes.status}`);
484
495
  const planData = await readJsonSafe(planRes);
485
- if (!planRes.ok && planData?.ok !== false) {
486
- // A transport-level refusal the endpoint didn't explain in its own shape
487
- // (auth/tenant-resolution/network) — surface it honestly.
496
+ if (verbose && !planRes.ok) {
497
+ const code = planData?.error || planData?.admission?.missing?.map((m) => m?.grant).join(",") || null;
498
+ if (code) vlog(`ship plan error: ${code}`);
499
+ }
500
+ if (planRes.status === 401 || planRes.status === 403) {
501
+ // The admission rule's own refusal (body shape `{ ok:false, error,
502
+ // admission:{ missing } }`) — name exactly what's missing instead of a
503
+ // generic "check the operator secret", which is actively wrong for the
504
+ // viewer-session transport (no operator secret involved at all).
488
505
  const msg = planData?.error || `HTTP ${planRes.status}`;
489
506
  console.error(
490
507
  fail(
491
508
  `the ship plan was refused: ${msg}`,
492
- planRes.status === 401 || planRes.status === 403
509
+ secret
493
510
  ? "check the operator secret and that it's authorised for this tenant"
494
- : "check --tenant / --url, then re-run",
511
+ : refusalHint(planRes.status, missingGrantsOf(planData)),
495
512
  ),
496
513
  );
497
514
  return 1;
498
515
  }
516
+ if (!planRes.ok && planData?.ok !== false) {
517
+ // A transport-level refusal the endpoint didn't explain in its own shape
518
+ // (tenant-resolution/network) — surface it honestly.
519
+ const msg = planData?.error || `HTTP ${planRes.status}`;
520
+ console.error(fail(`the ship plan was refused: ${msg}`, "check --tenant / --url, then re-run"));
521
+ return 1;
522
+ }
499
523
  const plan = normalizeShipPlan(planData);
500
524
  // The tenant's mounted publication spaces ride the same release (see the file header).
501
525
  const spaces = (await readSpaceRelease({ base, authHeaders, fetchImpl })) ?? { ready: [], waiting: [] };
@@ -726,6 +750,7 @@ export async function run(argv, ctx) {
726
750
  yes: args.yes,
727
751
  noOpen: args.noOpen,
728
752
  acceptQualityReason: args.acceptQualityReason,
753
+ verbose: args.verbose,
729
754
  env,
730
755
  },
731
756
  { openUrl: (u) => openBrowser(u) },
@@ -44,6 +44,66 @@ export function parseSessionCookie(setCookie) {
44
44
  return m ? m[1] : null;
45
45
  }
46
46
 
47
+ /** @param {string|null|undefined} url @returns {string|null} */
48
+ function originOf(url) {
49
+ if (!url) return null;
50
+ try {
51
+ return new URL(url).origin;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Turn a non-2xx `/api/dev/cli-session` response into one clean, actionable
59
+ * `{ message, hint }` — the ONE place this CLI interprets that endpoint's refusal
60
+ * shape, so ship/preview-token (both routed through `resolveViewerTransport`)
61
+ * never duplicate or drift from each other.
62
+ *
63
+ * - 401 `unresolved_token` carrying `trustedMcp`, where the CLI's OWN signed-in
64
+ * MCP (`signedInMcpUrl`, from `resolveDeveloperSession`) is a DIFFERENT origin
65
+ * — the genuine tier mismatch (signed in to qa, store trusts prod, or vice
66
+ * versa): say exactly which two origins disagree and the exact fix.
67
+ * - Any other 401 — a real "you're not recognized here" — keeps the plain
68
+ * login hint, but still surfaces the server's error code so a report isn't
69
+ * just "it didn't work".
70
+ * - 502/503 — the SEAM is broken (unconfigured, unreachable, malformed), which
71
+ * is never a login problem: say so, with the server's code, and suggest a
72
+ * retry instead of `tot login`.
73
+ *
74
+ * @param {number} status
75
+ * @param {Record<string, unknown>|null} body
76
+ * @param {string|null|undefined} signedInMcpUrl — this CLI's own `creds.mcpUrl`
77
+ * @returns {{ message: string, hint: string }}
78
+ */
79
+ export function describeCliSessionRefusal(status, body, signedInMcpUrl) {
80
+ const error = body && typeof body.error === "string" ? body.error : null;
81
+ const code = body && typeof body.code === "string" ? body.code : null;
82
+ const trustedMcp = body && typeof body.trustedMcp === "string" ? body.trustedMcp : null;
83
+
84
+ if (status === 502 || status === 503) {
85
+ return {
86
+ message: `couldn't verify your session — the store's identity check failed${code ? ` (${code})` : ""}.`,
87
+ hint: "retry shortly; if it persists, report it with `tot feedback`.",
88
+ };
89
+ }
90
+
91
+ if (error === "unresolved_token" && trustedMcp) {
92
+ const signedInOrigin = originOf(signedInMcpUrl);
93
+ if (signedInOrigin && signedInOrigin !== trustedMcp) {
94
+ return {
95
+ message: `you're signed in to ${signedInOrigin}, but this store trusts ${trustedMcp}.`,
96
+ hint: `run \`tot login --mcp ${trustedMcp}\` (or unset TOT_MCP_URL), then re-run.`,
97
+ };
98
+ }
99
+ }
100
+
101
+ return {
102
+ message: `your \`tot\` session wasn't recognized for this store${error && error !== "unresolved_token" ? ` (${error})` : ""}.`,
103
+ hint: "run `tot login`, then re-run.",
104
+ };
105
+ }
106
+
47
107
  /**
48
108
  * Resolve a viewer-session transport for `tenant`. Returns
49
109
  * { ok:true, base, authHeaders } — base = tenant host; cookie transport
@@ -54,7 +114,8 @@ export function parseSessionCookie(setCookie) {
54
114
  * developer can point the same command at a non-default deployment.
55
115
  *
56
116
  * @param {{ tenant:string, env?:NodeJS.ProcessEnv, fetchImpl?:typeof fetch,
57
- * storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession }} params
117
+ * storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession,
118
+ * verbose?:boolean, log?:(s:string)=>void }} params
58
119
  * @returns {Promise<
59
120
  * { ok:true, base:string, authHeaders:Record<string,string> } |
60
121
  * { ok:false, message:string, hint:string }
@@ -66,7 +127,12 @@ export async function resolveViewerTransport({
66
127
  fetchImpl = fetch,
67
128
  storefrontUrl = null,
68
129
  resolveDev = resolveDeveloperSession,
130
+ verbose = false,
131
+ log = (s) => console.error(`[verbose] ${s}`),
69
132
  }) {
133
+ // --verbose: request URL + HTTP status + the server's error code only — NEVER
134
+ // the bearer token or the minted session cookie.
135
+ const vlog = verbose ? log : () => {};
70
136
  // The developer's OWN MCP token (read + silently refreshed by the resolver). No
71
137
  // client needed — resolveDeveloperSession tolerates a null client.
72
138
  let dev;
@@ -96,6 +162,7 @@ export async function resolveViewerTransport({
96
162
  // cross-origin without a CORS preflight this worker never answers, so the
97
163
  // anti-CSRF property is preserved exactly. Spoofing an `Origin` header instead
98
164
  // would assert a browser context that does not exist and protect nothing.
165
+ vlog(`POST ${base}/api/dev/cli-session`);
99
166
  res = await fetchImpl(`${base}/api/dev/cli-session`, {
100
167
  method: "POST",
101
168
  headers: {
@@ -111,12 +178,17 @@ export async function resolveViewerTransport({
111
178
  hint: "check the --tenant domain / your network, then re-run.",
112
179
  };
113
180
  }
114
- if (res.status === 401) {
115
- return {
116
- ok: false,
117
- message: "your `tot` session wasn't recognized for this store.",
118
- hint: "run `tot login`, then re-run.",
119
- };
181
+ vlog(`cli-session → HTTP ${res.status}`);
182
+ if (res.status === 401 || res.status === 502 || res.status === 503) {
183
+ let body = null;
184
+ try {
185
+ body = await res.json();
186
+ } catch {
187
+ /* non-JSON error body — describeCliSessionRefusal falls back cleanly */
188
+ }
189
+ if (body?.error || body?.code) vlog(`cli-session error: ${[body.error, body.code].filter(Boolean).join(" / ")}`);
190
+ const { message, hint } = describeCliSessionRefusal(res.status, body, dev.mcpUrl);
191
+ return { ok: false, message, hint };
120
192
  }
121
193
  if (!res.ok) {
122
194
  return {