diff --git a/internal/check-dev-leaf-escape.mjs b/internal/check-dev-leaf-escape.mjs new file mode 100644 index 000000000..e74478c02 --- /dev/null +++ b/internal/check-dev-leaf-escape.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens. + * + * THE DEFECT. The dev server's leaf route, /__leaf//[/] (and a production embed URL, + * /[/]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the + * and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path + * was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server. + * internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile + * id and instance id has) before either sink, and escapes the <title> as well. + * + * The plugin's own middleware answers each request (no server, no browser): + * A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with + * its element: the rule refuses no real page; + * B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or + * backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to + * Vite, never a page; + * C. escHtml leaves none of & < > " ' raw; + * D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title> + * escaped: the title has its own escaping, not only the rule in front of it. + * Response bodies are never printed: a leaf page carries the build config's token. + * `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses + * the quote must each FAIL. + */ +import { readFileSync, writeFileSync, rmSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { discoverSpaRegistry } from './plugin-gallery/discover.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs') +const SRC = readFileSync(PLUGIN, 'utf8') +const GUARD = 'parts.some(s => !LEAF_PART.test(s))' +const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))" +const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/' +const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}' +const ESC_CLASS = `/[&<>"']/g` + +// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the +// file is removed as soon as it is loaded. +let seq = 0 +async function load(src) { + if (src === SRC) return import(pathToFileURL(PLUGIN).href) + const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`) + writeFileSync(tmp, src) + try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) } +} + +// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would). +function client(mod) { + let mw = null + mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } }) + return (url) => new Promise((done) => { + const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) } + try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) } + catch (e) { done({ status: 'threw', body: String(e && e.message) }) } + }) +} + +// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount. +function advertised(mod) { + const out = [] + const walk = (o, tag) => { + if (!o || typeof o !== 'object') return + for (const v of Object.values(o)) { + if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag }) + else walk(v, tag) + } + } + for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0]) + return [...new Map(out.map((x) => [x.url, x])).values()] +} + +const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"] + +async function audit(src) { + const f = [] + let mod + try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] } + const get = client(mod) + const leaves = advertised(mod) + if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`) + for (const { url, tag } of leaves) { + for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) { + const r = await get(u) + if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`) + } + } + const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/') + for (const h of HOSTILE) { + const e = encodeURIComponent(h) + for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) { + const leaf = '/__leaf/' + parts.join('/') + const r = await get(leaf) + if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`) + const embed = '/' + parts.join('/') + '/index.html' + const r2 = await get(embed) + if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`) + } + } + const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null + if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`) + if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one') + else { + let loose + try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) } + if (loose) { + const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`) + const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1] + if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`) + else if (title.includes('<b>') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the <title> raw') + } + } + return f +} + +if (process.argv.includes('--self-test')) { + const poisons = [ + ['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /], + ['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /], + ['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /], + ['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /], + ] + let missed = 0 + for (const [name, src, rx] of poisons) { + if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue } + const f = await audit(src); const hit = f.find((x) => rx.test(x)) + console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`) + if (!hit) missed++ + } + if (missed) process.exitCode = 1 +} else { + const f = await audit(SRC) + if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 } + else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`) +} diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 34cece14a..bdc6fbf5a 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -145,6 +145,18 @@ export function galleryModel(reg) { } } +// ── ONE PART OF A LEAF URL ─────────────────────────────────────────────────────────────────── +// /__leaf/<bundle>/<profile>[/<instance>] parts are written INTO the leaf page: its <title> and the +// element's profile-config='…' attribute. Refusing only `..` and `/` let a quote or a tag through, so a +// crafted link broke out of the attribute on a publicly reachable dev server. Every bundle folder, +// profile id and instance id in the tree has this shape (the address keys' own pattern, +// library/composables/useViewAddress.js), so anything else is refused before it reaches either sink; +// it also rules out `..` and `/`. check:dev-leaf-escape proves both directions. +export const LEAF_PART = /^[A-Za-z0-9][\w-]{0,63}$/ + +// For an HTML text node or a quoted attribute value. +export const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) + // ── THE PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ───────────────────────────────────── // The manifest advertises, and a person copies, tree-relative embed URLs of the shape // `<bundle>/<profile>/index.html` and `<bundle>/<profile>/<instance>/index.html` — served in @@ -199,8 +211,8 @@ export function leafRewrite(path, treeName = TREE_NAME) { // urlOwner 'app' (library/composables/useViewAddress.js) for a dev page that is OURS, the rule generate-tree bakes // into the built leaf: its shell writes ?tab=, so a clicked tab survives a reload and a copied link opens it. A -// FRAMED page drops it: Plugin Studio iframes its map rows from /__leaf/, and a history push from an iframe lands -// in the top page's Back stack. Write the element unowned and put this classic script right after it; it runs +// FRAMED page drops it: a partner page may iframe a leaf (the iframe embed), and a history push from an iframe +// lands in the top page's Back stack. (Plugin Studio mounts its rows directly; its panes are unowned by paneConfig.) Write the element unowned and put this classic script right after it; it runs // before the deferred module script upgrades the element. `<` is escaped: profile/instance come from the URL. export function ownIfTopScript(cfg = {}) { const owned = JSON.stringify(JSON.stringify({ ...cfg, urlOwner: 'app' })).replace(/</g, '\\u003c') @@ -347,8 +359,8 @@ export function autoSpaDevPlugin(token = '') { try { return decodeURIComponent(s) } catch { return '' } }) const [bundle, profile, instance] = parts - // Path traversal guard: these land in a filesystem-ish URL below. - if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) { + // These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only. + if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) { res.statusCode = 400; return res.end('bad leaf path') } let reg @@ -387,7 +399,7 @@ export function autoSpaDevPlugin(token = '') { res.setHeader('Cache-Control', 'no-cache') return res.end([ '<!DOCTYPE html>', - `<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}`, + `${escHtml(bundle)} · ${escHtml(profile)}`, '', '', leafTok ? `` : '', diff --git a/package.json b/package.json index 4929fddb0..7fe716620 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "check:describe-group": "node internal/check-describe-group.mjs", "check:descriptions": "node internal/check-profile-descriptions.mjs", "check:dev-assets": "node internal/check-dev-assets.mjs", + "check:dev-leaf-escape": "node internal/check-dev-leaf-escape.mjs", + "check:dev-leaf-escape:self-test": "node internal/check-dev-leaf-escape.mjs --self-test", "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs", "check:readme-size": "node internal/check-readme-web-folder-size.mjs", "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test", @@ -359,7 +361,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test check:pinned-fetch-fresh:self-test", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:upg100-zone-copy check:upg100-zone-copy:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh check:view-address check:view-address:self-test", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:upg100-zone-copy check:upg100-zone-copy:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh check:view-address check:view-address:self-test check:dev-leaf-escape check:dev-leaf-escape:self-test", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -505,6 +507,8 @@ "check:window-globals-released": "a component that puts something on window must take it back. Heap snapshots (leak-722ac7789) measured SR+APG mounted twice at 54.2 -> 65.1 MB with live Vue apps 6 -> 8: every unmounted app stayed in memory, each pinned by one window key holding a closure, which keeps the component, its shadow root and the whole app. Flags only values that RETAIN (closures and object references), not warn-once latches or page-wide caches keyed off themselves. NOT in gates:pre: 19 sites remain across other owners' plugins. Run it directly.", "check:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.", "check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)", + "check:dev-leaf-escape": "a dev leaf URL cannot write markup into the page it opens. The dev server's /__leaf//[/] route (and a production embed URL rewritten onto it) writes the URL's parts into the page's and the element's profile-config attribute; it refused only .. and /, so a quote or a tag broke out of the attribute on a publicly reachable dev server. internal/plugin-gallery/dev-plugin.mjs refuses any part outside LEAF_PART and escapes the <title>. Drives the plugin's own middleware (no server): every leaf the dev manifest advertises is served in both URL forms; 12 hostile parts are refused in every position; escHtml escapes & < > \" '; and behind the OLD rule the <title> still escapes. Bodies are never printed (a leaf page carries the token). Sub-5s.", + "check:dev-leaf-escape:self-test": "4 poisons must each make check:dev-leaf-escape fail: the old guard (.. and / only), a part rule too strict for a real profile, an unescaped <title>, an escHtml that misses the quote.", "check:element-census-vs-live": "what is on ONE side and not the other, ours vs the live doxa.life page we clone: harvests every visible TEXT NODE through every shadow root on both sides and diffs the SETS. OURS-ONLY unaccounted = a scope violation (FAIL), THEIRS-ONLY = a clone omission (FAIL), SANCTIONED = an allowlist entry carrying the ruling + date + card (refused without them), DATA DRIFT = same label, different digits (reported). The negative control is MANDATORY: a known ours-only string must land in the ours-only set or it prints CENSUS IS BLIND and exits 75; an unreachable live page is also 75. Presets: internal/element-census/<name>.json (--preset <name>, default all); ad hoc: --leaf <bundle/profile/index.html[#hash]> --live <url> --neg <text> [--sanctioned <file>]. Promoted from regions' probe; regions passes (list 229 vs 228, the only difference its sanctioned poster toggle). Through browser-slot.sh.", "check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.", "check:embed-basemap": "the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",