@tokenoftrust/cli 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -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;
@@ -1356,15 +1356,32 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1356
1356
 
1357
1357
  // 1. validate locally — refuse on errors.
1358
1358
  if (!args.skipValidate) {
1359
+ /** Warnings that would otherwise be swallowed, but describe a defect that ships
1360
+ * looking healthy: [rule, headline, what it costs if ignored]. */
1361
+ const LOUD_ADVISORY_RULES = [
1362
+ [
1363
+ "git-conflict-markers",
1364
+ "git conflict markers in submitted content — an unfinished merge/rebase?",
1365
+ "The preview will still build, but it will serve the broken markers. Resolve before shipping.",
1366
+ ],
1367
+ [
1368
+ "duplicate-skip-link",
1369
+ "duplicate skip link — your chrome already supplies one",
1370
+ "The page renders fine and every automated check passes; a screen-reader user hears it twice.",
1371
+ ],
1372
+ ];
1359
1373
  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");
1374
+ // Advisory but LOUD. Warnings are otherwise swallowed on the ok path, which is
1375
+ // wrong for defects that SHIP SILENTLY: the preview builds, the console is clean,
1376
+ // reconcile and compliance pass, and the flaw only surfaces to whoever reads the
1377
+ // rendered page. Those get surfaced here — never blocking, since none of them
1378
+ // makes the candidate unservable.
1379
+ for (const [rule, headline, consequence] of LOUD_ADVISORY_RULES) {
1380
+ const hits = findings.filter((f) => f.rule === rule);
1381
+ if (!hits.length) continue;
1382
+ console.error(`\n⚠ ${headline} (${hits.length} file(s))`);
1383
+ for (const f of hits) console.error(` ⚠ ${f.file} — ${f.message}`);
1384
+ console.error(` ${consequence}\n`);
1368
1385
  }
1369
1386
  if (!ok) {
1370
1387
  const errs = findings.filter((f) => f.level === ERROR);
package/src/validate.mjs CHANGED
@@ -452,6 +452,8 @@ function walk(dir, pred) {
452
452
  const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
453
453
  const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
454
454
  const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
455
+ /** An in-page skip link: an anchor to a #main-ish target, or one carrying a skip class. */
456
+ const SKIP_LINK_RE = /<a\b[^>]*(?:class="[^"]*\bskip[-\w]*\b[^"]*"|href="#(?:main|content|main-content)\b")/i;
455
457
 
456
458
  // --- platform-owned routes (framework surfaces, NOT tenant pages) ------------
457
459
  /**
@@ -643,6 +645,17 @@ export function validateTenant(tenantDir, opts = {}) {
643
645
  "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
646
  );
645
647
  }
648
+ // A body-only fragment is spliced INTO the tenant's chrome, and that chrome
649
+ // already supplies the skip link. A fragment that kept its own ships two — an
650
+ // a11y defect no browser reports and reconcile cannot see, so it survives every
651
+ // gate and lands in the assembled page.
652
+ if (!base && !isFullDocument(html) && hasChrome && SKIP_LINK_RE.test(html)) {
653
+ findings.push(
654
+ mk(WARN, "duplicate-skip-link", r,
655
+ "keeps its own skip link, but the tenant's chrome already supplies one — the assembled page will have two",
656
+ "delete the page-level skip link; the chrome's skipLink is the one that renders"),
657
+ );
658
+ }
646
659
  if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {
647
660
  findings.push(
648
661
  mk(WARN, "style-attrs", r,
@@ -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`, {