@tokenoftrust/cli 2.0.20 → 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.20",
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,26 +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 CARRIES the best diagnosis but is NOT a precondition, and the difference
291
- // matters in both directions:
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.
292
329
  //
293
- // - Discarding its refusal (the original behaviour) meant a genuinely unentitled tenant
294
- // surfaced later as tenant_checkout complaining about your PRIOR active tenant, which
295
- // reads as a scope-selection slip rather than a missing grant.
296
- // - Treating it as fatal breaks the STAFF-PREVIEW path, where the refusal is expected:
297
- // a staff human checking out a merchant tenant under an authorization holds no client
298
- // grant on it, so client_switch MUST fail while tenant_checkout legitimately succeeds
299
- // (it takes the tenant as an argument and resolves the app from the authorization).
300
- // `tot git-credential` mints through this same function, so a fatal switch also breaks
301
- // every fetch and push in such a checkout, not just the initial clone.
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).
302
337
  //
303
- // So: remember it, proceed, and surface it only if the checkout ALSO fails — at which
304
- // point it is the more specific of the two errors.
305
- let switchRefusal = null;
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;
306
343
  try {
307
344
  await client.callTool("client_switch", { tenant });
308
345
  } catch (e) {
309
- switchRefusal = e;
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
+ }
310
354
  }
311
355
  // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
312
356
  // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
@@ -328,14 +372,15 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
328
372
  try {
329
373
  checkout = await client.callTool("tenant_checkout", { tenant, tag });
330
374
  } catch (e) {
331
- // Both failed: the switch's refusal names the cause more precisely than a thrown
332
- // checkout, so lead with it.
333
- if (switchRefusal) {
334
- throw new CliError(redact(String(switchRefusal?.message || switchRefusal)), {
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)), {
335
380
  next:
336
381
  "confirm you're entitled to this store — `tot grants` lists what your session can " +
337
382
  "act on, and `tot login` refreshes a grant issued after you signed in",
338
- cause: switchRefusal,
383
+ cause: expectedSwitchRefusal,
339
384
  });
340
385
  }
341
386
  throw e;
@@ -346,15 +391,9 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
346
391
  // dump (James's 2026-07-19 first-experience failure).
347
392
  const err = checkoutError(checkout);
348
393
  if (err) {
349
- // Same precedence: an unentitled tenant is better explained by the switch.
350
- if (switchRefusal) {
351
- throw new CliError(redact(String(switchRefusal?.message || switchRefusal)), {
352
- next:
353
- "confirm you're entitled to this store — `tot grants` lists what your session can " +
354
- "act on, and `tot login` refreshes a grant issued after you signed in",
355
- cause: switchRefusal,
356
- });
357
- }
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.
358
397
  throw new CliError(redact(err.message), { next: err.next });
359
398
  }
360
399
  const gitRemote = checkout.gitRemote;