Skip to content

Audit phases 0–2: remove dead Monaco stack, fix JSON corruption, add consent-gated analytics + CI - #11

Merged
aduquehd merged 4 commits into
mainfrom
chore/phase-0-dead-weight-and-ci
Aug 26, 2026
Merged

aduquehd merged 4 commits into
mainfrom
chore/phase-0-dead-weight-and-ci

Conversation

@aduquehd

@aduquehd aduquehd commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

Audit remediation, phases 0–2. Four commits, each independently reviewable.

Net effect on analytics — read this first

Two commits here look contradictory in isolation: 94ae256 removes Google Analytics, 3ace60a restores it. The net result is:

  • GA4 is live, gated behind Google Consent Mode v2 (analytics_storage starts denied).
  • Vercel Web Analytics runs alongside it, cookieless and consent-free, so traffic lost to declines and ad blockers is still counted.
  • The seven behavioural events stay deleted — format, copy, paste, clear, tab_change, load_example, fix_json. Those recorded what people did with their JSON. Audience metrics only now: pageviews, countries, new vs returning.

NEXT_PUBLIC_GA_MEASUREMENT_ID is unchanged and still set in Vercel. Without it, no GA and no consent banner render at all.

Commits

a8f9a1a — remove dead Monaco stack, fix i18n resolution, add CI

  • Deleted the Monaco editor stack. It was fully dead: MonacoEditorWrapper.tsx, monacoLoader.ts, suppress-monaco-warnings.js, and the copy-monaco build step. CodeMirror 6 has been the only live editor. This is also what let 'unsafe-eval' come out of the CSP — Monaco's AMD loader was the only thing that ever needed it.
  • Fixed language resolution. load: 'languageOnly' was truncating every regional tag to its base, so zh-Hans / zh-SG / zh-Hant all fell through to English. Replaced with an explicit alias map and resolvedLanguage. Verified against 15 real navigator.language values.
  • Added GitHub Actions CI: pnpm check (Biome + ESLint + tsc + Vitest) and Playwright smoke tests.
  • Removed stray dev scratch files (LANGUAGE_TESTING.md, test-language.html, test-languages.sh).

2e0fd1c — stop the tool silently altering the user's JSON

The highest-severity finding in the audit. JSONFixer rewrote content inside string literals:

input:  {"glob": "src/*.js", "other": "*/x", "b": 1,}
output: {"glob":"srcx","b":1}

A glob pattern was destroyed and a key silently dropped, then written straight back into the editor by Format. Rewritten as a single string-aware scanDocument() pass. Fuzzing over 20,000 hazard-saturated valid documents: 7,437 corrupted before, 0 after.

Also added a guard against inventing data — }, ], }{, {}} used to "repair" to {}, handing back a value the user never wrote. Structure-only input now errors honestly.

Format is still lossy in ways JSON.parse/stringify inherently are (12345678901234567890 loses precision, 1e999 → null, -0 → 0, duplicate keys collapse). Not fixed here, but it now warns instead of doing it silently (jsonLossy.ts). Flagged for a follow-up.

94ae256 / 3ace60a — analytics and CSP

  • Consent banner (ConsentBanner.tsx + lib/consent.ts), translated across all 12 locales, both buttons at equal visual weight.
  • Consent Mode defaults are inlined into <head>, not set from React. next/script beforeInteractive carries no ordering guarantee in the App Router and Next lints against it; if it lands late, GA writes cookies in the gap and the default never applies.
  • Banner uses useSyncExternalStore rather than setState-in-effect, which would make the React Compiler skip the component.
  • CSP tightened. Removed: 'unsafe-eval', Google Fonts hosts (next/font/google self-hosts at build), blanket img-src https:, worker-src data:, dead *.tile.openstreetmap. GA hosts use wildcards per Google's own CSP guidance so EU regional endpoints aren't blocked.
  • All 24 JSON-LD blocks routed through a <JsonLd> component that escapes < as a \u003c unicode escape, so no injected payload can break out of the script tag.
  • localStorage access wrapped in safeStorage.ts — property access itself throws SecurityError when site data is blocked.

Verification

  • pnpm check clean: 545 unit tests / 14 files, tsc clean, ESLint 48 warnings / 0 errors, Biome 211 warnings / 0 errors.
  • 25 e2e passing, 2 skipped. The 2 skips are expected in CI — they need NEXT_PUBLIC_GA_MEASUREMENT_ID present at build time.
  • e2e/csp.spec.ts sweeps all 9 routes including /map, which is the one view pulling third-party images at runtime — so the img-src tightening is verified against the view most likely to break.
  • Consent gate verified against a real build with G-VERIFY123: [] cookies before Accept, _ga + _ga_VERIFY123 after, Decline stays cookieless.
  • e2e/consent.spec.ts types a canary string into the editor and asserts no outbound request carries it — "your JSON never leaves the browser" is now tested, not just claimed.
  • All 12 locales at identical 203 keys.
  • pnpm install --frozen-lockfile clean at the bumped packageManager pin (11.20.0 → 11.22.0, a pre-existing local change carried in here).

