PPLR data maps: d3 is pinned, integrity-checked and kept off the host page's window

The critic fact-check (CRITIC-9 / CRITIC-5): the ROP Sunburst loaded d3@7 (a floating range, no
SRI) as window.d3, overwriting a host's own d3, or running on whatever d3 the host already had.
ensureD3() now loads d3@7.9.0 exactly with integrity + crossorigin, keeps it in the module
(pplrD3()) and puts the host's window.d3 back (or leaves none). SemanticSunburst reads pplrD3(),
and if a caller forgot to await ensureD3 it loads it and builds instead of asking for a refresh.
Headless proof: the stubbed-DOM loader test, 6/6 (host d3 restored, none leaked, exact version +
integrity + crossorigin, a failure rejects). BUNDLE=pplr-data-maps builds. Plugin-only.
needs eyes: PPLR > ROP Sunburst draws; on a host page with its own window.d3 it is untouched.
This commit is contained in:
Doxa Maps 2026-09-26 02:01:27 +00:00
parent e1b9db2265
commit 4c45d76c68
2 changed files with 28 additions and 7 deletions

View file

@ -11,7 +11,7 @@
* at least `LABEL_MIN_RADIANS` of arc, and font scales up at higher zoom
* (label visibility re-evaluated on each zoom event).
*
* D3 is loaded from CDN globally — see index.html `<script src="d3.min.js">`.
* D3 comes from ensureD3()/pplrD3() in pplrData.js: pinned, integrity-checked, never window.d3.
*
* Props:
* tree — { name, code?, children?: Tree[], value?: number }
@ -21,6 +21,7 @@
* maxDepth — number — render up to this many rings (default 3)
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { pplrD3, ensureD3 } from '../utils/pplrData.js'
const props = defineProps({
tree: { type: Object, required: true },
@ -58,8 +59,9 @@ function arcColorOf(node) {
const LABEL_MIN_RADIANS = 0.10
function build() {
const d3 = window.d3
if (!d3) { status.value = 'D3 not loaded — refresh page'; return }
const d3 = pplrD3() // the pinned, scoped d3 (pplrData.js), never the host page's window.d3
// not loaded yet (a caller that did not await ensureD3): load it, then build; no refresh needed
if (!d3) { ensureD3().then(build, () => { status.value = 'D3 could not be loaded' }); return }
const el = container.value
if (!el || !props.tree) return

View file

@ -10,14 +10,33 @@ let base = './assets'
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
export function dataUrl(file) { return base + '/pplr/' + file }
// d3 v7 is a page global here (as in the original build). Load it once, on first need.
// d3 for the ROP Sunburst: PINNED, integrity-checked and kept OFF the host page's window
// (CRITIC-9 / CRITIC-5, the critic fact-check). It used to load d3@7 (a floating range, no SRI) as
// window.d3, overwriting a host page's own d3, or running on whatever d3 the host already had. Now one
// exact version with integrity + crossorigin, captured here (pplrD3()), and the host's window.d3
// put back exactly as it was.
const D3_URL = 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js'
const D3_SRI = 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i'
let d3Lib = null
let d3Promise = null
export function pplrD3() { return d3Lib }
export function ensureD3() {
if (typeof window === 'undefined' || window.d3) return Promise.resolve()
if (d3Lib) return Promise.resolve(d3Lib)
if (typeof window === 'undefined') return Promise.reject(new Error('no window'))
if (!d3Promise) d3Promise = new Promise((res, rej) => {
const hadOwn = Object.prototype.hasOwnProperty.call(window, 'd3')
const hostD3 = window.d3
const s = document.createElement('script')
s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'
s.onload = () => res(); s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load')) }
s.src = D3_URL
s.integrity = D3_SRI
s.crossOrigin = 'anonymous'
s.onload = () => {
d3Lib = window.d3
if (hadOwn) window.d3 = hostD3
else { try { delete window.d3 } catch (_) { window.d3 = undefined } }
res(d3Lib)
}
s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load (network or integrity)')) }
document.head.appendChild(s)
})
return d3Promise