@tokenoftrust/cli 4.11.1 → 4.11.3

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 CHANGED
@@ -371,7 +371,6 @@ async function main() {
371
371
  const mcpUrl = mcpIndex < 0 ? undefined : rest[mcpIndex + 1];
372
372
  if (mcpIndex >= 0 && (!mcpUrl || mcpUrl.startsWith("--"))) throw new Error("--mcp requires an HTTPS URL.");
373
373
  // One invocation context drives auth, telemetry and every runner child.
374
- delete process.env.TOT_SELECTED_MCP_URL;
375
374
  process.env.TOT_SELECTED_MCP_URL = selectedAuthority(process.env, { mcpUrl, cwd: ctx.cwd });
376
375
  process.env.MCP_BASE_URL = process.env.TOT_SELECTED_MCP_URL;
377
376
  const startedAt = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.11.1",
3
+ "version": "4.11.3",
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",
@@ -38,13 +38,15 @@ import { isInteractive, promptYesNo } from "../prompt.mjs";
38
38
  import { startProgress } from "../progress.mjs";
39
39
  import { openBrowser } from "../open.mjs";
40
40
  import { formatImageTypes, normalizeImageTypes } from "../image-census.mjs";
41
+ import { DEFAULT_STOREFRONT_ORIGIN, describeIdentity, resolveViewerTransport } from "../viewer-session.mjs";
42
+ import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
41
43
 
42
44
  const USAGE = `tot go-live — cut the apex domain over to the storefront
43
45
 
44
46
  tot go-live show apex readiness, then (if ready) connect the apex
45
47
  tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
46
48
  tot go-live --rollback restore the captured prior DNS records (the revert)
47
- tot go-live --url <base> storefront base URL (default: env TOT_STOREFRONT_URL / https://<owner>)
49
+ tot go-live --url <origin> storefront origin (default: env TOT_STOREFRONT_URL / ${DEFAULT_STOREFRONT_ORIGIN})
48
50
  tot go-live --owner <domain> owner/appDomain to act on (default: env TOT_STOREFRONT_OWNER / checkout tenant)
49
51
  tot go-live --no-open don't open the store in your browser afterward
50
52
 
@@ -52,7 +54,11 @@ const USAGE = `tot go-live — cut the apex domain over to the storefront
52
54
  same server readiness gate. It ALWAYS shows you the readiness itemization and
53
55
  asks for a single y/N confirmation first; a CONNECT is refused unless the server
54
56
  reports ready. There is no --yes/--force, and it refuses to run without an
55
- interactive terminal. Reversible: \`tot go-live --rollback\`.`;
57
+ interactive terminal. Reversible: \`tot go-live --rollback\`.
58
+
59
+ Authentication: your own \`tot login\` session. The store decides what you may
60
+ do: the owner reads readiness and cuts over; others see what the store admits.
61
+ Operators/CI may instead set TOT_STOREFRONT_OPERATOR_TOKEN or PREVIEW_RECONCILE_SECRET.`;
56
62
 
57
63
  /** Parse `tot go-live` argv. Pure. Deliberately NO --yes/--force (see the header). */
58
64
  export function parseGoLiveArgs(argv) {
@@ -251,7 +257,9 @@ export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, slee
251
257
  * @param {{ appDomain:string, action:"connect"|"rollback", rehearsal:boolean, noOpen?:boolean,
252
258
  * dnsSetup?: { domainConnectDeclared?: boolean, ciRoleCanChangeZone?: boolean, nameserversAtRegistrar?: boolean } }} params
253
259
  * @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
254
- * poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
260
+ * poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean,
261
+ * base?:string|null, identity?:{ account?:string|null, mcp?:string|null }|null }} [deps]
262
+ * `base`/`identity` only name where and as whom a failed readiness read was made.
255
263
  * @returns {Promise<number>} process exit code
256
264
  */
