Audit fixes: 7 of 13 P0s, billing portal, and the rewrite foundation - #42
Merged
Conversation
…e cron 404 page The not-found route reused the ComingSoon template, so a mistyped URL — including a mistyped /pricing, which is what a would-be buyer sees — rendered "Page not found / is coming in the Tappymaps URL tree." The stub now accepts its own body and sub copy, and the 404 gets a real sentence plus its own document title and description instead of "coming soon" framing. Export counter Defaulted to a literal 2 before the server's first `check` response, while the actual free allowance is 3. That understated the free tier by a third on first render. It was invisible while the signed-in export path was broken; fixing that path made it a real first impression. Now defaults to the full allowance, with the server still authoritative. Keepalive cron /api/keepalive was an unauthenticated, service-role-backed database call anyone could hammer, and it returned raw Postgres error text — which can name tables, columns and policy failures. It now checks Vercel's `Authorization: Bearer $CRON_SECRET` and returns no error detail. Left open with a warning when CRON_SECRET is unset, so adding this cannot silently break the keepalive that stops the Supabase project pausing — a worse outcome than an exposed no-op query. Set CRON_SECRET to close it. Verified in a browser: the 404 renders coherently on /tap-in and an unknown path, /about still resolves normally, and a fresh signed-in user is now told 3 free exports. validate, smoke (24 checks) and the 11 API tests all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
…back
Three defects the audit reproduced in the live app. All silent — zero console
errors — which is why they survived.
1. Sharing was broken for every data map.
encodeStateToURL() was a bare btoa(), which rejects any code point above
U+00FF. applyDataMap joins legend ranges with an en dash (U+2013), so EVERY
data map produced a title the encoder threw on; an emoji in a title did the
same. The throw escaped through updateURL, copyShareLink, copyEmbedLink and
the async saveCurrentMapToGallery — no toast, no hash, no saved map.
Now encodes UTF-8 bytes first. ASCII output is byte-identical to the old
path, so links shared before this still open; verified.
2. Three of four export presets were silently clamped.
`#createMap > #mapContainer { max-height: 100% }` capped the capture frame,
and an inline height does not beat max-height. Square, portrait and story
all rendered at the on-screen height instead of their own, which also made
the export-only legend clamp inert — that is why the story preset pasted the
legend over New England. Lifting the cap for the capture restores the true
frames, measured:
landscape 3252x2286 (unchanged) square 3252x3252 (was ~856 tall)
portrait 3252x4065 story 3252x5781
Exactly 1.4225 / 1:1 / 4:5 / 9:16. Story export re-rendered and inspected:
legend now sits in the frame corner, clear of Maine.
3. The html2canvas fallback did not work.
.legend-group's frosted-glass background uses color-mix(), which html2canvas
cannot parse — it threw "unsupported color function". So the safety net was
dead precisely for maps that have a legend, including whenever the shipped
12-second dom-to-image timeout fires. The legend background is now resolved
to rgba() for the capture only. Forcing dom-to-image to reject now yields a
correct 3252x2286 image instead of an exception.
Also pins the playwright devDependency exactly: the floating range resolved to
a newer build than the browsers on this machine and shadowed the global
install, breaking `npm run smoke`.
validate, 18 unit tests, 11 API tests and the 24-check smoke suite all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
…age scores County colours lived in two stores that never synced. Clicks wrote a module-level `const countyColors`; history, the legend-recolor path and the mobile IIFE all read appState.countyColors, which was never even declared. So undo restored an always-empty map and wiped every county the user had coloured — and then renderMap() drew the 51-path national map over the 254-path county view, leaving a county stats bar above a state map. There is now one store; history mutates it in place (reassigning would silently detach the click handlers), and undo in county view repaints the county paths instead of re-rendering states. Measured on California: 58 counties load, 3 clicks → 3 stored and painted, undo → still county view, 58 paths intact, 2 stored and 2 painted, redo → 3. Data maps painted stale values under a fresh legend. applyDataMap only set colours for states present in the response and never cleared the rest, so states coloured by the PREVIOUS dataset kept their fills while the legend below described a different measure entirely. ACS returns -666666666 for suppressed estimates and those get dropped, so a partial response is reachable in production. It now clears first, and says plainly when coverage is incomplete instead of reporting "loaded for 6 states" as success — a map that states the wrong number confidently is worse than one that fails. Stored XSS through localStorage scores. Arcade best scores and leaderboard rows were interpolated into innerHTML unescaped, with escapeHTML(e.name) sitting on the line above. Scores are now coerced to numbers at the read boundary, which removes the class rather than patching each render site, and the render sites escape as well. Verified: the payload that previously executed on every hub render now injects nothing. Also investigated and dismissed a suspected double-binding of the quick-fill chips. There are genuinely two chip elements per group — one live in the rail, one in the dormant hidden mobile panel — with a single listener each. Quick fill works; the earlier "paints nothing" reading was the Pro gate. Reverted that change; Block 1 is back to its 34,471-char baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
…p.xml
The core task of this product could not be performed without a pointer.
All 51 state paths were tabIndex -1 and would not take focus, and the 24
colour swatches were unlabelled <div>s. A full Tab walk of /design/make
reached zero states and zero swatches, so "tap a colour, tap a state" had no
keyboard equivalent at all — and every Arcade game and GeoDraft round is the
same interaction.
• Colourable states are now role="button", tabindex="0", aria-label=<name>,
activated by Enter or Space. Delete/Backspace clears, giving keyboards
parity with right-click-to-clear, which they could never reach. Focus
shows the tooltip, so the state name is announced. DC is aria-hidden —
it carries no action and is excluded from the 50-state count.
• Swatches are real <button>s with aria-label and aria-pressed, plus a CSS
reset so the UA button styling doesn't show through.
• Added :focus-visible indicators for both. Operable but invisible focus is
barely better than not operable.
Measured after: focusing Ohio and pressing Enter colours it, Enter again
toggles it off, Space colours it again; a swatch takes focus and Enter changes
the selected colour; the Tab walk now reaches 50 states and 24 swatches.
SEO: robots.txt and sitemap.xml both returned the 744KB app as text/html,
because the vercel.json rewrite exclusion listed json but not txt or xml. So
there were no robots directives and no sitemap — on a client-rendered app
where a crawler has no other way to discover /pricing or /games/arcade. Added
both files and the missing extensions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
…failed
Every unqualified Gallery entry point pointed at Recent — the Hub footer, the
Hub tile, the editor's "View Gallery" button, and the mode's own default. But
Recent and Featured both read the user_maps table, which does not exist in the
live database, so 100% of visitors arriving at the Gallery met an error. My
Maps is local-first and always works; it is now the default and the target of
those links. Recent and Featured remain reachable by their own tabs.
"Save to My Maps" reported success even when nothing was stored. The
localStorage write was wrapped in `catch (_) {}` and the caller showed a
success toast unconditionally, so in Safari private mode or against a full
quota a user was told "Saved on this device", then found an empty gallery with
no explanation. gallerySaveMine now reports whether the write landed, and the
caller says plainly that it failed — and points at Share, which still carries
the whole map in the link.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
…ging
CSV import reported work it had not done, three ways.
• Rows whose value could not be parsed were dropped from the choropleth but
still counted as coloured, and the status line said "All rows matched".
A five-row paste with two bad cells claimed five states and painted three.
It now counts what was actually painted and names the states it skipped.
• Category imports past 12 wrapped the palette with `% length`, so category
13 got the same colour as category 1 — and was then sliced out of the
12-entry legend, which attributed that colour to a different category.
The map stated something false. Categories beyond the cap now roll into an
explicit "Other" bucket, the legend is built from distinct colours so every
colour on the map has exactly one truthful label, and the overflow is named.
• A single-row CSV, or one where every value is identical, threw on `.hex()`
because the degenerate-scale branch returned a string where every caller
expects a chroma object. It threw AFTER stateColors had been cleared, so a
malformed paste wiped the user's map and painted nothing. Fixed, and a
single distinct value now gets a legend showing that value rather than a
meaningless "12 – 12" range.
Measured: 5-row paste with 2 bad values → 3 painted, both named; 13 categories
→ 13 states, 12 distinct colours, every map colour present in the legend, no
duplicate; 1-row paste → 1 painted, no throw, map intact.
Gallery: "Loading My Maps…" forever.
A signed-in user whose Supabase request never resolved sat on a spinner
indefinitely while their maps were in localStorage the whole time — their own
saved work hidden behind someone else's uptime. Every cloud call is now bounded
(6s), local maps paint immediately with the cloud merging in when it arrives,
and the fallback says plainly that sync is unavailable. The same bound covers
Recent and Featured.
Also modernised csv-import-smoke and cloud-maps-smoke: both hardcoded
/usr/local/bin/google-chrome (failing before a single check ran) and served
through python's http.server, so they drove an app with no rewrites and no map.
They now use scripts/devserver.mjs. cloud-maps-smoke was failing on master for
a real reason — it is the run that surfaced the hang above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
The product advertised "Cancel anytime" and offered no way to cancel, no way to update a card, and no invoice history. A subscriber's only recourse was to email someone. api/stripe/portal.js authenticates the caller against Supabase, looks up their stripe_customer_id, and returns a one-time Stripe Billing Portal URL. The return URL is pinned to an allowed origin rather than taken from the request — accepting one there would make this an open redirect behind an authenticated action, the same reasoning the checkout handler already applies. A free user with no subscription gets an actionable 404 rather than a bare failure, and Stripe's internal configuration errors stay in the logs. In the client, "Manage subscription & billing" appears in the account section only for users who actually have a subscription — a button that 404s for free users is worse than no button. 5 new tests (16 total), including one asserting the return URL ignores a hostile Origin header. Verified in a browser: hidden signed-out, hidden for signed-in free, visible for Pro, and clicking issues the POST and redirects. Still outstanding for taking money safely: Terms of Service, a Privacy Policy, and a refund policy. Those are yours or a lawyer's, not mine to invent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
mapzimus
marked this pull request as ready for review
August 21, 2026 22:47
The 169 findings existed only in an ephemeral scratchpad. They are the most reusable output of this work — every one reproduced in a browser, with repro steps, evidence and a suggested fix — so they belong in the repo. docs/audits/2026-08-21/ holds the eight per-domain reports (3,887 lines), the consolidated HTML summary, and a README with the severity table, the P0 fixed/open split, and the two claims that were investigated and withdrawn during the audit (the quick-fill double-binding, and the 744KB payload — it is 174KB gzipped) so they are not re-reported later. HANDOVER.md now leads with this pass: what shipped in #41 and #42, what the rewrite foundation under src/ actually is (data extracted and verified byte-identical, one shared renderer, a hardened URL codec — not yet an application), and the three things blocked on the owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #41 (merged). The full audit is now complete: 169 findings — 13 P0, 46 P1, 71 P2, 39 P3 across eight domains, every one reproduced in a browser.
📊 Full audit report
The pattern
This app fails silently. Across hundreds of probe runs the console was almost always clean while sharing was broken for every data map, the signed-in export tier had never worked, undo destroyed county maps, and saves reported success and stored nothing. That's why these survived.
P0s fixed here
encodeStateToURLwas a barebtoa(); data-map legends use an en dash, so every one threw. Killed Share, Embed, the URL hash and Save. Now UTF-8; legacy links still decode.window._supabaseread twice, assigned nowhere. Pre-fix{allowed:false, serverError:true}and zero requests totrack-export— the quota had never run. Post-fix{allowed:true, remaining:3}.max-height:100%capped the capture frame. Square/portrait/story rendered at on-screen height, which also made the legend clamp inert (legend over New England). Now 3252×3252 / ×4065 / ×5781 — exactly 1:1, 4:5, 9:16.color-mix()— dead precisely for maps with a legend.tabIndex -1, 24 swatches unlabelled<div>s. Tab walk reached 0 of each; now 50 states + 24 swatches, Enter/Space to colour.-666666666, so partial responses are reachable.Also fixed
Billing correctness (stale row = permanent free Pro;
past_duerevoked mid-paid-period; webhook body corrupt at 8 of 35 split offsets on non-ASCII; a checkout withoutclient_reference_idreturned 200 and dropped a paid subscription) · Stripe billing portal so "Cancel anytime" is true · gallery hung on "Loading My Maps…" forever with maps sitting in localStorage · saves reporting success when nothing stored · stored XSS via localStorage scores ·robots.txt/sitemap.xmlreturning the 744KB app · 404 copy · keepalive cron exposed.Rewrite foundation
All 23 data tables extracted mechanically and verified byte-identical against the running app — 39 ACS datasets, 28 templates, 50 draft categories, the FIPS map. Plus one shared map renderer (replacing five near-duplicates), a total URL-state codec with 18 adversarial tests, safe storage, and escaped-by-construction DOM.
Tests
validate· 18 unit · 16 API · 24-check smoke · csv-import · cloud-maps — all green. Smoke and API tests run in CI.Needs you
user_maps,map_reports,gallery_publish_counts,classroom_codes) — migrations written, never run. Cloud sync, public gallery and the $12/mo Classroom tier have never worked.🤖 Generated with Claude Code
https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
Generated by Claude Code