@tokenoftrust/cli 2.0.1 → 2.0.3

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.0.1",
3
+ "version": "2.0.3",
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",
@@ -526,7 +526,7 @@ export async function runIntegrate(
526
526
  "x-tot-capability": "ship-on-behalf",
527
527
  };
528
528
  } else {
529
- const viewer = await resolveViewer({ tenant, env, fetchImpl });
529
+ const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
530
530
  if (!viewer.ok) {
531
531
  console.error(fail(viewer.message, viewer.hint));
532
532
  return 2;
@@ -18,7 +18,8 @@
18
18
  * generic storefront origin (from PREVIEW_RECONCILE_SECRET / GRANTS_ADMIN_SECRET
19
19
  * / TOT_OPERATOR_SECRET, or `--secret`).
20
20
  * - VIEWER SESSION (an invited developer with no secret): mint a `tot_session`
21
- * from their own `tot login` on the tenant's own host (`resolveViewerTransport`).
21
+ * from their own `tot login`, routed through the shared storefront origin under
22
+ * the tenant's path prefix (`resolveViewerTransport`).
22
23
  * A caller who cannot see the accept queue cannot get a diagnosis of it — an auth
23
24
  * refusal (401/403) is relayed as a clean house-style failure, not a wrapped verdict.
24
25
  *
@@ -126,7 +127,10 @@ export async function runDoctor(
126
127
  "x-tot-capability": "ship-on-behalf",
127
128
  };
128
129
  } else {
129
- const viewer = await resolveViewer({ tenant, env, fetchImpl });
130
+ // `--url` steers BOTH transports. It previously reached only the operator-secret
131
+ // branch, so a developer diagnosing their own tenant had no way to redirect the
132
+ // command and the flag read as ignored.
133
+ const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
130
134
  if (!viewer.ok) {
131
135
  console.error(fail(viewer.message, viewer.hint));
132
136
  return 2;
@@ -52,7 +52,7 @@ import { checkoutTenant } from "./clone.mjs";
52
52
  // born-rebased submit rebuilds a candidate onto the current base with the SAME
53
53
  // engine `tot accept --refresh` uses, so the two read its result identically).
54
54
  import { normalizeRefreshResult } from "./accept.mjs";
55
- import { validateTenant, ERROR } from "../validate.mjs";
55
+ import { validateTenant, ERROR, printLoudAdvisories } from "../validate.mjs";
56
56
  import { openBrowser } from "../open.mjs";
57
57
  import { startProgress } from "../progress.mjs";
58
58
  import { fail } from "../errors.mjs";
@@ -1357,15 +1357,14 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1357
1357
  // 1. validate locally — refuse on errors.
1358
1358
  if (!args.skipValidate) {
1359
1359
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
1360
- // Advisory but LOUD: git conflict markers must never slip past as "validated"
1361
- // (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
1362
- // are otherwise swallowed here but they never block the submit.
1363
- const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
1364
- if (conflicts.length) {
1365
- console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) an unfinished merge/rebase?`);
1366
- for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
1367
- console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
1368
- }
1360
+ // Advisory but LOUD (LOUD_ADVISORY_RULES, ../validate.mjs shared with `tot
1361
+ // validate` so the callout reads identically from either command). Warnings
1362
+ // are otherwise swallowed on the ok path, which is wrong for defects that SHIP
1363
+ // SILENTLY: the preview builds, the console is clean, reconcile and compliance
1364
+ // pass, and the flaw only surfaces to whoever reads the rendered page. Those
1365
+ // get surfaced here never blocking, since none of them makes the candidate
1366
+ // unservable.
1367
+ printLoudAdvisories(findings);
1369
1368
  if (!ok) {
1370
1369
  const errs = findings.filter((f) => f.level === ERROR);
1371
1370
  console.error(
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { existsSync } from "node:fs";
17
17
  import { join, resolve } from "node:path";
18
- import { validateTenant, ERROR, WARN } from "../validate.mjs";
18
+ import { validateTenant, ERROR, WARN, printLoudAdvisories } from "../validate.mjs";
19
19
  import { fail } from "../errors.mjs";
20
20
  import { tenantDirSegments } from "../tenant-dirs.mjs";
21
21
 
@@ -94,6 +94,12 @@ export function run(argv, ctx) {
94
94
 
95
95
  const errors = findings.filter((f) => f.level === ERROR);
96
96
  const warns = findings.filter((f) => f.level === WARN);
97
+ // Named, grouped callouts for the advisories that ship a page looking healthy
98
+ // while quietly losing something — printed FIRST so the one step worth acting
99
+ // on (assign stable tracking ids) reads as a named step, not a warning buried
100
+ // among the full findings dump below. Same table `tot submit`/`tot preview`
101
+ // print, so the nudge reads identically wherever it's seen.
102
+ printLoudAdvisories(findings);
97
103
  // Never present the checkout PATH as if it were the tenant name — when the
98
104
  // tenant couldn't be resolved (e.g. an invalid/missing .tot/config.json),
99
105
  // the findings below say why; the header should say so too, not disguise
package/src/validate.mjs CHANGED
@@ -144,6 +144,25 @@ function validateRawHtmlBody(html) {
144
144
  // test: packages/public-runtime/tests/chrome-config-shape-parity.test.ts.
145
145
  const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
146
146
  const CHROME_FOOTER_VARIANTS = new Set(["default"]);
147
+ /**
148
+ * The declarative-interaction-tracking rule, mirrored: every actionable item —
149
+ * an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty
150
+ * `action` (its stable intent) or `tracking: "none"` (an explicit opt-out).
151
+ * Declaring neither or both is a violation, and nothing is derived from `id`.
152
+ * Contract: docs/architecture/interaction-tracking-attribute-contract.md
153
+ */
154
+ function chromeTrackingDeclared(value) {
155
+ if (Array.isArray(value)) return value.every(chromeTrackingDeclared);
156
+ if (value == null || typeof value !== "object") return true;
157
+ if (typeof value.id === "string" && typeof value.href === "string") {
158
+ const hasAction = typeof value.action === "string" && value.action.trim() !== "";
159
+ const optedOut = value.tracking === "none";
160
+ if (hasAction === optedOut) return false;
161
+ if (value.tracking !== undefined && !optedOut) return false;
162
+ }
163
+ return Object.values(value).every(chromeTrackingDeclared);
164
+ }
165
+
147
166
  export function looksLikeChromeConfig(value) {
148
167
  if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
149
168
  const header = value.header;
@@ -156,6 +175,7 @@ export function looksLikeChromeConfig(value) {
156
175
  if (footer == null || typeof footer !== "object" || Array.isArray(footer)) return false;
157
176
  if (!CHROME_FOOTER_VARIANTS.has(footer.variant)) return false;
158
177
  if (!Array.isArray(footer.columns)) return false;
178
+ if (!chromeTrackingDeclared(value)) return false;
159
179
  return true;
160
180
  }
161
181
 
@@ -428,6 +448,278 @@ function validateHomeDoc(doc, file) {
428
448
  return out;
429
449
  }
430
450
 
451
+ // --- CTA / actionable-identity advisory rules (unit dt-validator-cta-rules) -
452
+ // Three ADVISORY warnings (never block) that a schema pass alone doesn't
453
+ // reach. `validateChromeConfig` (@tot/public-runtime chrome.ts) already
454
+ // HARD-FAILS the governed chrome.json path on the either/or action/
455
+ // tracking:"none" rule (interaction-tracking.ts validateTracking) — this
456
+ // validator does not re-run that schema (deep per-item errors are the
457
+ // write-path's job, per the looksLikeChromeConfig comment above). These three
458
+ // catch what the schema pass doesn't:
459
+ // - `duplicate-cta` — a copy-paste mistake that still PASSES schema (two
460
+ // actionable items sharing one governed identity).
461
+ // - `cta-missing-id` — tenant-authored raw HTML fragments carry NO
462
+ // `data-tot-*` at all pre-derivation (see docs/architecture/interaction-
463
+ // tracking-attribute-contract.md "Deriving data-tot-el") — there is no
464
+ // schema here for validateChromeConfig to fail.
465
+ // - `cta-id-drift` — `data-tot-el` is a PERMANENT identity per
466
+ // dt-contract-v1's decision ledger ("a change here is DRIFT and is a
467
+ // defect"); this one needs a PRIOR version to compare against, which a
468
+ // single-snapshot validateTenant() run doesn't have on its own — see
469
+ // `opts.previousChromeJson`, below.
470
+ // Kept in step with scripts/tenant/validate.mjs's identical copy of this block.
471
+
472
+ // --- "assign stable tracking ids" — the one named, actionable step (unit
473
+ // dt-assign-ids-step). The three rules above are DETECTION; this is the single
474
+ // place every touchpoint (`tot validate`, `tot submit`/`tot preview`, and the
475
+ // `tot dev` save-loop nudge — scripts/dev/checkout-watch.mjs) points an author
476
+ // at the SAME fix in the SAME words, so it reads as one coherent nudge no
477
+ // matter which command surfaced it, never a fresh, differently-worded warning
478
+ // each time. The mechanism already exists (derivation: dt-id-derivation;
479
+ // writeback: dt-id-writeback, apps/storefront/src/pages/api/admin/tracking-
480
+ // writeback.ts) — this names the step and points at it, it invents nothing.
481
+ export const TRACKING_ID_STEP_NAME = "assign stable tracking ids";
482
+ export const CTA_MISSING_ID_FIX =
483
+ "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.";
484
+
485
+ /**
486
+ * The advisory rules LOUD enough to call out by NAME at `tot validate` / `tot
487
+ * submit` / `tot preview` — none of them block, but each ships a page that
488
+ * looks perfectly healthy (clean build, every other check green) while
489
+ * quietly losing something. `[rule, headline, what it costs if ignored]`.
490
+ * ONE place so every surface prints identical wording (see the block comment
491
+ * above); `printLoudAdvisories` is the shared renderer.
492
+ */
493
+ export const LOUD_ADVISORY_RULES = [
494
+ [
495
+ "git-conflict-markers",
496
+ "git conflict markers in submitted content — an unfinished merge/rebase?",
497
+ "The preview will still build, but it will serve the broken markers. Resolve before shipping.",
498
+ ],
499
+ [
500
+ "duplicate-skip-link",
501
+ "duplicate skip link — your chrome already supplies one",
502
+ "The page renders fine and every automated check passes; a screen-reader user hears it twice.",
503
+ ],
504
+ [
505
+ "duplicate-cta",
506
+ "duplicate CTA identity — two actionable items share one data-tot-el",
507
+ "Both elements still work, but the click listener and every downstream analytics query treat them as ONE interaction — you lose the ability to tell them apart.",
508
+ ],
509
+ [
510
+ "cta-missing-id",
511
+ `actionable element with no governed identity (data-tot-el) — needs to ${TRACKING_ID_STEP_NAME}`,
512
+ `It renders and works fine, but it's invisible to interaction tracking — nothing about it is ever recorded. ${CTA_MISSING_ID_FIX}`,
513
+ ],
514
+ [
515
+ "cta-id-drift",
516
+ "a previously-declared data-tot-el changed value",
517
+ "The page still works, but everything already recorded under the old id is now orphaned — this identity is supposed to be permanent.",
518
+ ],
519
+ ];
520
+
521
+ /**
522
+ * Print the LOUD-but-advisory findings as named, grouped callouts — one block
523
+ * per rule with a headline, every hit, and the consequence of ignoring it —
524
+ * so the step worth acting on (assign stable tracking ids) reads as a NAMED
525
+ * step, not a warning buried among dozens. Shared by `tot validate` and `tot
526
+ * submit`/`tot preview` so a developer sees the identical callout regardless
527
+ * of which command surfaced it. `log` defaults to `console.error` (this CLI's
528
+ * existing convention for advisory noise); injectable for tests.
529
+ * @param {Finding[]} findings
530
+ * @param {{ log?: (s: string) => void }} [opts]
531
+ */
532
+ export function printLoudAdvisories(findings, { log = (s) => console.error(s) } = {}) {
533
+ for (const [rule, headline, consequence] of LOUD_ADVISORY_RULES) {
534
+ const hits = findings.filter((f) => f.rule === rule);
535
+ if (!hits.length) continue;
536
+ log(`\n⚠ ${headline} (${hits.length} finding(s))`);
537
+ for (const f of hits) log(` ⚠ ${f.file} — ${f.message}`);
538
+ log(` ${consequence}\n`);
539
+ }
540
+ }
541
+
542
+ /** Every declared actionable `id` in a ChromeConfig, with a path for
543
+ * messages — the SAME field rawChrome.ts / SiteHeader.astro stamp verbatim as
544
+ * `data-tot-el` (ctaIdentityAttr for header.ctas; the direct `id` field on
545
+ * every other actionable shape — see @tot/public-runtime chrome.ts's
546
+ * ChromeLink/ChromeNavItem/ChromeCta/ChromeActionLink), so a collision here
547
+ * is a collision of GOVERNED IDENTITY, not just of an authoring label. Walks
548
+ * the same shapes validateChromeConfig itself walks. Pure.
549
+ */
550
+ function collectChromeActionableIds(config) {
551
+ const out = [];
552
+ const push = (id, path) => {
553
+ if (typeof id === "string" && id.trim()) out.push({ id: id.trim(), path });
554
+ };
555
+ const header = config?.header;
556
+ if (header && typeof header === "object" && !Array.isArray(header)) {
557
+ for (const [i, n] of (Array.isArray(header.nav) ? header.nav : []).entries()) {
558
+ if (n == null || typeof n !== "object") continue;
559
+ push(n.id, `header.nav[${i}]`);
560
+ for (const [j, c] of (Array.isArray(n.children) ? n.children : []).entries()) {
561
+ if (c && typeof c === "object") push(c.id, `header.nav[${i}].children[${j}]`);
562
+ }
563
+ for (const [j, col] of (Array.isArray(n.columns) ? n.columns : []).entries()) {
564
+ if (col == null || typeof col !== "object") continue;
565
+ for (const [k, l] of (Array.isArray(col.links) ? col.links : []).entries()) {
566
+ if (l && typeof l === "object") push(l.id, `header.nav[${i}].columns[${j}].links[${k}]`);
567
+ }
568
+ }
569
+ }
570
+ for (const [i, c] of (Array.isArray(header.ctas) ? header.ctas : []).entries()) {
571
+ if (c && typeof c === "object") push(c.id, `header.ctas[${i}]`);
572
+ }
573
+ if (header.memberCue && typeof header.memberCue === "object") push(header.memberCue.id, "header.memberCue");
574
+ const u = header.utilityNav;
575
+ if (u && typeof u === "object" && !Array.isArray(u)) {
576
+ for (const [i, l] of (Array.isArray(u.links) ? u.links : []).entries()) {
577
+ if (l && typeof l === "object") push(l.id, `header.utilityNav.links[${i}]`);
578
+ }
579
+ if (u.memberCue && typeof u.memberCue === "object") push(u.memberCue.id, "header.utilityNav.memberCue");
580
+ }
581
+ }
582
+ const footer = config?.footer;
583
+ if (footer && typeof footer === "object" && !Array.isArray(footer)) {
584
+ for (const [i, col] of (Array.isArray(footer.columns) ? footer.columns : []).entries()) {
585
+ if (col == null || typeof col !== "object") continue;
586
+ for (const [j, l] of (Array.isArray(col.links) ? col.links : []).entries()) {
587
+ if (l && typeof l === "object") push(l.id, `footer.columns[${i}].links[${j}]`);
588
+ }
589
+ }
590
+ }
591
+ return out;
592
+ }
593
+
594
+ /** `duplicate-cta`: two or more actionable items in one ChromeConfig resolve
595
+ * to the SAME governed identity. Almost always a copy-paste authoring
596
+ * mistake — the click listener's `closest("[data-tot-el]")` match and every
597
+ * downstream analytics query assume one element per id, so a duplicate
598
+ * silently merges two distinct interactions' data. WARN, never blocks: the
599
+ * page still renders and both elements still work.
600
+ */
601
+ function findDuplicateCtaIds(config, file) {
602
+ const byId = new Map();
603
+ for (const { id, path } of collectChromeActionableIds(config)) {
604
+ if (!byId.has(id)) byId.set(id, []);
605
+ byId.get(id).push(path);
606
+ }
607
+ const out = [];
608
+ for (const [id, paths] of byId) {
609
+ if (paths.length > 1) {
610
+ out.push(
611
+ mk(WARN, "duplicate-cta", file,
612
+ `id "${id}" is declared on ${paths.length} actionable items (${paths.join(", ")}) — data-tot-el must be unique per element; the click listener's closest() match and downstream analytics both assume one`,
613
+ "give every occurrence but one a distinct id"),
614
+ );
615
+ }
616
+ }
617
+ return out;
618
+ }
619
+
620
+ /**
621
+ * `cta-id-drift`: a governed actionable item's `data-tot-el` changed value
622
+ * between two versions of the SAME chrome.json, matched by structural
623
+ * position (e.g. "header.ctas[0]") — the same slot in both versions. Per
624
+ * dt-contract-v1's ledger `data-tot-el` is a PERMANENT identity ("a change
625
+ * here is DRIFT and is a defect"), unlike `data-tot-placement` which is
626
+ * expected to move on redesign.
627
+ *
628
+ * `previous` is an OPTIONAL caller-supplied prior chrome.json (already
629
+ * parsed) — this static, single-snapshot validator has no version history of
630
+ * its own, and no existing publish/preview utility in this repo diffs the
631
+ * CTA IDENTITY LIST across versions (apps/storefront/src/lib/publish and
632
+ * .../preview diff release ARTIFACT BYTES for the release panel, never this)
633
+ * to hook into instead. A version-aware caller (a future publish-time check,
634
+ * `tot validate --against <ref>`) supplies `previous`; this run alone never
635
+ * invents one, so omitting it is a silent no-op, not a missing check.
636
+ *
637
+ * A reorder/insert that shifts array positions can produce a false
638
+ * positive/negative here (position, not the item's own identity, is the
639
+ * match key) — a known limitation of matching without a stronger anchor.
640
+ */
641
+ function findCtaIdDrift(previous, current, file) {
642
+ if (previous == null || typeof previous !== "object") return [];
643
+ const before = new Map(collectChromeActionableIds(previous).map((e) => [e.path, e.id]));
644
+ const out = [];
645
+ for (const { id, path } of collectChromeActionableIds(current)) {
646
+ const priorId = before.get(path);
647
+ if (priorId !== undefined && priorId !== id) {
648
+ out.push(
649
+ mk(WARN, "cta-id-drift", file,
650
+ `${path}.id changed from "${priorId}" to "${id}" — data-tot-el is a permanent identity; a change here is drift, not a redesign, and breaks continuity with everything already recorded under the old id`,
651
+ "keep the original id (a deliberate identity migration is a separate, tracked decision, not an incidental edit)"),
652
+ );
653
+ }
654
+ }
655
+ return out;
656
+ }
657
+
658
+ // Compact mirror of @tot/public-runtime tracking-id-derivation.ts's trackable-
659
+ // tag set and `isWellFormedTrackingId` shape check — this script can't import
660
+ // that TS package at runtime (same published-CLI/no-build-step constraint as
661
+ // the chrome-shape mirrors above). Deliberately simplified: full structural
662
+ // derivation (region path, confidence) needs a real parser and an ancestor
663
+ // stack; this is a flat regex tag scan (the same style as HREF_RE/SRC_RE
664
+ // above), so it only answers "does this element carry a well-formed
665
+ // data-tot-el at all", not "what would a good one look like".
666
+ const ACTIONABLE_TAG_RE = /<([a-z][a-z0-9-]*)\b([^>]*)>/gi;
667
+ const TRACKABLE_HTML_TAGS = new Set(["a", "area", "button", "details", "summary", "form", "input", "select", "textarea", "label"]);
668
+ const TRACKABLE_HTML_ROLES = new Set(["button", "link", "tab", "menuitem", "switch", "checkbox"]);
669
+
670
+ function htmlAttr(attrs, name) {
671
+ const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i").exec(attrs);
672
+ return m ? m[1] : undefined;
673
+ }
674
+
675
+ /** Mirror of tracking-id-derivation.ts's `isTrackableNode`: an `<a>`/`<area>`
676
+ * needs a real `href`; the fixed tag set is always a candidate; anything
677
+ * else needs an interactive ARIA role. */
678
+ function isTrackableHtmlTag(tag, attrs) {
679
+ if (tag === "a" || tag === "area") {
680
+ const href = htmlAttr(attrs, "href");
681
+ return typeof href === "string" && href.trim().length > 0;
682
+ }
683
+ if (TRACKABLE_HTML_TAGS.has(tag)) return true;
684
+ const role = htmlAttr(attrs, "role");
685
+ return typeof role === "string" && TRACKABLE_HTML_ROLES.has(role.trim().toLowerCase());
686
+ }
687
+
688
+ /** Mirror of tracking-id-derivation.ts's `isWellFormedTrackingId`: lowercase
689
+ * kebab tokens, non-empty, within the same 64-char budget. */
690
+ function isWellFormedCtaId(el) {
691
+ return typeof el === "string" && el.length > 0 && el.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(el);
692
+ }
693
+
694
+ /**
695
+ * `cta-missing-id`: a tenant-authored HTML fragment's actionable element
696
+ * carries no well-formed `data-tot-el`. Tenant fragments ship NO `data-tot-*`
697
+ * at all pre-derivation (docs/architecture/interaction-tracking-attribute-
698
+ * contract.md "Deriving data-tot-el") — `validateChromeConfig`'s hard
699
+ * either/or failure only reaches the SCHEMA-GOVERNED chrome.json path, never
700
+ * a raw fragment, so an un-identified CTA here would otherwise ship silently
701
+ * unmeasurable: the page renders fine, every other check passes, and the
702
+ * element is simply invisible to interaction tracking. WARN, never blocks.
703
+ */
704
+ function findMissingCtaIds(html, file) {
705
+ const out = [];
706
+ ACTIONABLE_TAG_RE.lastIndex = 0;
707
+ let m;
708
+ while ((m = ACTIONABLE_TAG_RE.exec(html))) {
709
+ const tag = m[1].toLowerCase();
710
+ const attrs = m[2] || "";
711
+ if (!isTrackableHtmlTag(tag, attrs)) continue;
712
+ const totEl = htmlAttr(attrs, "data-tot-el");
713
+ if (isWellFormedCtaId(totEl)) continue;
714
+ out.push(
715
+ mk(WARN, "cta-missing-id", file,
716
+ `a <${tag}> actionable element has ${totEl ? `a malformed data-tot-el (${JSON.stringify(totEl)})` : "no data-tot-el"} — it renders and functions but is invisible to interaction tracking`,
717
+ CTA_MISSING_ID_FIX),
718
+ );
719
+ }
720
+ return out;
721
+ }
722
+
431
723
  // --- filesystem helpers ------------------------------------------------------
432
724
  function readJsonSafe(path) {
433
725
  try {
@@ -452,6 +744,8 @@ function walk(dir, pred) {
452
744
  const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
453
745
  const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
454
746
  const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
747
+ /** An in-page skip link: an anchor to a #main-ish target, or one carrying a skip class. */
748
+ const SKIP_LINK_RE = /<a\b[^>]*(?:class="[^"]*\bskip[-\w]*\b[^"]*"|href="#(?:main|content|main-content)\b")/i;
455
749
 
456
750
  // --- platform-owned routes (framework surfaces, NOT tenant pages) ------------
457
751
  /**
@@ -515,10 +809,13 @@ function resolvePlatformRouteOwnership(config, hostIsPlatform) {
515
809
  /**
516
810
  * Full static validation of a tenant directory (content/ public/ theme.json [.tot/]).
517
811
  * @param {string} tenantDir absolute path to the tenant dir
518
- * @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace"}} [opts]
812
+ * @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace", previousChromeJson?:any}} [opts]
519
813
  * `mode` — "monorepo" (default): served by this platform, so commerce tenants own
520
814
  * the framework routes. "workspace": a standalone checkout, conservative about
521
815
  * platform-route ownership unless the config resolves the host (`hostPlatform`).
816
+ * `previousChromeJson` — an already-parsed PRIOR version of content/chrome.json,
817
+ * supplied by a version-aware caller, so the `cta-id-drift` check has something
818
+ * to compare against (see findCtaIdDrift above). Omitted ⇒ that check is a no-op.
522
819
  * @returns {{ok:boolean, findings:Finding[]}}
523
820
  */
524
821
  export function validateTenant(tenantDir, opts = {}) {
@@ -611,6 +908,11 @@ export function validateTenant(tenantDir, opts = {}) {
611
908
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
612
909
  } else if (name === "home.json") {
613
910
  findings.push(...validateHomeDoc(value, "content/home.json"));
911
+ } else if (name === "chrome.json") {
912
+ findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
913
+ if (opts.previousChromeJson != null) {
914
+ findings.push(...findCtaIdDrift(opts.previousChromeJson, value, "content/chrome.json"));
915
+ }
614
916
  }
615
917
  }
616
918
  }
@@ -643,6 +945,17 @@ export function validateTenant(tenantDir, opts = {}) {
643
945
  "make it a full <!doctype html> document, add a content/chrome.json with header.variant/header.brand.label/header.nav and footer.variant/footer.columns, or add content/chrome.html with <!--PAGE_BODY-->"),
644
946
  );
645
947
  }
948
+ // A body-only fragment is spliced INTO the tenant's chrome, and that chrome
949
+ // already supplies the skip link. A fragment that kept its own ships two — an
950
+ // a11y defect no browser reports and reconcile cannot see, so it survives every
951
+ // gate and lands in the assembled page.
952
+ if (!base && !isFullDocument(html) && hasChrome && SKIP_LINK_RE.test(html)) {
953
+ findings.push(
954
+ mk(WARN, "duplicate-skip-link", r,
955
+ "keeps its own skip link, but the tenant's chrome already supplies one — the assembled page will have two",
956
+ "delete the page-level skip link; the chrome's skipLink is the one that renders"),
957
+ );
958
+ }
646
959
  if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {
647
960
  findings.push(
648
961
  mk(WARN, "style-attrs", r,
@@ -650,6 +963,9 @@ export function validateTenant(tenantDir, opts = {}) {
650
963
  "use a bare <style> tag"),
651
964
  );
652
965
  }
966
+ // actionable elements with no (or malformed) governed identity — advisory,
967
+ // never blocking (see findMissingCtaIds above).
968
+ findings.push(...findMissingCtaIds(html, r));
653
969
 
654
970
  for (const m of html.matchAll(HREF_RE)) {
655
971
  findings.push(...checkLink(m[1].trim(), r, scope, pageTargets, platformRoutes.owns));
@@ -17,11 +17,22 @@ import { resolveDeveloperSession, AuthUnavailableError } from "./auth.mjs";
17
17
 
18
18
  const SESSION_COOKIE = "tot_session";
19
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()}`;
20
+ /** Default storefront origin serving every tenant under a path prefix. */
21
+ export const DEFAULT_STOREFRONT_ORIGIN = "https://storefront.tokenoftrust.store";
22
+
23
+ /** The tenant's storefront base on the shared worker: `<origin>/<tenant>`.
24
+ *
25
+ * The dev-viewer admission derives the tenant from the REQUEST, not from an
26
+ * X-Tot-Owner header (that only steers the operator-secret path) — but it reads
27
+ * it from the leading path segment, which the middleware strips, so the shared
28
+ * origin serves every tenant. Addressing `https://<tenant>` directly only works
29
+ * for a tenant whose apex has already cut over to the storefront; for everyone
30
+ * else that host is still their legacy site and answers 404 to every
31
+ * `/api/...` path. Routing through the shared origin works in both cases. */
32
+ /** @param {string} tenant @param {string|null} [storefrontUrl] @returns {string} */
33
+ function tenantBase(tenant, storefrontUrl = null) {
34
+ const origin = String(storefrontUrl || DEFAULT_STOREFRONT_ORIGIN).trim().replace(/\/+$/, "");
35
+ return `${origin}/${String(tenant || "").trim().toLowerCase()}`;
25
36
  }
26
37
 
27
38
  /** Pull `tot_session=<id>` out of a (possibly comma-folded) Set-Cookie header. The
@@ -38,8 +49,11 @@ export function parseSessionCookie(setCookie) {
38
49
  * { ok:false, message, hint } — a clean, actionable refusal
39
50
  * Never throws.
40
51
  *
52
+ * `storefrontUrl` overrides the storefront origin (the CLI's `--url`), so a
53
+ * developer can point the same command at a non-default deployment.
54
+ *
41
55
  * @param {{ tenant:string, env?:NodeJS.ProcessEnv, fetchImpl?:typeof fetch,
42
- * resolveDev?:typeof resolveDeveloperSession }} params
56
+ * storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession }} params
43
57
  * @returns {Promise<
44
58
  * { ok:true, base:string, authHeaders:Record<string,string> } |
45
59
  * { ok:false, message:string, hint:string }
@@ -49,6 +63,7 @@ export async function resolveViewerTransport({
49
63
  tenant,
50
64
  env = process.env,
51
65
  fetchImpl = fetch,
66
+ storefrontUrl = null,
52
67
  resolveDev = resolveDeveloperSession,
53
68
  }) {
54
69
  // The developer's OWN MCP token (read + silently refreshed by the resolver). No
@@ -67,7 +82,7 @@ export async function resolveViewerTransport({
67
82
  };
68
83
  }
69
84
 
70
- const base = tenantBase(tenant);
85
+ const base = tenantBase(tenant, storefrontUrl);
71
86
  let res;
72
87
  try {
73
88
  res = await fetchImpl(`${base}/api/dev/cli-session`, {