Not included

Phases 3–6 (performance, SEO/metadata, structure/a11y, tests/docs) remain queued.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB

aduquehd and others added 4 commits August 23, 2026 00:05
Phase 0 of the codebase audit: dead weight, stale config, and the two
correctness bugs that were cheap to fix alongside them.

Remove the Monaco editor stack. Nothing has imported it since the
CodeMirror migration, yet it still cost: a 24 MB copy into public/ on
every install and build, a beforeInteractive script on all 17 routes that
monkey-patched console.warn/console.error/window.onerror, two
dependencies, and a CSP 'unsafe-eval' allowance. Deletes
MonacoEditorWrapper, monacoLoader, suppress-monaco-warnings.js and
scripts/copy-monaco.mjs (with its prebuild/postinstall hooks). The
misnamed-but-live .monaco-editor-container class is renamed to
.editor-container rather than dropped. Clears all four pnpm audit
advisories, which came from Monaco's transitive dompurify, and shrinks
public/ from ~26 MB to 1.8 MB. The now-unused CSP allowance is left in
place with a comment; tightening the policy is its own change.

Fix Chinese language resolution. load: 'languageOnly' stripped the region
tag, so every Simplified-Chinese visitor resolved to the Traditional 'zh'
bundle and the entire zh-CN bundle was dead weight. Removing it restores
zh-CN, and explicit zh-Hans/zh-SG/zh-Hant aliases stop the other
script-tagged variants landing on the wrong script. Region-tagged
variants of every other language still resolve to their base language
(verified for es-MX, en-GB, nl-BE, hi-IN, ta-IN, ms-MY; pt-BR and fr
still fall back to English). 'zh' was also mislabelled 中文 (新加坡) 🇸🇬 —
Singapore uses Simplified — and is now 中文 (繁體) 🇹🇼.

Language detection now happens in exactly one place. I18nProvider
re-implemented it with a divergent list that omitted zh-CN, and the
unreferenced I18nProvider-BrowserFirst.tsx carried the same stale list.
LanguageSelector matches on resolvedLanguage, so a browser reporting
es-MX no longer renders Spanish while ticking English.

Stop the help page linking into redirects. Its "Dedicated tool pages"
grid pointed at six /tools/* URLs that all 301 to / or /tree; it now
renders from the seoViews catalog, so it cannot drift from the real
routes again.

Add GitHub Actions CI. pnpm check and the Playwright suite previously ran
only when a developer remembered; the pre-commit hook never ran e2e.

Also: StatsView's Arrays panel reused stats.noData ("No JSON data to
analyze") — adds a translated stats.noArrays to all 12 locales; drops the
Google Fonts preconnect/dns-prefetch hints, which opened TLS to Google on
every page although next/font self-hosts; adds lang="en" to global-error;
points the navbar's Open Source link at json-viewer rather than
json-formatter; prunes stale pnpm-workspace allowBuilds entries, the
spent Biome 2.5.7 exclusions, the biome.json schema version, and a
vercel.json functions block referencing a file that moved; deletes the
tracked LANGUAGE_TESTING.md / test-language.html / test-languages.sh rig
and rewrites .env.local.example around the one env var the code reads.

Verified: tsc clean, 51/51 unit tests, 12/12 e2e against a production
build, pnpm install --frozen-lockfile passes, pnpm audit --prod reports
no known vulnerabilities. ESLint 65 -> 64 warnings, Biome 240 -> 235, 0
errors throughout.

Note: the packageManager pin bump to pnpm@11.22.0 was already in the
working tree before this work and rides along here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
Phase 1 of the codebase audit. Every item here is a way the app could
change, lose, or misreport a document without telling anyone.

Rewrite JSONFixer as a string-aware scanner. The old implementation was a
pipeline of regexes applied to the raw text with no notion of where a
string literal starts or ends, so it rewrote content *inside* strings.
`{"glob": "src/*.js", "other": "*/x", "b": 1,}` came back as
`{"glob":"srcx","b":1}` — a key silently dropped and a value mangled,
reported as a successful fix — because `/*` in one string and `*/` in
another looked like a block comment. Any document containing a URL failed
outright, since `//` was stripped as a line comment. A fuzz harness over
20,000 hazard-saturated documents corrupted 7,437 valid ones against the
old code and none against the new. The scanner is also single-pass, which
removes the quadratic comment-stripping: 380 KB of unclosed `/*` went from
818 ms to single-digit ms.

