@tokenoftrust/cli 2.5.1 → 2.5.2

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/README.md CHANGED
@@ -48,13 +48,36 @@ The same `tot` does the right thing wherever you run it (walks up like `git`):
48
48
 
49
49
  `tot` talks to the Token of Trust MCP (default `https://mcp.tokenoftrust.com`, override with `--mcp` or `MCP_BASE_URL`). It is **single-plane**: the only identity is **you**, signed in against the MCP over OAuth.
50
50
 
51
- - Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached at `~/.tot/credentials.json` and refreshed silently. Every later command (`tot clone`, `tot start`, `tot preview`, `tot ship`, …) runs as you, with no re-auth. Entitlement is derived server-side from your ToT memberships.
51
+ - Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached privately under `~/.tot/` for that MCP authority and refreshed silently. Every later command (`tot clone`, `tot start`, `tot preview`, `tot ship`, …) runs as you, with no re-auth. Entitlement is derived server-side from your ToT memberships.
52
52
  - Not signed in? On a terminal, `tot start` / `tot clone` **offer to sign you in right there** and continue in-flow — no "run `tot login`, then re-run".
53
53
  - The old operator env-triple (`TOT_API_KEY` / `TOT_SECRET_KEY` / `TOT_APP_DOMAIN`) **no longer signs the CLI in** — tot-mcp went OAuth-first on 2026-07-23. If those vars are set, `tot` prints a one-line advisory and uses your `tot login` session anyway; it never reads them for auth.
54
54
 
55
+ ### QA and production on one machine
56
+
57
+ Sign in to each authority once:
58
+
59
+ ```sh
60
+ tot login --mcp https://mcp.qa.tokenoftrust.com
61
+ tot login --mcp https://mcp.tokenoftrust.com
62
+ ```
63
+
64
+ Both sessions remain available. Commands select `--mcp` first, the checkout's local
65
+ `tot.mcp-url` binding next, then `MCP_BASE_URL` / `TOT_MCP_URL`, then production.
66
+ Cloning records the authenticated authority in local git configuration. In an existing
67
+ store or kit checkout, a successful `tot login --mcp <url>` records its authority.
68
+ The binding contains no token and is never committed; the shared forge hostname does
69
+ not identify an environment.
70
+
71
+ `tot whoami --mcp <url>` inspects one session. `tot logout --mcp <url>` clears only
72
+ that authority in the active profile. Git operations and the local activity bridge
73
+ use the checkout's authority, so a production login leaves QA work signed in.
74
+ Cached tokens are never attached to a different MCP authority. Existing valid
75
+ credentials are converted once into an authority-specific file with owner-only
76
+ permissions. No separate home directory is needed.
77
+
55
78
  ### Optional: multiple identities at once (`TOT_PROFILE`)
56
79
 
57
- **Most people never set this** — the single default session is all you need, and a plain `tot login` just replaces it. Reach for a profile only when you want **more than one identity live at the same time**: a staff `@tokenoftrust.com` sign-in alongside a plain developer one, or many parallel test identities. Set it per shell and each gets its own credential file under `~/.tot` (the renderer cache and everything else stay shared):
80
+ **Most people never set this** — the default identity profile holds your sessions for each authority, and `tot login` replaces only the selected authority's session. Reach for a profile only when you want **more than one identity live at the same time**: a staff `@tokenoftrust.com` sign-in alongside a plain developer one, or many parallel test identities. Set it per shell and each gets its own credential file under `~/.tot` (the renderer cache and everything else stay shared):
58
81
 
59
82
  ```
60
83
  # terminal A # terminal B
@@ -69,7 +92,7 @@ export TOT_PROFILE=$(uuidgen) # a fresh isolated identity per terminal / test
69
92
  tot login
70
93
  ```
71
94
 
72
- A clean short name (`staff`, `dev`, `test-7`) becomes a readable `credentials.<name>.json`; any other value (symbols, uppercase, long) is hashed to a stable `credentials.h<hash>.json` — so an arbitrary id never collides or escapes `~/.tot`. Unset → the default session, unchanged. `tot whoami` shows the active profile.
95
+ A clean short profile name is included in the credential filename; other labels are hashed. Every profile holds independent sessions per MCP authority. `tot whoami` shows the active profile and authority.
73
96
 
74
97
  ## Design notes
75
98
 
package/bin/tot.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import "../src/ensure-node.mjs"; // hard Node-version gate — must stay first (see the module doc)
18
18
  import { readFileSync } from "node:fs";
19
+ import { selectedAuthority } from "../src/token-store.mjs";
19
20
  import { detectContext } from "../src/context.mjs";
20
21
  import { printError } from "../src/errors.mjs";
21
22
  import { recordActivity, redactArgs } from "../src/activity-log.mjs";
