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

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/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
+ }