From 8fd5bab3f16158512bb0487346cf87bdc75fb7f4 Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Thu, 1 Oct 2026 14:31:49 +0700
Subject: [PATCH] fix(stats): resolve derived regions via BigDataCloud, not
Nominatim
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nominatim returned 429 for our IP because the read-time derivation runs from
every viewer on every load — its usage policy forbids that, and a 429 makes
reverseGeocodeRegions() (which returns empty on !res.ok) fail silently, freezing
the resolve counter and stalling the region card.
The stats fallback now uses BigDataCloud's reverse-geocode-client (no key, CORS
on both hops, English countryName/countryCode/city), spaced ~300ms. Nominatim
stays for create/edit geocoding only (author-initiated, one call per spot).
/privacy now names both providers.
---
AGENTS.md | 21 ++++++++++------
src/lib/derivedRegions.ts | 53 +++++++++++++++++++++++++++++++--------
src/pages/PrivacyPage.tsx | 2 +-
3 files changed, 58 insertions(+), 18 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index a55ca93..ba9ae3e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -910,13 +910,20 @@ session with **no other memory of this project**. So:
of an untagged pin's own coordinates** (`lib/derivedRegions.ts`, localStorage
`skatespots:derived-regions-v1`, keyed by ~110 m-rounded coords). A
`useDeriveRegions()` hook mounted on `/stats` scans the community relay for
- untagged spots/shops, fills cache misses at **1.1 s spacing** (Nominatim
- 1 req/s) and invalidates `growth-stats`; the card shows "Resolving N…" until
- done. ⚠️ This geocodes pins **when the stats page is viewed**, so Nominatim
- sees the viewer's IP + public pin coords — disclosed on `/privacy`
- ("Spot/shop geocoding"). It is a **display fallback only**; the backfill above
- is what writes real `city`/`country` tags. Either way the card's `Uncategorised`
- row now means "couldn't be tagged OR resolved", not "no tag".
+ untagged spots/shops, fills cache misses, and invalidates `growth-stats` every
+ 5 resolved (and at the end); the card shows a live "Resolving N…" counter.
+ ⚠️ **Do NOT use Nominatim for this** — it is per-VIEWER volume (every device,
+ every reload), Nominatim's policy forbids it and it **429'd us** (2026-10-01),
+ which silently broke every remaining lookup (`reverseGeocodeRegions()` returns
+ empty on `!res.ok`, so the counter froze and the card stalled). The fallback
+ now uses **BigDataCloud** `reverse-geocode-client` (no key, CORS `*` on both
+ `api.bigdatacloud.net` → 307 → `api-bdc.io`, English `countryName`/`countryCode`/
+ `city`), spaced ~300 ms. Nominatim stays for **create/edit** only (the author's
+ own pin, one call per spot). ⚠️ This geocodes pins when the stats page is
+ viewed, so the provider sees the viewer's IP + public pin coords — disclosed on
+ `/privacy` ("Spot/shop geocoding", names both providers). It is a **display
+ fallback only**; the backfill above writes real `city`/`country` tags. Either
+ way the card's `Uncategorised` row now means "couldn't be tagged OR resolved".
- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out
free handles so a new skater has a human name from minute one. The registry is
the Netlify Function **`netlify/functions/nip05.mjs`**
diff --git a/src/lib/derivedRegions.ts b/src/lib/derivedRegions.ts
index 4466630..1b14c39 100644
--- a/src/lib/derivedRegions.ts
+++ b/src/lib/derivedRegions.ts
@@ -11,15 +11,18 @@
* cached in localStorage (keyed by ~110 m-rounded coordinates), so each pin is
* reverse-geocoded at most once per device.
*
- * ⚠️ Privacy: unlike create/edit geocoding (which is the author's own pin), this
- * runs when the STATS PAGE IS VIEWED, so Nominatim sees the viewer's IP and the
- * (public) coordinates of untagged pins. Disclosed on /privacy.
+ * ⚠️ Uses **BigDataCloud** (`reverse-geocode-client`, no key, CORS, designed for
+ * browsers) — NOT Nominatim. Nominatim's usage policy forbids this kind of
+ * per-viewer volume and returned 429 (see AGENTS.md). Nominatim stays for
+ * create/edit geocoding (author-initiated, one call per spot).
+ *
+ * ⚠️ Privacy: unlike create/edit geocoding (the author's own pin), this runs when
+ * the STATS PAGE IS VIEWED, so BigDataCloud sees the viewer's IP and the (public)
+ * coordinates of untagged pins. Disclosed on /privacy.
*/
import ngeohash from 'ngeohash';
-import { reverseGeocodeRegions } from '@/lib/geocodeRegions';
-
export interface DerivedRegion {
city: string;
country: string;
@@ -27,8 +30,12 @@ export interface DerivedRegion {
}
const STORAGE_KEY = 'skatespots:derived-regions-v1';
-/** Nominatim's usage policy allows at most one request per second. */
-const GEOCODE_SPACING_MS = 1100;
+/**
+ * BigDataCloud tolerates far more than Nominatim's 1 req/s, but keep it polite:
+ * ~3 req/s. The whole list is one pass per device, then cached.
+ */
+const GEOCODE_SPACING_MS = 300;
+const REQUEST_TIMEOUT_MS = 8000;
let memoryCache: Record | null = null;
@@ -87,9 +94,35 @@ export function coordsOf(event: { tags: string[][] }): { lat: number; lng: numbe
return null;
}
+/** Best-effort reverse geocode via BigDataCloud. Never throws. */
+async function geocodeClient(lat: number, lng: number): Promise {
+ const empty: DerivedRegion = { city: '', country: '', countryCode: '' };
+ try {
+ const res = await fetch(
+ `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`,
+ { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) },
+ );
+ if (!res.ok) return empty;
+ const data = (await res.json()) as {
+ city?: string;
+ locality?: string;
+ countryName?: string;
+ countryCode?: string;
+ };
+ return {
+ city: data.city || data.locality || '',
+ country: data.countryName || '',
+ countryCode: String(data.countryCode ?? '').trim().toUpperCase(),
+ };
+ } catch {
+ return empty;
+ }
+}
+
/**
* Reverse-geocode a list of coordinates and fill the cache. Sequential and
- * spaced for Nominatim's 1 req/s policy. Returns how many NEW regions were added.
+ * lightly spaced. `onProgress` fires after each item (so a UI counter can tick).
+ * Returns how many NEW regions were added.
*/
export async function fillDerivedRegions(
coords: Array<{ lat: number; lng: number }>,
@@ -103,9 +136,9 @@ export async function fillDerivedRegions(
// Skip duplicates / already-cached pins, but still advance the progress bar.
if (!seen.has(key) && !getDerivedRegion(lat, lng)) {
seen.add(key);
- const geo = await reverseGeocodeRegions(lat, lng);
+ const geo = await geocodeClient(lat, lng);
if (geo.city || geo.country || geo.countryCode) {
- setDerivedRegion(lat, lng, { city: geo.city, country: geo.country, countryCode: geo.countryCode });
+ setDerivedRegion(lat, lng, geo);
filled += 1;
}
if (i < coords.length - 1) {
diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx
index c9dff7c..6f7699d 100644
--- a/src/pages/PrivacyPage.tsx
+++ b/src/pages/PrivacyPage.tsx
@@ -148,7 +148,7 @@ export default function PrivacyPage() {
- Map tiles (OpenStreetMap) — see your IP and which area of the map you look at.
- - Spot/shop geocoding (OpenStreetMap Nominatim) — when you create a spot or shop we send its coordinates to fill in the city/country tag, and when you open Community Stats we may send the coordinates of pins that have no location tag yet, so they can be grouped by region. Each is cached on your device, so it happens at most once per pin. Nominatim sees your IP. These are the pin you placed / public pin coordinates, never your device's live GPS.
+ - Spot/shop geocoding — when you create or edit a spot or shop, its coordinates go to OpenStreetMap Nominatim to fill in the city/country tag. When you open Community Stats, the coordinates of pins that have no location tag go to BigDataCloud so they can be grouped by region (cached on your device — at most once per pin). Both see your IP. These are the pin you placed / public pin coordinates, never your device's live GPS.
- Bitcoin places (BTCMap, when you switch that layer on) — see your IP and the area you're viewing.
- Nostr relays — see your IP when the app reads or publishes.
- Cashu mints — see your IP whenever your wallet sends, receives or melts.