@@ -362,6 +363,13 @@ function safeSubcommand(cmd, rest) {
362
363
  async function main() {
363
364
  const [cmd, ...rest] = process.argv.slice(2);
364
365
  const ctx = detectContext();
366
+ const mcpIndex = rest.indexOf("--mcp");
367
+ const mcpUrl = mcpIndex < 0 ? undefined : rest[mcpIndex + 1];
368
+ if (mcpIndex >= 0 && (!mcpUrl || mcpUrl.startsWith("--"))) throw new Error("--mcp requires an HTTPS URL.");
369
+ // One invocation context drives auth, telemetry and every runner child.
370
+ delete process.env.TOT_SELECTED_MCP_URL;
371
+ process.env.TOT_SELECTED_MCP_URL = selectedAuthority(process.env, { mcpUrl, cwd: ctx.cwd });
372
+ process.env.MCP_BASE_URL = process.env.TOT_SELECTED_MCP_URL;
365
373
  const startedAt = Date.now();
366
374
  const command = cmd || "(none)";
367
375
  resetInvocationDiagnostics({ command, cliVersion: VERSION });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.5.1",
3
+ "version": "2.5.2",
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",
package/src/activity.mjs CHANGED
@@ -32,7 +32,7 @@
32
32
  * Dependency-free (node: builtins + global fetch, Node ≥22).
33
33
  */
34
34
  import { createHash } from "node:crypto";
35
- import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
35
+ import { readSessionCredentials } from "./token-store.mjs";
36
36
 
37
37
  // ─────────────────────────────────────────────────────────────────────────────
38
38
  // Envelope (mirror of event.ts)
@@ -235,14 +235,14 @@ export function isActivityDisabled(env = process.env) {
235
235
  * configured (→ emitActivity is a no-op). Mirrors the resolution the obstacle
236
236
  * beacon (obstacle.mjs) and the heartbeat (dev-heartbeat.mjs) use: the env override
237
237
  * `TOT_DEV_ACTIVITY_URL`/`TOT_DEV_ACTIVITY_TOKEN` (threaded to runner-spawning
238
- * paths) wins, else the `activityUrl`/`activityToken` cached in ~/.tot/credentials.json.
238
+ * paths) wins, else the `activityUrl`/`activityToken` cached in the selected authority/profile cache.
239
239
  */
240
240
  export function resolveActivityBridge(env = process.env) {
241
241
  const url = env.TOT_DEV_ACTIVITY_URL;
242
242
  const token = env.TOT_DEV_ACTIVITY_TOKEN;
243
243
  if (url && token) return { url, token };
244
244
  try {
245
- const creds = readCredentials(defaultCredentialsPath(env));
245
+ const creds = readSessionCredentials(env);
246
246
  if (creds?.activityUrl && creds?.activityToken) {
247
247
  return { url: creds.activityUrl, token: creds.activityToken };
248
248
  }
@@ -264,7 +264,7 @@ export function resolveActor(env = process.env) {
264
264
  const kind = env.TOT_ACTOR_KIND === "agent" ? "agent" : "dev";
265
265
  let seed = "anonymous";
266
266
  try {
267
- const creds = readCredentials(defaultCredentialsPath(env));
267
+ const creds = readSessionCredentials(env);
268
268
  if (creds?.emailHint) seed = `hint:${creds.emailHint}`;
269
269
  else if (creds?.activityToken) seed = `tok:${creds.activityToken}`;
270
270
  } catch {
@@ -282,7 +282,7 @@ export function resolveActor(env = process.env) {
282
282
  function resolveScope(env = process.env, extra = {}) {
283
283
  const scope = /** @type {any} */ ({ ...extra });
284
284
  try {
285
- const creds = readCredentials(defaultCredentialsPath(env));
285
+ const creds = readSessionCredentials(env);
286
286
  if (creds?.traceId && !scope.traceId) scope.traceId = creds.traceId;
287
287
  } catch {
288
288
  /* best-effort */
package/src/auth.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * SINGLE PLANE: there is exactly one identity that drives `tot` — the human
6
6
  * developer, signed in as THEMSELVES against the MCP via OAuth (the same identity
7
7
  * `claude mcp add … tot` + `/mcp` establishes). `tot login` runs the MCP OAuth
8
- * PKCE loopback and caches the token (~/.tot/credentials.json via token-store.mjs);
8
+ * PKCE loopback and caches the token (the selected authority/profile cache via token-store.mjs);
9
9
  * this provider reads it back, silently refreshing when expired, and attaches it as
10
10
  * the client's Bearer. Entitlement is derived server-side from the developer's ToT
11
11
  * memberships — no local check. Every command calls `establishSession(client)`.
@@ -20,7 +20,7 @@
20
20
  * read or transmit the secret.
21
21
  */
22
22
  import {
23
- defaultCredentialsPath, readCredentials, writeCredentials, isExpired,
23
+ defaultCredentialsPath, readCredentials, writeCredentials, isExpired, normalizeAuthority, selectedAuthority,
24
24
  } from "./token-store.mjs";
25
25
  import { refreshAccessToken, credentialsFromToken } from "./oauth.mjs";
26
26
 
@@ -140,19 +140,31 @@ export function warnLegacyOperatorEnv(env = process.env, warn = (m) => console.e
140
140
  */
141
141
  export async function resolveDeveloperSession(client, env, deps = {}) {
142
142
  const { fetchImpl = fetch, now = Date.now() } = deps;
143
- const path = defaultCredentialsPath(env);
143
+ const authority = selectedAuthority(env, { mcpUrl: client?.mcpUrl });
144
+ const path = defaultCredentialsPath(env, authority);
144
145
  let creds = readCredentials(path);
145
146
 
146
- if (!creds || !creds.accessToken) {
147
+ if (!creds || typeof creds.accessToken !== "string" || !creds.accessToken) {
147
148
  throw new AuthUnavailableError(
148
149
  "you're not signed in to Token of Trust.",
149
150
  {
150
151
  reason: "missing",
151
- hint: "run `tot login` to sign in (or `tot login --code <token>` to paste your invite token), then re-run.",
152
+ hint: `run \`tot login --mcp ${authority}\` to sign in, then re-run.`,
152
153
  },
153
154
  );
154
155
  }
155
156
 
157
+ let matches = false;
158
+ try {
159
+ matches = normalizeAuthority(creds.mcpUrl) === authority &&
160
+ (!creds.tokenEndpoint || normalizeAuthority(creds.tokenEndpoint) === authority);
161
+ } catch { /* malformed cache is unauthenticated */ }
162
+ if (!matches) {
163
+ throw new AuthUnavailableError("your cached session does not match this MCP authority.", {
164
+ reason: "authorityMismatch", hint: `run \`tot login --mcp ${authority}\`, then re-run.`,
165
+ });
166
+ }
167
+
156
168
  if (isExpired(creds, { now })) {
157
169
  if (!creds.refreshToken || !creds.tokenEndpoint) {
158
170
  throw new AuthUnavailableError(
@@ -169,7 +181,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
169
181
  );
170
182
  } catch (e) {
171
183
  throw new AuthUnavailableError(
172
- `couldn't refresh your Token of Trust session: ${e?.message || e}`,
184
+ "couldn't refresh your Token of Trust session.",
173
185
  { reason: "refreshFailed", hint: developerCredentialRecoveryHint(creds) },
174
186
  );
175
187
  }
@@ -64,6 +64,7 @@
64
64
  *
65
65
  * Dependency-free (global fetch + `git` via child_process).
66
66
  */
67
+ import { recordRepositoryAuthority } from "../token-store.mjs";
67
68
  import { execFile, execFileSync } from "node:child_process";
68
69
  import { existsSync } from "node:fs";
69
70
  import { join } from "node:path";
@@ -472,7 +473,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
472
473
  if (!cloneDir) {
473
474
  return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
474
475
  }
475
- const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, cloneDeps);
476
+ const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, { ...cloneDeps, mcpUrl: client.mcpUrl });
476
477
  return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
477
478
  }
478
479
 
@@ -502,6 +503,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
502
503
  * @param {(s: string) => string} redact
503
504
  * @param {{
504
505
  * store?: boolean,
506
+ * mcpUrl?: string,
505
507
  * git?: (cargs: string[]) => Promise<string>,
506
508
  * gitSync?: (cargs: string[]) => string,
507
509
  * installHelper?: (git: (cargs: string[]) => string, opts: { host?: string }) => boolean,
@@ -510,6 +512,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
510
512
  */
511
513
  async function cloneRepo(gitRemote, dir, redact, {
512
514
  store = true,
515
+ mcpUrl = undefined,
513
516
  git = async (/** @type {string[]} */ cargs) => (await execFileP("git", cargs)).stdout.toString(),
514
517
  gitSync = (/** @type {string[]} */ cargs) => execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" }),
515
518
  installHelper = installForgeCredentialHelper,
@@ -551,6 +554,7 @@ async function cloneRepo(gitRemote, dir, redact, {
551
554
  command: "clone", operation: "credential_helper_install", required: false, degraded: true,
552
555
  });
553
556
  }
557
+ if (mcpUrl) recordRepositoryAuthority(gitSync, mcpUrl);
554
558
  if (record) {
555
559
  // Not best-effort: without the record, the credential helper cannot tell what this
556
560
  // checkout is, so every later fetch would fail with nothing to say why.
@@ -660,6 +664,7 @@ export async function clonePublicationSpaces(client, { tenant, dir, topology, re
660
664
  const scrub = (/** @type {string} */ s) => (cred ? redact(s).split(cred.token).join("***") : redact(s));
661
665
  const { head } = await cloneRepo(gitRemote, target, scrub, {
662
666
  ...deps,
667
+ mcpUrl: client.mcpUrl,
663
668
  store: false,
664
669
  record: (gitSync) => recordPublicationSpaceCheckout(gitSync, { tenant, spaceId: space.id, repo: space.source.repo }),
665
670
  });
@@ -752,7 +757,7 @@ function serverReason(error) {
752
757
  * (a server that dropped the `alias` key would otherwise hand back the caller's own store).
753
758
  * The server reports no expiry — it reaps the reader token after ~24h — so the helper's TTL
754
759
  * and erase→re-mint are what bound a cached copy.
755
- * @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
760
+ * @param {{ callTool: (name: string, args: unknown) => Promise<any>, mcpUrl?: string }} client
756
761
  * @param {{ target: string, repo: string }} alias
757
762
  * @param {(s: string) => string} [redact]
758
763
  * @returns {Promise<{ gitRemote: string, publicUrl: string }>}
@@ -797,7 +802,7 @@ export async function mintReadOnlyAlias(client, alias, redact = (s) => s) {
797
802
  * `tot clone agency-kit [dir]`: mint the read-only credential, clone token-free through a
798
803
  * one-shot `http.extraheader`, install the host-scoped credential helper, and record the
799
804
  * checkout as a read-only kit clone so the helper re-mints it the same way.
800
- * @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
805
+ * @param {{ callTool: (name: string, args: unknown) => Promise<any>, mcpUrl?: string }} client
801
806
  * @param {{ alias: { target: string, repo: string }, cloneDir?: string|null, redact?: (s:string)=>string }} opts
802
807
  * @param {Parameters<typeof cloneRepo>[3]} [deps]
803
808
  */
@@ -806,6 +811,7 @@ export async function cloneReadOnlyAlias(client, { alias, cloneDir = null, redac
806
811
  if (!cloneDir) return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
807
812
  const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, {
808
813
  ...deps,
814
+ mcpUrl: client.mcpUrl,
809
815
  store: false,
810
816
  record: (gitSync) => recordReadOnlyKitCheckout(gitSync, alias.target),
811
817
  });
@@ -38,7 +38,7 @@ import { pipeline } from "node:stream/promises";
38
38
  import { setTimeout as delay } from "node:timers/promises";
39
39
  import { createMcpClient, CLI_VERSION, setRunnerVersion, versionStamp } from "../mcp.mjs";
40
40
  import { establishSession } from "../auth.mjs";
41
- import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
41
+ import { readSessionCredentials } from "../token-store.mjs";
42
42
  import { emitObstacle } from "../obstacle.mjs";
43
43
  import { CliError, fail, formatError } from "../errors.mjs";
44
44
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
@@ -241,7 +241,7 @@ async function runNativePublic(workspace, args, ctx) {
241
241
  * @returns {{TOT_DEV_ACTIVITY_TOKEN?: string, TOT_DEV_ACTIVITY_URL?: string}}
242
242
  */
243
243
  export function activityBridgeEnv(env = process.env) {
244
- const creds = readCredentials(defaultCredentialsPath(env));
244
+ const creds = readSessionCredentials(env);
245
245
  if (!creds?.activityToken || !creds?.activityUrl) return {};
246
246
  return { TOT_DEV_ACTIVITY_TOKEN: creds.activityToken, TOT_DEV_ACTIVITY_URL: creds.activityUrl };
247
247
  }
@@ -347,7 +347,7 @@ async function runNative(workspace, args, ctx) {
347
347
  /**
348
348
  * The env bootNative threads into the spawned runner. NEVER thread a real
349
349
  * developer's activity-bridge credential into the zero-login --sample run:
350
- * activityBridgeEnv() reads ~/.tot/credentials.json unconditionally, so a
350
+ * activityBridgeEnv() reads the selected authority/profile cache unconditionally, so a
351
351
  * developer who's ever run `tot login --code` and then runs `tot dev --sample`
352
352
  * (the "free taste"/demo path) would otherwise leak their real credential,
353
353
  * posting fake sample-store activity to their real hosted /dev panel —
@@ -30,7 +30,7 @@ import {
30
30
  nativeWindowsDetail,
31
31
  shouldAttemptDoctorAutoLogin,
32
32
  } from "../platform.mjs";
33
- import { defaultCredentialsPath, readCredentials, isExpired } from "../token-store.mjs";
33
+ import { readSessionCredentials, isExpired } from "../token-store.mjs";
34
34
  import { dockerAvailable, tryStartDocker, RENDERER_CACHE_ROOT, rendererArtifactComplete } from "./dev.mjs";
35
35
  import { loginAndCache } from "./login.mjs";
36
36
 
@@ -140,7 +140,7 @@ export function collectChecks(_ctx, env = process.env) {
140
140
  blocking: false,
141
141
  });
142
142
 
143
- const devCreds = readCredentials(defaultCredentialsPath(env));
143
+ const devCreds = readSessionCredentials(env);
144
144
  const devUsable = !!devCreds?.accessToken && (!isExpired(devCreds) || !!devCreds.refreshToken);
145
145
  checks.push({
146
146
  name: "auth identity",
@@ -24,7 +24,7 @@ import { createInterface } from "node:readline/promises";
24
24
  import { createMcpClient } from "../mcp.mjs";
25
25
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
26
26
  import { readActivity, formatActivity } from "../activity-log.mjs";
27
- import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
27
+ import { readSessionCredentials } from "../token-store.mjs";
28
28
  import { fail } from "../errors.mjs";
29
29
  import { formatDiagnostics, readDiagnostics, recordDiagnostic } from "../diagnostics.mjs";
30
30
 
@@ -154,7 +154,7 @@ export async function run(argv) {
154
154
  // session. Read it BEFORE establishSession — a silent token refresh rewrites the
155
155
  // credential file, so read the cached value first. Absent for a bare (non-invite)
156
156
  // login; omitted from the payload then.
157
- const traceId = readCredentials(defaultCredentialsPath(env))?.traceId || null;
157
+ const traceId = readSessionCredentials(env)?.traceId || null;
158
158
 
159
159
  // Preview — sending publishes to Token of Trust, so show exactly what goes out.
160
160
  console.error("\nAbout to send to Token of Trust:");
@@ -50,7 +50,8 @@
50
50
  import { readFileSync } from "node:fs";
51
51
  import { execFileSync } from "node:child_process";
52
52
  import { createMcpClient } from "../mcp.mjs";
53
- import { establishSession } from "../auth.mjs";
53
+ import { selectedAuthority, readSessionCredentials } from "../token-store.mjs";
54
+ import { AuthUnavailableError, establishSession } from "../auth.mjs";
54
55
  import { checkoutTenant, mintPublicationSpace, mintReadOnlyAlias, resolveReadOnlyAlias } from "./clone.mjs";
55
56
  import { scopeToTenant } from "../tenant-scope.mjs";
56
57
  import { detectContext } from "../context.mjs";
@@ -127,6 +128,13 @@ export async function mintOrCacheCredential({
127
128
  createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
128
129
  mintReadOnly = mintReadOnlyAlias, mintSpace = mintPublicationSpace,
129
130
  } = {}) {
131
+ let authority;
132
+ try { authority = selectedAuthority(env, { cwd }); }
133
+ catch { console.error("tot: this checkout has an invalid MCP authority. Run tot login --mcp <url> in this checkout."); return null; }
134
+ env = { ...env, TOT_SELECTED_MCP_URL: authority, MCP_BASE_URL: authority };
135
+ const authFailure = (e) => {
136
+ if (e instanceof AuthUnavailableError) console.error(`tot: ${e.message} ${e.hint || `Run tot login --mcp ${authority}.`}`);
137
+ };
130
138
  const recorded = recordedCheckout(cwd);
131
139
  if (recorded?.kind === "invalid") return null; // a half-recorded space mints nothing, never the store's
132
140
  if (recorded?.kind === PUBLICATION_SPACE_KIND) {
@@ -135,7 +143,8 @@ export async function mintOrCacheCredential({
135
143
  const client = createClient(baseUrl);
136
144
  try {
137
145
  await establish(client, { env });
138
- } catch {
146
+ } catch (e) {
147
+ authFailure(e);
139
148
  return null;
140
149
  }
141
150
  try {
@@ -147,6 +156,7 @@ export async function mintOrCacheCredential({
147
156
  const cred = splitAuthedRemote(res.gitRemote || "");
148
157
  return cred ? { username: cred.username, password: cred.token } : null;
149
158
  } catch (e) {
159
+ authFailure(e);
150
160
  if (e instanceof CliError) console.error(formatError(e));
151
161
  return null;
152
162
  }
@@ -158,7 +168,7 @@ export async function mintOrCacheCredential({
158
168
  if (!alias) return null; // a recorded target this CLI does not know — never guess a mint
159
169
  const cachePath = kitCredentialCachePath(alias.target, env);
160
170
  const cached = readCachedCredential(cachePath);
161
- if (cached) return { username: cached.username, password: cached.password };
171
+ if (cached && readSessionCredentials(env)?.accessToken) return { username: cached.username, password: cached.password };
162
172
  const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
163
173
  const client = createClient(baseUrl);
164
174
  try {
@@ -174,6 +184,7 @@ export async function mintOrCacheCredential({
174
184
  // report it on stderr so `git fetch` doesn't dead-end the developer in silence. A
175
185
  // bare network/auth failure (not signed in, MCP unreachable) has no decision to
176
186
  // report and stays silent, same as every other "nothing this helper can do" path.
187
+ authFailure(e);
177
188
  if (e instanceof CliError) console.error(formatError(e));
178
189
  return null;
179
190
  }
@@ -227,7 +238,8 @@ export async function mintOrCacheCredential({
227
238
  const client = createClient(baseUrl);
228
239
  try {
229
240
  await establish(client, { env });
230
- } catch {
241
+ } catch (e) {
242
+ authFailure(e);
231
243
  return null; // not signed in (or session unrefreshable) — nothing this helper can do
232
244
  }
233
245
  try {
@@ -295,7 +307,8 @@ export function forgetCredential({ env = process.env, cwd = process.cwd(), expec
295
307
  const kit = kitCheckout(cwd);
296
308
  if (!kit) return;
297
309
  if (expectedHost && !hostMatches(kit.originUrl, expectedHost)) return;
298
- dropCachedCredential(kitCredentialCachePath(kit.target, env));
310
+ const authority = selectedAuthority(env, { cwd });
311
+ dropCachedCredential(kitCredentialCachePath(kit.target, { ...env, TOT_SELECTED_MCP_URL: authority }));
299
312
  } catch {
300
313
  /* erase is advisory — never fail git over it */
301
314
  }
@@ -23,7 +23,7 @@
23
23
  * binds this identity — the same ordering that fixes the "no stores" dead-end).
24
24
  * Dependency-free.
25
25
  */
26
- import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
26
+ import { readSessionCredentials } from "../token-store.mjs";
27
27
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
28
28
  import { createMcpClient } from "../mcp.mjs";
29
29
  import { storeListError, noStoresGuidance } from "./clone.mjs";
@@ -190,7 +190,7 @@ export async function run(argv, _ctx) {
190
190
 
191
191
  // Same MCP-URL resolution as whoami: the cached session's origin wins, then env
192
192
  // overrides, then the public default.
193
- const creds = readCredentials(defaultCredentialsPath(env));
193
+ const creds = readSessionCredentials(env);
194
194
  const mcpUrl = creds?.mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
195
195
  const client = createMcpClient(mcpUrl);
196
196
 
@@ -4,7 +4,7 @@
4
4
  * Runs the MCP OAuth 2.1 PKCE loopback (the same ceremony `claude mcp add` runs):
5
5
  * opens the browser to the MCP's authorize page, the developer signs in with their
6
6
  * ToT identity + approves, and the loopback catches the code and exchanges it for a
7
- * token cached at ~/.tot/credentials.json. Every later command (`tot clone`,
7
+ * token cached at the selected authority/profile cache. Every later command (`tot clone`,
8
8
  * `tot submit`, …) then runs as that developer with NO re-auth.
9
9
  *
10
10
  * The MCP defaults to the same target `tot submit` talks to (env MCP_BASE_URL /
@@ -20,7 +20,9 @@
20
20
  * Dependency-free (node built-ins via oauth.mjs).
21
21
  */
22
22
  import { loginFlow, deviceLoginFlow, rendezvousLoginFlow, NoOpenerError } from "../oauth.mjs";
23
- import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
23
+ import { defaultCredentialsPath, readCredentials, writeCredentials, normalizeAuthority, selectedAuthority, recordRepositoryAuthority } from "../token-store.mjs";
24
+ import { execFileSync } from "node:child_process";
25
+ import { detectContext } from "../context.mjs";
24
26
  import { openBrowser } from "../open.mjs";
25
27
  import { fail } from "../errors.mjs";
26
28
  import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
@@ -28,8 +30,6 @@ import { loginFailureNext } from "../platform.mjs";
28
30
  import { isInteractive, promptYesNo } from "../prompt.mjs";
29
31
  import { versionStamp } from "../mcp.mjs";
30
32
 
31
- const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
32
-
33
33
  function parseArgs(argv) {
34
34
  const a = {
35
35
  mcp: null, device: false, code: null, help: false,
@@ -122,9 +122,12 @@ After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot subm
122
122
  * @returns {Promise<object>} the credentials written to disk.
123
123
  */
124
124
  export async function loginAndCache(mcpUrl, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}), device = false } = {}) {
125
- const path = defaultCredentialsPath(env);
126
- const prior = readCredentials(path);
127
- const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
125
+ mcpUrl = normalizeAuthority(mcpUrl);
126
+ const path = defaultCredentialsPath(env, mcpUrl);
127
+ let prior = readCredentials(path);
128
+ try { if (normalizeAuthority(prior?.mcpUrl) !== mcpUrl) prior = null; }
129
+ catch { prior = null; }
130
+ const clientId = prior?.clientId;
128
131
 
129
132
  let creds;
130
133
  if (device) {
@@ -198,7 +201,8 @@ export async function offerSignIn(mcpUrl, env = process.env, {
198
201
  * @returns {Promise<object>} the credentials written to disk.
199
202
  */
200
203
  export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}) } = {}) {
201
- const path = defaultCredentialsPath(env);
204
+ mcpUrl = normalizeAuthority(mcpUrl);
205
+ const path = defaultCredentialsPath(env, mcpUrl);
202
206
  const prior = readCredentials(path);
203
207
  const creds = await rendezvousLoginFlow({ mcpUrl, code, log });
204
208
  const merged = mergeActivityBridge(prior, mcpUrl, creds);
@@ -225,7 +229,8 @@ export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /*
225
229
  * `TOT_PROFILE` (separate credential files — no carry to guard at all).
226
230
  */
227
231
  export function mergeActivityBridge(prior, mcpUrl, creds) {
228
- if (prior?.mcpUrl !== mcpUrl) return creds;
232
+ try { if (normalizeAuthority(prior?.mcpUrl) !== normalizeAuthority(mcpUrl)) return creds; }
233
+ catch { return creds; }
229
234
  const priorHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
230
235
  const newHint = redactEmailForHint(emailFromJwt(creds?.accessToken || "") || "");
231
236
  if (priorHint && newHint && priorHint !== newHint) return creds; // different identity → no bleed
@@ -260,16 +265,26 @@ export async function run(argv, _ctx) {
260
265
  // a "why did sign-in behave oddly" investigation needs (e.g. a stale shadowing `tot`).
261
266
  console.error(versionStamp());
262
267
 
263
- const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
268
+ const mcpUrl = selectedAuthority(env, { mcpUrl: args.mcp });
269
+ const loginEnv = { ...env, TOT_SELECTED_MCP_URL: mcpUrl };
270
+ const bindCheckout = () => {
271
+ if (!args.mcp) return;
272
+ const git = (cargs) => execFileSync("git", cargs, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
273
+ let kind = "";
274
+ try { kind = git(["config", "--local", "--get", "tot.checkoutKind"]).trim(); } catch { /* root store or loose directory */ }
275
+ if (detectContext().mode !== "checkout" && !["read-only-kit", "publication-space"].includes(kind)) return;
276
+ recordRepositoryAuthority(git, mcpUrl);
277
+ };
264
278
 
265
279
  if (args.code) {
266
280
  console.error(`~ signing in to Token of Trust with your invite code (${mcpUrl})`);
267
281
  try {
268
- await redeemAndCache(mcpUrl, args.code, env, { log: (m) => console.error(m) });
269
- cacheActivityBridge(env, args.activityToken, args.activityUrl);
270
- cacheTraceId(env, args.traceId);
271
- cacheEmailHint(env, args.emailHint);
272
- console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
282
+ await redeemAndCache(mcpUrl, args.code, loginEnv, { log: (m) => console.error(m) });
283
+ cacheActivityBridge(loginEnv, args.activityToken, args.activityUrl);
284
+ cacheTraceId(loginEnv, args.traceId);
285
+ cacheEmailHint(loginEnv, args.emailHint);
286
+ bindCheckout();
287
+ console.log("\n+ signed in. Session cached privately for this MCP authority.");
273
288
  console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
274
289
  return 0;
275
290
  } catch (e) {
@@ -285,9 +300,10 @@ export async function run(argv, _ctx) {
285
300
 
286
301
  console.error(`~ signing in to Token of Trust (${mcpUrl})`);
287
302
  try {
288
- await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
289
- cacheTraceId(env, args.traceId);
290
- console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
303
+ await loginAndCache(mcpUrl, loginEnv, { log: (m) => console.error(m), device: args.device });
304
+ cacheTraceId(loginEnv, args.traceId);
305
+ bindCheckout();
306
+ console.log("\n+ signed in. Session cached privately for this MCP authority.");
291
307
  console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
292
308
  return 0;
293
309
  } catch (e) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `tot logout` — sign this machine out of Token of Trust.
3
3
  *
4
- * Deletes the cached session (`~/.tot/credentials.json`) so the next command starts
4
+ * Deletes only the selected authority/profile session so the next command starts
5
5
  * clean — the counterpart to `tot login` / `tot login --code`. Idempotent: logging
6
6
  * out when already signed out is success, not an error.
7
7
  *
@@ -10,11 +10,11 @@
10
10
  *
11
11
  * Dependency-free (node built-ins via token-store.mjs).
12
12
  */
13
- import { defaultCredentialsPath, readCredentials, clearCredentials } from "../token-store.mjs";
13
+ import { defaultCredentialsPath, readCredentials, clearCredentials, selectedAuthority } from "../token-store.mjs";
14
14
 
15
15
  const USAGE = `tot logout — sign this machine out of Token of Trust
16
16
 
17
- tot logout delete the cached session (~/.tot/credentials.json)
17
+ tot logout [--mcp <url>] clear only the selected authority/profile session
18
18
 
19
19
  Idempotent — safe to run when already signed out. Clears only this machine's cache;
20
20
  it does not revoke server-side. Run \`tot whoami\` to confirm you're signed out.`;
@@ -25,12 +25,14 @@ export async function run(argv) {
25
25
  console.log(USAGE);
26
26
  return 0;
27
27
  }
28
- const path = defaultCredentialsPath(process.env);
28
+ const index = argv.indexOf("--mcp");
29
+ const authority = selectedAuthority(process.env, { mcpUrl: index < 0 ? undefined : argv[index + 1] });
30
+ const path = defaultCredentialsPath(process.env, authority);
29
31
  // Report against the prior state so "signed out" vs "already clean" is honest.
30
32
  const wasSignedIn = !!readCredentials(path);
31
33
  const removed = clearCredentials(path);
32
34
  if (wasSignedIn || removed) {
33
- console.log(`+ signed out. Cleared ${path}.`);
35
+ console.log(`+ signed out of ${authority}.`);
34
36
  console.log(" Run `tot login` (or `tot login --code <token>`) to sign in again.");
35
37
  } else {
36
38
  console.log("+ already signed out — no cached session to clear.");
@@ -57,7 +57,7 @@ import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
57
57
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
58
58
  import { startProgress } from "../progress.mjs";
59
59
  import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
60
- import { readCredentials, defaultCredentialsPath } from "../token-store.mjs";
60
+ import { readSessionCredentials } from "../token-store.mjs";
61
61
  import { collectChecks } from "./doctor.mjs";
62
62
  import { normalizeStores, storeListError, checkoutTenant, noStoresGuidance } from "./clone.mjs";
63
63
  import {
@@ -173,7 +173,7 @@ export function decideStartMode({ sampleFlag, hasSession }) {
173
173
  */
174
174
  export function cachedMcpUrl(env) {
175
175
  try {
176
- const creds = readCredentials(defaultCredentialsPath(env));
176
+ const creds = readSessionCredentials(env);
177
177
  return creds && typeof creds.mcpUrl === "string" && creds.mcpUrl ? creds.mcpUrl : null;
178
178
  } catch {
179
179
  return null;
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * `tot whoami` — who is `tot` signed in as, and what can they touch.
3
3
  *
4
- * Reads the cached session (~/.tot/credentials.json). If a live token is present
4
+ * Reads the cached session (the selected authority/profile cache). If a live token is present
5
5
  * it best-effort asks the MCP for the stores this developer may act on (proving the
6
6
  * token still works), degrading cleanly to the cached status if the MCP is
7
7
  * unreachable or the developer isn't entitled yet.
8
8
  *
9
9
  * Dependency-free.
10
10
  */
11
- import { defaultCredentialsPath, readCredentials, isExpired, activeProfile } from "../token-store.mjs";
11
+ import { readSessionCredentials, isExpired, activeProfile } from "../token-store.mjs";
12
12
  import { establishSession } from "../auth.mjs";
13
13
  import { createMcpClient } from "../mcp.mjs";
14
14
  import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
@@ -32,7 +32,7 @@ export function sessionStatus(creds, { now = Date.now() } = {}) {
32
32
  /** @param {string[]} argv @param {any} _ctx */
33
33
  export async function run(argv, _ctx) {
34
34
  const env = process.env;
35
- const creds = readCredentials(defaultCredentialsPath(env));
35
+ const creds = readSessionCredentials(env);
36
36
  const status = sessionStatus(creds);
37
37
 
38
38
  const profile = activeProfile(env);
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
21
21
  import { createHash } from "node:crypto";
22
22
  import { join, dirname } from "node:path";
23
23
  import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
24
- import { readCredentials, writeCredentials } from "./token-store.mjs";
24
+ import { readCredentials, writeCredentials, selectedAuthority, profileSlug, readSessionCredentials } from "./token-store.mjs";
25
25
 
26
26
  /** The git config value that routes credential requests through `tot` — the
27
27
  * `!` tells git to run this as a shell command (git appends the operation,
@@ -129,10 +129,13 @@ export function kitCredentialCacheKey(target) {
129
129
  return `kit-${createHash("sha256").update(`kit|${target}`).digest("hex").slice(0, 32)}`;
130
130
  }
131
131
 
132
- /** Absolute path to a read-only kit checkout's cached credential. */
132
+ /** Kit credentials are isolated by authority, profile and the current OAuth grant. */
133
133
  export function kitCredentialCachePath(target, env = process.env) {
134
134
  const home = env.TOT_HOME || homedir();
135
- return join(home, ".tot", "git-credentials", `${kitCredentialCacheKey(target)}.json`);
135
+ const grant = readSessionCredentials(env)?.accessToken || "";
136
+ const scope = createHash("sha256")
137
+ .update(JSON.stringify([selectedAuthority(env), profileSlug(env), grant])).digest("hex");
138
+ return join(home, ".tot", "git-credentials", `${kitCredentialCacheKey(target)}-${scope}.json`);
136
139
  }
137
140
 
138
141
  /** Is a cached credential still trusted? A cache with no `mintedAt` is
package/src/oauth.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  * The pieces are exported individually so the flow is unit-testable against a
23
23
  * mocked `fetch` with no network. Dependency-free (node:http/crypto/timers + open.mjs).
24
24
  */
25
+ import { normalizeAuthority } from "./token-store.mjs";
25
26
  import http from "node:http";
26
27
  import crypto from "node:crypto";
27
28
  import { setTimeout as delay } from "node:timers/promises";
@@ -45,6 +46,7 @@ async function oauthJson(url, init, operation, fetchImpl) {
45
46
  try {
46
47
  res = await fetchImpl(url, {
47
48
  ...init,
49
+ redirect: "error",
48
50
  headers: { ...(init?.headers || {}), "X-Trace-Id": traceId },
49
51
  });
50
52
  } catch (cause) {
@@ -143,7 +145,7 @@ export function randomState() {
143
145
 
144
146
  /** Fetch the Authorization Server metadata (RFC 8414). */
145
147
  export async function discoverMetadata(mcpUrl, fetchImpl = fetch) {
146
- const root = String(mcpUrl).replace(/\/+$/, "");
148
+ const root = normalizeAuthority(mcpUrl);
147
149
  const url = `${root}/.well-known/oauth-authorization-server`;
148
150
  const { res, body: meta, context } = await oauthJson(
149
151
  url, { headers: { Accept: "application/json" } }, "oauth_metadata", fetchImpl,
@@ -247,7 +249,7 @@ export function refreshAccessToken(tokenEndpoint, { refreshToken, clientId }, fe
247
249
  export function credentialsFromToken({ mcpUrl, clientId, tokenEndpoint, scope = SCOPE, token, now = Date.now() }) {
248
250
  const expiresIn = Number(token.expires_in) || 0;
249
251
  return {
250
- mcpUrl,
252
+ mcpUrl: normalizeAuthority(mcpUrl),
251
253
  clientId,
252
254
  tokenEndpoint,
253
255
  scope: token.scope || scope,
package/src/obstacle.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * ESM front door to the obstacle beacon (src/obstacle-beacon.cjs). The post-login
3
3
  * failure paths — `pnpm-missing`, `install-failed`, `clone-failed` — run on a
4
4
  * supported Node where the bridge credential is already cached, so they resolve
5
- * {url, token} from ~/.tot/credentials.json (the same source activityBridgeEnv +
5
+ * {url, token} from the selected authority/profile cache (the same source activityBridgeEnv +
6
6
  * the heartbeat use) rather than argv. `node-too-old` does NOT come through here:
7
7
  * it fires from the ES5 launcher pre-login, where creds may not exist yet.
8
8
  *
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import beaconCore from "./obstacle-beacon.cjs";
15
15
  import { CLI_VERSION } from "./mcp.mjs";
16
- import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
16
+ import { readSessionCredentials } from "./token-store.mjs";
17
17
 
18
18
  /**
19
19
  * Best-effort obstacle beacon for a post-login failure. No-op (silent) when no
@@ -24,7 +24,7 @@ import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
24
24
  */
25
25
  export async function emitObstacle(kind, { have, need, env = process.env } = {}) {
26
26
  try {
27
- const creds = readCredentials(defaultCredentialsPath(env));
27
+ const creds = readSessionCredentials(env);
28
28
  const url = creds?.activityUrl;
29
29
  const token = creds?.activityToken;
30
30
  if (!url || !token) return;
@@ -1,54 +1,14 @@
1
1
  /**
2
- * The developer credential cache for `tot` — where `tot login` writes the MCP
3
- * OAuth token and every developer-session command reads it back.
4
- *
5
- * ONE file, `~/.tot/credentials.json` (0600 in a 0700 dir), holding exactly what
6
- * a later command needs to authenticate AND to silently refresh:
7
- *
8
- * { mcpUrl, clientId, tokenEndpoint, scope,
9
- * accessToken, refreshToken, expiresAt (epoch ms), obtainedAt,
10
- * activityToken, activityUrl, traceId, emailHint }
11
- *
12
- * We persist `clientId` + `tokenEndpoint` so a refresh needs no re-discovery /
13
- * re-registration, and `mcpUrl` so we never present a token minted for one MCP
14
- * to a different one. `activityToken`/`activityUrl` are a SEPARATE credential
15
- * (storefront-issued, not MCP OAuth) that `tot login --code` caches when the
16
- * invite paste carries it — see commands/login.mjs `cacheActivityBridge` and
17
- * commands/dev.mjs `activityBridgeEnv`, which every runner-spawning path (native
18
- * monorepo, native standalone, Docker) threads in so the local dev-loop process
19
- * can report file saves to the developer's hosted /dev panel. `traceId` is the
20
- * storefront-minted invite→terminal→problem trace id (cached by `tot login`'s
21
- * `--trace-id` flag, see commands/login.mjs `cacheTraceId`); `tot feedback`
22
- * stamps it onto a problem report so triage can join it to the invite + the live
23
- * heartbeat session. `emailHint` is masked, display-only recovery context from
24
- * the setup command, used to orient cockpit/login after a local credential
25
- * expires without persisting a raw address.
26
- *
27
- * The read/write is a plain JSON pass-through (no schema) — new fields like
28
- * `traceId` ride along additively; readers simply pick the keys they need.
29
- * Dependency-free (node:fs/os/path).
30
- *
31
- * `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
32
- *
33
- * PROFILES (`TOT_PROFILE`) — OPTIONAL, for parallel work. Almost every developer
34
- * leaves this UNSET and uses the single default `credentials.json` — that path is
35
- * unchanged and is the norm. It exists only when you want MORE THAN ONE identity
36
- * live at once (a staff `@tokenoftrust.com` sign-in and a plain developer one, or
37
- * many parallel test identities): export a profile per shell and each gets its OWN
38
- * credential file under the same `~/.tot` (the renderer cache, last-tenant, etc.
39
- * stay shared — only the identity splits). The value is an OPAQUE label — any
40
- * string works, so `export TOT_PROFILE=$(uuidgen)` per terminal/test is fine:
41
- * - a clean short token (`staff`, `dev`, `test-7`) is used verbatim for a
42
- * readable `credentials.<profile>.json`;
43
- * - any other value (symbols, uppercase, long) is HASHED to a stable, safe
44
- * `credentials.h<hash>.json` — so an arbitrary opaque id can never collide
45
- * with another or escape `~/.tot`, while `tot whoami` still shows what you set.
2
+ * OAuth sessions are private files keyed by MCP authority and identity profile.
3
+ * Endpoint paths share an authority; QA and production retain separate sessions.
4
+ * TOT_HOME selects a private home and TOT_PROFILE selects an independent identity.
46
5
  */
47
6
  import {
48
- readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync,
7
+ readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync, linkSync, unlinkSync,
49
8
  } from "node:fs";
50
9
  import { homedir } from "node:os";
51
- import { createHash } from "node:crypto";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { execFileSync } from "node:child_process";
52
12
  import { join, dirname } from "node:path";
53
13
 
54
14
  /** A short token safe to drop straight into a filename (readable profiles). */
@@ -78,24 +38,134 @@ export function profileSlug(env = process.env) {
78
38
  return `h${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
79
39
  }
80
40
 
81
- /** Absolute path to the credential file for this environment (+ TOT_PROFILE). */
82
- export function defaultCredentialsPath(env = process.env) {
41
+ export const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
42
+ export const MCP_AUTHORITY_KEY = "tot.mcp-url";
43
+
44
+ /** Identity boundary shared by every MCP endpoint on one origin. */
45
+ export function normalizeAuthority(value) {
46
+ try {
47
+ const url = new URL(value);
48
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
49
+ if (url.username || url.password || url.search || url.hash ||
50
+ (url.protocol !== "https:" && !(url.protocol === "http:" && local))) throw new Error();
51
+ return url.origin;
52
+ } catch {
53
+ throw new Error("invalid MCP authority — use an HTTPS URL without credentials, query or fragment.");
54
+ }
55
+ }
56
+
57
+ /** Local checkout provenance, never inferred from a shared forge hostname. */
58
+ export function repositoryAuthority(cwd = process.cwd()) {
59
+ let value;
60
+ try {
61
+ value = execFileSync("git", ["-C", cwd, "config", "--local", "--get", MCP_AUTHORITY_KEY], {
62
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
63
+ }).trim();
64
+ } catch { return null; }
65
+ return value ? normalizeAuthority(value) : null;
66
+ }
67
+
68
+ /**
69
+ * Explicit request > checkout provenance > shell configuration > production.
70
+ * @param {NodeJS.ProcessEnv} env
71
+ * @param {{mcpUrl?: string|null, cwd?: string}} opts
72
+ */
73
+ export function selectedAuthority(env = process.env, { mcpUrl, cwd = process.cwd() } = {}) {
74
+ return normalizeAuthority(mcpUrl || env.TOT_SELECTED_MCP_URL || repositoryAuthority(cwd) || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL);
75
+ }
76
+
77
+ /** Record the authority used to authenticate this checkout. No credentials land in git. */
78
+ export function recordRepositoryAuthority(git, mcpUrl) {
79
+ git(["config", "--local", MCP_AUTHORITY_KEY, normalizeAuthority(mcpUrl)]);
80
+ }
81
+
82
+ function credentialsPath(env, authority) {
83
+ const home = env.TOT_HOME || homedir();
84
+ const profile = profileSlug(env);
85
+ const key = createHash("sha256").update(normalizeAuthority(authority)).digest("hex");
86
+ return join(home, ".tot", `credentials${profile ? `.${profile}` : ""}.${key}.json`);
87
+ }
88
+
89
+ /** Cache failures expose fixed diagnostics, never raw filesystem text or causes. */
90
+ class CredentialCacheError extends Error {
91
+ constructor(conflict = false) {
92
+ super(conflict
93
+ ? "credential cache authority conflict — refusing to replace or clear another authority's session."
94
+ : "could not access the local credential cache — check permissions and retry.");
95
+ this.code = conflict ? "credential_cache_authority_conflict" : "credential_cache_unavailable";
96
+ }
97
+ }
98
+
99
+ /** Convert one valid unscoped session once, without overwriting another session. */
100
+ function convertCredentials(env) {
83
101
  const home = env.TOT_HOME || homedir();
84
- const slug = profileSlug(env);
85
- return join(home, ".tot", slug ? `credentials.${slug}.json` : "credentials.json");
102
+ const profile = profileSlug(env);
103
+ const oldPath = join(home, ".tot", profile ? `credentials.${profile}.json` : "credentials.json");
104
+ const prior = readCredentials(oldPath);
105
+ if (typeof prior?.accessToken !== "string" || !prior.accessToken || typeof prior.mcpUrl !== "string") return;
106
+ const target = credentialsPath(env, prior.mcpUrl);
107
+ try {
108
+ // link is atomic and refuses an existing destination. Unlink completes the move.
109
+ linkSync(oldPath, target);
110
+ } catch (error) {
111
+ // A current session for this same authority/profile supersedes the unscoped copy.
112
+ // Retire that duplicate so logout cannot restore it on the next cache read.
113
+ if (error?.code === "ENOENT") return; // another reader already retired the source
114
+ if (error?.code !== "EEXIST") throw error;
115
+ const current = readCredentials(target);
116
+ let currentAuthority = null;
117
+ try { currentAuthority = normalizeAuthority(current?.mcpUrl); } catch { /* corrupt destination */ }
118
+ if (!currentAuthority || typeof current?.accessToken !== "string" || !current.accessToken) {
119
+ writeCredentials(target, prior);
120
+ } else if (currentAuthority !== normalizeAuthority(prior.mcpUrl)) {
121
+ throw new CredentialCacheError(true);
122
+ }
123
+ }
124
+ chmodSync(dirname(target), 0o700);
125
+ chmodSync(target, 0o600);
126
+ // Another first reader may have retired this source after our EEXIST result.
127
+ try { unlinkSync(oldPath); }
128
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
129
+ }
130
+
131
+ /**
132
+ * Absolute path to the selected authority/profile session.
133
+ * @param {NodeJS.ProcessEnv} env
134
+ * @param {string|undefined} mcpUrl
135
+ */
136
+ export function defaultCredentialsPath(env = process.env, mcpUrl = undefined) {
137
+ try { convertCredentials(env); }
138
+ catch (error) { throw error instanceof CredentialCacheError ? error : new CredentialCacheError(); }
139
+ return credentialsPath(env, selectedAuthority(env, { mcpUrl }));
86
140
  }
87
141
 
88
142
  /**
89
- * Read + parse the cached credentials, or null if absent/unreadable/malformed.
90
- * Never throws — a missing or corrupt cache simply means "not signed in".
143
+ * Read a session only when its issuer matches the selected destination.
144
+ * @param {NodeJS.ProcessEnv} env
145
+ * @param {string|undefined} mcpUrl
146
+ */
147
+ export function readSessionCredentials(env = process.env, mcpUrl = undefined) {
148
+ const authority = selectedAuthority(env, { mcpUrl });
149
+ const creds = readCredentials(defaultCredentialsPath(env, authority));
150
+ try { return creds && normalizeAuthority(creds.mcpUrl) === authority ? creds : null; }
151
+ catch { return null; }
152
+ }
153
+
154
+ /**
155
+ * Read + parse credentials, or null if absent/malformed. Operational read errors
156
+ * refuse so an unreadable session cannot be mistaken for a missing session.
91
157
  */
92
158
  export function readCredentials(filePath) {
93
- try {
94
- const parsed = JSON.parse(readFileSync(filePath, "utf8"));
95
- return parsed && typeof parsed === "object" ? parsed : null;
96
- } catch {
97
- return null;
159
+ let text;
160
+ try { text = readFileSync(filePath, "utf8"); }
161
+ catch (error) {
162
+ if (error?.code === "ENOENT") return null;
163
+ throw new CredentialCacheError();
98
164
  }
165
+ try {
166
+ const parsed = JSON.parse(text);
167
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
168
+ } catch { return null; }
99
169
  }
100
170
 
101
171
  /**
@@ -106,10 +176,15 @@ export function readCredentials(filePath) {
106
176
  */
107
177
  export function writeCredentials(filePath, creds) {
108
178
  mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
109
- const tmp = `${filePath}.tmp`;
110
- writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
111
- renameSync(tmp, filePath);
112
- chmodSync(filePath, 0o600);
179
+ chmodSync(dirname(filePath), 0o700);
180
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
181
+ try {
182
+ writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600, flag: "wx" });
183
+ renameSync(tmp, filePath);
184
+ chmodSync(filePath, 0o600);
185
+ } finally {
186
+ rmSync(tmp, { force: true });
187
+ }
113
188
  }
114
189
 
115
190
  /**
@@ -126,7 +201,7 @@ export function isExpired(creds, { now = Date.now(), skewMs = 60_000 } = {}) {
126
201
  /**
127
202
  * Delete the cached session (the whole "signed in" state), idempotently. Returns
128
203
  * true if a credential file was actually removed, false if there was nothing to
129
- * remove. Never throws — "already signed out" is success. Local-only: this clears
204
+ * remove. An unreadable/locked cache refuses rather than claiming sign-out. Local-only: this clears
130
205
  * the machine's cache; it does NOT revoke the grant server-side.
131
206
  */
132
207
  export function clearCredentials(filePath) {
@@ -134,7 +209,7 @@ export function clearCredentials(filePath) {
134
209
  try {
135
210
  rmSync(filePath, { force: true });
136
211
  } catch {
137
- /* best-effort: a missing/locked file still means "not signed in here" */
212
+ throw new Error("could not clear the selected session cache — check its permissions, then retry.");
138
213
  }
139
214
  return existed;
140
215
  }