Client library versions
Purpose
The Dédalo client loads 23 third-party browser libraries. Since 2026-07-12 most of them are package-manager tracked, which is what this document used to exist to compensate for.
The old model was a 118 MB gitignored client/dedalo/lib/ directory of hand-dropped
bundles that no package manager watched — so SEC-103 made a human re-check every
one against the CVE feeds each release. That gap is now closed on both sides: 17 of
the 23 libs are pinned dependencies in package.json, so Dependabot and bun audit
see them like any other dep; the other six are committed under vendor/, pinned by a
digest, bound to their declared version, licensed on record, and watched by a
gated advisory ledger
rather than by a human's memory. See also
Integrity.
The index of record is src/core/client_libs/registry.ts. It maps each lib to
its root, and it is the allowlist the serving route enforces. This document is the
human-facing companion; the registry is what the code reads.
How a lib reaches the browser
Every lib is served at /dedalo/lib/<id>/<subpath>. There is no lib/ directory in
the repo any more. Two sources back that URL, and that is the whole story:
| Source | Count | Root | In git? |
|---|---|---|---|
| npm | 17 | node_modules/ |
no — bun install |
| vendor | 6 | vendor/ |
yes — committed (ckeditor, json-view, lz-string, pdfjs, transformers, xlsx) |
A sixth committed tree, swagger-ui, is not a client lib at all: it is the Swagger
page of the v1 publication API and lives inside that self-contained folder
(publication/server_api/v1/docu/ui/swagger-ui/) under a manifest row with an
explicit root — see Every committed third-party byte.
No install-time fetch step, deliberately
An earlier design downloaded pdf.js from its GitHub release via a postinstall
hook. That meant a hand-rolled zip extractor, a download cache, and — worse — a
hard dependency on GitHub being reachable on the deploy path (deploy.sh
runs bun install). All of that machinery existed for exactly one library. It
was cheaper to commit the 3.5 MB. A clone is now self-contained: bun install
is the entire setup, and nothing can be half-materialised.
The lib id in the URL is deliberately decoupled from the package name, so
swapping the underlying package never touches a client file (jsoneditor →
vanilla-jsoneditor).
The registry is a security chokepoint
node_modules/ also holds the server's dependencies (the Anthropic SDK, the
MCP SDK, zod, Puppeteer). The route keys on a registered id and never maps a
request path into node_modules — a prefix passthrough would publish the whole
dependency tree. test/unit/client_libs_tripwire.test.ts asserts this.
Versions
Pinned exactly (no ^). The pins were chosen by byte-comparing each file
against the previously-vendored copy: 18 of the 20 files the client loads are
byte-identical to what shipped before this migration.
All 23, re-measured against package.json and vendor/vendor_manifest.json on
2026-09-04.
| id | Package | Version | Notes |
|---|---|---|---|
| three | three |
0.185.1 | examples/jsm/ reached via the client import map. |
| pdfjs | (vendor) | 6.2.108 | Committed, minus 4 sourcemaps + the demo PDF. Bumped 2026-08-28 for CVE-2026-16633. See below. |
| ckeditor | (vendor) | CKEditor 5 42.0.1 | Custom build. See below. |
| jsoneditor | vanilla-jsoneditor |
3.13.0 | |
| leaflet | leaflet |
1.9.4 | |
| geoman | @geoman-io/leaflet-geoman-free |
2.20.0 | Was bundled inside leaflet's dist/; now its own dep. |
| turf | @turf/turf |
7.3.5 | Ditto. |
| highlightjs | @highlightjs/cdn-assets |
11.11.1 | Not highlight.js — see below. |
| svgedit | @svgedit/svgcanvas |
7.4.2 | Upgraded 2026-07-12 from a vendored ~7.2.x build. See below. |
| d3 | d3 |
7.9.0 | The version no longer appears in the URL. |
| xlsx | (vendor) | 0.20.3 | Vendored 2026-08-24 — was a CDN tarball URL with no lockfile integrity. See below. |
| flatpickr | flatpickr |
4.6.13 | |
| split | split.js |
1.6.5 | Used by tool_indexation. |
| iro | @jaames/iro |
5.5.2 | |
| codex-tooltip | codex-tooltip |
1.0.6 | |
| transformers | (vendor) | 4.2.0 | The in-browser AI runtime (tool_transcription, tool_lang, the remove-background processor). Vendored 2026-09-04 — was the @huggingface/transformers npm pin, which no engine module imported and which shipped 567 MB of native Node code to every install. See below. |
| qrcode | easyqrcodejs |
4.6.2 | tool_qr. Pinned 2026-09-04 — was a 4.6.1 copy committed under tools/tool_qr/lib/ with no digest. |
| client-zip | client-zip |
2.5.0 | tool_export's streaming ZIP download. Pinned 2026-09-04 — was a byte-identical copy under tools/tool_export/js/lib/. |
| lz-string | (vendor) | 1.5.0 | URL-state compression (tool_common, component_text_area). UMD-only upstream; committed with one declared patch. See below. |
| onnxruntime | onnxruntime-web |
1.29.0 | Transformers.js's WASM runtime; since the bundle is vendored this is the only onnxruntime-web the lockfile holds — see the registry's reason. |
| json-view | (vendor) | — | The bundle carries no version string at all. See below. |
| swagger-ui | (vendor) | 5.32.14 | Not a client lib: the v1 publication API's Swagger page, rooted inside that folder. Bumped 2026-09-04 from 4.5.2. See below. |
| mocha | mocha |
11.8.0 | devDependency — client test harness. |
| chai | chai |
6.2.2 | devDependency — client test harness. |
This table is prose, and prose rots
Eight of these rows were stale when they were re-measured on 2026-08-28 — the
npm pins had moved under them with nothing to notice. The vendored rows
are gated (test/unit/vendor_advisory_tripwire.test.ts asserts this table
against vendor/vendor_manifest.json, which is in turn bound to the bytes); the
npm rows are not, and package.json remains the only authority for them.
Two files are not byte-identical to the old copies, both benignly: highlightjs
differs by the build hash in its banner (same 11.9.0 release), and the old chai
was a CDN UMD build where the package has been ESM-only since chai 5 — the test
harness loads it through an import map, and it is test-only either way.
devOnly libs, and the install that ships them
Two entries are marked devOnly in the registry: mocha and chai, the
browser test harness. The route refuses them outright unless DEDALO_DEV_MODE is
true, so a production deployment cannot serve a test harness even by accident.
That flag has to agree with where the package comes from, and the two halves fail in opposite directions:
devOnly⇒ adevDependency, so the production image never carries it;- served in production ⇒ a runtime
dependency, because a deploy host installs withbun install --frozen-lockfile --productionand adevDependencywould simply not be there. The lib then404s in production only.
test/unit/client_libs_tripwire.test.ts asserts both directions, so getting this
wrong is a red gate rather than a blank widget.
To run the harness inside a container, the image needs the devDependencies
back: build the Dockerfile's dev target (the production image plus a full
bun install) and set DEDALO_DEV_MODE=true. Both, or the harness stays
unreachable — see
the troubleshooting entry for the misleading
MIME type error this produces.
svgedit — upgraded off the vendor tree (2026-07-12)
It used to be a 2.0 MB hand-dropped svgcanvas.js matching no published version
(2,089,802 bytes — between 7.2.3 and 7.2.4, so a build off an unreleased commit).
It is now @svgedit/svgcanvas@7.4.2: on npm, maintained, 1.4 MB, and CVE-tracked.
Verified as a drop-in in a real browser, not by inspection:
- Same default export (
SvgCanvasconstructor), constructed with the exact configvector_editor.jspasses. - All 29 methods and 3 properties the editor uses exist on the live instance.
(An earlier count of "40 methods" was wrong — 11 of them appear only in
commented-out code, e.g.
stage.add()andstage.getSegType().) - Identical add → serialise → read-back round trip: same 467-byte SVG, same layer structure, same child count.
xlink:hrefsurvives. This was the real risk: the 7.2.7 changelog says "prefer href to xlink href", andvector_editor.js:1269readsgetAttribute('xlink:href')whilegetJsonFromSvgElementsproduces the layer JSON that gets stored in the record. 7.4.2 still reads backxlink:hrefand still savesxlink:href, so existing records load unchanged.
The import path moved from lib/svgedit/svgcanvas.js to lib/svgedit/dist/svgcanvas.js.
The trees that cannot come from npm
Each carries its reason in the registry, next to the code, not only here.
- ckeditor — a custom Dédalo webpack build: a bespoke
ddEditorclass plus thededalo_image_tagsandreferenceplugins. The build project no longer exists in any checkout.vendor/ckeditor/build/ckeditor.js.mapis the only surviving copy of that plugin source, which is why the map is committed too. ⚠️ This is a standing risk: the bundle cannot be patched or rebuilt, only replaced wholesale. Reconstructing the plugins as a maintained project is worth scheduling. -
pdfjs — two things stack up. First, npm's
pdfjs-distships the pdf.js component library (web/pdf_viewer.mjs), not the standalone viewer app;component_pdfiframesweb/viewer.html, the whole Mozilla app. That exists only in thepdfjs-<version>-dist.zipGitHub release. Second, bun installs a tarball URL but not a zip (Mozilla publishes a.zip) — and the tarball-URL half of that sentence is precisely whatxlsxused to rely on, and why it is vendored now.So it is committed, taken from the sha256-verified 6.2.108 release (archive digest
7bf642d5…e95ba— the full digest GitHub publishes for the asset, not a prefix copied by hand). Two prunes make that affordable — 21 MB → 3.5 MB gzipped (3,694,821 bytes, measured 2026-08-28):- 4 sourcemaps, 9,034,413 bytes. A browser never fetches a
.mapunless devtools is open. Verified: loading the viewer issues zero requests for them. - the 1,016,315-byte demo PDF (
compressed.tracemonkey-pldi-09.pdf). The client setsdefaultUrlto''precisely to stop pdf.js auto-loading it, so it can never be reached.
Re-measured 2026-08-28 against a fresh download of that archive: it holds 409 files, the tree holds 404, the 5 missing ones are exactly those, and every one of the 404 is byte-identical to the archive's copy. The trim is reproducible rather than hand-done —
scripts/vendor_fetch.ts --drop '**/*.map' --drop 'web/compressed.tracemonkey-pldi-09.pdf'.Verified in a real browser by reproducing
component_pdf's exact flow: iframeviewer.htmlwith no?file=, wait forwebviewerloaded, cleardefaultUrl, thenPDFViewerApplication.open({url}). The page renders and the text layer extracts.Bumped 2026-08-28 from 5.7.284, which had sat for 22 days inside GHSA-hq66-cqwq-w95j / CVE-2026-16633 (HIGH, arbitrary JavaScript execution on opening a malicious PDF,
>= 5.6.83, < 6.2.108) with every gate green — the finding that produced the advisory axis described below. 6.2.108 is also the npm dist-taglatest, so the fix and this project's latest-stable law were one move. The mount additionally forcesenableScripting:false— the advisory's own stated workaround — behinddisablePreferences:true, which is what stops a storedpdfjs.preferencesentry from putting scripting back on. - highlightjs — thehighlight.jspackage'ses/entry is a bundler stub that chains into a CommonJSlib/; it cannot load in a browser without a bundler.@highlightjs/cdn-assetsships the same release as browser-ready ESM. - json-view —pgrabovets/json-viewis distributed via GitHub/jsDelivr only and was never published to npm. It is 16 KB, so it is simply committed. - 4 sourcemaps, 9,034,413 bytes. A browser never fetches a
-
lz-string — on npm, but UMD only:
libs/lz-string.jsdeclares a top-levelvar LZStringand exports throughdefine/module/angular; no release up to 1.5.0 has an ESM entry. A classic<script>would make it a global; an ES module import cannot reach a module-scopedvar, and this client is all modules — so the registry copy is unusable as served. Committed 2026-09-04 from the npm 1.5.0 archive (sha256 recorded) with one declared patch,var LZString =→export const LZString =plus the UMD tail removed, stated in the manifest row and covered by its tree digest. Until then a 1.4.5 copy with the same patch lived atclient/dedalo/core/common/js/utils/lzstring.js, outside every gate. -
swagger-ui — on npm as
swagger-ui-dist, but it must sit inside the v1 publication API, a self-contained web-server-served folder with nonode_modulesand no engine to serve/dedalo/lib/. Its manifest row carries an explicitroot(publication/server_api/v1/docu/ui/swagger-ui). Bumped 2026-09-04 from a 4.5.2 drop (2022, built from a dirty tree, 16 MB with every map and es bundle) to 5.32.14, trimmed to the two bundles, the stylesheet, the favicons and the licence texts. -
transformers — on npm as
@huggingface/transformers, and the registry package is a Node-side bundle first: itsdependenciespullonnxruntime-node(211 MB of native binaries, unpacked at install byadm-zip) andsharp/@img(16 MB of libvips) into everybun install --productionof every installation and every code-update quarantine — while no engine module imports the package at all. The only consumer is the browser, and the only file it loads isdist/transformers.js, a self-contained esbuild bundle that fetches its WASM glue from theonnxruntimerow. That never-executed native closure was also the only path to two of the three HIGH advisories the dependency baseline accepted (adm-zip,sharp). Committed 2026-09-04 from the npm 4.2.0 archive (sha256 recorded; its sha512 equals the registry's publishedintegrity), byte-identical to the lockfile install it replaces, trimmed todist/transformers.js+ the Apache-2.0 LICENSE.test/unit/production_import_tripwire.test.tsis what keeps a never-imported production dependency from coming back. -
xlsx — SheetJS left the npm registry (npm's
xlsxis abandoned at 0.18.5), so the dep used to be pinned to their tarball URL,https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz. That looked lockfile-pinned and was not: a tarball-URL dependency is the one shape bun records with no integrity — 581 entries inbun.lockcarriedsha512-, that one carried nothing. And these bytes are served to browsers (tool_exportimports/dedalo/lib/xlsx/xlsx.mjs), while every code update re-runsbun installin the quarantine, so each update re-fetched unverified third-party client code. Committed 2026-08-24 from the installed 0.20.3 tree, byte-verified against a fresh download of the upstream.tgz(archive sha2568dc73fc3…), trimmed toxlsx.mjs— the only file the client loads — plus the LICENSE.
Every committed third-party byte: the derived census
Until 2026-09-04 the committed arm of dependency_integrity_tripwire was
vendor/ ↔ vendor_manifest.json — a directory, not a question. Seven third-party
files were committed elsewhere and outside every axis above: a byte-identical
@huggingface/transformers dist and two EasyQRCodeJS copies under tools/,
client-zip under tools/, lz-string and a dead findAndReplaceDOMText UMD
under client/, and the swagger-ui drop under publication/. Three became
package.json pins, two became rows, two were deleted.
What stops it recurring is scripts/lib/third_party_census.ts: every git-tracked
.js/.mjs/.cjs/.css/.less under client/, deploy/, install/,
publication/, tools/ and vendor/ is read — and every tracked model
artifact there (.onnx, .safetensors, .wasm…, a tokenizer.json-style
model-card file, a config.json with a model_type), because code is not the only
third-party byte: the review of this closure found 20 MB of a TranslateGemma
tokenizer committed under tools/tool_lang/, now gone — a model lives in the
install's model store, never in the code tree —
and a file that wears a third-party
signature (a .min/-min name, a line over 1000 characters, a /*! banner, a
sourceMappingURL pointer, a copyright notice naming someone other than Dédalo,
or being a model artifact at all)
and no first-party marker (the AGPL LibreJS tag, or a .css built from a
sibling .less) must lie under a manifest row's root — vendor/<id> or the row's
explicit root — or be one of a short, shrink-only list of enumerated exemptions,
each with its reason and licence written next to it (normalize.css inlined in
reset.less; the reference plugin's CKSource-scaffolded source). The gate also
requires every manifest root to contain at least one census hit, so the signatures
cannot silently stop seeing bundles.
A row may carry a root only for a tree that must live inside another
self-contained subsystem; the root must be a tracked directory outside vendor/ and
node_modules, and the row's note must say why. The complement law over vendor/
is unchanged: its directories equal the rows without a root.
Licence. Every row also carries licence: { spdx, file } — an id from the closed
set in scripts/vendor_verify.ts (MIT, ISC, BSD, Apache-2.0, the GPL/LGPL family,
MPL-2.0 — all compatible with this project's AGPL-3.0-only) and a licence text
inside the tree that must exist and read as that licence. Four trees shipped with
no licence text before this; ckeditor (GPL-2.0-or-later) and json-view (MIT) now
carry the upstream files. The four package.json manifests declare
"license": "AGPL-3.0-only", gated equal.
Integrity: the manifest, and why vendored code needs one
An npm lib is pinned by the lockfile's sha512-. A vendored lib has no lockfile
line at all, so vendor/vendor_manifest.json is the pin: one row per tree with its
version, upstream URL, the upstream archive sha256 when one was verified, a
tree digest, the file count, the date a human last reviewed it, a
version_evidence block binding the declared version to the bytes, and an
advisory block (coordinate, review window, ledger) — the last two are the axis
described under Advisories and staleness.
The tree digest is sha256 over the sorted <relpath>\0<sha256(bytes)> lines of
every file under the lib root, so it moves when a byte changes and when a file is
added or removed.
| Command | What |
|---|---|
bun run scripts/vendor_verify.ts |
Recompute every tree digest and compare. |
bun run scripts/vendor_verify.ts --write |
Rewrite the derived fields (digest, file count) after a deliberate bump. Refuses when a row's declared version is not evidenced in its own bytes. |
bun run scripts/ci/audit.ts [--require-network] |
Integrity, then the offline advisory/review arm, then the networked one. |
bun run scripts/vendor_fetch.ts --lib <id> --version <v> --url <archive> --sha256 <expected> |
Download a bump and refuse unless the archive's sha256 is the stated one. Lands under the row's root when it has one. |
Where it runs: test/unit/dependency_integrity_tripwire.test.ts and
test/unit/vendor_advisory_tripwire.test.ts (every bun test) and
scripts/ci/audit.ts, which the hermetic CI tier already invokes — the vendor pass
runs before the networked advisory audit so the offline skip cannot skip integrity.
It does not run at boot: hashing 11 MB of pdf.js on every start would only re-read
code the process is already running.
That same gate refuses the shape that caused all this: no dependencies /
devDependencies / peerDependencies / overrides / resolutions entry may be a
http(s):, git, github:, file: or link: specifier, and no lockfile tuple may
resolve outside the registry or lack an integrity hash — across all three packages
that have a lockfile.
What this does not cover
The manifest proves the checkout is intact and correctly labelled. Whether the
contents are benign is the advisory axis above — and even that is a version
comparison against a published feed, not an exploitability judgement. Nothing here
covers distribution:
test/unit/release_archive_tripwire.test.ts hashes nothing (it only rejects
symlinks), so an installation receiving an update is protected by the update's
own archive-sha refusal, not by an independent signature over vendor/.
Advisories and staleness: the axis a digest cannot cover
A digest proves the bytes are the ones we pinned. It never proves the ones we pinned
are benign — and until 2026-08-28 nothing in the repo asked that second question about
a vendored tree. vendor/pdfjs sat at 5.7.284 for 22 days inside a HIGH advisory
with three green gates over it: bun audit reads lockfiles and vendor/ has none;
this document's staleness print "nudged, it did not fail"; and the integrity gate says
in its own header that it proves a digest matches, never that the bytes are safe.
So the manifest row now carries an advisory block, and it is a gate:
- the coordinate an advisory feed is keyed to (
npm+pdfjs-dist+ a plain semver), or anunkeyable_reasonwhen no coordinate can exist —json-viewhas no version string and was never published to npm, so its row says exactly that; - a per-row
review_window_days, because a dead-upstream bundle (ckeditor, 180) and an actively released viewer (pdf.js, 90) do not share one honest cutoff. Past it, the gate is red. That date is the watch Dependabot cannot keep; - a ledger of the published advisories known to touch that version. Being inside a
ledgered range is a hard failure unless the row carries an acceptance — and an
acceptance needs a reason code from a closed set, an expiry date, and at least one
verifyclause the checker re-proves against the tree on every run. Both CKEditor advisories are accepted that way: they require the General HTML Support or HTML Embed plugin, and the clause asserts those strings are absent from the served bundle. Rebuild the bundle with the feature in it and the acceptance evaporates.
And the version itself is bound to the bytes. Every check above reasons about the
version the row declares, so a row reading 6.2.108 over a 5.7.284 tree would have
satisfied the digest, the range check and the feed query at once. version_evidence
closes that: clauses naming a file inside the lib's own tree and a literal that
must appear in it, where the literal must itself contain the declared version
(vendor/pdfjs/build/pdf.mjs must contain const version = "6.2.108";, and two more
in the worker and the viewer). scripts/vendor_verify.ts --write refuses to write
a digest for a tree whose bytes do not state its declared version.
Two arms run these questions, and neither is sufficient alone:
| Arm | Where | What it can do |
|---|---|---|
| offline | test/unit/vendor_advisory_tripwire.test.ts, every bun test |
Re-prove the committed ledger, the acceptances, the review windows and the version bindings. It cannot learn about an advisory published after it was written. |
| networked | scripts/ci/audit.ts, hermetic CI |
Ask the GitHub advisory feed the same question per coordinate and fail on anything the ledger does not carry. It cannot run where there is no egress. |
A lookup that did not happen is not a finding, and not a pass
The advisory endpoint is anonymous at 60 requests/hour per IP, and a shared CI
runner can arrive with that budget already spent. So the networked arm has three
outcomes, never two: a finding (the feed answered and named something
unledgered) is red; a rejected request (400/401/404/410/422 — a coordinate we
built badly, a token the API refused) is red, because that half is ours; a
degraded lookup (429, 403/rate limit, 5xx, or no transport at all) is printed
loudly per coordinate with the rate-limit headers and is not a failure. Failing a
build for a rate limit and calling it a vulnerability is how a security gate gets
commented out. What a degraded run loses is only discovery; the ledger and the
review windows still ran, offline and unskippable. GITHUB_TOKEN raises the limit,
and --require-network makes a degraded lookup fatal on a tier that can guarantee
egress.
Upgrade checklist (SEC-103, reduced)
For the 15 npm-tracked libs this is now ordinary dependency hygiene: read the Dependabot/advisory alert, bump the pin, run the gates.
For the four vendored trees the old manual ritual still applies once per release
cycle (and bun run scripts/ci/audit.ts prints when each was last reviewed):
- Check the upstream release feed for security advisories since the pinned version.
- If a fix applies, drop the new files into
vendor/<name>/. For pdfjs, take thepdfjs-<version>-dist.zipGitHub release, check its sha256 against the digest GitHub publishes in the release API, and unzip it excluding*.mapandcompressed.tracemonkey-pldi-09.pdf(see above — they are 10 MB of dead weight). - Update the version in the table above and the row in
vendor/vendor_manifest.json—version,upstream,archive_sha256,reviewed,note,version_evidence.clauses(the literals carry the version, so they change with it) andadvisory.version. Thenbun run scripts/vendor_verify.ts --write, which refuses if the new bytes do not state the new version. Reference the CVE in the commit. - Ledger the advisory that prompted the bump in
advisory.advisorieseven though the new version is outside its range: the row is inert there and goes red the moment anyone takes the tree back below the patched version. - Run
bun test test/unit/client_libs_tripwire.test.ts test/unit/dependency_integrity_tripwire.test.ts test/unit/vendor_advisory_tripwire.test.tsandbun run test:client, then smoke-test the component that uses the lib.
What was removed
The 2026-07-12 prune dropped ~39 MB of libs with zero call sites: wkhtmltopdf
(a 17 MB 32-bit macOS wkhtmltox binary — an architecture macOS has not executed
since Catalina), vexflow, nvd3, pdfkit, an empty LESS-compiler husk, a second
CKEditor build (build_html_text/), and a stale d3-7.8.5 sitting beside 7.9.0.
The sublime-text/ and visual-studio/ directories were never libraries at all —
they are Dédalo's own editor snippets. Everything removed remains recoverable from
the repository history if it is ever wanted back.
dropzone went on 2026-08-03, for a different reason: it had a call site, but
it was the terminal release (5.9.3) of an unmaintained package — v6 was abandoned at
beta — and it made the engine carry two upload transports for one job. Its
multi-file drag-and-drop UI was rebuilt in-house as the multiple: true mode of
service_upload, so /dedalo/lib/dropzone/ no longer serves and the dep is gone
from package.json. See Services.
References
- Registry / allowlist:
src/core/client_libs/registry.ts - Gates:
test/unit/client_libs_tripwire.test.ts,test/unit/dependency_integrity_tripwire.test.tsandtest/unit/vendor_advisory_tripwire.test.ts(all inengineering/TRIPWIRES.md) - Manifest:
vendor/vendor_manifest.json; scriptsscripts/vendor_verify.ts,scripts/vendor_fetch.ts - Audit finding SEC-103 (phase-2 master register); companion SEC-097 (pdfjs CVE-2024-4367 — the pinned 6.2.108 is well past the 4.2.67 fix line), and CLI-26 (2026-08-28), the 22 days at 5.7.284 that produced the advisory axis.