@tokenoftrust/cli 2.2.10 → 2.2.12

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.2.10",
3
+ "version": "2.2.12",
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",
@@ -17,8 +17,8 @@
17
17
  * every store-only step. The checkout records its kind in local git config so
18
18
  * `tot git-credential` re-mints it the same way.
19
19
  *
20
- * This is the SAME MCP code path a developer gets when they switch to a tenant:
21
- * sign-in → client_switch(tenant) → tenant_checkout, where the MCP derives your
20
+ * This is the SAME MCP code path a developer's agent takes: sign-in →
21
+ * tenant_checkout({ tenant }) — the tenant named on the call, no client_switch — where the MCP derives your
22
22
  * per-tenant Git user and mints a FRESH, single-active, repo-scoped push
23
23
  * credential (a later clone for the same tenant rotates it). `tot` performs NO
24
24
  * privileged forge work itself — the MCP owns that. ("checkout" survives only as
@@ -238,9 +238,8 @@ export async function run(argv, ctx, deps = {}) {
238
238
  return 0;
239
239
  }
240
240
 
241
- console.error(`~ client_switch → ${args.tenant}`);
242
241
  console.error(
243
- `~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
242
+ `~ tenant_checkout (tenant=${args.tenant}) — MCP mints a fresh repo-scoped credential`,
244
243
  );
245
244
  const res = await checkoutTenant(client, {
246
245
  tenant: args.tenant,
@@ -308,7 +307,7 @@ function reportFailure(e, redact) {
308
307
  /**
309
308
  * `tot clone <alias> [dir]` for a read-only alias (the agency kit). Only the steps a
310
309
  * non-store repo needs: sign-in, the read mint, the token-free clone, the helper. No store
311
- * listing, client_switch, identity_bind, tenant_checkout, tenant inference, `.nvmrc` or
310
+ * listing, identity_bind, tenant_checkout, tenant inference, `.nvmrc` or
312
311
  * `tot dev` guidance.
313
312
  */
314
313
  async function runReadOnlyAlias(alias, args, { env, createClient, establish, signIn, cloneDeps }) {
@@ -376,23 +375,13 @@ export function checkoutPointsAtEntitlement(checkout) {
376
375
  * cloned: boolean, dir: string|null, head: string|null }>}
377
376
  */
378
377
  export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
379
- // tenant_checkout is the SOLE AUTHORITATIVE checkout decision. The pin's refusal may be
380
- // entirely expected: a staff human checking out a merchant tenant under a staff-preview
381
- // authorization holds no client grant on it, so the pin MUST refuse while the checkout
382
- // legitimately succeeds. `tot git-credential` mints through this same function, so
383
- // treating the pin as fatal breaks every fetch and push in such a checkout, not only the
384
- // clone. scopeToTenant remembers that expected refusal as `pinRefusal` and throws at once
385
- // on any other pin failure (a broken session must not masquerade as an authorization
386
- // problem).
387
- //
388
- // Two uses of the remembered refusal are BOTH wrong, and the second is the dangerous one:
389
- // - discarding it loses the only good explanation when a tenant genuinely is not yours;
390
- // - letting it OVERRIDE a checkout denial replaces an authoritative answer ("your staff
391
- // authorization expired / was revoked / does not cover this resource") with grant
392
- // guidance that sends you chasing the wrong thing entirely.
393
- // So an expected pin refusal may never speak over a checkout denial (see below).
378
+ // tenant_checkout is the SOLE AUTHORITATIVE checkout decision, and it names the tenant
379
+ // (scopeToTenant injects it), so the server checks out that held tenant directly — or
380
+ // answers for a staff-preview authorization, or refuses naming the tenants the session
381
+ // holds. No client_switch is sent: it writes a selection shared by every windowless
382
+ // session on this login, so it can lose to another session's switch before the checkout
383
+ // lands, and it moves that other session's selection too.
394
384
  const scoped = await scopeToTenant(client, tenant, { redact });
395
- const expectedSwitchRefusal = scoped.pinRefusal;
396
385
  // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
397
386
  // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
398
387
  // `appForCaller` for them instead of 403-ing "No version-control app is bound." The
@@ -409,45 +398,18 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
409
398
  } catch {
410
399
  /* fail-open — see above */
411
400
  }
412
- let checkout;
413
- try {
414
- checkout = await scoped.callTool("tenant_checkout", { tag });
415
- } catch (e) {
416
- // A THROWN checkout is not a denial — it never rendered an authorization decision. Only
417
- // here may the expected switch refusal speak, because it is the only explanation either
418
- // call produced.
419
- if (expectedSwitchRefusal) {
420
- throw new CliError(redact(String(expectedSwitchRefusal?.message || expectedSwitchRefusal)), {
421
- next:
422
- "confirm you're entitled to this store — `tot grants` lists what your session can " +
423
- "act on, and `tot login` refreshes a grant issued after you signed in. " +
424
- ACCESS_EXPLAIN_HINT(tenant),
425
- cause: expectedSwitchRefusal,
426
- });
427
- }
428
- throw e;
429
- }
401
+ const checkout = await scoped.callTool("tenant_checkout", { tag });
430
402
 
431
403
  // A non-checkout result (not provisioned / not entitled / failed) must surface
432
404
  // the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
433
405
  // dump (James's 2026-07-19 first-experience failure).
434
406
  const err = checkoutError(checkout);
435
407
  if (err) {
436
- // AUTHORITATIVE: the checkout rendered a decision, so it is the answer — even when the
437
- // switch also refused. Substituting grant guidance here is what would tell someone whose
438
- // staff authorization had EXPIRED to go looking at their grants. Only when the checkout's
439
- // OWN denial points at entitlement does the switch refusal ride below it: for an invited
440
- // developer it is the one that names the cause (the store is not among the tenants their
441
- // session resolved), and dropping it left only a downstream "Over-scoped" that reads like
442
- // a selection error.
443
- if (expectedSwitchRefusal && checkoutPointsAtEntitlement(checkout)) {
444
- const switchMessage = String(expectedSwitchRefusal?.message || expectedSwitchRefusal);
445
- throw new CliError(
446
- redact(`${err.message}\n client_switch also refused: ${switchMessage}`),
447
- { next: `${err.next} ${ACCESS_EXPLAIN_HINT(tenant)}`, cause: expectedSwitchRefusal },
448
- );
449
- }
450
- throw new CliError(redact(err.message), { next: err.next });
408
+ // AUTHORITATIVE: the checkout rendered a decision, so it is the answer. When that denial
409
+ // points at entitlement (the store is not among the tenants the session holds), the
410
+ // access_explain route rides below it: it names the exact broken step for an invitee.
411
+ const next = checkoutPointsAtEntitlement(checkout) ? `${err.next} ${ACCESS_EXPLAIN_HINT(tenant)}` : err.next;
412
+ throw new CliError(redact(err.message), { next });
451
413
  }
452
414
  const gitRemote = checkout.gitRemote;
453
415
  const cloneUrl = checkout.cloneUrl ?? null;
@@ -1545,7 +1545,7 @@ export async function run(argv, ctx, {
1545
1545
  pointerScope = await scopeToTenant(client, tenant);
1546
1546
  } catch (error) {
1547
1547
  recordDiagnostic(error, {
1548
- command: verb, operation: "client_switch", required: false, degraded: true,
1548
+ command: verb, operation: "tenant_scope", required: false, degraded: true,
1549
1549
  }, env);
1550
1550
  }
1551
1551
  const resolved = pointerScope
@@ -1661,7 +1661,7 @@ export async function run(argv, ctx, {
1661
1661
  let progress = null;
1662
1662
  let candidate = null;
1663
1663
  let previewPrUrl = null;
1664
- let operation = "client_switch";
1664
+ let operation = "tenant_scope";
1665
1665
  const candidateRequired = changedEntries.length > 0;
1666
1666
  try {
1667
1667
  // Every candidate/preview call below goes through this tenant-scoped handle
@@ -5,13 +5,15 @@
5
5
  * from a previous command. A command that acts on a store calls `scopeToTenant(client,
6
6
  * tenant)` once and makes every store call through the handle it returns:
7
7
  *
8
- * - It pins the session with `client_switch`. A server that resolves the tenant of each
9
- * call from its own `tenant` argument does not need the pin; one that still reads the
10
- * session's active tenant does, and the pin is also what scopes the repo-addressed tools
11
- * (PIN_SCOPED_TOOLS) for an invited developer.
12
8
  * - It names the tenant on every call to a tool in TENANT_SCOPED_TOOLS — tools whose
13
9
  * `tenant` argument means "the tenant this call acts on". The server uses a held tenant
14
10
  * directly and refuses one the session does not hold; it never falls back to another.
11
+ * These calls never send `client_switch`.
12
+ * - It sends `client_switch` only before the first call to a tool in PIN_SCOPED_TOOLS, and
13
+ * at most once. `client_switch` writes a selection the server shares across every
14
+ * windowless session on the same login, so a pin can lose to another session's switch
15
+ * between the pin and the call, and it moves that other session's selection too. A
16
+ * command that needs no repo-addressed tool therefore never pins.
15
17
  * - It refuses a result that reports acting on a different tenant than the one asked for.
16
18
  *
17
19
  * `test/tenant-scope-contract.test.mjs` fails the build if any file under `src/` calls a
@@ -41,7 +43,8 @@ export const TENANT_SCOPED_TOOLS = Object.freeze(new Set([
41
43
  * argument (`candidate_status` / `candidate_list` accept `tenant` only as an alias FOR `repo`;
42
44
  * `candidate_delete` / `candidate_refresh` have no `tenant` field and the server drops one).
43
45
  * The server confines them to the session's held tenants — for an invited developer, the
44
- * pinned one — so they go through the handle (which pinned first) with their args untouched.
46
+ * pinned one — so they go through the handle (which pins before the first of them) with
47
+ * their args untouched.
45
48
  */
46
49
  export const PIN_SCOPED_TOOLS = Object.freeze(new Set([
47
50
  "candidate_status",
@@ -109,22 +112,18 @@ export function isNotClientTenantRefusal(error) {
109
112
  /**
110
113
  * @typedef {{
111
114
  * tenant: string,
112
- * pinRefusal: any,
113
115
  * callTool: (name: string, args?: Record<string, unknown>) => Promise<any>,
114
116
  * }} ScopedClient
115
- * `pinRefusal` is the expected client_switch refusal (see isNotClientTenantRefusal) when
116
- * the pin was refused, else null. The calls still name the tenant, so the server answers
117
- * them authoritatively; `pinRefusal` is only the explanation a caller may add when no
118
- * call produced a better one.
119
117
  */
120
118
 
121
119
  /**
122
- * Pin the session to `tenant` and return the handle every store call goes through.
120
+ * Return the handle every store call for `tenant` goes through. Sends nothing by itself.
123
121
  *
124
- * An expected client_switch refusal (the tenant is not one of the caller's clients — the
125
- * normal case for a staff human under a staff-preview authorization) is remembered on the
126
- * handle and the calls proceed, each naming the tenant, so the server decides. Any other
127
- * client_switch failure means the session is unusable and throws a CliError at once.
122
+ * The pin (`client_switch`) is sent lazily, once, before the first PIN_SCOPED_TOOLS call.
123
+ * An expected refusal of it (the tenant is not one of the caller's clients — the normal
124
+ * case for a staff human under a staff-preview authorization) is tolerated and the call
125
+ * proceeds, so the server decides. Any other pin failure means the session is unusable and
126
+ * that call throws a CliError without being sent.
128
127
  *
129
128
  * @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
130
129
  * @param {string} tenant
@@ -140,18 +139,20 @@ export async function scopeToTenant(client, tenant, { redact = (s) => s } = {})
140
139
  }
141
140
  const name = String(tenant).trim();
142
141
 
143
- let pinRefusal = null;
144
- try {
145
- await client.callTool("client_switch", { tenant: name });
146
- } catch (e) {
147
- if (isNotClientTenantRefusal(e)) {
148
- pinRefusal = e;
149
- } else {
150
- throw new CliError(redact(String(/** @type {any} */ (e)?.message || e)), {
151
- next: "the session could not be scoped — check your connection and run `tot whoami`",
152
- cause: e,
153
- });
154
- }
142
+ /** @type {Promise<void>|null} */
143
+ let pinned = null;
144
+ function pin() {
145
+ pinned ??= client.callTool("client_switch", { tenant: name }).then(
146
+ () => undefined,
147
+ (e) => {
148
+ if (isNotClientTenantRefusal(e)) return;
149
+ throw new CliError(redact(String(/** @type {any} */ (e)?.message || e)), {
150
+ next: "the session could not be scoped — check your connection and run `tot whoami`",
151
+ cause: e,
152
+ });
153
+ },
154
+ );
155
+ return pinned;
155
156
  }
156
157
 
157
158
  /**
@@ -159,6 +160,10 @@ export async function scopeToTenant(client, tenant, { redact = (s) => s } = {})
159
160
  * @param {Record<string, unknown>} [args]
160
161
  */
161
162
  async function callTool(tool, args = {}) {
163
+ if (PIN_SCOPED_TOOLS.has(tool)) {
164
+ await pin();
165
+ return client.callTool(tool, args);
166
+ }
162
167
  if (!TENANT_SCOPED_TOOLS.has(tool)) return client.callTool(tool, args);
163
168
  if (args && Object.hasOwn(args, "tenant") && normalizeTenant(args.tenant) !== requested) {
164
169
  throw new CliError(
@@ -180,5 +185,5 @@ export async function scopeToTenant(client, tenant, { redact = (s) => s } = {})
180
185
  return result;
181
186
  }
182
187
 
183
- return { tenant: name, pinRefusal, callTool };
188
+ return { tenant: name, callTool };
184
189
  }
@@ -25,7 +25,7 @@
25
25
  },
26
26
  {
27
27
  "label": "About",
28
- "href": "/about"
28
+ "href": "/pages/about-us"
29
29
  }
30
30
  ],
31
31
  "footer": {
@@ -57,15 +57,15 @@
57
57
  "links": [
58
58
  {
59
59
  "label": "About",
60
- "href": "/about"
60
+ "href": "/pages/about-us"
61
61
  },
62
62
  {
63
63
  "label": "Shipping & Returns",
64
- "href": "/shipping-returns"
64
+ "href": "/pages/shipping-returns"
65
65
  },
66
66
  {
67
67
  "label": "Privacy",
68
- "href": "/privacy"
68
+ "href": "/pages/privacy-policy"
69
69
  }
70
70
  ]
71
71
  }