@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 +1 -1
- package/src/commands/accept.mjs +1 -1
- package/src/commands/preview-doctor.mjs +6 -2
- package/src/commands/submit.mjs +9 -10
- package/src/commands/validate.mjs +7 -1
- package/src/validate.mjs +317 -1
- package/src/viewer-session.mjs +22 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
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",
|
package/src/commands/accept.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/src/commands/submit.mjs
CHANGED
|
@@ -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
|
|
1361
|
-
//
|
|
1362
|
-
// are otherwise swallowed
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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));
|
package/src/viewer-session.mjs
CHANGED
|
@@ -17,11 +17,22 @@ import { resolveDeveloperSession, AuthUnavailableError } from "./auth.mjs";
|
|
|
17
17
|
|
|
18
18
|
const SESSION_COOKIE = "tot_session";
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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`, {
|