Leaf pages: a token in the address leaves it after the page reads it
A leaf page reads a Mapbox token from ?tk= and keeps it in the browser, but left it in the address. Now that the address names the open tab, people copy the address bar to share one, and the token went with it. A leaf that owns its address now removes tk with one replace, only when it is there; the other parameters keep their exact text, and the hash and history state stay. A framed page never writes. The token still works on a reload from the browser's store. check:leaf-drops-tk runs every generated leaf's own script in a sandbox.
This commit is contained in:
parent
ceb7168bf3
commit
6dd3b732f3
3 changed files with 158 additions and 0 deletions
140
internal/check-leaf-drops-tk.mjs
Normal file
140
internal/check-leaf-drops-tk.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-leaf-drops-tk.mjs — a leaf page that owns its address does not keep a token in it.
|
||||
*
|
||||
* THE DEFECT. A generated leaf page reads a Mapbox token from ?tk= and keeps it in the browser (MAPS_STAGING_TK), but it
|
||||
* left it in the address. Now that the address names the open tab (?tab=, useViewAddress), people copy the address bar
|
||||
* to share a tab, and the token travelled with it. internal/generate-tree.mjs's leaf script now removes tk from the
|
||||
* address with a replace, on a page that owns its address (urlOwner 'app'; a framed page has none), and only when tk is
|
||||
* there, keeping the other parameters' exact text, the hash and history.state.
|
||||
*
|
||||
* Every generated leaf page (public/<tree>/<bundle>/<profile>[/<instance>]/index.html) runs its own leaf script in a
|
||||
* sandbox (location, history, localStorage and a stub DOM; fetch never answers):
|
||||
* W. the page carries the template's block verbatim (a stale tree fails: regenerate it);
|
||||
* A. owned, ?tk=pk.T&tab=x&lang=ar#h → ONE replace, to ?tab=x&lang=ar#h, history.state kept; the token stored; and
|
||||
* where a page that needs a token mounts, the element is given that token;
|
||||
* B. framed (a partner's iframe) with ?tk= → no write;
|
||||
* C. owned, no tk → no write;
|
||||
* D. other parameters keep their exact text (?f=a,b&tk=…&q=%C3%A9 → ?f=a,b&q=%C3%A9);
|
||||
* E. an encoded key (?%74k=…) is still a tk.
|
||||
* `--self-test`: no strip, a strip on a framed page, a strip that drops every parameter, one that loses the hash and a
|
||||
* push instead of a replace must each FAIL. `--splice` runs on pages with the template's block put in place, as the
|
||||
* generator will emit them (for a source change whose tree is not regenerated yet; never in gates).
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const REPO = new URL('..', import.meta.url).pathname
|
||||
const TREE = join(REPO, 'public/dx-maps')
|
||||
const TEMPLATE = readFileSync(join(REPO, 'internal/generate-tree.mjs'), 'utf8')
|
||||
const ANCHOR = "if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }\n"
|
||||
|
||||
// The block as the template writes it: from its comment to the closing brace of its `if`.
|
||||
function templateBlock() {
|
||||
const at = TEMPLATE.indexOf(' // ★ THE TOKEN LEAVES THE ADDRESS.')
|
||||
if (at < 0) return ''
|
||||
const end = TEMPLATE.indexOf('\n }\n', TEMPLATE.indexOf("if (urlTk && BASE_CONFIG.urlOwner === 'app') {", at))
|
||||
return end < 0 ? '' : TEMPLATE.slice(at, end + '\n }\n'.length)
|
||||
}
|
||||
|
||||
function leafPages() {
|
||||
const out = []
|
||||
const walk = (d, depth) => {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
const p = join(d, e.name)
|
||||
if (e.isDirectory() && depth < 3) walk(p, depth + 1)
|
||||
else if (e.name === 'index.html' && depth >= 2) {
|
||||
const html = readFileSync(p, 'utf8')
|
||||
const s = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]).find((x) => x.includes('var BASE_CONFIG'))
|
||||
if (s) out.push({ file: p.slice(REPO.length), script: s })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync(TREE)) walk(TREE, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// Run one leaf script; returns what it did to the address, the store and the element it mounted.
|
||||
function run(script, { search, hash = '', framed = false, state = { k: 1 } }) {
|
||||
const writes = []
|
||||
const loc = { pathname: '/dx-maps/b/p/', search, hash }
|
||||
const hist = {
|
||||
state,
|
||||
replaceState(s, _, url) { writes.push('replace'); this.state = s; const u = new URL(url, 'https://x.test'); loc.pathname = u.pathname; loc.search = u.search; loc.hash = u.hash },
|
||||
pushState() { writes.push('push') },
|
||||
}
|
||||
const store = new Map()
|
||||
const mounted = []
|
||||
const stub = () => ({ style: {}, className: '', textContent: '', innerHTML: '', parentNode: null, attrs: {}, setAttribute(k, v) { this.attrs[k] = v }, appendChild(c) { mounted.push(c) }, removeChild() {}, addEventListener() {} })
|
||||
const win = {
|
||||
location: loc, history: hist, URL, URLSearchParams, JSON, Object, String, console: { log() {}, warn() {}, error() {} },
|
||||
localStorage: { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
|
||||
document: { getElementById: () => stub(), createElement: () => stub(), documentElement: { style: {} }, body: { style: {} } },
|
||||
fetch: () => new Promise(() => {}),
|
||||
}
|
||||
win.window = win; win.self = win; win.top = framed ? {} : win
|
||||
let error = ''
|
||||
try { vm.runInNewContext(script, win, { timeout: 2000 }) } catch (e) { error = String(e && e.message) }
|
||||
const el = mounted.find((m) => m.attrs['profile-config'])
|
||||
let tk
|
||||
if (el) { try { tk = JSON.parse(el.attrs['profile-config']).tk } catch { tk = undefined } }
|
||||
return { writes, search: loc.search, hash: loc.hash, state: hist.state, stored: store.get('MAPS_STAGING_TK'), mountedTk: el ? tk : null, error }
|
||||
}
|
||||
|
||||
function audit({ splice = false, mutate = (s) => s } = {}) {
|
||||
const f = []
|
||||
const block = templateBlock()
|
||||
if (!block) return ['W: internal/generate-tree.mjs has no token-leaves-the-address block']
|
||||
const pages = leafPages()
|
||||
if (pages.length < 20) f.push(`W: only ${pages.length} generated leaf pages found under public/dx-maps`)
|
||||
let mountedWithTk = 0
|
||||
for (const p of pages) {
|
||||
let s = p.script
|
||||
if (splice && !s.includes(block)) s = s.includes(ANCHOR) ? s.replace(ANCHOR, ANCHOR + block) : s
|
||||
if (!s.includes(block)) { f.push(`W: ${p.file} does not carry the template's block (regenerate the tree)`); continue }
|
||||
s = mutate(s)
|
||||
const st = { k: 1 }
|
||||
const a = run(s, { search: '?tk=pk.T&tab=x&lang=ar', hash: '#h', state: st })
|
||||
if (a.error) f.push(`A: ${p.file} threw: ${a.error.slice(0, 80)}`)
|
||||
if (a.writes.join() !== 'replace') f.push(`A: ${p.file} wrote [${a.writes}] (want one replace)`)
|
||||
if (a.search !== '?tab=x&lang=ar' || a.hash !== '#h') f.push(`A: ${p.file} left ${a.search}${a.hash} (want ?tab=x&lang=ar#h)`)
|
||||
if (a.state !== st) f.push(`A: ${p.file} did not keep history.state`)
|
||||
if (a.stored !== 'pk.T') f.push(`A: ${p.file} did not keep the token (MAPS_STAGING_TK)`)
|
||||
// "used" applies to a page that needs a token; a token-free one (a shell, a MapLibre map) mounts without it.
|
||||
const needsToken = /var TOKEN_FREE = false;/.test(s)
|
||||
if (needsToken && a.mountedTk !== null) { if (a.mountedTk !== 'pk.T') f.push(`A: ${p.file} mounted without the token`); else mountedWithTk++ }
|
||||
const b = run(s, { search: '?tk=pk.T&tab=x', framed: true })
|
||||
if (b.writes.length || b.search !== '?tk=pk.T&tab=x') f.push(`B: ${p.file} framed: wrote [${b.writes}], left ${b.search}`)
|
||||
const c = run(s, { search: '?tab=x&lang=ar', hash: '#h' })
|
||||
if (c.writes.length || c.search !== '?tab=x&lang=ar') f.push(`C: ${p.file} without tk: wrote [${c.writes}]`)
|
||||
const d = run(s, { search: '?f=a,b&tk=pk.T&q=%C3%A9' })
|
||||
if (d.search !== '?f=a,b&q=%C3%A9') f.push(`D: ${p.file} left ${d.search} (want ?f=a,b&q=%C3%A9)`)
|
||||
const e = run(s, { search: '?%74k=pk.T&tab=x' })
|
||||
if (e.search !== '?tab=x') f.push(`E: ${p.file} left ${e.search} for an encoded tk`)
|
||||
}
|
||||
if (pages.length && !mountedWithTk) f.push('A: no page mounted its element, so "the token is used" was never checked')
|
||||
return f
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const RE = "history.replaceState(history.state, '', location.pathname + (keep ? '?' + keep : '') + location.hash);"
|
||||
const poisons = [
|
||||
['no strip', (s) => s.replace(RE, ''), /^A: /],
|
||||
['a strip on a framed page', (s) => s.replace("if (urlTk && BASE_CONFIG.urlOwner === 'app') {", 'if (urlTk) {'), /^B: /],
|
||||
['a strip that drops every parameter', (s) => s.replace("return p && k !== 'tk';", 'return false;'), /^[AD]: /],
|
||||
['a strip that loses the hash', (s) => s.replace(RE, RE.replace(' + location.hash', '')), /^A: /],
|
||||
['a push instead of a replace', (s) => s.replace(RE, RE.replace('replaceState', 'pushState')), /^A: /],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, mutate, rx] of poisons) {
|
||||
const f = audit({ splice: true, mutate }); 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 = audit({ splice: process.argv.includes('--splice') })
|
||||
if (f.length) { for (const x of f.slice(0, 30)) console.log('✗ ' + x); if (f.length > 30) console.log(`… ${f.length - 30} more`); console.log(`FAIL check:leaf-drops-tk (${f.length})`); process.exitCode = 1 }
|
||||
else console.log(`PASS check:leaf-drops-tk — every generated leaf that owns its address drops ?tk= with one replace (the other parameters, the hash and history.state kept; the token kept in the browser and used); a framed page and an address without tk are never written${process.argv.includes('--splice') ? ' [--splice: pages as the generator will emit them]' : ''}`)
|
||||
}
|
||||
|
|
@ -1187,6 +1187,20 @@ ${apiScript}
|
|||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
// ★ THE TOKEN LEAVES THE ADDRESS. It is kept just above (rung 2b), so a reload still has it; left in the address
|
||||
// it travels with every copied link to a tab, now that the address names the tab. A page that owns its address
|
||||
// (urlOwner, dropped above on a framed page) removes it with a replace, and only when it is there, so every
|
||||
// other address is untouched: the other parameters keep their exact text; the hash and history.state stay.
|
||||
if (urlTk && BASE_CONFIG.urlOwner === 'app') {
|
||||
try {
|
||||
var keep = location.search.slice(1).split('&').filter(function (p) {
|
||||
var k = p.split('=')[0];
|
||||
try { k = decodeURIComponent(k); } catch (e) {}
|
||||
return p && k !== 'tk';
|
||||
}).join('&');
|
||||
history.replaceState(history.state, '', location.pathname + (keep ? '?' + keep : '') + location.hash);
|
||||
} catch (e) {}
|
||||
}
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
|
||||
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
|
||||
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@
|
|||
"check:hardcoded-english:self-test": "node internal/check-hardcoded-english.mjs --self-test",
|
||||
"check:embed-contract": "node internal/check-gospel-embed-contract.mjs",
|
||||
"check:engine-contract": "node internal/check-gospel-engine-contract.mjs",
|
||||
"check:leaf-drops-tk": "node internal/check-leaf-drops-tk.mjs",
|
||||
"check:leaf-drops-tk:self-test": "node internal/check-leaf-drops-tk.mjs --self-test",
|
||||
"check:link-coverage": "node internal/check-link-verification-coverage.mjs",
|
||||
"dropin": "node internal/sync-dropin.mjs --from-config",
|
||||
"gospel:bridge-reach": "node plugin-designs/gospel-resources/scripts/build-bridge-reach-table.mjs",
|
||||
|
|
@ -501,6 +503,8 @@
|
|||
"check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source — the credit is in the TileJSON, merged into the SOURCE object after load — so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.",
|
||||
"check:script-registry": "one bundle script executes ONCE PER PAGE. Every plugin is a self-contained IIFE, so library/utils/embedScript.js is compiled into each one and a module-scoped cache was one cache per BUNDLE: two shells on a page each loaded the same child, which re-executed a whole bundle (a second Vue, i18n and pinia) before its guarded customElements.define silently declined. The check imports the module twice under different URLs — two module instances, one globalThis, the same shape as two IIFEs — and asserts the second caller receives the FIRST ONE'S PROMISE, because counting script tags alone is satisfied by the DOM-adoption path and would not catch a regression. Headless; in gates:pre.",
|
||||
"check:map-control-overlap": "two controls pinned to the same corner of the research-dashboard map must not land on each other. The zoom toolbar was pinned at a literal top:34px, tuned when the projection toggle measured 23px tall; a later WCAG 2.5.8 fix gave the toggle min-height:24px, which in a shadow root with no global box-sizing rendered 34px and ran 8px into a toolbar drawn over it at z-index 10. The check COMPUTES the toggle's rendered height from its own declarations, honouring box-sizing, and requires the toolbar to clear it and to DERIVE its offset rather than restate a number. Headless; in gates:pre.",
|
||||
"check:leaf-drops-tk": "a generated leaf page that owns its address does not keep a token in it. The leaf reads a Mapbox token from ?tk= and keeps it in the browser (MAPS_STAGING_TK, rung 2b); now that the address names the open tab, people copy the address bar to share one, so internal/generate-tree.mjs's leaf script removes tk with ONE replace on a page with urlOwner app (a framed page has none), only when tk is there, keeping the other parameters' exact text, the hash and history.state. Runs every generated leaf's own script in a sandbox: W the page carries the template's block (a stale tree fails); A owned + ?tk= → one replace, the token stored and, where needed, used; B framed → no write; C no tk → no write; D exact text kept; E an encoded key is still tk. --splice runs on pages as the generator will emit them (never in gates).",
|
||||
"check:leaf-drops-tk:self-test": "5 poisons must each make check:leaf-drops-tk fail: no strip, a strip on a framed page, a strip that drops every parameter, one that loses the hash, a push instead of a replace.",
|
||||
"check:legend-data-contract": "nobody may read a key off useLegendData that it does not return. It returns items, never rows; two plugins read legend.rows anyway and JavaScript answered undefined, so the same mistake surfaced as a THROWN Export poster in countries-map (exp NE-17) and as a silently BLANK poster legend in research-map. The returned key set is read from the composable rather than restated. NOT in gates:pre yet: it still reports countries-map, which is another owner's file (exp's d6916e05e covers .rows but not the dead .build/.refresh calls). Run it directly.",
|
||||
"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.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue