@tokenoftrust/cli 2.4.8 → 2.4.10

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.8",
3
+ "version": "2.4.10",
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",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The next step to print when the storefront refuses a call with 401/403.
3
+ *
4
+ * The server's admission rule (apps/storefront lib/pipeline/admission.ts) names exactly what is
5
+ * missing in `admission.missing[].grant`, and its reason is already in the refusal's `error`.
6
+ * The hint follows that answer instead of restating a policy of its own: a client copy of the
7
+ * rule goes stale — every refusal here used to say "you need a live ship-on-behalf grant", which
8
+ * is wrong for an unpaid store, where an invited developer needs no grant at all.
9
+ */
10
+
11
+ /** The grant names a refusal body reports missing, or `[]` when it names none. */
12
+ export function missingGrantsOf(body) {
13
+ const missing = body && typeof body === "object" ? body.admission?.missing : undefined;
14
+ return Array.isArray(missing)
15
+ ? missing.map((m) => m?.grant).filter((g) => typeof g === "string")
16
+ : [];
17
+ }
18
+
19
+ /** One actionable next step for a refused call, from the grants the server named. */
20
+ export function refusalHint(status, missingGrants = []) {
21
+ if (status === 401 || missingGrants.includes("session")) return "run `tot login`, then re-run";
22
+ if (missingGrants.includes("tenant-binding")) {
23
+ return "your sign-in is for a different store — check --tenant, then re-run";
24
+ }
25
+ if (missingGrants.includes("tenant-standing")) {
26
+ return "ask the store owner to invite you to this store from its Team page, then re-run";
27
+ }
28
+ if (missingGrants.includes("ship-on-behalf")) {
29
+ return "this store's live site is active, so ask the store owner for a ship-on-behalf grant from its Team page, then re-run";
30
+ }
31
+ if (missingGrants.includes("owner-standing")) return "only the store owner can do this";
32
+ if (missingGrants.includes("console-capability")) {
33
+ return "the operator secret is acting with preview-access — set X-Tot-Capability: ship-on-behalf (or ACCEPT_OPERATOR_CAPABILITY), then re-run";
34
+ }
35
+ return "ask the store owner for access to this store — or use an operator secret authorised for it";
36
+ }
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;
@@ -47,8 +47,9 @@ import { createMcpClient } from "../mcp.mjs";
47
47
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
48
48
  import { scopeToTenant } from "../tenant-scope.mjs";
49
49
  // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
50
- // login and integrate as themselves (server gates on their live ship-on-behalf grant).
50
+ // login and integrate as themselves (the server's admission rule decides).
51
51
  import { resolveViewerTransport } from "../viewer-session.mjs";
52
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
52
53
  // The doctor PUSH: on a failed accept/integrate, auto-append the COMPACT
53
54
  // "here's what's blocking you" summary over the SAME transport we just used, so a
54
55
  // developer doesn't have to remember to run `tot preview doctor` themselves.