257
265
  export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dnsSetup }, deps = {}) {
@@ -264,12 +272,8 @@ export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dn
264
272
  try {
265
273
  readiness = normalizeReadiness(await http.get("/api/domain/readiness"));
266
274
  } catch (e) {
267
- console.error(
268
- fail(
269
- `couldn't read apex readiness: ${String(e?.message || e)}`,
270
- "check your connection and that the operator token/owner are set, then re-run",
271
- ),
272
- );
275
+ const { message, hint } = describeReadinessFailure(e, { base: deps.base ?? null, identity: deps.identity ?? null });
276
+ console.error(fail(message, hint));
273
277
  return 1;
274
278
  }
275
279
  const domain = appDomain || readiness.appDomain;
@@ -459,21 +463,99 @@ function cap(s) {
459
463
  }
460
464
 
461
465
  /**
462
- * Build an HTTP client over the storefront apex-domain endpoints. Auth mirrors the
463
- * headless operator trust boundary the storefront's `resolveOwnerSession` Path 2
464
- * accepts: a Bearer operator secret + `X-Tot-Owner`, plus the `x-tot-capability`
465
- * ship floor. Throws on a non-2xx with the server's error message (so the caller
466
- * surfaces the real refusal). `fetchImpl` is injectable for tests.
466
+ * Why the readiness read failed, as one `{ message, hint }`. Pure. Reads the HTTP
467
+ * status and server error code `createStorefrontHttp` attaches; an error without a
468
+ * status never reached a server, and only that case is a connection problem.
469
+ * @param {any} e
470
+ * @param {{ base?: string|null, identity?: { account?: string|null, mcp?: string|null }|null }} [ctx]
471
+ * @returns {{ message: string, hint: string }}
472
+ */
473
+ export function describeReadinessFailure(e, { base = null, identity = null } = {}) {
474
+ const status = typeof e?.status === "number" ? e.status : null;
475
+ const code = typeof e?.code === "string" ? e.code : null;
476
+ const where = base ? ` at ${base}` : "";
477
+ if (status === 404) {
478
+ return {
479
+ message: `couldn't read apex readiness${where}: HTTP 404 — that host is not serving this store from the storefront yet (before cutover the domain still points at the old platform)`,
480
+ hint: `drop --url / TOT_STOREFRONT_URL to use the shared storefront, or pass --url ${DEFAULT_STOREFRONT_ORIGIN}`,
481
+ };
482
+ }
483
+ if (status === 401 || status === 403 || code === "store-not-public") {
484
+ const who = describeIdentity(identity);
485
+ const missing = missingGrantsOf(e?.body);
486
+ return {
487
+ message: `the store refused the readiness read${where} (${code || `HTTP ${status}`})${who ? ` for ${who}` : ""}`,
488
+ // The server names what is missing; with nothing named, a refusal here means the
489
+ // caller is not signed in as someone this store admits.
490
+ hint: missing.length
491
+ ? refusalHint(status ?? 401, missing)
492
+ : "run `tot login` as the store owner (or a developer invited to the store), then re-run",
493
+ };
494
+ }
495
+ if (status !== null) {
496
+ return {
497
+ message: `couldn't read apex readiness${where}: ${code || `HTTP ${status}`}`,
498
+ hint: "retry shortly; if it persists, report it with `tot feedback`",
499
+ };
500
+ }
501
+ return {
502
+ message: `couldn't reach the storefront${where}: ${String(e?.message || e)}`,
503
+ hint: "check your connection, then re-run",
504
+ };
505
+ }
506
+
507
+ /**
508
+ * The storefront origin go-live talks to. Pure. The shared storefront serves every
509
+ * tenant before and after cutover, so it is the default; the apex is never assumed.
510
+ * A `--url` already scoped to this tenant (`<origin>/<owner>`) names the same store.
511
+ * @param {{ url?: string|null, envUrl?: string|null, owner: string }} p
512
+ */
513
+ export function resolveGoLiveOrigin({ url = null, envUrl = null, owner }) {
514
+ const raw = String(url || envUrl || DEFAULT_STOREFRONT_ORIGIN).trim().replace(/\/+$/, "");
515
+ const suffix = `/${String(owner || "").trim().toLowerCase()}`;
516
+ return suffix.length > 1 && raw.toLowerCase().endsWith(suffix) ? raw.slice(0, -suffix.length) : raw;
517
+ }
518
+
519
+ /**
520
+ * The transport for the apex-domain endpoints. An operator secret (operators/CI)
521
+ * keeps the Bearer + `X-Tot-Owner` route on the origin; without one, the caller's own
522
+ * `tot login` is exchanged for a storefront viewer session on the tenant's base, the
523
+ * same transport `tot ship` / `tot accept` use. The server's admission decides what
524
+ * either may do; this only carries the identity.
525
+ * @param {{ origin: string, owner: string, token?: string|null, env?: NodeJS.ProcessEnv,
526
+ * fetchImpl?: typeof fetch, resolveViewer?: typeof resolveViewerTransport }} p
527
+ * @returns {Promise<{ ok: true, base: string, token: string|null, headers: Record<string,string>,
528
+ * identity: { account: string|null, mcp: string|null }|null } | { ok: false, message: string, hint: string }>}
529
+ */
530
+ export async function resolveGoLiveTransport({
531
+ origin, owner, token = null, env = process.env, fetchImpl = fetch, resolveViewer = resolveViewerTransport,
532
+ }) {
533
+ if (token) return { ok: true, base: origin, token, headers: {}, identity: null };
534
+ const viewer = await resolveViewer({ tenant: owner, env, fetchImpl, storefrontUrl: origin });
535
+ if (!viewer.ok) return { ok: false, message: viewer.message, hint: viewer.hint };
536
+ return { ok: true, base: viewer.base, token: null, headers: viewer.authHeaders, identity: viewer.identity };
537
+ }
538
+
539
+ /**
540
+ * Build an HTTP client over the storefront apex-domain endpoints. With `token`, it
541
+ * sends the headless operator trust boundary `resolveOwnerSession` Path 2 accepts:
542
+ * a Bearer operator secret + `X-Tot-Owner`. `headers` carries a viewer-session
543
+ * cookie instead. Every request carries the `x-tot-capability` ship floor. Throws on
544
+ * a non-2xx with the server's error message, plus `status` and `code` (the server's
545
+ * error code), so the caller can tell a refusal from a missing route.
546
+ * `fetchImpl` is injectable for tests.
467
547
  * @param {string} base
468
- * @param {{ token?:string, owner?:string, capability?:string, fetchImpl?:typeof fetch }} [auth]
548
+ * @param {{ token?:string|null, owner?:string, capability?:string, headers?:Record<string,string>,
549
+ * fetchImpl?:typeof fetch }} [auth]
469
550
  */
470
- export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
551
+ export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", headers: extra = {}, fetchImpl } = {}) {
471
552
  const root = base.replace(/\/+$/, "");
472
553
  const doFetch = fetchImpl || fetch;
473
554
  const headers = () => ({
474
555
  ...(token ? { authorization: `Bearer ${token}` } : {}),
475
- ...(owner ? { "x-tot-owner": owner } : {}),
556
+ ...(token && owner ? { "x-tot-owner": owner } : {}),
476
557
  "x-tot-capability": capability,
558
+ ...extra,
477
559
  });
478
560
  const parse = async (res) => {
479
561
  const text = await res.text();
@@ -484,9 +566,12 @@ export function createStorefrontHttp(base, { token, owner, capability = "ship-on
484
566
  body = null;
485
567
  }
486
568
  if (!res.ok) {
487
- const msg = (body && typeof body === "object" && typeof body.error === "string" && body.error) ||
488
- `HTTP ${res.status}`;
489
- throw new Error(msg);
569
+ const code = (body && typeof body === "object" && typeof body.error === "string" && body.error) || null;
570
+ const err = /** @type {Error & { status?: number, code?: string|null, body?: any }} */ (new Error(code || `HTTP ${res.status}`));
571
+ err.status = res.status;
572
+ err.code = code;
573
+ err.body = body;
574
+ throw err;
490
575
  }
491
576
  return body;
492
577
  };
@@ -527,9 +612,8 @@ export async function run(argv, ctx) {
527
612
  return 2;
528
613
  }
529
614
 
530
- // The owner/appDomain to act on, and the storefront base URL. Owner defaults to
531
- // the checkout's tenant (owner == appDomain in this platform); the base URL
532
- // defaults to https://<owner> unless overridden.
615
+ // The owner/appDomain to act on. Owner defaults to the checkout's tenant
616
+ // (owner == appDomain in this platform).
533
617
  const owner = args.owner || env.TOT_STOREFRONT_OWNER || ctx.tenant;
534
618
  if (!owner) {
535
619
  console.error(
@@ -540,17 +624,24 @@ export async function run(argv, ctx) {
540
624
  );
541
625
  return 1;
542
626
  }
543
- const base = args.url || env.TOT_STOREFRONT_URL || `https://${owner}`;
544
- const token = env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET;
545
-
546
- // The storefront apex-domain endpoints authenticate via the operator token +
547
- // X-Tot-Owner (resolveOwnerSession Path 2), NOT the MCP OAuth session — so there
548
- // is no MCP sign-in step here. Owner INTENT is enforced by the explicit confirm
549
- // below and, authoritatively, by the server's owner-only readiness/dispatch gate.
550
- const http = createStorefrontHttp(base, { token, owner });
627
+ const origin = resolveGoLiveOrigin({ url: args.url, envUrl: env.TOT_STOREFRONT_URL, owner });
628
+ const transport = await resolveGoLiveTransport({
629
+ origin,
630
+ owner,
631
+ token: env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET || null,
632
+ env,
633
+ });
634
+ if (!transport.ok) {
635
+ console.error(fail(transport.message, transport.hint));
636
+ return 2;
637
+ }
638
+
639
+ // Owner INTENT is enforced by the explicit confirm in runGoLive and,
640
+ // authoritatively, by the server's owner-only readiness/dispatch gate.
641
+ const http = createStorefrontHttp(transport.base, { token: transport.token, owner, headers: transport.headers });
551
642
  return await runGoLive(
552
643
  http,
553
644
  { appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
554
- { openUrl: (u) => openBrowser(u) },
645
+ { openUrl: (u) => openBrowser(u), base: transport.base, identity: transport.identity },
555
646
  );
556
647
  }
@@ -111,7 +111,7 @@ export function describeCliSessionRefusal(status, body, signedInMcpUrl) {
111
111
  };
112
112
  }
113
113
 
114
- if (error === "unresolved_token" && trustedMcp) {
114
+ if (error === "unresolved_token" && trustedMcp && TOKEN_OF_TRUST_MCP_ORIGINS.has(trustedMcp)) {
115
115
  const signedInOrigin = originOf(signedInMcpUrl);
116
116
  if (signedInOrigin && signedInOrigin !== trustedMcp) {
117
117
  // `TOT_SELECTED_MCP_URL=… tot login` adds a session for the storefront's MCP