Service setup¶
How each external service (Cloudflare, Supabase, Stripe, Mapbox) is configured for LocaScore. Use this if you ever need to recreate the account, hand off to someone, or onboard a second dev.
For the actual deploy commands, see Deployment & ops. For the libraries and rationale, see Stack reference.
Cloudflare¶
Account ID: f03eba090a21541da9cbd714e7adb106
Plan¶
- Workers Paid (CHF 5/month) — required for KV write headroom (free tier caps at 1,000 writes/day, one pipeline upload uses 17,097)
- All other Cloudflare services included in this plan
Pages project: neighborhood-report¶
- Build output:
dist/(fromfrontend/) - Custom domain:
locascore.ch - Build command: not configured (we deploy pre-built via wrangler)
- Deploy command:
Worker: neighborhood-report-api¶
- Entry:
worker/src/index.ts - Compatibility date:
2025-03-30 - Compatibility flags:
nodejs_compat - Custom route:
locascore.ch/api/*→ forwarded to the worker - Defined in:
worker/wrangler.toml
KV namespaces¶
| Binding | ID | Purpose |
|---|---|---|
NEIGHBORHOOD_KV |
69e40836e3ec453f874e1531d2ed5d54 |
Cell data (17,097 entries) |
RATE_LIMIT_KV |
21feb70a6678420fadbda7a9391b4162 |
Rate limit counters + unique lookup tracking |
Both bound in wrangler.toml (root + production env).
Worker secrets¶
Set via wrangler secret put <NAME> (NOT in wrangler.toml):
- SUPABASE_URL — https://<project>.supabase.co
- SUPABASE_SERVICE_KEY — service role key from Supabase
- STRIPE_SECRET_KEY — sk_live_... from Stripe
- STRIPE_WEBHOOK_SECRET — whsec_... from the Stripe webhook config
- MAPBOX_TOKEN — public token, used by the (currently dormant) geocode route
- TURNSTILE_SECRET_KEY — currently unused
Worker environment vars (in wrangler.toml)¶
ALLOWED_ORIGIN = "https://locascore.ch"— used for CORS allowlisting
Bot Fight Mode¶
- Enabled in Cloudflare dashboard → Security → Bots
- Provides edge-level bot filtering on all public endpoints (teaser, geocode, email-capture)
DNS¶
locascore.ch— proxied through Cloudflare (orange cloud)A/AAAArecords auto-managed by PagesMXnot configured (no email infra yet)
Supabase¶
Project: <project>.supabase.co (region: EU West, free tier)
Plan¶
- Free tier: 500 MB DB, 50K MAU, 5 GB egress/month, daily backups, point-in-time recovery (7 days)
- Sufficient for soft launch — would upgrade to Pro at scale
Auth configuration¶
- Email + password enabled (primary)
- Email confirmation: required (user gets a magic link to confirm)
- OAuth providers: Google ready but disabled (commit
337606chides the button until credentials are configured) - Password recovery: enabled, redirects to
/auth/forgot-password - JWT expiry: default (1 hour, refreshed automatically)
Database schema¶
Migrations in supabase/migration_*.sql, run sequentially in the SQL editor.
For each table see Backend.
| # | Migration | Purpose |
|---|---|---|
| 001 | Base | profiles, purchases, reports, RLS, signup trigger |
| 002 | Tokens | Add token_balance to profiles |
| 003 | Location key | Add location_key (H3 cell) to reports for live KV re-fetch |
| 004 | Atomic unlock | unlock_report() RPC (token deduct + report insert in one tx) |
| 005 | Atomic purchase | process_purchase() RPC (idempotent Stripe webhook handler) |
| 006 | Lock RPCs | REVOKE EXECUTE from public/authenticated on internal RPCs |
| 007 | Feedback | feedback table + strict RLS + REVOKE ALL FROM anon |
| 008 | Email captures | email_captures table + strict RLS + REVOKE |
| 009 | Admin views | 8 read-only views for the founder dashboard |
Row-level security¶
Every user-facing table has RLS enabled. Two patterns are used:
1. auth.uid() = user_id — for tables users should be able to read (profiles, reports)
2. No SELECT policy at all — for tables only the worker should read (feedback, email_captures)
The worker uses the service role key to bypass RLS for atomic operations.
How to run a new migration¶
- Open Supabase dashboard → SQL editor
- Paste the contents of the migration file
- Hit Run
- Verify in the Table Editor that the schema looks right
Migrations are NOT idempotent for tables (CREATE TABLE not IF NOT
EXISTS) — running twice will fail, which is intentional.
Service role key¶
- Set in Supabase dashboard → Settings → API → service_role
- Stored as a Worker secret (
SUPABASE_SERVICE_KEY) - Never exposed to the frontend or committed to source
Stripe¶
Mode: Live (not test mode — we ship to real users now)
Products¶
Currently hardcoded in worker/src/types.ts::STRIPE_PRODUCTS:
| Key | Tokens | Price (CHF) | Stripe price ID |
|---|---|---|---|
single |
1 | 19.00 | price_single_chf_19 |
pack3 |
3 | 49.00 | price_pack3_chf_49 |
pack10 |
10 | 129.00 | price_pack10_chf_129 |
(Replace IDs with actual price_xxx from Stripe dashboard.)
Payment methods¶
- Card (Visa, Mastercard, Amex)
- TWINT — Swiss mobile payment, must be enabled per-product in Stripe dashboard. Critical for the Swiss market.
- Apple Pay / Google Pay — auto-enabled via Stripe Checkout
Promo codes¶
Enabled — see commit 42c6d0f for the implementation. The 3-pack page
shows a strikethrough price + discount messaging when promo codes are
applied.
Webhook¶
- Endpoint:
https://locascore.ch/api/webhook/stripe - Events:
checkout.session.completed - Signing secret: stored as
STRIPE_WEBHOOK_SECRETworker secret - Verification:
stripe.webhooks.constructEventinworker/src/routes/webhook.ts
Idempotency¶
The process_purchase() RPC uses stripe_session_id as a natural key
in the purchases table. If Stripe retries the webhook, the second
call is a no-op.
Refunds¶
Currently manual — issued via Stripe dashboard. The webhook does NOT
handle charge.refunded yet (would need to clawback tokens). Low
priority while we're at low volume.
Test cards¶
For local testing without burning real money, use Stripe test mode +
test card 4242 4242 4242 4242 with any future expiry + any CVC.
Switch to test mode by swapping the secret keys.
Mapbox¶
Plan¶
- Free tier: 50,000 map loads / month
- Currently well under budget — soft launch traffic is minimal
Configuration¶
- Public token: stored as
VITE_MAPBOX_TOKENenv var (frontend only) - Private token: stored as
MAPBOX_TOKENworker secret (geocode route, currently dormant) - Style: Mapbox Standard (commit
7c5819aupgraded from Streets v12) - 3D buildings: enabled in the report map
Why we DON'T use Mapbox geocoding anymore¶
Mapbox has commercial use restrictions for "real estate" applications that we wanted to avoid risk on. We switched to the Swiss Federal geocoder (api3.geo.admin.ch) which is free, no API key, no commercial restrictions. The Mapbox geocode worker route still exists but is unused.
GoDaddy / Domain¶
- Domain:
locascore.ch - Registrar: Cloudflare Registrar (transferred from original registrar)
- Annual cost: ~CHF 4.64
- Auto-renew: enabled
- Privacy: WHOIS privacy enabled (Cloudflare default)
Google Analytics 4 (optional)¶
Setup¶
- Property: LocaScore (Web)
- Measurement ID:
G-XXXXXXXXXX(set inVITE_GA_MEASUREMENT_IDenv var) - Tracking: consent-gated — only loaded after the user accepts the cookie banner
Events instrumented¶
33 funnel events covering: address search → teaser → auth → checkout →
unlock → post-purchase. See frontend/src/utils/analytics.ts::EVENTS
for the full list. See also Frontend > Analytics.
Retention¶
- User data: 14 months (default)
- Anonymize IP: enabled
Local development setup¶
For a new dev (or when restoring a machine), the steps to get running locally:
1. Install conda + the pipeline env¶
# Install miniforge or miniconda
conda create -n hood-analyzer python=3.11
conda activate hood-analyzer
pip install pandas geopandas h3 pandana osmnx rasterio duckdb pyproj shapely
pip install r5py # optional (Java 21+ required)
pip install mkdocs-material # for docs
2. Install Node + frontend/worker dependencies¶
3. Set up environment variables¶
# frontend/.env.local (NOT committed)
VITE_API_URL=https://neighborhood-report-api.dn-de-ridder.workers.dev
VITE_SUPABASE_URL=https://<project>.supabase.co
VITE_SUPABASE_ANON_KEY=<anon-key>
VITE_MAPBOX_TOKEN=<public-token>
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX # optional
# worker secrets — set via wrangler, NOT in any file
cd worker
npx wrangler secret put SUPABASE_URL
npx wrangler secret put SUPABASE_SERVICE_KEY
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
npx wrangler secret put MAPBOX_TOKEN
4. Run things locally¶
# Frontend dev server (uses mocks if VITE_API_URL is unset)
cd frontend && npm run dev
# Worker local dev
cd worker && npx wrangler dev
# Pipeline (one-shot)
cd code && conda activate hood-analyzer && python pipeline.py
# Tests
cd frontend && npm run test:e2e
# Docs
cd .. && mkdocs serve