@@ -529,7 +530,7 @@ export async function runIntegrate(
529
530
  // - OPERATOR SECRET (operators/CI): Bearer + X-Tot-Owner on the generic host.
530
531
  // - VIEWER SESSION (an invited developer, no secret): mint a `tot_session` from
531
532
  // their OWN `tot` login on the TENANT'S host and send it as a cookie. The
532
- // server authorizes on their live ship-on-behalf grant either way.
533
+ // server's admission rule authorizes either way.
533
534
  // No `content-type` here: it's added per-POST; the mergeable preflight GET wants none.
534
535
  let authHeaders;
535
536
  if (secret) {
@@ -688,7 +689,7 @@ function reportIntegrateHttpError(result, res, { tenant, label }) {
688
689
  fail(
689
690
  `the integration queue refused the request: ${msg}`,
690
691
  res.status === 401 || res.status === 403
691
- ? "you need a live ship-on-behalf grant on this tenant (ask the store owner) — or an operator secret authorised for it"
692
+ ? refusalHint(res.status, missingGrantsOf(result.raw))
692
693
  : res.status === 404
693
694
  ? `check that ${label} has a built candidate in ${tenant}'s queue (\`tot pr list --tenant ${tenant}\`)`
694
695
  : "check --tenant / --url / --pr, then re-run",
@@ -38,6 +38,7 @@
38
38
  */
39
39
  import { fail } from "../errors.mjs";
40
40
  import { resolveOperatorSecret } from "./ship.mjs";
41
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
41
42
  import { resolveViewerTransport } from "../viewer-session.mjs";
42
43
  import {
43
44
  DOCTOR_PATH,
@@ -165,7 +166,7 @@ export async function runDoctor(
165
166
  console.error(
166
167
  fail(
167
168
  `not authorized to diagnose ${tenant}'s merges: ${data?.error || `HTTP ${res.status}`}`,
168
- "you need a live ship-on-behalf grant on this tenant (ask the store owner), or an operator secret authorised for it — then re-run",
169
+ refusalHint(res.status, missingGrantsOf(data)),
169
170
  ),
170
171
  );
171
172
  return 2;
@@ -45,8 +45,9 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
45
45
  // speak ONE operator-auth contract.
46
46
  import { resolveOperatorSecret } from "./ship.mjs";
47
47
  // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
48
- // login. The server still gates the request on their live ship-on-behalf grant.
48
+ // login. The server's admission rule still gates the request.
49
49
  import { resolveViewerTransport } from "../viewer-session.mjs";
50
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
50
51
  import { isJobAccepted, waitForPipelineJob } from "../pipeline-job.mjs";
51
52
 
52
53
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
@@ -337,7 +338,7 @@ export async function runRevert(
337
338
  fail(
338
339
  `the revert request was refused: ${msg}`,
339
340
  res.status === 401 || res.status === 403
340
- ? "you need a live ship-on-behalf grant on this tenant (ask the store owner) — or an operator secret authorised for it"
341
+ ? refusalHint(res.status, missingGrantsOf(data))
341
342
  : "check --tenant / --url / --preview, then re-run",
342
343
  ),
343
344
  );
@@ -47,7 +47,8 @@
47
47
  *
48
48
  * TRANSPORT: operators/CI may use a Bearer operator secret. A signed-in owner or
49
49
  * developer instead mints a storefront viewer session from their OWN `tot login`
50
- * identity; the server still enforces the live owner / `ship-on-behalf` grant.
50
+ * identity; the server's admission rule decides who may ship (owner; a live
51
+ * `ship-on-behalf` grant; or, while the store is unpaid, any developer invited to it).
51
52
  */
52
53
  import { createHash } from "node:crypto";
53
54
  import { fail } from "../errors.mjs";
@@ -61,6 +62,7 @@ import { emitActivity } from "../activity.mjs";
61
62
  // `tot preview doctor` run.
62
63
  import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
63
64
  import { resolveViewerTransport } from "../viewer-session.mjs";
65
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
64
66
 
65
67
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
66
68
 
@@ -75,11 +77,13 @@ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live
75
77
  accept this revision's overridable quality findings,
76
78
  record the required reason, then continue publishing
77
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
78
82
 
79
- Authentication: a signed-in owner/developer with a live ship-on-behalf grant
80
- can use their own \`tot login\` session. Operators/CI may instead provide a
81
- secret via PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET,
82
- TOT_OPERATOR_SECRET, or --secret.
83
+ Authentication: use your own \`tot login\` session if you own the store, hold
84
+ a live ship-on-behalf grant, or — while the store is unpaid — were invited to
85
+ it as a developer. Operators/CI may instead provide a secret via
86
+ PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET, or --secret.
83
87
 
84
88
  \`tot ship\` has ONE meaning: it publishes the tenant's shared preview
85
89
  aggregate — the batch of PRs that integrated cleanly and went GREEN — to
@@ -107,6 +111,7 @@ export function parseShipArgs(argv) {
107
111
  url: null,
108
112
  secret: null,
109
113
  acceptQualityReason: null,
114
+ verbose: false,
110
115
  help: false,
111
116
  };
112
117
  for (let i = 0; i < argv.length; i++) {
@@ -117,6 +122,7 @@ export function parseShipArgs(argv) {
117
122
  else if (t === "--url") a.url = argv[++i];
118
123
  else if (t === "--secret") a.secret = argv[++i];
119
124
  else if (t === "--accept-quality-findings") a.acceptQualityReason = argv[++i];
125
+ else if (t === "--verbose") a.verbose = true;
120
126
  else if (t === "--help" || t === "-h") a.help = true;
121
127
  }
122
128
  return a;
@@ -422,9 +428,9 @@ async function readJsonSafe(res) {
422
428
  *
423
429
  * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
424
430
  * yes?:boolean, noOpen?:boolean, acceptQualityReason?:string|null,
425
- * env?:NodeJS.ProcessEnv }} params
431
+ * verbose?:boolean, env?:NodeJS.ProcessEnv }} params
426
432
  * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
427
- * openUrl?:(u:string)=>boolean, progress?:boolean,
433
+ * openUrl?:(u:string)=>boolean, progress?:boolean, log?:(s:string)=>void,
428
434
  * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
429
435
  * @returns {Promise<number>} process exit code
430
436
  */
@@ -436,6 +442,7 @@ export async function runShip(
436
442
  yes = false,
437
443
  noOpen = false,
438
444
  acceptQualityReason = null,
445
+ verbose = false,
439
446
  env = process.env,
440
447
  },
441
448
  deps = {},
@@ -443,6 +450,9 @@ export async function runShip(
443
450
  const fetchImpl = deps.fetch || globalThis.fetch;
444
451
  const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
445
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}`))) : () => {};
446
456
  let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
447
457
  let authHeaders;
448
458
  if (secret) {
@@ -452,7 +462,7 @@ export async function runShip(
452
462
  "x-tot-capability": "ship-on-behalf",
453
463
  };
454
464
  } else {
455
- const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
465
+ const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl, verbose, log: vlog });
456
466
  if (!viewer.ok) {
457
467
  console.error(fail(viewer.message, viewer.hint));
458
468
  return 2;
@@ -471,6 +481,7 @@ export async function runShip(
471
481
 
472
482
  // 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
473
483
  // rollback target + paywall verdict. Zero side effects.
484
+ vlog(`GET ${base}/api/changes/ship`);
474
485
  let planRes;
475
486
  try {
476
487
  planRes = await fetchImpl(`${base}/api/changes/ship`, { method: "GET", headers: authHeaders });
@@ -480,21 +491,35 @@ export async function runShip(
480
491
  );
481
492
  return 1;
482
493
  }
494
+ vlog(`ship plan → HTTP ${planRes.status}`);
483
495
  const planData = await readJsonSafe(planRes);
484
- if (!planRes.ok && planData?.ok !== false) {
485
- // A transport-level refusal the endpoint didn't explain in its own shape
486
- // (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).
487
505
  const msg = planData?.error || `HTTP ${planRes.status}`;
488
506
  console.error(
489
507
  fail(
490
508
  `the ship plan was refused: ${msg}`,
491
- planRes.status === 401 || planRes.status === 403
509
+ secret
492
510
  ? "check the operator secret and that it's authorised for this tenant"
493
- : "check --tenant / --url, then re-run",
511
+ : refusalHint(planRes.status, missingGrantsOf(planData)),
494
512
  ),
495
513
  );
496
514
  return 1;
497
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
+ }
498
523
  const plan = normalizeShipPlan(planData);
499
524
  // The tenant's mounted publication spaces ride the same release (see the file header).
500
525
  const spaces = (await readSpaceRelease({ base, authHeaders, fetchImpl })) ?? { ready: [], waiting: [] };
@@ -725,6 +750,7 @@ export async function run(argv, ctx) {
725
750
  yes: args.yes,
726
751
  noOpen: args.noOpen,
727
752
  acceptQualityReason: args.acceptQualityReason,
753
+ verbose: args.verbose,
728
754
  env,
729
755
  },
730
756
  { openUrl: (u) => openBrowser(u) },
@@ -6,9 +6,10 @@
6
6
  * a storefront VIEWER session from the developer's OWN MCP token (POST
7
7
  * /api/dev/cli-session on the tenant's own host, which resolves WHO the opaque token
8
8
  * is via the MCP and hands back a `tot_session` cookie), then hands the caller a
9
- * cookie-based transport pointed at the tenant host. Authorization is still the
10
- * developer's live `ship-on-behalf` grant, enforced server-side at the ship route —
11
- * this only carries their identity, it grants nothing.
9
+ * cookie-based transport pointed at the tenant host. Authorization is the server's
10
+ * admission rule at the ship route (owner; a live `ship-on-behalf` grant; or, while the
11
+ * store is unpaid, any developer invited to it) — this only carries their identity, it
12
+ * grants nothing.
12
13
  *
13
14
  * Minted FRESH per call (no disk cache): a `tot accept` is interactive + infrequent,
14
15
  * and minting-per-call means a revoked session/grant is never honored past its life.
@@ -43,6 +44,66 @@ export function parseSessionCookie(setCookie) {
43
44
  return m ? m[1] : null;
44
45
  }
45
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
+
46
107
  /**
47
108
  * Resolve a viewer-session transport for `tenant`. Returns
48
109
  * { ok:true, base, authHeaders } — base = tenant host; cookie transport
@@ -53,7 +114,8 @@ export function parseSessionCookie(setCookie) {
53
114
  * developer can point the same command at a non-default deployment.
54
115
  *
55
116
  * @param {{ tenant:string, env?:NodeJS.ProcessEnv, fetchImpl?:typeof fetch,
56
- * storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession }} params
117
+ * storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession,
118
+ * verbose?:boolean, log?:(s:string)=>void }} params
57
119
  * @returns {Promise<
58
120
  * { ok:true, base:string, authHeaders:Record<string,string> } |
59
121
  * { ok:false, message:string, hint:string }
@@ -65,7 +127,12 @@ export async function resolveViewerTransport({
65
127
  fetchImpl = fetch,
66
128
  storefrontUrl = null,
67
129
  resolveDev = resolveDeveloperSession,
130
+ verbose = false,
131
+ log = (s) => console.error(`[verbose] ${s}`),
68
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 : () => {};
69
136
  // The developer's OWN MCP token (read + silently refreshed by the resolver). No
70
137
  // client needed — resolveDeveloperSession tolerates a null client.
71
138
  let dev;
@@ -95,6 +162,7 @@ export async function resolveViewerTransport({
95
162
  // cross-origin without a CORS preflight this worker never answers, so the
96
163
  // anti-CSRF property is preserved exactly. Spoofing an `Origin` header instead
97
164
  // would assert a browser context that does not exist and protect nothing.
165
+ vlog(`POST ${base}/api/dev/cli-session`);
98
166
  res = await fetchImpl(`${base}/api/dev/cli-session`, {
99
167
  method: "POST",
100
168
  headers: {
@@ -110,12 +178,17 @@ export async function resolveViewerTransport({
110
178
  hint: "check the --tenant domain / your network, then re-run.",
111
179
  };
112
180
  }
113
- if (res.status === 401) {
114
- return {
115
- ok: false,
116
- message: "your `tot` session wasn't recognized for this store.",
117
- hint: "run `tot login`, then re-run.",
118
- };
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 };
119
192
  }
120
193
  if (!res.ok) {
121
194
  return {