@tokenoftrust/cli 1.4.0-rc.20 → 1.4.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Shared plumbing for `tot` acting as a git credential helper (unit u10,
3
+ * workstream tot-merge-conflict-resolution-ux — absorbs u7's `tot fetch`
4
+ * self-heal idea) — so a checkout never needs a LIVE forge token baked into
5
+ * `.git/config`'s remote URL. `tot clone` configures a fresh checkout's
6
+ * `credential.helper` to `CREDENTIAL_HELPER` (git's own extension point for
7
+ * exactly this — see `git help gitcredentials`), which git then invokes with
8
+ * `get`/`store`/`erase` on every network operation instead of reading a
9
+ * persisted secret. This module holds the pieces BOTH the `git-credential`
10
+ * command (src/commands/git-credential.mjs, which mints fresh tokens via the
11
+ * MCP) and the self-heal migration (called at the top of every command that
12
+ * touches git — submit/sync/… — so ANY CLI touch of a legacy checkout
13
+ * migrates it) share: parsing/formatting git's credential protocol, the
14
+ * on-disk credential cache, and rewriting a checkout's remote to drop its
15
+ * embedded token.
16
+ *
17
+ * Dependency-free — node:fs/os/path/crypto only. The MCP mint itself (real
18
+ * network I/O) lives in the command layer, which this module never imports.
19
+ */
20
+ import { homedir } from "node:os";
21
+ import { createHash } from "node:crypto";
22
+ import { join } from "node:path";
23
+ import { readCredentials, writeCredentials } from "./token-store.mjs";
24
+
25
+ /** The git config value that routes credential requests through `tot` — the
26
+ * `!` tells git to run this as a shell command (git appends the operation,
27
+ * e.g. `get`, as the final argument) — the same convention `gh auth
28
+ * git-credential` uses. Repo-LOCAL only (never --global): a checkout not
29
+ * built with `tot` must never have its credential resolution silently
30
+ * redirected. */
31
+ export const CREDENTIAL_HELPER = "!tot git-credential";
32
+
33
+ /** How long a minted credential is trusted before `tot git-credential get`
34
+ * mints a fresh one — comfortably under the forge push token's own
35
+ * multi-hour expiry (see pushPreviewRef in submit.mjs), so a long-running
36
+ * session still self-refreshes well before the cached one goes stale. */
37
+ export const CREDENTIAL_TTL_MS = 20 * 60 * 1000;
38
+
39
+ /**
40
+ * Split an authenticated forge remote URL (basic-auth `user:token@host`, as
41
+ * the MCP mints it via `tenant_checkout`) into its tokenless public URL +
42
+ * the embedded credential, so the token can be handed to git EPHEMERALLY for
43
+ * one operation instead of being persisted in `.git/config`. Returns null
44
+ * when the URL won't parse or carries no token — the caller then falls back
45
+ * to the checkout's existing remote. Pure — unit-tested.
46
+ * @param {string} remoteUrl
47
+ * @returns {{ publicUrl: string, username: string, token: string }|null}
48
+ */
49
+ export function splitAuthedRemote(remoteUrl) {
50
+ try {
51
+ const u = new URL(String(remoteUrl));
52
+ const token = u.password ? decodeURIComponent(u.password) : "";
53
+ if (!token) return null;
54
+ const username = u.username ? decodeURIComponent(u.username) : "";
55
+ return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The `http.extraheader` value that hands a basic-auth credential to a
63
+ * SINGLE git invocation (base64 of `user:token`) — so a freshly-minted forge
64
+ * token authenticates one operation without ever being written to
65
+ * `.git/config`. Pure — unit-tested.
66
+ * @param {string} username
67
+ * @param {string} token
68
+ * @returns {string}
69
+ */
70
+ export function basicAuthExtraHeader(username, token) {
71
+ const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
72
+ return `Authorization: Basic ${b64}`;
73
+ }
74
+
75
+ /**
76
+ * Parse git's credential-helper protocol (key=value lines, terminated by a
77
+ * blank line or EOF) into a plain object. A malformed line is skipped rather
78
+ * than throwing — git's own helpers are lenient the same way. Pure.
79
+ * @param {string} text
80
+ * @returns {Record<string,string>}
81
+ */
82
+ export function parseCredentialInput(text) {
83
+ /** @type {Record<string,string>} */
84
+ const out = {};
85
+ for (const line of String(text).split("\n")) {
86
+ const trimmed = line.trim();
87
+ if (!trimmed) continue;
88
+ const i = trimmed.indexOf("=");
89
+ if (i <= 0) continue;
90
+ out[trimmed.slice(0, i)] = trimmed.slice(i + 1);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Format a credential response for git's `get` operation — only the fields
97
+ * present are emitted (git only needs `username`/`password` filled in;
98
+ * echoing `protocol`/`host` back is harmless and conventional). Pure.
99
+ * @param {Record<string,string|undefined|null>} fields
100
+ * @returns {string}
101
+ */
102
+ export function formatCredentialOutput(fields) {
103
+ const lines = [];
104
+ for (const key of ["protocol", "host", "path", "username", "password"]) {
105
+ if (fields[key] !== undefined && fields[key] !== null) lines.push(`${key}=${fields[key]}`);
106
+ }
107
+ return `${lines.join("\n")}\n`;
108
+ }
109
+
110
+ /** A filesystem-safe, collision-resistant cache key for one (tenant, tag) —
111
+ * hashed (not the raw tenant string) so an unusual tenant name can never
112
+ * escape `~/.tot/git-credentials/` or collide across tags. Pure. */
113
+ export function credentialCacheKey(tenant, tag) {
114
+ return createHash("sha256").update(`${tenant}|${tag}`).digest("hex").slice(0, 32);
115
+ }
116
+
117
+ /** Absolute path to one (tenant, tag)'s cached credential — same `~/.tot`
118
+ * root (and `TOT_HOME` override) as the OAuth session cache. */
119
+ export function credentialCachePath(tenant, tag, env = process.env) {
120
+ const home = env.TOT_HOME || homedir();
121
+ return join(home, ".tot", "git-credentials", `${credentialCacheKey(tenant, tag)}.json`);
122
+ }
123
+
124
+ /** Is a cached credential still trusted? A cache with no `mintedAt` is
125
+ * treated as stale (mint fresh rather than trust an unknown age). Pure. */
126
+ export function isFreshCredential(cred, { now = Date.now(), ttlMs = CREDENTIAL_TTL_MS } = {}) {
127
+ return Boolean(cred && cred.username && cred.password && cred.mintedAt && now - cred.mintedAt < ttlMs);
128
+ }
129
+
130
+ /** Read a cached credential (reusing token-store.mjs's generic reader), or
131
+ * null if absent/unreadable/malformed/stale. Never throws. */
132
+ export function readCachedCredential(filePath, opts = {}) {
133
+ const cred = readCredentials(filePath);
134
+ return isFreshCredential(cred, opts) ? cred : null;
135
+ }
136
+
137
+ /** Cache a freshly-minted credential — atomic write, owner-only permissions
138
+ * (0600 in a 0700 dir, via token-store.mjs's writer): this file holds a LIVE
139
+ * forge push token, same security bar as the OAuth session cache. */
140
+ export function writeCachedCredential(filePath, { username, password }, { now = Date.now() } = {}) {
141
+ writeCredentials(filePath, { username, password, mintedAt: now });
142
+ }
143
+
144
+ /**
145
+ * Self-heal a LEGACY checkout: if `origin`'s remote still carries an
146
+ * embedded token (the pre-u10 `tot clone` shape, or one predating `tot
147
+ * login` entirely), strip it — rewriting the remote to the tokenless public
148
+ * URL — and install the credential helper so future git operations mint
149
+ * fresh creds through `tot` instead of relying on a token that silently
150
+ * expires. Meant to be called at the top of every command that touches git,
151
+ * best-effort (the caller decides how to handle a thrown error — this never
152
+ * blocks the actual command on a migration hiccup). A no-op on an
153
+ * already-migrated, tokenless, or non-http(s) (e.g. ssh) remote.
154
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
155
+ * @returns {{ migrated: boolean }}
156
+ */
157
+ export function ensureTokenlessRemote(git) {
158
+ let remote;
159
+ try {
160
+ remote = git(["remote", "get-url", "origin"]).trim();
161
+ } catch {
162
+ return { migrated: false }; // no `origin` (or not a git repo) — nothing to migrate
163
+ }
164
+ let migrated = false;
165
+ try {
166
+ const u = new URL(remote);
167
+ if (u.password) {
168
+ git(["remote", "set-url", "origin", `${u.protocol}//${u.host}${u.pathname}`]);
169
+ migrated = true;
170
+ }
171
+ } catch {
172
+ return { migrated }; // not a parseable URL (e.g. an ssh remote) — leave it alone entirely
173
+ }
174
+ let helper = "";
175
+ try {
176
+ helper = git(["config", "--local", "--get", "credential.helper"]).trim();
177
+ } catch {
178
+ /* unset — falls through to configuring it below */
179
+ }
180
+ if (helper !== CREDENTIAL_HELPER) {
181
+ git(["config", "--local", "credential.helper", CREDENTIAL_HELPER]);
182
+ migrated = true;
183
+ }
184
+ return { migrated };
185
+ }
package/src/mcp.mjs CHANGED
@@ -142,7 +142,12 @@ export function createMcpClient(baseUrl, opts = {}) {
142
142
  return parsed?.result;
143
143
  }
144
144
 
145
- /** Call an MCP tool and unwrap its structured / text result to a plain object. */
145
+ /**
146
+ * Call an MCP tool and unwrap its structured / text result to a plain object.
147
+ * @param {string} name
148
+ * @param {unknown} [args]
149
+ * @returns {Promise<any>}
150
+ */
146
151
  async function callTool(name, args) {
147
152
  const r = await callRaw("tools/call", { name, arguments: args });
148
153
  if (r?.structuredContent) return r.structuredContent;
package/src/oauth.mjs CHANGED
@@ -188,7 +188,7 @@ export function startLoopbackListener({ host = "127.0.0.1" } = {}) {
188
188
  let settle, reject;
189
189
  const callback = new Promise((res, rej) => { settle = res; reject = rej; });
190
190
  const server = http.createServer((req, res) => {
191
- const u = new URL(req.url, `http://${host}`);
191
+ const u = new URL(req.url ?? "/", `http://${host}`);
192
192
  if (u.pathname !== "/callback") {
193
193
  res.writeHead(404, { "Content-Type": "text/plain" });
194
194
  res.end("not found");
@@ -204,12 +204,12 @@ export function startLoopbackListener({ host = "127.0.0.1" } = {}) {
204
204
  settle({ code: u.searchParams.get("code"), state: u.searchParams.get("state") });
205
205
  }
206
206
  });
207
- const listening = new Promise((res, rej) => {
207
+ const listening = /** @type {Promise<void>} */ (new Promise((res, rej) => {
208
208
  server.once("error", rej);
209
209
  server.listen(0, host, () => res());
210
- });
210
+ }));
211
211
  return {
212
- async ready() { await listening; return server.address().port; },
212
+ async ready() { await listening; return /** @type {import("net").AddressInfo} */ (server.address()).port; },
213
213
  waitForCallback() { return callback; },
214
214
  close() { try { server.close(); } catch { /* already closed */ } },
215
215
  };
@@ -247,7 +247,7 @@ export async function loginFlow({
247
247
  clientId,
248
248
  fetchImpl = fetch,
249
249
  open = openBrowser,
250
- log = () => {},
250
+ log = /** @type {(m?: string) => void} */ (() => {}),
251
251
  now = () => Date.now(),
252
252
  }) {
253
253
  const meta = await discoverMetadata(mcpUrl, fetchImpl);
@@ -364,7 +364,7 @@ export async function rendezvousLoginFlow({
364
364
  mcpUrl,
365
365
  code,
366
366
  fetchImpl = fetch,
367
- log = () => {},
367
+ log = /** @type {(m?: string) => void} */ (() => {}),
368
368
  sleep = delay,
369
369
  now = () => Date.now(),
370
370
  }) {
@@ -474,11 +474,15 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifi
474
474
  * expires), honoring the server's `interval` and the `slow_down` backoff
475
475
  * (RFC 8628 §3.5: +5s, keep polling — not a failure). Injectable `sleep`/
476
476
  * `now` so it's testable with no real waiting.
477
+ * @param {string} tokenEndpoint
478
+ * @param {{ deviceCode: any, clientId: any, codeVerifier?: any, intervalSec?: any, expiresInSec?: any }} params
479
+ * @param {typeof fetch} [fetchImpl]
480
+ * @param {{ sleep?: Function, now?: () => number }} [timing]
477
481
  * @returns {Promise<object>} the raw token response (→ credentialsFromToken)
478
482
  */
479
483
  export async function pollDeviceToken(
480
484
  tokenEndpoint,
481
- { deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
485
+ { deviceCode, clientId, codeVerifier = undefined, intervalSec, expiresInSec },
482
486
  fetchImpl = fetch,
483
487
  { sleep = delay, now = () => Date.now() } = {},
484
488
  ) {
@@ -505,7 +509,7 @@ export async function deviceLoginFlow({
505
509
  mcpUrl,
506
510
  clientId,
507
511
  fetchImpl = fetch,
508
- log = () => {},
512
+ log = /** @type {(m?: string) => void} */ (() => {}),
509
513
  sleep = delay,
510
514
  now = () => Date.now(),
511
515
  }) {
@@ -103,9 +103,9 @@ function beacon(opts, done) {
103
103
 
104
104
  /** Promise wrapper for the ESM side (src/obstacle.mjs) so a failure path can await delivery. */
105
105
  function beaconAsync(opts) {
106
- return new Promise(function (resolve) {
106
+ return /** @type {Promise<void>} */ (new Promise(function (resolve) {
107
107
  try { beacon(opts, resolve); } catch (e) { resolve(); }
108
- });
108
+ }));
109
109
  }
110
110
 
111
111
  module.exports = { parseActivityArgs: parseActivityArgs, beacon: beacon, beaconAsync: beaconAsync };
package/src/obstacle.mjs CHANGED
@@ -19,7 +19,7 @@ import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
19
19
  * Best-effort obstacle beacon for a post-login failure. No-op (silent) when no
20
20
  * bridge credential is cached — the developer signed in with a build that didn't
21
21
  * carry the activity flags, or ran a bare `tot login`.
22
- * @param {"pnpm-missing"|"install-failed"|"clone-failed"} kind
22
+ * @param {"pnpm-missing"|"install-failed"|"clone-failed"|"renderer-native-bindings-missing"} kind
23
23
  * @param {{ have?: string, need?: string, env?: NodeJS.ProcessEnv }} [opts]
24
24
  */
25
25
  export async function emitObstacle(kind, { have, need, env = process.env } = {}) {
package/src/plan.mjs CHANGED
@@ -57,11 +57,11 @@ function targetLabel({ pr, changeId }) {
57
57
  * context?: "developer"|"operator",
58
58
  * pinnedSha?: string|null,
59
59
  * artifactDigest?: string|null,
60
- * includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>,
60
+ * includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
61
61
  * rollbackTarget?: { receiptId: string, aggregateSha: string } | null,
62
62
  * paywall?: { allowed: boolean, message?: string|null } | null,
63
- * refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }>,
64
- * bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>,
63
+ * refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }>|null,
64
+ * bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
65
65
  * bypassedPreviewSha?: string|null,
66
66
  * }} params
67
67
  * @returns {string[]} plan lines (no leading/trailing blank line)
package/src/sample.mjs CHANGED
@@ -94,7 +94,7 @@ export function pickNvmrcVersion(env = process.env) {
94
94
  const best = readdirSync(root)
95
95
  .map((name) => /^v(\d+)\.(\d+)\.(\d+)$/.exec(name))
96
96
  .filter((m) => m && nodeMeetsFloor(m.slice(1).join(".")))
97
- .map((m) => m.slice(1).map(Number))
97
+ .map((m) => /** @type {RegExpExecArray} */ (m).slice(1).map(Number))
98
98
  .sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2])[0];
99
99
  if (best) return best.join(".");
100
100
  } catch {
@@ -177,7 +177,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
177
177
  const err = new Error(
178
178
  `${dir} isn't empty and isn't a sample checkout — scaffold into an empty directory (or pass a new --workspace)`,
179
179
  );
180
- err.code = "ENOTEMPTY_SAMPLE";
180
+ /** @type {any} */ (err).code ="ENOTEMPTY_SAMPLE";
181
181
  throw err;
182
182
  }
183
183
 
@@ -187,7 +187,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
187
187
  "the sample store isn't available in this release yet — it's coming soon. " +
188
188
  "To build a real store now: `tot login --code <invite>` then `tot start`.",
189
189
  );
190
- err.code = "SAMPLE_UNAVAILABLE";
190
+ /** @type {any} */ (err).code ="SAMPLE_UNAVAILABLE";
191
191
  throw err;
192
192
  }
193
193
 
package/src/validate.mjs CHANGED
@@ -27,6 +27,38 @@ function mk(level, rule, file, message, fix) {
27
27
  return { level, rule, file, message, fix };
28
28
  }
29
29
 
30
+ // --- git conflict markers ----------------------------------------------------
31
+ // A half-resolved merge/rebase can commit literal conflict markers into content
32
+ // (the incident: `<<<<<<<`/`=======`/`>>>>>>>` in content/home.html slipped past
33
+ // preview as "validated"). These are the default and diff3 marker lines, anchored
34
+ // at line start and exactly 7 chars with a trailing boundary — precise enough that
35
+ // real content never matches. `=======` / `|||||||` ALONE are NOT flagged (a lone
36
+ // `=======` is a common markdown/prose horizontal rule); only the START (`<<<<<<<`)
37
+ // and END (`>>>>>>>`) markers trigger — either one is a near-certain conflict, so
38
+ // we err false-negative-averse and flag on either.
39
+ const CONFLICT_START = /^<{7}(?=[ \t]|$)/;
40
+ const CONFLICT_END = /^>{7}(?=[ \t]|$)/;
41
+
42
+ /**
43
+ * Line numbers (1-based) of git conflict markers in `content`. Empty ⇒ none.
44
+ * Pure — exported for focused testing.
45
+ * @param {string} content
46
+ * @returns {number[]}
47
+ */
48
+ export function detectConflictMarkers(content) {
49
+ if (typeof content !== "string" || (!content.includes("<<<<<<<") && !content.includes(">>>>>>>"))) return [];
50
+ const lines = content.split(/\r?\n/);
51
+ const hits = [];
52
+ for (let i = 0; i < lines.length; i++) {
53
+ if (CONFLICT_START.test(lines[i]) || CONFLICT_END.test(lines[i])) hits.push(i + 1);
54
+ }
55
+ return hits;
56
+ }
57
+
58
+ // Text artifacts a conflict marker can hide in (images/fonts live in public/, not scanned).
59
+ const CONFLICT_SCAN_EXT = new Set([".html", ".htm", ".json", ".md", ".txt", ".css", ".js", ".mjs", ".svg", ".xml"]);
60
+ const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])[0].toLowerCase());
61
+
30
62
  // --- canonical `.tot/config.json` shape (the #24/#25 regression guard) --------
31
63
  const KNOWN_KINDS = new Set(["file", "tree"]);
32
64
  const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
@@ -539,6 +571,26 @@ export function validateTenant(tenantDir, opts = {}) {
539
571
  }
540
572
  }
541
573
 
574
+ // 5. git conflict markers — advisory (never blocks), but LOUD: a half-resolved
575
+ // merge/rebase must not slip past as "validated". Scans text artifacts under
576
+ // content/ plus the root config files.
577
+ const conflictScanFiles = [
578
+ ...walk(contentDir, hasScanExt),
579
+ ...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
580
+ .map((f) => join(tenantDir, f))
581
+ .filter((p) => existsSync(p)),
582
+ ];
583
+ for (const p of conflictScanFiles) {
584
+ const lines = detectConflictMarkers(readFileSync(p, "utf8"));
585
+ if (lines.length) {
586
+ findings.push(
587
+ mk(WARN, "git-conflict-markers", rel(p),
588
+ `git conflict markers at line(s) ${lines.join(", ")} — looks like an unfinished merge/rebase (the page would still build/serve broken)`,
589
+ "resolve the conflict and remove the <<<<<<< / ======= / >>>>>>> lines before submitting"),
590
+ );
591
+ }
592
+ }
593
+
542
594
  const ok = !findings.some((f) => f.level === ERROR);
543
595
  return { ok, findings };
544
596
  }
@@ -555,6 +607,10 @@ function buildPageTargetSet(contentDir, pagesDir) {
555
607
  return set;
556
608
  }
557
609
 
610
+ /**
611
+ * @param {any} href @param {any} file @param {any} scope @param {any} pageTargets
612
+ * @param {(path: string) => boolean} [ownsPlatformRoute]
613
+ */
558
614
  function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = () => false) {
559
615
  const out = [];
560
616
  if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Viewer-session transport for the ship surface — the NO-operator-secret path.
3
+ *
4
+ * An invited developer holds a `tot login` MCP session but no operator secret and no
5
+ * storefront cookie, so the old ship-surface transport dead-ended on them. This mints
6
+ * a storefront VIEWER session from the developer's OWN MCP token (POST
7
+ * /api/dev/cli-session on the tenant's own host, which resolves WHO the opaque token
8
+ * is via the MCP and hands back a `tot_session` cookie), then hands the caller a
9
+ * cookie-based transport pointed at the tenant host. Authorization is still the
10
+ * developer's live `ship-on-behalf` grant, enforced server-side at the ship route —
11
+ * this only carries their identity, it grants nothing.
12
+ *
13
+ * Minted FRESH per call (no disk cache): a `tot accept` is interactive + infrequent,
14
+ * and minting-per-call means a revoked session/grant is never honored past its life.
15
+ */
16
+ import { resolveDeveloperSession, AuthUnavailableError } from "./auth.mjs";
17
+
18
+ const SESSION_COOKIE = "tot_session";
19
+
20
+ /** The tenant's own storefront host — the dev-viewer admission derives the tenant
21
+ * from the request host, so the session + integrate MUST target it (not the generic
22
+ * storefront origin + X-Tot-Owner, which only steers the operator-secret path). */
23
+ function tenantBase(tenant) {
24
+ return `https://${String(tenant || "").trim().toLowerCase()}`;
25
+ }
26
+
27
+ /** Pull `tot_session=<id>` out of a (possibly comma-folded) Set-Cookie header. The
28
+ * id is base64url — no comma/semicolon — so a non-greedy stop-set is unambiguous. */
29
+ export function parseSessionCookie(setCookie) {
30
+ if (!setCookie) return null;
31
+ const m = new RegExp(`${SESSION_COOKIE}=([^;,\\s]+)`).exec(setCookie);
32
+ return m ? m[1] : null;
33
+ }
34
+
35
+ /**
36
+ * Resolve a viewer-session transport for `tenant`. Returns
37
+ * { ok:true, base, authHeaders } — base = tenant host; cookie transport
38
+ * { ok:false, message, hint } — a clean, actionable refusal
39
+ * Never throws.
40
+ *
41
+ * @param {{ tenant:string, env?:NodeJS.ProcessEnv, fetchImpl?:typeof fetch,
42
+ * resolveDev?:typeof resolveDeveloperSession }} params
43
+ * @returns {Promise<
44
+ * { ok:true, base:string, authHeaders:Record<string,string> } |
45
+ * { ok:false, message:string, hint:string }
46
+ * >}
47
+ */
48
+ export async function resolveViewerTransport({
49
+ tenant,
50
+ env = process.env,
51
+ fetchImpl = fetch,
52
+ resolveDev = resolveDeveloperSession,
53
+ }) {
54
+ // The developer's OWN MCP token (read + silently refreshed by the resolver). No
55
+ // client needed — resolveDeveloperSession tolerates a null client.
56
+ let dev;
57
+ try {
58
+ dev = await resolveDev(null, env, { fetchImpl });
59
+ } catch (e) {
60
+ if (e instanceof AuthUnavailableError) {
61
+ return { ok: false, message: e.message, hint: e.hint || "run `tot login`, then re-run." };
62
+ }
63
+ return {
64
+ ok: false,
65
+ message: `couldn't read your Token of Trust session: ${e?.message || e}`,
66
+ hint: "run `tot login`, then re-run.",
67
+ };
68
+ }
69
+
70
+ const base = tenantBase(tenant);
71
+ let res;
72
+ try {
73
+ res = await fetchImpl(`${base}/api/dev/cli-session`, {
74
+ method: "POST",
75
+ headers: { authorization: `Bearer ${dev.token}` },
76
+ });
77
+ } catch (e) {
78
+ return {
79
+ ok: false,
80
+ message: `couldn't reach ${base} to start a session: ${e?.message || e}`,
81
+ hint: "check the --tenant domain / your network, then re-run.",
82
+ };
83
+ }
84
+ if (res.status === 401) {
85
+ return {
86
+ ok: false,
87
+ message: "your `tot` session wasn't recognized for this store.",
88
+ hint: "run `tot login`, then re-run.",
89
+ };
90
+ }
91
+ if (!res.ok) {
92
+ return {
93
+ ok: false,
94
+ message: `couldn't start a session at ${base} (HTTP ${res.status}).`,
95
+ hint: "retry shortly; if it persists, this store may not be set up for CLI publishing yet.",
96
+ };
97
+ }
98
+
99
+ const cookie = parseSessionCookie(res.headers.get("set-cookie"));
100
+ if (!cookie) {
101
+ return {
102
+ ok: false,
103
+ message: "the store started a session but returned no session cookie.",
104
+ hint: "re-run; if it persists, report it via `tot` feedback.",
105
+ };
106
+ }
107
+
108
+ return {
109
+ ok: true,
110
+ base,
111
+ authHeaders: {
112
+ cookie: `${SESSION_COOKIE}=${cookie}`,
113
+ // Matches the admin browser client; the server IGNORES this hint and re-reads
114
+ // the live ship-on-behalf grant, so it authorizes nothing on its own.
115
+ "x-tot-capability": "ship-on-behalf",
116
+ },
117
+ };
118
+ }