The fixer no longer invents values. `}`, `]` and `}{` used to "repair" to
an empty container, so Format would replace what the user typed with `{}`.
A document with no content to rescue is now an error; `"a": 1}` still
repairs to `{"a": 1}`.

Validation no longer runs the repair pipeline on every keystroke — it
parses. Repair happens on an explicit Format.

Format warns before it rewrites. The parse/stringify round trip is lossy
in ways users cannot see: integers past 2^53 lose digits, `1e999` becomes
null, `-0` becomes 0, duplicate keys collapse, long decimals truncate. A
new scanner detects these in the source text and warns first. It compares
the emitted text against the literal's exact value rather than thresholding
on MAX_SAFE_INTEGER, so it stays quiet on large integers that survive the
round trip unchanged.

Close the graph tooltip's HTML-injection sink. It concatenated raw JSON
keys and values into `.html()`, and production CSP allows inline script, so
a hostile key executed on hover. It builds DOM with `.text()` now.

Guard every recursive walker. V8's JSON.parse is iterative, so a deeply
nested document parsed fine and then blew the stack in each view. A shared
depth guard now refuses past 512 levels with a translated notice —
measured against a structuredClone ceiling of 2,139, which the tree
editor's write path depends on. StatusBar, which renders outside every
ErrorBoundary and so took the whole app down with it, is guarded and
wrapped.

Fix the search. The Path Pattern filter tested the regex against the
container's path rather than the candidate's, starting from the empty
string, so no real pattern could ever match. User regexes are now bounded
by an empirical cost probe plus an input clamp rather than a syntactic
denylist that `((a+))+$` and `(a|a)*$` walked straight through. Results are
capped with the cap disclosed, patterns compile once instead of per node,
and word counting uses a Map so the word "constructor" stops turning every
percentage into NaN.

Fix the tree editor. Focusing a string value and clicking away re-parsed
it, so "123" became the number 123 and "null" became null with no edit at
all. Renaming a key onto an existing sibling destroyed that sibling.
Paths were strings split on `.`/`[`/`]`, so a key containing any of those
collided with real nesting and edits silently misrouted. Rows are now
keyboard-operable with tree/treeitem semantics.

Fix the map. Objects in a `name` or `country` field crashed the whole view
into the ErrorBoundary; `{lat: 0, lng: 10}` was dropped by a truthiness
check; a Polygon's coordinates coerced into a bogus marker; no range
validation. Tile loading is now opt-in and names every provider the grant
covers, since the requested tiles are a function of the coordinates in the
user's private JSON.

Fix the diff. `key in left` consulted the prototype chain, so a key named
`constructor` was compared against `Object` and crashed the semantic
filter. Arrays are aligned by content, so one prepended item no longer
reports every following item as modified. Both cap fallbacks now return a
degraded flag that CompareWorkspace surfaces, instead of presenting a
coarser answer as the real one.

Falsy scalars are documents. `0`, `false`, `null` and `""` are valid JSON;
truthiness checks reported them invalid in the workbench, the status bar,
jsonUtils and all five views. Validity is now carried explicitly.

Tests: 51 -> 538, with eight new pure modules extracted to make the
behaviour testable (jsonFixer scanner, jsonSearch, jsonPath, geoDetect,
jsonWalk, jsonStats, jsonDocument, jsonLossy). tsc clean, ESLint 64 -> 48
warnings, Biome 235 -> 208, 0 errors throughout, 12/12 e2e against a
production build, all 12 locales at an identical 199 keys.

Known limitation, deliberately not fixed: Format is still lossy for the
cases above — it now warns rather than staying silent. A lossless
formatter is its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
Phase 2 of the codebase audit: make the privacy posture match what the
site claims, and close the gap the dead Monaco stack left in the CSP.

