Exploration Maps
Browser-based map builder for mineral exploration: search provincial claim
registries, import drill/geology data, style investor-ready maps, and export
them as PNG/SVG/PDF. Live at explorationmaps.com.
Stack
- Frontend: React 18 + Vite 5 single-page app (
src/), Leaflet map engine
- Backend: Supabase (auth, Postgres, RLS) + Vercel serverless functions (
api/)
- Static marketing content: generated blog + company pages under
public/
(see scripts/generate-blog.js and scripts/pseo/)
Requirements
- Node 20+ (Vite 5 requirement; dev/CI verified on Node 20/22)
- npm (a
package-lock.json is committed — use npm ci for reproducible installs)
Development
The serverless functions under api/ run on Vercel. For full-stack local
work use vercel dev; with plain npm run dev the claims search and
analytics endpoints are absent (the UI degrades gracefully).
Commands
Environment variables
Documented in .env.example. Summary:
Public (VITE_ — compiled into the client bundle, never secret):
VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY — Supabase project + anon key
(production-required; without them auth/cloud features disable themselves)
VITE_ADMIN_EMAIL — controls admin dashboard visibility only; real
authorization is server-side (admin_users table + is_admin())
Server-only secrets (set in Vercel, never referenced by client code):
SUPABASE_SERVICE_ROLE_KEY — used by /api/track for analytics ingestion.
Production-required once migration 20260710000004 is applied.
ADMIN_API_SECRET — optional; unlocks the claims-API diagnostic modes in
production via the x-admin-secret header.
SUPABASE_URL / SUPABASE_ANON_KEY — optional server-side aliases; the
VITE_-prefixed values are used as fallbacks by api/claims.js (Quebec)
and api/track.js.
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_MONTHLY_ID,
STRIPE_PRICE_YEARLY_ID — Pro-subscription billing (see docs/billing.md).
Until set, billing endpoints return 503 and all plan checks fail open —
safe to deploy the code before configuring Stripe.
Architecture notes
Tenure Monitor (/tenure-monitor)
Watches a saved portfolio of B.C. mineral tenures: days remaining against each
claim's good-to-date, email reminders, change detection, and one-click hand-off
into the map editor. Read-only with respect to government transactions — it
never touches MTO on a user's behalf.
- Data: a mirror of DataBC's
MTA_ACQUIRED_TENURE_SVW layer, loaded by
scripts/tenure-sync/ from GitHub Actions (nightly full + twice-daily
targeted). A partial or failed government response aborts without writing,
so a bad day at the province cannot damage stored portfolios.
- Reminders:
scripts/tenure-alerts/, daily. De-duplication is a unique
database constraint rather than application logic.
- Deadlines are computed in
America/Vancouver throughout
(src/utils/tenureDates.js), never in the browser's timezone.
- Developer docs:
docs/tenure-monitor.md.
User help: docs/tenure-monitor-help.md.
Serverless APIs (api/)
api/claims.js — multi-province claims search proxy (BC WFS, ArcGIS
provinces ON/SK/MB/NL/YT, Quebec via a self-hosted Supabase table). Full
pagination with honest meta: { totalKnown, returned, truncated, pagesFetched, provider }; rate-limited; CORS-restricted; sanitized errors.
api/bc-claims.js — BC-only WFS proxy (nearby-claims bbox + legacy search).
api/tenure-search.js — Tenure Monitor search over the Exploration Maps
mirror of the B.C. mineral tenure registry (tenure number, pasted list, owner
name, client number, map extent, geometry). Anonymous-safe, rate-limited, and
every response states when the mirror was last synchronized. Reads with the
ANON key under the public-read policy, never the service role.
api/track.js — all analytics ingestion (page views, live-presence pings,
product/search events, landing clicks, leads). Enforces an event-name
allowlist, payload size/depth limits, and session-id shape; derives geo
from edge headers (never the body); resolves user identity from a verified
Supabase access token; writes with the service role; rate-limits per IP.
Without SUPABASE_SERVICE_ROLE_KEY it accepts-and-drops so analytics can
never break the app. Identified automated browsers are dropped before any
write. Admin reports also exclude admin accounts and browsers marked
internal; opening the admin dashboard marks that browser, while
?em_internal=1 and ?em_internal=0 provide an explicit set/clear control.
api/stripe-checkout.js / api/stripe-portal.js / api/stripe-webhook.js
— Pro-subscription billing (Stripe Checkout + customer portal + the
signature-verified webhook that syncs public.user_plans). Grandfathered
accounts (pre-launch) are never downgraded. See docs/billing.md.
api/_lib/ — shared helpers (pagination, request guards, esri→GeoJSON);
the underscore path means Vercel does not expose them as endpoints.
Shared maps
Share links are /map/<id> where <id> is a client-generated
crypto.randomUUID() (a 122-bit random token). Reads go through the
get_shared_map(share_id) RPC — table-level SELECT is revoked, so
shared_maps cannot be enumerated from the public client. Old links keep
working: the id remains the lookup key.
Authentication & admin
Supabase auth (magic-link default, password fallback). Admin access is a row
in public.admin_users checked by is_admin() inside SECURITY DEFINER
reporting functions; all definer functions have a pinned search_path and
explicit execute grants. To add an admin:
Local project storage
src/utils/projectStorage.js: projects + the working draft live in
localStorage, deflate-compressed (gz1: prefix), with a schema version and
deterministic migrations. Corrupted records are preserved under a
.recovery key (never auto-deleted) and surfaced with manual export/discard
helpers. Writes return structured results — the UI never claims success on a
failed write. On sign-in, local projects migrate to the cloud with
per-project status + retry (src/utils/cloudMigration.js).
Import formats
CSV (RFC 4180 via Papa Parse; auto-detected lat/long columns, plus azimuth,
dip and length for drill traces), GeoJSON (validated), zipped shapefiles,
loose .shp/.dbf/.prj/.shx sets (the .prj is honored — projected files are
reprojected to WGS84 via proj4), KML, KMZ (bounded: entry count, uncompressed
size, path traversal), and georeferenced images (.png/.jpg/.gif/.webp with
a world file or typed-in edges, lat/long or UTM; resampled to 2048 px and
stored in the project). Published tile ({z}/{x}/{y}) and WMS services can
be added as layers under Reference Overlays.
Claim registries
Canada: BC (WFS), Ontario/Saskatchewan/Manitoba/Newfoundland &
Labrador/Yukon (official ArcGIS services), Quebec (weekly-synced Supabase
mirror). United States: federal BLM MLRS claims for 11 western states,
behind VITE_ENABLE_US_CLAIMS — federal claims only, no state-managed
tenure (Alaska state claims not included); boundaries are generalized BLM
representations, not legal surveys. See docs/us-claims.md for the data
source, field mapping, and the post-deploy verification checklist.
Database migrations
Timestamped, additive migrations live in supabase/migrations/. The legacy
supabase-*.sql files in the repo root are the historical hand-run setup
scripts — they document existing production state; new changes go in
supabase/migrations/ only.
Apply order matters — two migrations are deploy-coupled:
Each migration header includes rollback instructions. After applying, verify
RLS as an anonymous client, a normal authenticated user, and the admin —
verification queries are embedded in ...000005.
20260901000001_shared_rate_limit.sql is additive and can be applied any
time, but until it is, the cross-instance rate limiter in api/_lib/guard.js
has nothing to call and fails open (only the per-instance limiter applies).
The send-welcome edge function must also be redeployed
(supabase functions deploy send-welcome) to pick up its lead-row check.
Deployment & rollback
Pushes to main deploy via Vercel (npm run build → dist/, plus api/
functions). Rollback = redeploy the previous Vercel deployment (instant);
if a deploy-coupled migration was applied after the deploy being rolled
back, also run the rollback SQL from that migration's header.
Testing
Vitest + React Testing Library (tests/, config in vitest.config.js).
Coverage focuses on regression protection: save/autosave race conditions,
claims request ordering/cancellation, provider pagination, shapefile
projection + CSV/GeoJSON parsing, storage failure/corruption handling,
local→cloud migration retries, API hardening, ArcGIS geometry conversion,
and shared-map access.