Skip to content

Tappymaps revamp: full-app audit + fixes - #41

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

Tappymaps revamp: full-app audit + fixes#41
mapzimus merged 7 commits into
masterfrom
claude/tappymaps-revamp-audit-4gm8pj

Conversation

@mapzimus

Copy link
Copy Markdown
Owner

What this is

A full-scale audit of Tappymaps followed by fixes. Ten agents drove the real app in a real browser — Create editor, export pipeline, Arcade, GeoDraft, Gallery/Embed/Share, monetization + serverless, mobile/responsive, a11y/SEO/perf, code health + security, and data maps/CSV import — and the findings are being fixed on this branch.

Status: audit in progress. This PR is open early so the work is durable. It will be updated as findings land and fixes ship.

Landed so far — the safety net

The project's own smoke test was giving false green. Reproduced before touching it:

WHAT SMOKE SEES  : mode=design/make -> reported "ok"
WHAT IS REAL     : statePaths: 0, chroma: undefined, topojson: undefined,
                   domtoimage: undefined, html2canvas: undefined, supabase: undefined
raw console errors        : 6
errors AFTER smoke IGNORE : 0   <- why it reported green

It served the repo over python -m http.server (no Vercel rewrites, no /api) and its ignore list contained a broad net::ERR, so a total CDN failure read as a clean boot.

  • scripts/devserver.mjs (new) — local Vercel-alike server reproducing vercel.json: SPA rewrite, /s/:hash, /api/* stubs. Vendors the six third-party <script> tags and both us-atlas TopoJSON files into .cache/vendor/, so the harness is hermetic and a missing asset is a hard 500 rather than an ignorable network error. npm run dev now serves the app the way production does.
  • scripts/smoke.mjs (rewritten) — 24 positive functional assertions instead of error-absence: libraries loaded, 51 state paths rendered, a click colors a state and a second click toggles it off, captureMapImage returns a non-blank canvas at real dimensions, every route resolves to a mode, the Arcade run fills its prompt queue and reproduces from a seed, GeoDraft opens in the pick phase having ranked all 50 states, both games render their own 51-state SVG.
  • Export composition invariants — the rasterizer entry points are wrapped to snapshot computed style and bounding box at the moment the node is handed over, asserting the logo, map title, and legend are genuinely visible in the captured output. The logo check encodes the Phase 0 §9 mandatory-logo rule.
  • CI — smoke now runs on every push and PR (~10s), alongside validate.
  • .gitattributes — closes the long-standing CRLF/LF open item.

The suite was adversarially tested

Injected a runtime TDZ (a top-level read of fipsToState from the Arcade block): 13 failed checks, exit 1, while validate still reported PASS — exactly the blind spot smoke exists to cover.

Injected a silently hidden legend inside captureMapImage: it passed, because a distinct-colour heuristic can't see a missing legend. That gap is why the capture-time invariant checks were added; the same injection now fails as it should.

Verification

npm run validate and npm run smoke both green on every commit here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9


Generated by Claude Code

claude added 4 commits August 21, 2026 18:21
…d quiet

The committed smoke test served the repo over `python -m http.server` (no
Vercel rewrites, no /api) and ignored every console error matching
/net::ERR|Failed to load US map|census|topojson/. On a machine that cannot
reach the CDN it printed "all 10 routes booted clean" while ZERO state paths
had rendered and chroma / topojson / dom-to-image / html2canvas / supabase
were all undefined. Reproduced and confirmed before rewriting.

A green smoke run on a dead app is worse than no smoke run, so:

- add scripts/devserver.mjs — a local Vercel-alike server that reproduces
  vercel.json (SPA rewrite, /s/:hash, /api/* stubs) and vendors the six
  third-party <script> tags plus both us-atlas TopoJSON files into
  .cache/vendor/. Hermetic after first run, and a missing vendor file is a
  hard 500 rather than a silent network error a test can ignore.

- rewrite scripts/smoke.mjs around positive functional assertions instead of
  error-absence: libraries actually loaded, 51 state paths rendered, a click
  colors a state and a second click toggles it off, captureMapImage returns a
  non-blank canvas at real dimensions, every route resolves to a mode, the
  arcade run fills its prompt queue and reproduces from a seed, and GeoDraft
  opens in the pick phase having ranked all 50 states. Both games are checked
  to render their own 51-state SVG.

- narrow the ignore list to favicon and the two stubbed endpoints.

24 assertions, all passing. `npm run dev` now serves the app the way
production does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
Now that smoke.mjs makes real functional assertions and runs hermetically
through scripts/devserver.mjs, it is worth gating on: ~10s locally. Validate
alone only proves the file parses, which is blind to the runtime TDZ class of
bug that blanks the whole app.

Adds a second job that installs Chromium, caches the vendored libs, and runs
the smoke suite. Bumps the Node version to 22 to match local development.

Whether these checks block merges is a branch-protection setting on the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
Adversarially tested the new smoke suite by injecting two regressions.

It caught the runtime TDZ (a top-level read of fipsToState from the Arcade
block): 13 failed checks, exit 1, while `validate` still reported PASS —
which is exactly the blind spot the smoke test exists to cover.

It did NOT catch a silently hidden legend inside captureMapImage. Every
export check still passed, because a distinct-colour heuristic is far too
coarse to notice a missing legend — the kind of regression that looks fine in
the editor and only shows up in the downloaded file.

So the rasterizer entry points are now wrapped to snapshot computed style and
bounding box at the exact moment the node is handed over, asserting the logo,
map title, and legend are all genuinely visible in the captured output. The
logo check encodes the Phase 0 §9 rule that it is mandatory on every export
for every tier. Re-ran the injected regression: it now fails as it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
Closes a long-standing open item from HANDOVER.md: CRLF/LF warnings fired on
every edit from a Windows checkout. Cosmetic, but it made `git status` noisy
enough to hide real changes.

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 6:37pm
tappymaps Ready Ready Preview Aug 21, 2026 6:37pm

CI went red on /design/gallery/recent while the same route passed locally.
The difference was reachability: GitHub Actions can reach Supabase and got a
404 back; locally the request simply never completed. A suite that flips on a
third party's uptime gets ignored, so everything leaving localhost is now
intercepted and fulfilled with an empty-but-valid response.

Fulfil rather than abort — an aborted request logs its own console error,
which the suite would then have to special-case away, and a broad "ignore
network errors" rule is precisely what made the previous version blind.

Blocked origins are printed as an informational footer, never a failure, so a
newly added runtime dependency is still visible in the log.

The 404 that surfaced this is a real product bug and is being tracked
separately: the client queries user_maps, map_reports, gallery_publish_counts
and classroom_codes, and none of those tables exist in the live database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
checkExportPermission() and recordExport() both read `window._supabase`,
which is never assigned anywhere in the file — the client is `supabaseClient`,
declared above them, and that is the idiom every other call site uses.

So the first line of each try block threw a TypeError, the catch returned
`{allowed:false, serverError:true}`, and every signed-in free user was told
"check your connection" and blocked from exporting. /api/stripe/track-export
was never reached, which means the server-enforced 3-exports-per-month quota
has never actually run in production.

Measured, before and after, driving the real app:

  pre-fix   checkExportPermission -> {allowed:false, serverError:true}
            track-export requests -> NONE
  post-fix  checkExportPermission -> {allowed:true, remaining:3}
            track-export requests -> POST /api/stripe/track-export

Both call sites now use supabaseClient and bail out cleanly when it is null
(the Supabase CDN script failing to load should not read as a server error).

Nothing exercised the signed-in export path, which is why this survived, so
the smoke suite now asserts the quota endpoint is genuinely reached. Verified
the new check fails against the pre-fix code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
…nd dropped checkouts

The money path had no tests. Adding some found three real defects and one bug
in the first attempt at fixing them.

verify-subscription
- A stored row was treated as proof of entitlement forever. One dropped
  customer.subscription.deleted webhook meant permanent free Pro. The row is
  now authoritative only while the period it describes is still running, plus
  a 3-day grace window for clock skew and late renewal webhooks. Past that, it
  reconciles against Stripe and repairs the row, so a dropped webhook costs one
  extra API call instead of an unbounded free subscription.
- past_due was excluded from the entitling statuses, so the first failed charge
  revoked Pro mid-period — for a period the customer had already paid for. It
  now entitles through the dunning window; the period-end check ends access.
- Reconciliation fails OPEN on a Stripe outage. A third party having a bad
  minute must not lock out a paying customer. A genuine 404 still revokes.

webhook
- getRawBody used `data += chunk`, decoding each chunk independently as UTF-8.
  A multi-byte character straddling a chunk boundary corrupted the body so it
  no longer matched the bytes Stripe signed. Measured across every split offset
  of a payload containing an accented name: corrupts at 8 of 35 (22.9%).
  Now collects Buffers and concatenates. The test scans every offset — a single
  hand-picked boundary passes against the broken version, which is how this
  presented as a flaky webhook rather than an obvious break.
- checkout.session.completed without client_reference_id hit `break` and
  returned 200, telling Stripe the event was handled and permanently dropping
  a subscription the customer had paid for. It now attempts recovery via
  stripe_customer_id and otherwise returns 500 so Stripe retries and the
  failure is visible.

Writing the tests also surfaced that a missing STRIPE_SECRET_KEY makes the new
reconcile path silently fail open; the handler now warns at startup.

11 tests, wired into `npm test` and a CI job. No production calls — Supabase
and Stripe are stubbed through a module loader hook so the handlers run
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
@mapzimus
mapzimus marked this pull request as ready for review August 21, 2026 21:55
@mapzimus
mapzimus merged commit 3a72112 into master Aug 21, 2026
6 checks passed
mapzimus pushed a commit that referenced this pull request Aug 21, 2026
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