Skip to content

Service setup

← Back to index

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/ (from frontend/)
  • Custom domain: locascore.ch
  • Build command: not configured (we deploy pre-built via wrangler)
  • Deploy command:
    cd frontend && npm run build
    npx wrangler pages deploy dist --project-name=neighborhood-report --commit-dirty=true
    

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_URLhttps://<project>.supabase.co - SUPABASE_SERVICE_KEY — service role key from Supabase - STRIPE_SECRET_KEYsk_live_... from Stripe - STRIPE_WEBHOOK_SECRETwhsec_... 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/AAAA records auto-managed by Pages
  • MX not 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 337606c hides 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

  1. Open Supabase dashboard → SQL editor
  2. Paste the contents of the migration file
  3. Hit Run
  4. 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_SECRET worker secret
  • Verification: stripe.webhooks.constructEvent in worker/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_TOKEN env var (frontend only)
  • Private token: stored as MAPBOX_TOKEN worker secret (geocode route, currently dormant)
  • Style: Mapbox Standard (commit 7c5819a upgraded 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 in VITE_GA_MEASUREMENT_ID env 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

# Install Node.js 22+ (e.g., via nvm)
cd frontend && npm install
cd ../worker && npm install

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

← Back to index