Replace Google Analytics with Vercel Web Analytics. GA4 loaded
unconditionally whenever the measurement ID was set, dropping _ga cookies
before the visitor had any say — non-compliant with EU ePrivacy, which
requires consent before non-essential storage. Vercel Web Analytics sets
no cookies at all: visitors are identified by a hash of the incoming
request that is discarded after 24 hours, so there is nothing to consent
to and no banner to build. It is also first-party and same-origin
(/_vercel/insights/*), so no request leaves for Google.

Drop behavioural event tracking entirely. gtag.ts sent seven events —
format, compact, clear, copy, paste, tab switch, example used — recording
what people did with their JSON rather than that they visited. Nothing in
the product needs that. What remains is page views with country, referrer,
device and browser.

The trade-off, recorded deliberately: a 24-hour hash carries no cross-day
identity, so "visitors" means unique-per-period and new-vs-returning is
not available. Getting it back would need a persistent identifier, i.e. a
cookie and a consent banner.

Tighten the CSP now that everything it was propping up is gone:
  - 'unsafe-eval'        only Monaco's AMD loader ever needed it
  - googletagmanager,    five Google wildcards, replaced by one named
    google-analytics,    fallback host for the analytics script
    analytics.google.com
  - fonts.googleapis,    next/font/google self-hosts at build time
    fonts.gstatic
  - img-src https:       a blanket allowance letting any host serve
                         images; the two tile providers are named instead
  - worker-src data:     Monaco's worker stub
  - *.tile.openstreetmap no map style has pointed there for some time
'unsafe-inline' is the one remaining relaxation; removing it needs a nonce,
which needs middleware, which would make these statically prerendered pages
dynamic. Left as its own decision.

Add e2e/csp.spec.ts, which pins the policy and loads all nine routes
watching for violations. Browsers report CSP failures to the console, not
to the test runner, so without this a regression is invisible — and the
allowances tend to creep back the first time something looks broken.

Storage access can no longer take the page down. localStorage was read
bare in four places; the property access itself throws SecurityError when
a browser is set to block site data, and every call site runs inside an
effect, so the root ErrorBoundary replaced the whole page with an error
panel over a remembered theme. A safeStorage helper degrades to "no stored
value" instead.

Route all 24 JSON-LD blocks through one JsonLd component. JSON.stringify
escapes for JSON, not HTML, so a value containing </script> would close the
block. Every schema is built from static literals today, so there was no
live injection path; the component keeps that true by construction rather
than by remembering across twelve files.

Make the privacy copy honest. The help page claimed "No servers, no
tracking, no data ever leaves your machine" and "nothing is ever uploaded"
while GA4 shipped. It now says the JSON is never uploaded — which is true
and is the part users care about — and that page visits are counted
anonymously without cookies.

Verified: tsc clean, 545 unit tests, 22/22 e2e including the new CSP suite,
ESLint 48 warnings / 0 errors.

Note: Web Analytics must be enabled for the project in the Vercel
dashboard; the component is inert until it is, and off-platform the script
route does not exist at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
Reinstates Google Analytics alongside Vercel Web Analytics. The previous
commit replaced GA outright, which lost the audience metrics that were
wanted — new vs returning users needs a persistent identifier, and no
cookieless system can provide it.

Both now run. GA4 answers new-vs-returning and country breakdowns and is
free at any volume; Vercel Web Analytics needs no cookie and no consent, so
it keeps counting through declines and blockers, and covers the Hobby-plan
gap GA would otherwise leave.

GA runs behind Google Consent Mode. `analytics_storage` starts denied, so
the tag sends cookieless pings carrying no identifiers until the visitor
accepts. The default is inlined in the document head rather than set from a
component: it has to execute before the gtag script initialises, or GA
writes its cookies in the gap and the default never applies. It reads the
stored choice inline too, so a returning visitor who accepted is not
downgraded to cookieless for the first few hundred milliseconds of every
load. `next/script` with beforeInteractive is not usable here — outside
pages/_document it carries no ordering guarantee, and Next lints against it.

Verified against a build with a real measurement ID: `consent default`
fires with analytics_storage denied, zero _ga cookies exist before the
choice, and _ga plus _ga_<id> appear only after Accept, alongside a
`consent update` granting storage. Declining leaves it cookieless.

Behavioural events stay gone. The seven format / compact / clear / copy /
paste / tab-switch / example-used events removed in the previous commit are
not restored: they recorded what people did with their documents, which
audience measurement does not need. Page views only.

The banner reads consent through useSyncExternalStore rather than setting
state in an effect, so it neither trips react-hooks/set-state-in-effect nor
opts the component out of the React Compiler. Accept and Decline carry
equal visual weight — until a choice is made GA is already cookieless, so
declining costs the visitor nothing and should not look like the lesser
option. Translated across all 12 locales (203 keys each).

CSP regains the googletagmanager and google-analytics hosts, with the
wildcard subdomains Google's own guidance calls for so EU regional
collection endpoints are not blocked. Everything else stays tightened; the
e2e CSP spec now asserts the required hosts are present as well as that the
retired ones are gone, so neither direction can drift unnoticed.

Adds e2e/consent.spec.ts, including a test that types a canary string into
the editor and asserts no outbound request carries it — the JSON-stays-local
promise, checked rather than stated.

Verified: tsc clean, 545 unit tests, 25 e2e passing (2 skipped, they need a
measurement ID at build time), ESLint 48 warnings / 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5zaYdMzMZAVhvuLWQqKWB
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
json-formatter Ready Ready Preview Aug 26, 2026 2:27am

Request Review

@aduquehd
aduquehd merged commit edebd53 into main Aug 26, 2026
4 checks passed
@aduquehd
aduquehd deleted the chore/phase-0-dead-weight-and-ci branch August 26, 2026 02:30

This branch was successfully deployed

1 active deployment
Preview — 3ace60aa Deployed Aug 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant