From 24f965b6a40e8a990b2b08341424be7de6527ab8 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 01:50:17 +0000 Subject: [PATCH] A gated bundle builds outside public/, and a gate keeps it out of the tree key-cities is a gated profile: generate-tree leaves it out of the manifest and the pages. Its bundle file was still in public/dx-maps/key-cities/key-cities.js, committed, and the landing freshness pass rebuilt it into public/ every time it looked "stale", so any host that serves or drops in the tree folder whole shipped it. - vite.config.js builds a gated bundle into .gated// (ignored by git) instead of public/. - check:composite-freshness skips gated bundles: they have no committed bytes to be fresh against. - The committed public/dx-maps/key-cities/ is removed. History is not rewritten. - generate-tree removes a gated folder it finds in the tree (an older build, or a landing that kept the tree's side of a conflict), so the tree it writes is clean whatever came before. Measured: a planted public/dx-maps/key-cities/key-cities.js fails the gate, and one regeneration removes it. - The gated list is read in one place, internal/_tree-name.mjs, beside the tree name. - check:gated-not-in-tree (gates:pre) fails if a gated name has a folder in the tree, a manifest entry, or a page referencing its script, and proves each arm on a planted leak first. It FAILED on the tree before this change (key-cities/ is in the tree) and PASSES after. The data tables are left alone on purpose: key-cities.json also feeds the production Cities with Internet map, and whether that dataset should be public is a separate decision. --- .gitignore | 2 + internal/_tree-name.mjs | 11 + internal/check-composite-freshness.mjs | 7 +- internal/check-gated-not-in-tree.mjs | 93 ++++++ internal/generate-tree.mjs | 8 + package.json | 4 +- public/dx-maps/key-cities/key-cities.js | 385 ------------------------ vite.config.js | 9 +- 8 files changed, 129 insertions(+), 390 deletions(-) create mode 100644 internal/check-gated-not-in-tree.mjs delete mode 100644 public/dx-maps/key-cities/key-cities.js diff --git a/.gitignore b/.gitignore index 2b28a08ab..8a2c02156 100644 --- a/.gitignore +++ b/.gitignore @@ -152,4 +152,6 @@ data-private/ /.pb/ # bun run desktop:build : the staged plugin and the built sidecar engines (never committed) /.desktop/ +# gated bundles (gatedProfiles) build here, never into public/ (vite.config.js) +/.gated/ /deployment/src-tauri/binaries/ diff --git a/internal/_tree-name.mjs b/internal/_tree-name.mjs index eeefde608..9f4a6d352 100644 --- a/internal/_tree-name.mjs +++ b/internal/_tree-name.mjs @@ -39,6 +39,17 @@ function read() { } export const TREE_NAME = read() +// GATED profiles (the config's gatedProfiles): bundles that build but must never reach a public surface. +// generate-tree leaves them out of the manifest and pages; vite.config.js builds them OUTSIDE public/ +// (.gated//); check:gated-not-in-tree fails if one appears in the tree anyway. +function readGated() { + try { + const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8')) + return Array.isArray(cfg.gatedProfiles) ? cfg.gatedProfiles.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : [] + } catch { return [] } +} +export const GATED_PROFILES = readGated() + // The name escaped for use inside a RegExp source (a '.' in a future name must not match any char). export const TREE_NAME_RE = TREE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') diff --git a/internal/check-composite-freshness.mjs b/internal/check-composite-freshness.mjs index d84851fb7..e805afe60 100644 --- a/internal/check-composite-freshness.mjs +++ b/internal/check-composite-freshness.mjs @@ -42,7 +42,7 @@ import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { TREE_NAME } from './_tree-name.mjs' +import { TREE_NAME, GATED_PROFILES } from './_tree-name.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const DESIGNS = join(ROOT, 'plugin-designs') @@ -67,7 +67,10 @@ const byTag = new Map() for (const b of manifest.bundles || []) for (const t of b.elements || []) byTag.set(t, b.bundle) // A plugin that is not built into the web tree (plugin-targets.mjs: targets without "web") has no committed // bundle by design, so it is not held to one. -const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js')) && isWebBuilt(join(DESIGNS, d)))) +// A GATED bundle (gatedProfiles) builds outside public/ on purpose (vite.config.js), so it has no committed +// bytes to be fresh against. Holding it to them is what rebuilt key-cities into public/ on every landing. +const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js')) && isWebBuilt(join(DESIGNS, d)) + && !GATED_PROFILES.includes(d))) // A bare PROFILE name of the same bundle (gospel-resources composes "resources-map", "data-tables" — // its own sibling profiles) resolves to that bundle, as the studio resolves it. const profilesOf = new Map((manifest.bundles || []).map((b) => [b.bundle, new Set((b.profiles || []).map((p) => p.profile))])) diff --git a/internal/check-gated-not-in-tree.mjs b/internal/check-gated-not-in-tree.mjs new file mode 100644 index 000000000..9e9ee2fff --- /dev/null +++ b/internal/check-gated-not-in-tree.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * check-gated-not-in-tree — a GATED profile (the build config's gatedProfiles) never reaches the public tree. + * + * A gated bundle builds, but must appear on no public surface. generate-tree already left it out of the + * manifest and the pages; its bundle file did not: public//key-cities/key-cities.js was committed, + * rebuilt into public/ by every landing that found it "stale", and copied by any host that serves or + * drops in the tree folder whole. vite.config.js now builds a gated bundle into .gated// (ignored + * by git), and this asserts the result where it matters: in the built tree. + * + * FAILS if, for any gated name: // exists, manifest.json lists it as a bundle or a profile, + * or any page in the tree references /.js. Data tables are NOT in scope: a table can be + * shared with a public map (key-cities.json feeds Cities with Internet), so whether a dataset is public + * is a separate decision, not a property of the gated bundle. + * Two-sided: each arm is first run against a planted leak in a temporary copy. + * + * Node only, no browser. Exit 0 pass · 1 fail. + */ +import { existsSync, readdirSync, readFileSync, statSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { join, resolve, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { TREE_NAME, GATED_PROFILES } from './_tree-name.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const TREE = join(ROOT, 'public', TREE_NAME) + +function htmlFiles(dir, out = []) { + for (const f of readdirSync(dir)) { + const p = join(dir, f) + if (statSync(p).isDirectory()) { if (f !== 'assets') htmlFiles(p, out) } + else if (f.endsWith('.html')) out.push(p) + } + return out +} + +export function gatedLeaks(treeDir, gated) { + const out = [] + for (const g of gated) { + if (existsSync(join(treeDir, g))) out.push(`${TREE_NAME}/${g}/ is in the tree (${readdirSync(join(treeDir, g)).join(', ') || 'empty'})`) + } + const mf = join(treeDir, 'manifest.json') + if (existsSync(mf)) { + const m = JSON.parse(readFileSync(mf, 'utf8')) + for (const b of m.bundles || []) { + if (gated.includes(b.bundle)) out.push(`manifest.json lists the gated bundle "${b.bundle}"`) + for (const p of b.profiles || []) if (gated.includes(p.profile)) out.push(`manifest.json lists the gated profile "${p.profile}" (in ${b.bundle})`) + } + } + if (existsSync(treeDir)) { + for (const f of htmlFiles(treeDir)) { + const s = readFileSync(f, 'utf8') + for (const g of gated) if (s.includes(`${g}/${g}.js`)) out.push(`${f.slice(treeDir.length + 1)} references ${g}/${g}.js`) + } + } + return out +} + +const fails = [] +if (!existsSync(join(TREE, 'manifest.json'))) { + console.error(`GATED-NOT-IN-TREE: FAIL — no built tree at public/${TREE_NAME}/manifest.json`) + process.exit(1) +} + +// Planted leaks first, in a temporary tree: a gated bundle folder, a manifest entry, a page reference. +const probe = GATED_PROFILES[0] || 'gated-probe' +const tmp = mkdtempSync(join(tmpdir(), 'gated-probe-')) +try { + const clean = { bundles: [{ bundle: 'open-map', profiles: [{ profile: 'open-map' }] }] } + writeFileSync(join(tmp, 'manifest.json'), JSON.stringify(clean)) + if (gatedLeaks(tmp, [probe]).length) fails.push('SELF-TEST: a clean probe tree was reported as leaking') + mkdirSync(join(tmp, probe)); writeFileSync(join(tmp, probe, `${probe}.js`), '/* planted */') + if (!gatedLeaks(tmp, [probe]).some((l) => l.includes(`${probe}/ is in the tree`))) fails.push('SELF-TEST: a planted gated bundle folder was not caught') + rmSync(join(tmp, probe), { recursive: true, force: true }) + writeFileSync(join(tmp, 'manifest.json'), JSON.stringify({ bundles: [...clean.bundles, { bundle: probe, profiles: [{ profile: probe }] }] })) + if (!gatedLeaks(tmp, [probe]).some((l) => l.includes('manifest.json lists'))) fails.push('SELF-TEST: a planted manifest entry was not caught') + writeFileSync(join(tmp, 'manifest.json'), JSON.stringify(clean)) + mkdirSync(join(tmp, 'open-map')); writeFileSync(join(tmp, 'open-map', 'index.html'), ``) + if (!gatedLeaks(tmp, [probe]).some((l) => l.includes('references'))) fails.push('SELF-TEST: a planted page reference was not caught') +} finally { rmSync(tmp, { recursive: true, force: true }) } + +// The real tree. +const leaks = gatedLeaks(TREE, GATED_PROFILES) +for (const l of leaks) fails.push(l) + +if (fails.length) { + console.error(`GATED-NOT-IN-TREE: FAIL — ${fails.length} problem(s)`) + for (const f of fails) console.error(' - ' + f) + console.error(' A gated bundle builds into .gated/ (vite.config.js); remove its folder from public/ and regenerate the tree.') + process.exit(1) +} +console.log(`GATED-NOT-IN-TREE: PASS — ${GATED_PROFILES.length ? GATED_PROFILES.join(', ') : 'no gated profiles'}: no folder in public/${TREE_NAME}/, ` + + 'no manifest entry, no page reference (3 planted leaks each caught).') diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 645ee6375..f41b04603 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -180,6 +180,14 @@ const MAPBOX_GL_JS = CDN.mapboxGlJs.url // the ONE registry the bundle's loade const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name) +// ★ A GATED bundle's folder never stays in the tree. vite.config.js builds it into .gated/ already; this +// removes one that arrived anyway (an older build, or a landing that kept the tree's side of a conflict), +// so the tree this writes is clean whatever came before. check:gated-not-in-tree asserts the result. +for (const g of DEST_CONFIG.gatedProfiles) { + const dir = join(DEFAULT_ROOT, g) + if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); console.log(`[generate-tree] removed gated bundle folder ${TREE_NAME}/${g}/ from the tree`) } +} + // ★ CUSTOM-ELEMENT NAME RULE (HTML spec): lowercase, must contain a hyphen, no spaces. // @element is NOT a comment — the build copies it VERBATIM into manifest.json and consumers feed // it straight to document.querySelector(). Anything else is a downstream SyntaxError. diff --git a/package.json b/package.json index 245bf8b5b..13c8f5c2f 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "check:gallery-embed:js": "node internal/check-research-gallery-embed.mjs --mode js", "check:gallery-embed:live-cors": "node internal/check-research-gallery-embed.mjs --live-cors", "check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre", + "check:gated-not-in-tree": "node internal/check-gated-not-in-tree.mjs", "check:gospel-all": "node internal/check-gospel-all.mjs", "check:gospel-internet": "node plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-i18n.mjs && node plugin-designs/gospel-internet/scripts/check-language-tree.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs", "check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs", @@ -314,7 +315,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:done-evidence check:script-flags check:backlog-status 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:backlog-refs 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: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: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:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library", - "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:composite-token-url check:language-tree-layout check:i18n-keys-exist 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:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable", + "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:composite-token-url check:language-tree-layout check:i18n-keys-exist 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:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree", "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", @@ -423,6 +424,7 @@ "check:frozen-labels": "a label resolved ONCE that never re-resolves on a mid-session locale switch: t() or tStatic() stored at module/setup scope, or tStatic() inside a computed (it reads a plain global the computed cannot track). Parsed, not regexed. Scoped by PROFILE stage: a production profile or library/ FAILS, an experimental profile is REPORTED only. In gates:post. The :self-test plants 4 bad shapes and 6 good ones (14 assertions).", "check:gallery-embed": "the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare