@tokenoftrust/cli 2.2.3 → 2.2.5

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.3",
3
+ "version": "2.2.5",
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",
@@ -45,6 +45,7 @@ import { resolveOperatorSecret } from "./ship.mjs";
45
45
  // same way `tot pr` reaches `candidate_status`/`candidate_close`.
46
46
  import { createMcpClient } from "../mcp.mjs";
47
47
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
48
+ import { scopeToTenant } from "../tenant-scope.mjs";
48
49
  // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
49
50
  // login and integrate as themselves (server gates on their live ship-on-behalf grant).
50
51
  import { resolveViewerTransport } from "../viewer-session.mjs";
@@ -270,8 +271,8 @@ export function notMergeableOfferLines(tenant, { pr, changeId }) {
270
271
  * Run `candidate_refresh` over the MCP client — the ONE-CLICK rebuild of a
271
272
  * not-mergeable candidate onto the current preview tip. `candidate_refresh` has no
272
273
  * operator-secret HTTP route, so this reaches it exactly as `tot pr` reaches
273
- * `candidate_status`: an OAuth developer session (`tot login`) + `client_switch` to
274
- * bind the tenant scope. `createClient`/`establishSession` are injected so it's
274
+ * `candidate_status`: an OAuth developer session (`tot login`) scoped to the tenant with
275
+ * `scopeToTenant`. `createClient`/`establishSession` are injected so it's
275
276
  * unit-tested with no live MCP.
276
277
  *
277
278
  * @param {{ tenant:string, changeId:string, strategy:string, mcpUrl?:string|null,
@@ -289,14 +290,8 @@ export async function runRefresh(
289
290
  const client = create(baseUrl);
290
291
  try {
291
292
  await establish(client, { env, prefer: identity || undefined });
292
- // Bind the active tenant so candidate_refresh resolves the right scope (mirrors
293
- // `tot pr`). Best-effort — the tool also takes `repo` explicitly.
294
- try {
295
- await client.callTool("client_switch", { tenant });
296
- } catch {
297
- /* best-effort scope bind */
298
- }
299
- const raw = await client.callTool("candidate_refresh", { repo: tenant, changeId, strategy });
293
+ const scoped = await scopeToTenant(client, tenant);
294
+ const raw = await scoped.callTool("candidate_refresh", { repo: tenant, changeId, strategy });
300
295
  return normalizeRefreshResult(raw);
301
296
  } catch (e) {
302
297
  if (e instanceof AuthUnavailableError) {
@@ -30,6 +30,7 @@ import { execFileSync } from "node:child_process";
30
30
  import { createMcpClient } from "../mcp.mjs";
31
31
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
32
32
  import { fail } from "../errors.mjs";
33
+ import { scopeToTenant } from "../tenant-scope.mjs";
33
34
  import { repoNameFromRemote } from "./submit.mjs";
34
35
 
35
36
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -256,9 +257,9 @@ export async function run(argv, ctx) {
256
257
  const client = createMcpClient(baseUrl);
257
258
  try {
258
259
  await establishSession(client, { env, prefer: args.identity || undefined });
259
- if (tenant) await client.callTool("client_switch", { tenant });
260
+ const scoped = await scopeToTenant(client, /** @type {string} */ (tenant));
260
261
 
261
- const report = await client.callTool("candidate_list", {
262
+ const report = await scoped.callTool("candidate_list", {
262
263
  repo,
263
264
  ...(args.staleAfterDays != null && Number.isFinite(args.staleAfterDays)
264
265
  ? { staleAfterDays: args.staleAfterDays }
@@ -27,6 +27,7 @@
27
27
  import { createMcpClient } from "../mcp.mjs";
28
28
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
29
29
  import { fail } from "../errors.mjs";
30
+ import { scopeToTenant } from "../tenant-scope.mjs";
30
31
  import { planForAction, printPlanAndConfirm } from "../plan.mjs";
31
32
  import { resolveRepoAndTenant, renderBranchesTable, formatBranchRow } from "./branches.mjs";
32
33
 
@@ -154,9 +155,9 @@ export async function run(argv, ctx) {
154
155
  const client = createMcpClient(baseUrl);
155
156
  try {
156
157
  await establishSession(client, { env, prefer: args.identity || undefined });
157
- if (tenant) await client.callTool("client_switch", { tenant });
158
+ const scoped = await scopeToTenant(client, /** @type {string} */ (tenant));
158
159
 
159
- const report = await client.callTool("candidate_list", {
160
+ const report = await scoped.callTool("candidate_list", {
160
161
  repo,
161
162
  ...(args.staleAfterDays != null && Number.isFinite(args.staleAfterDays)
162
163
  ? { staleAfterDays: args.staleAfterDays }
@@ -226,7 +227,7 @@ export async function run(argv, ctx) {
226
227
  let failures = 0;
227
228
  for (const b of eligible) {
228
229
  try {
229
- const res = await client.callTool("candidate_delete", {
230
+ const res = await scoped.callTool("candidate_delete", {
230
231
  repo,
231
232
  ref: b.ref,
232
233
  expectedSha: b.sha,
@@ -58,6 +58,7 @@ import { createMcpClient } from "../mcp.mjs";
58
58
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
59
59
  import { offerSignIn } from "./login.mjs";
60
60
  import { CliError, fail, formatError } from "../errors.mjs";
61
+ import { scopeToTenant } from "../tenant-scope.mjs";
61
62
  import { writeNvmrc } from "../sample.mjs";
62
63
  import { emitObstacle } from "../obstacle.mjs";
63
64
  import {
@@ -364,41 +365,8 @@ export function checkoutPointsAtEntitlement(checkout) {
364
365
  }
365
366
 
366
367
  /**
367
- * Is this client_switch failure the EXPECTED "that tenant is not one of your clients"
368
- * refusal, as opposed to a broken session (transport, auth, malformed envelope, internal
369
- * error)? The distinction is load-bearing: an expected refusal is a normal step on the
370
- * staff-preview path and must be tolerated, while anything else means the session itself
371
- * is unusable and must surface immediately rather than be mistaken for an authorization
372
- * problem later.
373
- *
374
- * Matched on the server's STRUCTURED signal first (`serverCode` / status), because message
375
- * text is not a contract. The text fallback exists only for servers predating a typed code
376
- * and is deliberately narrow — when in doubt this returns false, which escalates. Treating
377
- * an unknown failure as "expected" is the unsafe direction: it would let a dead connection
378
- * pass for a routine refusal.
379
- *
380
- * Pure + exported so it is unit-tested without any I/O.
381
- * @param {unknown} error
382
- * @returns {boolean}
383
- */
384
- export function isNotClientTenantRefusal(error) {
385
- const e = /** @type {any} */ (error);
386
- if (!e) return false;
387
- // A transport/protocol-level failure is never an authorization refusal.
388
- const category = e?.diagnostic?.category ?? e?.category;
389
- if (category && category !== "tool_error") return false;
390
- const code = e?.diagnostic?.serverCode ?? e?.serverCode;
391
- if (typeof code === "string") {
392
- return code === "not_client_tenant" || code === "over_scoped";
393
- }
394
- // Text fallback (pre-typed-code servers) — narrow on purpose.
395
- const message = typeof e?.message === "string" ? e.message : "";
396
- return /not one of your client tenants/i.test(message);
397
- }
398
-
399
- /**
400
- * The privileged checkout core, composable in-process (used by `tot clone`
401
- * and by `tot start`): client_switch → tenant_checkout → optionally clone. The
368
+ * The privileged checkout core, composable in-process (used by `tot clone`, `tot start`
369
+ * and `tot git-credential`): scope to the tenant → tenant_checkout → optionally clone. The
402
370
  * MCP mints a fresh, repo-scoped push credential each time. Assumes `client` is
403
371
  * already initialized + has a validated session.
404
372
  *
@@ -408,38 +376,23 @@ export function isNotClientTenantRefusal(error) {
408
376
  * cloned: boolean, dir: string|null, head: string|null }>}
409
377
  */
410
378
  export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
411
- // tenant_checkout is the SOLE AUTHORITATIVE checkout decision. client_switch is a scope
412
- // convenience whose refusal may be entirely expected: a staff human checking out a
413
- // merchant tenant under a staff-preview authorization holds no client grant on it, so the
414
- // switch MUST refuse while the checkout legitimately succeeds. `tot git-credential` mints
415
- // through this same function, so treating the switch as fatal breaks every fetch and push
416
- // in such a checkout, not only the clone.
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).
417
387
  //
418
- // Two failure modes are therefore BOTH wrong, and the second is the dangerous one:
419
- // - discarding the refusal loses the only good explanation when a tenant genuinely is
420
- // not yours;
421
- // - letting the refusal OVERRIDE a checkout denial replaces an authoritative answer
422
- // ("your staff authorization expired / was revoked / does not cover this resource")
423
- // with grant guidance that sends you chasing the wrong thing entirely.
424
- // So an EXPECTED switch refusal may never speak over a checkout denial (see below).
425
- //
426
- // An UNEXPECTED switch failure — transport, auth, a malformed envelope — is not the
427
- // not-a-client case at all. It means the session itself is broken, and carrying it
428
- // silently would let a dead connection masquerade as an authorization problem, so it is
429
- // raised immediately rather than remembered.
430
- let expectedSwitchRefusal = null;
431
- try {
432
- await client.callTool("client_switch", { tenant });
433
- } catch (e) {
434
- if (isNotClientTenantRefusal(e)) {
435
- expectedSwitchRefusal = e;
436
- } else {
437
- throw new CliError(redact(String(e?.message || e)), {
438
- next: "the session could not be scoped — check your connection and run `tot whoami`",
439
- cause: e,
440
- });
441
- }
442
- }
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).
394
+ const scoped = await scopeToTenant(client, tenant, { redact });
395
+ const expectedSwitchRefusal = scoped.pinRefusal;
443
396
  // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
444
397
  // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
445
398
  // `appForCaller` for them instead of 403-ing "No version-control app is bound." The
@@ -458,7 +411,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
458
411
  }
459
412
  let checkout;
460
413
  try {
461
- checkout = await client.callTool("tenant_checkout", { tenant, tag });
414
+ checkout = await scoped.callTool("tenant_checkout", { tag });
462
415
  } catch (e) {
463
416
  // A THROWN checkout is not a denial — it never rendered an authorization decision. Only
464
417
  // here may the expected switch refusal speak, because it is the only explanation either
@@ -27,6 +27,7 @@ import { execFileSync } from "node:child_process";
27
27
  import { createMcpClient } from "../mcp.mjs";
28
28
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
29
29
  import { fail } from "../errors.mjs";
30
+ import { scopeToTenant } from "../tenant-scope.mjs";
30
31
  import { repoNameFromRemote, currentBranch } from "./submit.mjs";
31
32
  // `tot pr list` reuses `tot ship --pr`'s operator-secret transport helpers verbatim
32
33
  // (same env precedence, same queue normalisation) so the two speak one wire, not two.
@@ -368,10 +369,9 @@ export async function run(argv, ctx) {
368
369
  try {
369
370
  const session = await establishSession(client, { env, prefer: args.identity || undefined });
370
371
  void session;
371
- // Bind the active tenant so candidate_status/close read the right scope.
372
- await client.callTool("client_switch", { tenant });
372
+ const scoped = await scopeToTenant(client, tenant);
373
373
 
374
- const candidates = extractCandidates(await client.callTool("candidate_status", { repo }));
374
+ const candidates = extractCandidates(await scoped.callTool("candidate_status", { repo }));
375
375
 
376
376
  if (args.sub === "list") {
377
377
  if (!candidates.length) {
@@ -408,7 +408,7 @@ export async function run(argv, ctx) {
408
408
  }
409
409
 
410
410
  // close
411
- await client.callTool("candidate_close", {
411
+ await scoped.callTool("candidate_close", {
412
412
  repo,
413
413
  changeId: match.changeId,
414
414
  ...(args.reason ? { reason: args.reason } : {}),
@@ -36,6 +36,7 @@
36
36
  import { createMcpClient } from "../mcp.mjs";
37
37
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
38
38
  import { fail } from "../errors.mjs";
39
+ import { scopeToTenant } from "../tenant-scope.mjs";
39
40
  import { isInteractive, promptYesNo } from "../prompt.mjs";
40
41
  import { startProgress } from "../progress.mjs";
41
42
 
@@ -224,7 +225,7 @@ export function renderRollbackPreview({ tenant, target, toVersionId, preview })
224
225
  * confirm → commit (with a target). Split out from `run` so it's driven in tests
225
226
  * with a mock client + injected confirm/interactive, no network/TTY.
226
227
  *
227
- * @param {{callTool:Function}} client an MCP client (real or mock)
228
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
228
229
  * @param {{ tenant:string, target:string, toVersionId:string|null,
229
230
  * revisionBase?:string|null }} params `revisionBase` is the storefront origin
230
231
  * the listing's `/rev/<sha>` links resolve against; omit to list
@@ -233,7 +234,7 @@ export function renderRollbackPreview({ tenant, target, toVersionId, preview })
233
234
  * progress?:boolean }} [deps]
234
235
  * @returns {Promise<number>} process exit code
235
236
  */
236
- export async function runRollback(client, { tenant, target, toVersionId, revisionBase }, deps = {}) {
237
+ export async function runRollback(scoped, { tenant, target, toVersionId, revisionBase }, deps = {}) {
237
238
  const interactive = deps.interactive || isInteractive;
238
239
  const confirm = deps.confirm || promptYesNo;
239
240
 
@@ -241,7 +242,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
241
242
  if (!toVersionId) {
242
243
  let status;
243
244
  try {
244
- status = normalizeStatus(await client.callTool("promotion_status", { target }));
245
+ status = normalizeStatus(await scoped.callTool("promotion_status", { target }));
245
246
  } catch (e) {
246
247
  console.error(
247
248
  fail(
@@ -272,7 +273,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
272
273
  let preview;
273
274
  try {
274
275
  preview = normalizeRollbackPreview(
275
- await client.callTool("promotion_rollback", { target, toVersionId, dryRun: true }),
276
+ await scoped.callTool("promotion_rollback", { target, toVersionId, dryRun: true }),
276
277
  );
277
278
  } catch (e) {
278
279
  console.error(
@@ -320,7 +321,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
320
321
  let result;
321
322
  try {
322
323
  result = normalizeRollbackResult(
323
- await client.callTool("promotion_rollback", { target, toVersionId, dryRun: false }),
324
+ await scoped.callTool("promotion_rollback", { target, toVersionId, dryRun: false }),
324
325
  );
325
326
  } catch (e) {
326
327
  console.error(
@@ -383,10 +384,9 @@ export async function run(argv, ctx) {
383
384
  const client = createMcpClient(baseUrl);
384
385
  try {
385
386
  await establishSession(client, { env, prefer: args.identity || undefined });
386
- // Bind the active tenant so the promotion tools read/write the right scope.
387
- await client.callTool("client_switch", { tenant });
387
+ const scoped = await scopeToTenant(client, tenant);
388
388
 
389
- return await runRollback(client, { tenant, target, toVersionId: args.versionId, revisionBase });
389
+ return await runRollback(scoped, { tenant, target, toVersionId: args.versionId, revisionBase });
390
390
  } catch (e) {
391
391
  if (e instanceof AuthUnavailableError) {
392
392
  console.error(fail("sign in to roll back", e.hint || "run `tot login`, then re-run `tot rollback`"));
@@ -188,15 +188,15 @@ export function normalizeChangeResult(r) {
188
188
  /**
189
189
  * Poll `change_status` until the change reports shipped (or the attempts budget
190
190
  * runs out). Injectable delay/attempts.
191
- * @param {{callTool:Function}} client
192
- * @param {{ id:string, tenant?:string }} target
191
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
192
+ * @param {{ id:string }} target
193
193
  * @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
194
194
  */
195
- export async function pollChangeShipped(client, { id, tenant }, { attempts = 6, delayMs = 1500, sleep } = {}) {
195
+ export async function pollChangeShipped(scoped, { id }, { attempts = 6, delayMs = 1500, sleep } = {}) {
196
196
  const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
197
197
  let last = null;
198
198
  for (let i = 0; i < attempts; i++) {
199
- last = normalizeChangeResult(await client.callTool("change_status", { id, ...(tenant ? { tenant } : {}) }));
199
+ last = normalizeChangeResult(await scoped.callTool("change_status", { id }));
200
200
  if (last.shipped) return last;
201
201
  if (i < attempts - 1) await wait(delayMs);
202
202
  }
@@ -45,6 +45,7 @@ import { setTimeout as delay } from "node:timers/promises";
45
45
  import { createMcpClient } from "../mcp.mjs";
46
46
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
47
47
  import { checkoutTenant } from "./clone.mjs";
48
+ import { scopeToTenant } from "../tenant-scope.mjs";
48
49
  // Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
49
50
  // born-rebased submit rebuilds a candidate onto the current base with the SAME
50
51
  // engine `tot accept --refresh` uses, so the two read its result identically).
@@ -497,13 +498,13 @@ export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
497
498
  * throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
498
499
  * without the tool, a transient failure — normalizes to a non-ok error result the
499
500
  * caller submits-as-is on, NEVER blocking the push that already landed.
500
- * @param {{callTool:Function}} client
501
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
501
502
  * @param {{ repo: string, changeId: string, strategy: string }} opts
502
503
  * @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
503
504
  */
504
- export async function runBornRebased(client, { repo, changeId, strategy }) {
505
+ export async function runBornRebased(scoped, { repo, changeId, strategy }) {
505
506
  try {
506
- const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
507
+ const raw = await scoped.callTool("candidate_refresh", { repo, changeId, strategy });
507
508
  return normalizeRefreshResult(raw);
508
509
  } catch (e) {
509
510
  const diagnostic = recordDiagnostic(e, {
@@ -957,13 +958,13 @@ export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
957
958
  * that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
958
959
  * Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
959
960
  * plain array. Injectable client for tests.
960
- * @param {{callTool:Function}} client
961
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
961
962
  * @param {{ repo: string, changeId: string }} opts
962
963
  * @returns {Promise<string|null>}
963
964
  */
964
- export async function candidateStateFor(client, { repo, changeId }) {
965
+ export async function candidateStateFor(scoped, { repo, changeId }) {
965
966
  try {
966
- const r = await client.callTool("candidate_status", { repo, changeId });
967
+ const r = await scoped.callTool("candidate_status", { repo, changeId });
967
968
  const c = Array.isArray(r)
968
969
  ? r.find((x) => x?.changeId === changeId)
969
970
  : Array.isArray(r?.candidates)
@@ -993,13 +994,13 @@ export async function candidateStateFor(client, { repo, changeId }) {
993
994
  * state drops the pointer. When it does, `dropped` carries the old changeId + the
994
995
  * terminal state so the caller can tell the operator and forget the on-disk pointer.
995
996
  * Injectable client for tests.
996
- * @param {{callTool:Function}} client
997
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
997
998
  * @param {{ repo: string|null, active: string|null }} opts
998
999
  * @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
999
1000
  */
1000
- export async function resolveActivePointer(client, { repo, active }) {
1001
+ export async function resolveActivePointer(scoped, { repo, active }) {
1001
1002
  if (!active || !repo) return { active };
1002
- const state = await candidateStateFor(client, { repo, changeId: active });
1003
+ const state = await candidateStateFor(scoped, { repo, changeId: active });
1003
1004
  if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
1004
1005
  return { active };
1005
1006
  }
@@ -1039,19 +1040,19 @@ export class CandidateRegistrationError extends Error {
1039
1040
  * has already pushed its real commits, so this call sends only the exact candidate
1040
1041
  * ref and asserted HEAD plus PR metadata. Any registration failure is fatal: a
1041
1042
  * pushed ref without a review PR is not a successful submit.
1042
- * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
1043
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
1043
1044
  * @param {{ repo: string|null, changeId: string, ref: string, headSha: string,
1044
1045
  * changeSummary: {title:string, body:string[]}, hasChanges: boolean,
1045
1046
  * quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
1046
1047
  * result is still returned for the caller's JSON payload.
1047
1048
  */
1048
- export async function submitCandidate(client, { repo, changeId, ref, headSha, changeSummary, hasChanges, quiet = false }) {
1049
+ export async function submitCandidate(scoped, { repo, changeId, ref, headSha, changeSummary, hasChanges, quiet = false }) {
1049
1050
  if (!repo) {
1050
1051
  throw new CandidateRegistrationError("Candidate not created: couldn't derive the forge repo from origin.");
1051
1052
  }
1052
1053
  if (!hasChanges) throw new CandidateRegistrationError("Candidate not created: no content changes were found.");
1053
1054
  try {
1054
- const result = await client.callTool("candidate_open", {
1055
+ const result = await scoped.callTool("candidate_open", {
1055
1056
  repo,
1056
1057
  changeId,
1057
1058
  // Defensively bounded to candidate_open's own schema limits (title<=200,
@@ -1536,17 +1537,20 @@ export async function run(argv, ctx, {
1536
1537
  // and opening a NEW PR that inherits a guaranteed conflict (the live incident this
1537
1538
  // guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
1538
1539
  // there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
1539
- // Needs the tenant scope bound for candidate_status to resolve — idempotent with
1540
- // the later client_switch / the fresh-mint checkoutTenant.
1540
+ // Runs scoped to the tenant (idempotent with the later scoping / the fresh-mint
1541
+ // checkoutTenant); a session that cannot be scoped leaves the pointer untouched.
1541
1542
  if (active && repo && !args.forkCandidate) {
1543
+ let pointerScope = null;
1542
1544
  try {
1543
- await client.callTool("client_switch", { tenant });
1545
+ pointerScope = await scopeToTenant(client, tenant);
1544
1546
  } catch (error) {
1545
1547
  recordDiagnostic(error, {
1546
1548
  command: verb, operation: "client_switch", required: false, degraded: true,
1547
1549
  }, env);
1548
1550
  }
1549
- const resolved = await resolveActivePointer(client, { repo, active });
1551
+ const resolved = pointerScope
1552
+ ? await resolveActivePointer(pointerScope, { repo, active })
1553
+ : /** @type {Awaited<ReturnType<typeof resolveActivePointer>>} */ ({ active });
1550
1554
  if (resolved.dropped) {
1551
1555
  console.error(
1552
1556
  `~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
@@ -1611,8 +1615,7 @@ export async function run(argv, ctx, {
1611
1615
  // (decision B). The token baked into `.git/config` at clone time expires within
1612
1616
  // hours, so we re-mint right before the push and hand it to git ephemerally
1613
1617
  // (never persisted to `.git/config`), re-minting once on an auth failure.
1614
- // `checkoutTenant(cloneDir:null)` mints without re-cloning AND client_switch()es,
1615
- // binding the tenant scope the candidate/preview read-back below reads.
1618
+ // `checkoutTenant(cloneDir:null)` mints without re-cloning.
1616
1619
  const tag = tagFromRepoName(repo, tenant);
1617
1620
  const mintRemote = async () => {
1618
1621
  try {
@@ -1661,21 +1664,22 @@ export async function run(argv, ctx, {
1661
1664
  let operation = "client_switch";
1662
1665
  const candidateRequired = changedEntries.length > 0;
1663
1666
  try {
1664
- // Bind the active tenant so preview_status/candidate_open read the right scope
1665
- // (idempotent — checkoutTenant already switched when the fresh mint succeeded).
1667
+ // Every candidate/preview call below goes through this tenant-scoped handle
1668
+ // (idempotent with checkoutTenant's own scoping when the fresh mint succeeded).
1669
+ let scoped;
1666
1670
  try {
1667
- await client.callTool("client_switch", { tenant });
1671
+ scoped = await scopeToTenant(client, tenant);
1668
1672
  } catch (e) {
1669
1673
  throw new CandidateRegistrationError(
1670
1674
  `Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`,
1671
- e,
1675
+ /** @type {any} */ (e)?.cause ?? e,
1672
1676
  );
1673
1677
  }
1674
1678
 
1675
1679
  // 2b. Register the exact ref and asserted commit that git just pushed. No file
1676
1680
  // bytes cross MCP and the server does not reconstruct or replace these commits.
1677
1681
  operation = "candidate_open";
1678
- candidate = await submitCandidate(client, {
1682
+ candidate = await submitCandidate(scoped, {
1679
1683
  repo, changeId, ref, headSha: commit, changeSummary,
1680
1684
  hasChanges: changedEntries.length > 0, quiet: args.json,
1681
1685
  });
@@ -1699,7 +1703,7 @@ export async function run(argv, ctx, {
1699
1703
  // baseDrift (0 when skipped).
1700
1704
  if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
1701
1705
  operation = "candidate_refresh";
1702
- const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
1706
+ const rebased = await runBornRebased(scoped, { repo, changeId, strategy: bornRebasedStrategy });
1703
1707
  if (rebased.ok) {
1704
1708
  for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
1705
1709
  if (!args.json) console.log(line);
@@ -1757,7 +1761,7 @@ export async function run(argv, ctx, {
1757
1761
  let status;
1758
1762
  operation = "preview_status";
1759
1763
  if (args.noWait) {
1760
- status = normalizePreviewStatus(await client.callTool("preview_status", { commit: statusSha }));
1764
+ status = normalizePreviewStatus(await scoped.callTool("preview_status", { commit: statusSha }));
1761
1765
  } else {
1762
1766
  // One in-place status line (TTY: a spinner with an elapsed-seconds counter;
1763
1767
  // non-TTY: a ~10s heartbeat) instead of a newline per poll — the wait reads
@@ -1777,7 +1781,7 @@ export async function run(argv, ctx, {
1777
1781
  stages: [{ afterMs: 45_000, text: `still reconciling ${statusShort}… (larger changes take longer)` }],
1778
1782
  });
1779
1783
  }
1780
- status = await pollPreviewStatus(client, statusSha, {
1784
+ status = await pollPreviewStatus(scoped, statusSha, {
1781
1785
  ...(args.watch ? WATCH_POLL : DEFAULT_POLL),
1782
1786
  onTick: (s) => {
1783
1787
  // Reconcile is done but we're still waiting on a ship decision (--watch):
@@ -1900,14 +1904,14 @@ export function previewSubmitSucceeded(status) {
1900
1904
  * observed. Injectable delay/attempts/waitMs/grace + a `now` clock and `sleep`
1901
1905
  * fn for tests (so the fallback-sleep cadence can be asserted deterministically
1902
1906
  * off the injected clock instead of real wall-clock elapsed time).
1903
- * @param {{callTool:Function}} client
1907
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
1904
1908
  * @param {string} commit
1905
1909
  * @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
1906
1910
  * notDispatchedGraceMs?: number, now?: () => number, sleep?: (ms:number) => Promise<void>,
1907
1911
  * onTick?: (s:object,i:number)=>void }} [opts]
1908
1912
  */
1909
1913
  export async function pollPreviewStatus(
1910
- client,
1914
+ scoped,
1911
1915
  commit,
1912
1916
  {
1913
1917
  attempts = 8,
@@ -1926,7 +1930,7 @@ export async function pollPreviewStatus(
1926
1930
  for (let i = 0; i < attempts; i++) {
1927
1931
  const startedAt = now();
1928
1932
  const args = waitMs ? { commit, waitMs } : { commit };
1929
- last = normalizePreviewStatus(await client.callTool("preview_status", args));
1933
+ last = normalizePreviewStatus(await scoped.callTool("preview_status", args));
1930
1934
  if (last.dispatched) everDispatched = true;
1931
1935
  last.everDispatched = everDispatched;
1932
1936
  if (onTick) onTick(last, i);
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The ONE way a `tot` command acts on a store.
3
+ *
4
+ * Every `tot` command is a fresh MCP session, so nothing about "which store" carries over
5
+ * from a previous command. A command that acts on a store calls `scopeToTenant(client,
6
+ * tenant)` once and makes every store call through the handle it returns:
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
+ * - It names the tenant on every call to a tool in TENANT_SCOPED_TOOLS — tools whose
13
+ * `tenant` argument means "the tenant this call acts on". The server uses a held tenant
14
+ * directly and refuses one the session does not hold; it never falls back to another.
15
+ * - It refuses a result that reports acting on a different tenant than the one asked for.
16
+ *
17
+ * `test/tenant-scope-contract.test.mjs` fails the build if any file under `src/` calls a
18
+ * TENANT_SCOPED_TOOLS / PIN_SCOPED_TOOLS tool other than through a handle (named `scoped`),
19
+ * or calls `client_switch` anywhere but here, or calls a tool this file does not classify.
20
+ */
21
+ import { CliError } from "./errors.mjs";
22
+
23
+ /**
24
+ * Tools whose `tenant` argument is the tenant the call acts on. Action-harness tools get the
25
+ * argument from `registerActionTool` (tot-mcp `src/modules/mcp/tools/action-tool.ts`), which
26
+ * resolves it through `resolveCallTenant` and echoes the tenant it acted on in every success
27
+ * result; `tenant_checkout` declares the same argument itself and echoes it too.
28
+ */
29
+ export const TENANT_SCOPED_TOOLS = Object.freeze(new Set([
30
+ "candidate_open",
31
+ "candidate_close",
32
+ "change_status",
33
+ "preview_status",
34
+ "promotion_rollback",
35
+ "promotion_status",
36
+ "tenant_checkout",
37
+ ]));
38
+
39
+ /**
40
+ * Tools that act on a store named by their `repo` argument and take no tenant-of-the-call
41
+ * argument (`candidate_status` / `candidate_list` accept `tenant` only as an alias FOR `repo`;
42
+ * `candidate_delete` / `candidate_refresh` have no `tenant` field and the server drops one).
43
+ * 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.
45
+ */
46
+ export const PIN_SCOPED_TOOLS = Object.freeze(new Set([
47
+ "candidate_status",
48
+ "candidate_list",
49
+ "candidate_delete",
50
+ "candidate_refresh",
51
+ ]));
52
+
53
+ /**
54
+ * Tools that act on no store, or name their own target with a meaning other than "the
55
+ * tenant of this call" (`repo_read_credential`'s `tenant` is the active scope or a FOREIGN
56
+ * tenant for an authorized cross-tenant read, and its `alias` form takes no tenant at all).
57
+ * Called directly on the client.
58
+ */
59
+ export const TENANT_NEUTRAL_TOOLS = Object.freeze(new Set([
60
+ "client_list",
61
+ "identity_bind",
62
+ "identity_link_begin",
63
+ "identity_link_poll",
64
+ "grant_introspect",
65
+ "dev_renderer_artifact",
66
+ "dev_image_pull_token",
67
+ "feedback_submit",
68
+ "repo_read_credential",
69
+ ]));
70
+
71
+ /** @param {unknown} tenant */
72
+ function normalizeTenant(tenant) {
73
+ return typeof tenant === "string" ? tenant.trim().toLowerCase() : "";
74
+ }
75
+
76
+ /**
77
+ * Is this client_switch failure the EXPECTED "that tenant is not one of your clients"
78
+ * refusal, as opposed to a broken session (transport, auth, malformed envelope, internal
79
+ * error)? The distinction is load-bearing: an expected refusal is a normal step on the
80
+ * staff-preview path and must be tolerated, while anything else means the session itself
81
+ * is unusable and must surface immediately rather than be mistaken for an authorization
82
+ * problem later.
83
+ *
84
+ * Matched on the server's STRUCTURED signal first (`serverCode` / status), because message
85
+ * text is not a contract. The text fallback exists only for servers predating a typed code
86
+ * and is deliberately narrow — when in doubt this returns false, which escalates. Treating
87
+ * an unknown failure as "expected" is the unsafe direction: it would let a dead connection
88
+ * pass for a routine refusal.
89
+ *
90
+ * Pure + exported so it is unit-tested without any I/O.
91
+ * @param {unknown} error
92
+ * @returns {boolean}
93
+ */
94
+ export function isNotClientTenantRefusal(error) {
95
+ const e = /** @type {any} */ (error);
96
+ if (!e) return false;
97
+ // A transport/protocol-level failure is never an authorization refusal.
98
+ const category = e?.diagnostic?.category ?? e?.category;
99
+ if (category && category !== "tool_error") return false;
100
+ const code = e?.diagnostic?.serverCode ?? e?.serverCode;
101
+ if (typeof code === "string") {
102
+ return code === "not_client_tenant" || code === "over_scoped";
103
+ }
104
+ // Text fallback (pre-typed-code servers) — narrow on purpose.
105
+ const message = typeof e?.message === "string" ? e.message : "";
106
+ return /not one of your client tenants/i.test(message);
107
+ }
108
+
109
+ /**
110
+ * @typedef {{
111
+ * tenant: string,
112
+ * pinRefusal: any,
113
+ * callTool: (name: string, args?: Record<string, unknown>) => Promise<any>,
114
+ * }} 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
+ */
120
+
121
+ /**
122
+ * Pin the session to `tenant` and return the handle every store call goes through.
123
+ *
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.
128
+ *
129
+ * @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
130
+ * @param {string} tenant
131
+ * @param {{ redact?: (s: string) => string }} [opts]
132
+ * @returns {Promise<ScopedClient>}
133
+ */
134
+ export async function scopeToTenant(client, tenant, { redact = (s) => s } = {}) {
135
+ const requested = normalizeTenant(tenant);
136
+ if (!requested) {
137
+ throw new CliError("no store to act on: scopeToTenant needs a tenant", {
138
+ next: "run this from a `tot clone` checkout, or pass --tenant <appDomain>",
139
+ });
140
+ }
141
+ const name = String(tenant).trim();
142
+
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
+ }
155
+ }
156
+
157
+ /**
158
+ * @param {string} tool
159
+ * @param {Record<string, unknown>} [args]
160
+ */
161
+ async function callTool(tool, args = {}) {
162
+ if (!TENANT_SCOPED_TOOLS.has(tool)) return client.callTool(tool, args);
163
+ if (args && Object.hasOwn(args, "tenant") && normalizeTenant(args.tenant) !== requested) {
164
+ throw new CliError(
165
+ `refusing ${tool}: the call names tenant "${String(args.tenant)}" but this command is scoped to "${name}"`,
166
+ { next: "this is a bug in tot — please report it with `tot feedback`" },
167
+ );
168
+ }
169
+ const result = await client.callTool(tool, { ...args, tenant: name });
170
+ const echoed = /** @type {any} */ (result)?.tenant;
171
+ if (typeof echoed === "string" && normalizeTenant(echoed) !== requested) {
172
+ throw new CliError(
173
+ `${tool} answered for "${echoed}", not "${name}" — refusing to use a result for another store`,
174
+ {
175
+ next: "tot stopped here. This is a server-side scoping fault: report it with `tot feedback`, " +
176
+ "and run `tot whoami` to see which stores your session holds",
177
+ },
178
+ );
179
+ }
180
+ return result;
181
+ }
182
+
183
+ return { tenant: name, pinRefusal, callTool };
184
+ }
package/src/validate.mjs CHANGED
@@ -653,6 +653,24 @@ export const TRACKING_ID_STEP_NAME = "assign stable tracking ids";
653
653
  export const CTA_MISSING_ID_FIX =
654
654
  "assign stable tracking ids: GET/POST /api/admin/tracking-writeback proposes a data-tot-el for every element that's missing one, as a reviewable candidate PR (GET first for a dry-run preview) — or run `tot validate` to see the full list before you submit.";
655
655
 
656
+ // `public/**` is git-tracked and wholesale-ingested into R2 on every publish
657
+ // with no content-hash dedup (ingestAssetVersion re-copies every file under a
658
+ // new version key, changed or not) — see docs/versioned-assets-contract.md.
659
+ // A large binary there works, but it compounds: every future publish re-pays
660
+ // its storage cost, and the tenant's Gitea repo keeps that blob permanently.
661
+ // WARN only — never blocks a submit. Idea tracked for a built-in fix (auto
662
+ // relocation to a deduped, content-addressed store): fb-1790956035713-snpovm.
663
+ export const LARGE_PUBLIC_ASSET_BYTES = 100 * 1024;
664
+ export const LARGE_PUBLIC_ASSET_IDEA_ID = "fb-1790956035713-snpovm";
665
+ export const LARGE_PUBLIC_ASSET_FIX =
666
+ `storing it under public/ will work, but every publish re-copies it into R2 and ` +
667
+ `your Gitea repo keeps it forever — on a tenant with many of these, expect ` +
668
+ `slower publishes and slower clones over time. Today, host it on a shared drive ` +
669
+ `you control and tell us the URL so we can pull it in and serve it from R2 directly, ` +
670
+ `rather than committing it to the tenant repo. We're tracking a built-in fix ` +
671
+ `(automatic relocation to deduped storage, idea ${LARGE_PUBLIC_ASSET_IDEA_ID}) — ` +
672
+ `vote for it if you'd like this handled for you sooner.`;
673
+
656
674
  /**
657
675
  * The advisory rules LOUD enough to call out by NAME at `tot validate` / `tot
658
676
  * submit` / `tot preview` — none of them block, but each ships a page that
@@ -682,6 +700,11 @@ export const LOUD_ADVISORY_RULES = [
682
700
  `actionable element with no governed identity (data-tot-el) — needs to ${TRACKING_ID_STEP_NAME}`,
683
701
  `It renders and works fine, but it's invisible to interaction tracking — nothing about it is ever recorded. ${CTA_MISSING_ID_FIX}`,
684
702
  ],
703
+ [
704
+ "large-public-asset",
705
+ `a public/ asset is ${(LARGE_PUBLIC_ASSET_BYTES / 1024).toFixed(0)}KB or larger`,
706
+ LARGE_PUBLIC_ASSET_FIX,
707
+ ],
685
708
  [
686
709
  "cta-id-drift",
687
710
  "a previously-declared data-tot-el changed value",
@@ -1208,6 +1231,9 @@ export function validateTenant(tenantDir, opts = {}) {
1208
1231
  }
1209
1232
  }
1210
1233
 
1234
+ // large public assets — advisory (never blocks); see LARGE_PUBLIC_ASSET_FIX.
1235
+ findings.push(...findLargePublicAssets(publicDir, rel));
1236
+
1211
1237
  const ok = !findings.some((f) => f.level === ERROR);
1212
1238
  return { ok, findings };
1213
1239
  }
@@ -1276,4 +1302,20 @@ function checkAsset(src, file, publicDir, tenantId) {
1276
1302
  return null;
1277
1303
  }
1278
1304
 
1305
+ /**
1306
+ * Every file under `public/**` at or above `LARGE_PUBLIC_ASSET_BYTES`. WARN only —
1307
+ * the file works fine, this is a cost/perf nudge toward the hosted-upload path
1308
+ * (see LARGE_PUBLIC_ASSET_FIX for why: no R2 dedup, permanent Gitea growth).
1309
+ */
1310
+ function findLargePublicAssets(publicDir, rel) {
1311
+ const out = [];
1312
+ for (const p of walk(publicDir, () => true)) {
1313
+ const size = statSync(p).size;
1314
+ if (size >= LARGE_PUBLIC_ASSET_BYTES) {
1315
+ out.push(mk(WARN, "large-public-asset", rel(p), `${(size / 1024).toFixed(0)}KB`));
1316
+ }
1317
+ }
1318
+ return out;
1319
+ }
1320
+
1279
1321
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");