Skip to content

Audit fixes: 7 of 13 P0s, billing portal, and the rewrite foundation - #42

Merged
mapzimus merged 8 commits into
masterfrom
claude/tappymaps-revamp-audit-4gm8pj
Aug 22, 2026
Merged

Audit fixes: 7 of 13 P0s, billing portal, and the rewrite foundation#42
mapzimus merged 8 commits into
masterfrom
claude/tappymaps-revamp-audit-4gm8pj

Conversation

@mapzimus

Copy link
Copy Markdown
Owner

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

Finding Evidence
Sharing broken for every data map encodeStateToURL was a bare btoa(); 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.
Signed-in free users could never export window._supabase read twice, assigned nowhere. Pre-fix {allowed:false, serverError:true} and zero requests to track-export — the quota had never run. Post-fix {allowed:true, remaining:3}.
3 of 4 export presets silently clamped 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.
Export fallback was dead html2canvas throws on the legend's color-mix() — dead precisely for maps with a legend.
Undo destroyed county maps Two stores that never synced. Measured: 58 counties, 3 clicks → undo → still county view, 58 paths intact, 2 painted.
Core task had no keyboard path 51 paths tabIndex -1, 24 swatches unlabelled <div>s. Tab walk reached 0 of each; now 50 states + 24 swatches, Enter/Space to colour.
Data maps painted stale values under a fresh legend Only responding states were recoloured. ACS suppresses estimates as -666666666, so partial responses are reachable.
CSV lost rows silently, 3 ways Unparseable values counted as coloured; 13th category reused colour 1 then got sliced from the legend; a 1-row paste threw after clearing and wiped the map.

Also fixed

Billing correctness (stale row = permanent free Pro; past_due revoked mid-paid-period; webhook body corrupt at 8 of 35 split offsets on non-ASCII; a checkout without client_reference_id returned 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.xml returning 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

  • 4 tables don't exist in production (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.
  • No ToS, Privacy or refund policy while charging money.
  • 2 P0s are product decisions: client-side entitlements and unwatermarked anonymous exports can't be fully closed in a client-rendered app.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9


Generated by Claude Code

claude added 7 commits August 21, 2026 22:03
…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
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
mapparatus Ready Ready Preview Aug 21, 2026 10:49pm
tappymaps Ready Ready Preview Aug 21, 2026 10:49pm

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
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.

2 participants