@tokenoftrust/cli 2.0.19 → 2.0.21

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.0.19",
3
+ "version": "2.0.21",
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",
@@ -275,6 +275,39 @@ export async function run(argv, ctx) {
275
275
  }
276
276
  }
277
277
 
278
+ /**
279
+ * Is this client_switch failure the EXPECTED "that tenant is not one of your clients"
280
+ * refusal, as opposed to a broken session (transport, auth, malformed envelope, internal
281
+ * error)? The distinction is load-bearing: an expected refusal is a normal step on the
282
+ * staff-preview path and must be tolerated, while anything else means the session itself
283
+ * is unusable and must surface immediately rather than be mistaken for an authorization
284
+ * problem later.
285
+ *
286
+ * Matched on the server's STRUCTURED signal first (`serverCode` / status), because message
287
+ * text is not a contract. The text fallback exists only for servers predating a typed code
288
+ * and is deliberately narrow — when in doubt this returns false, which escalates. Treating
289
+ * an unknown failure as "expected" is the unsafe direction: it would let a dead connection
290
+ * pass for a routine refusal.
291
+ *
292
+ * Pure + exported so it is unit-tested without any I/O.
293
+ * @param {unknown} error
294
+ * @returns {boolean}
295
+ */
296
+ export function isNotClientTenantRefusal(error) {
297
+ const e = /** @type {any} */ (error);
298
+ if (!e) return false;
299
+ // A transport/protocol-level failure is never an authorization refusal.
300
+ const category = e?.diagnostic?.category ?? e?.category;
301
+ if (category && category !== "tool_error") return false;
302
+ const code = e?.diagnostic?.serverCode ?? e?.serverCode;
303
+ if (typeof code === "string") {
304
+ return code === "not_client_tenant" || code === "over_scoped";
305
+ }
306
+ // Text fallback (pre-typed-code servers) — narrow on purpose.
307
+ const message = typeof e?.message === "string" ? e.message : "";
308
+ return /not one of your client tenants/i.test(message);
309
+ }
310
+
278
311
  /**
279
312
  * The privileged checkout core, composable in-process (used by `tot clone`
280
313
  * and by `tot start`): client_switch → tenant_checkout → optionally clone. The
@@ -287,21 +320,37 @@ export async function run(argv, ctx) {
287
320
  * cloned: boolean, dir: string|null, head: string|null }>}
288
321
  */
289
322
  export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
290
- // The switch is LOAD-BEARING, so its refusal is the error the developer must see. When
291
- // the tenant is not one of this session's clients the server refuses here with the real
292
- // cause (no grant / a grant issued to a client this identity is not bound to / a grant
293
- // issued after sign-in that needs a re-auth) — far more specific than the tenant_checkout
294
- // refusal that follows it. Surfacing the switch's own words, with the server's nextAction,
295
- // stops a genuinely-unentitled tenant from being reported as a scope-selection slip.
323
+ // tenant_checkout is the SOLE AUTHORITATIVE checkout decision. client_switch is a scope
324
+ // convenience whose refusal may be entirely expected: a staff human checking out a
325
+ // merchant tenant under a staff-preview authorization holds no client grant on it, so the
326
+ // switch MUST refuse while the checkout legitimately succeeds. `tot git-credential` mints
327
+ // through this same function, so treating the switch as fatal breaks every fetch and push
328
+ // in such a checkout, not only the clone.
329
+ //
330
+ // Two failure modes are therefore BOTH wrong, and the second is the dangerous one:
331
+ // - discarding the refusal loses the only good explanation when a tenant genuinely is
332
+ // not yours;
333
+ // - letting the refusal OVERRIDE a checkout denial replaces an authoritative answer
334
+ // ("your staff authorization expired / was revoked / does not cover this resource")
335
+ // with grant guidance that sends you chasing the wrong thing entirely.
336
+ // So an EXPECTED switch refusal may never speak over a checkout denial (see below).
337
+ //
338
+ // An UNEXPECTED switch failure — transport, auth, a malformed envelope — is not the
339
+ // not-a-client case at all. It means the session itself is broken, and carrying it
340
+ // silently would let a dead connection masquerade as an authorization problem, so it is
341
+ // raised immediately rather than remembered.
342
+ let expectedSwitchRefusal = null;
296
343
  try {
297
344
  await client.callTool("client_switch", { tenant });
298
345
  } catch (e) {
299
- throw new CliError(redact(String(e?.message || e)), {
300
- next:
301
- "confirm you're entitled to this store — `tot grants` lists what your session can " +
302
- "act on, and `tot login` refreshes a grant issued after you signed in",
303
- cause: e,
304
- });
346
+ if (isNotClientTenantRefusal(e)) {
347
+ expectedSwitchRefusal = e;
348
+ } else {
349
+ throw new CliError(redact(String(e?.message || e)), {
350
+ next: "the session could not be scoped — check your connection and run `tot whoami`",
351
+ cause: e,
352
+ });
353
+ }
305
354
  }
306
355
  // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
307
356
  // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
@@ -319,13 +368,32 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
319
368
  } catch {
320
369
  /* fail-open — see above */
321
370
  }
322
- const checkout = await client.callTool("tenant_checkout", { tenant, tag });
371
+ let checkout;
372
+ try {
373
+ checkout = await client.callTool("tenant_checkout", { tenant, tag });
374
+ } catch (e) {
375
+ // A THROWN checkout is not a denial — it never rendered an authorization decision. Only
376
+ // here may the expected switch refusal speak, because it is the only explanation either
377
+ // call produced.
378
+ if (expectedSwitchRefusal) {
379
+ throw new CliError(redact(String(expectedSwitchRefusal?.message || expectedSwitchRefusal)), {
380
+ next:
381
+ "confirm you're entitled to this store — `tot grants` lists what your session can " +
382
+ "act on, and `tot login` refreshes a grant issued after you signed in",
383
+ cause: expectedSwitchRefusal,
384
+ });
385
+ }
386
+ throw e;
387
+ }
323
388
 
324
389
  // A non-checkout result (not provisioned / not entitled / failed) must surface
325
390
  // the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
326
391
  // dump (James's 2026-07-19 first-experience failure).
327
392
  const err = checkoutError(checkout);
328
393
  if (err) {
394
+ // AUTHORITATIVE: the checkout rendered a decision, so it is the answer — even when the
395
+ // switch also refused. Substituting grant guidance here is what would tell someone whose
396
+ // staff authorization had EXPIRED to go looking at their grants.
329
397
  throw new CliError(redact(err.message), { next: err.next });
330
398
  }
331
399
  const gitRemote = checkout.gitRemote;