From 8f169f1ecc24dd6fcef6c4347da5aa5261e139aa Mon Sep 17 00:00:00 2001 From: C3B2W23 <217007207+C3B2W23@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:43:59 -0700 Subject: [PATCH 1/3] Add runtime CARTO_API_KEY for basemap tiles CARTO now requires an API key for its basemap tiles; without one every tile in the DEFAULT dark/light map carries an "API KEY REQUIRED" watermark. The tile URLs were hardcoded in mapStyles.ts with no way to supply a key, and because the frontend ships as a prebuilt image a NEXT_PUBLIC_ variable would be baked in empty for every Docker user. - New frontend-local route GET /api/basemap-config reads CARTO_API_KEY from the frontend container's environment at request time (same pattern as BACKEND_URL), so no image rebuild is needed. - useBasemapConfig() fetches it once per page load; MaplibreViewer builds the MapLibre style from it via buildBasemapStyle(theme, key) and defers the map's first style load until the config has settled, avoiding a burst of unkeyed tile requests followed by a style swap. - Tile URLs move to CARTO's documented rastertiles/ path with ?key= appended when configured. Unkeyed URLs serve byte-identical tiles to the old path, so deployments without a key behave exactly as before. - CARTO_API_KEY wired through docker-compose.yml and documented in .env.example, README (data source table + frontend env table) and docs/OUTBOUND_DATA.md. - Tests cover the route (unset / set / trimmed) and the style builder. Co-Authored-By: Claude Fable 5.1 --- .env.example | 6 ++ README.md | 3 +- docker-compose.yml | 3 + docs/OUTBOUND_DATA.md | 1 + .../src/__tests__/map/basemapConfig.test.ts | 67 ++++++++++++++ frontend/src/app/api/basemap-config/route.ts | 41 +++++++++ frontend/src/components/MaplibreViewer.tsx | 13 ++- .../src/components/map/styles/mapStyles.ts | 91 +++++++++++-------- frontend/src/hooks/useBasemapConfig.ts | 52 +++++++++++ 9 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 frontend/src/__tests__/map/basemapConfig.test.ts create mode 100644 frontend/src/app/api/basemap-config/route.ts create mode 100644 frontend/src/hooks/useBasemapConfig.ts diff --git a/.env.example b/.env.example index e02c4f8..9f4cf28 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,12 @@ AIS_API_KEY= # Windy Webcams global CCTV layer — free key from https://api.windy.com/webcams/docs # WINDY_API_KEY= +# CARTO basemap tiles (DEFAULT dark/light map). CARTO now requires an API key; +# without one the map still loads but every tile carries an "API KEY REQUIRED" +# watermark. Free key (no CARTO account needed, 5M tiles/month fair use): +# https://carto.com/basemaps/apikey — used by the frontend container only. +# CARTO_API_KEY= + # Telegram OSINT map layer — scrapes public t.me/s channel previews (no bot token). # TELEGRAM_OSINT_ENABLED=true # TELEGRAM_OSINT_CHANNELS=osintdefender,insiderpaper,aljazeeraenglish,nexta_live,war_monitor diff --git a/README.md b/README.md index 05401b0..1217bce 100644 --- a/README.md +++ b/README.md @@ -666,7 +666,7 @@ ShadowBroker v0.9.7 is composed of three vertically-stacked planes — the **Ope | [Wikidata SPARQL](https://query.wikidata.org) | Head of state data | On-demand (cached 24h) | No | | [Wikipedia API](https://en.wikipedia.org/api) | Location summaries & aircraft images | On-demand (cached) | No | | [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No | -| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No | +| [CARTO Basemaps](https://carto.com) | Dark/light map tiles | Continuous | **Yes** (free, `CARTO_API_KEY`) | **Outbound privacy & audit (#348–#366):** Each self-hosted install uses its own backend IP and per-install User-Agent handle. See [docs/OUTBOUND_DATA.md](docs/OUTBOUND_DATA.md) for what contacts third parties, opt-in/env controls, and accepted tradeoffs (CCTV Referer, basemap CDN, LiveUAMap, etc.). @@ -1173,6 +1173,7 @@ Then confirm authenticated `GET /api/wormhole/status` or `GET /api/settings/worm |---|---|---| | `BACKEND_URL` | `environment` in `docker-compose.yml`, or shell env | URL the Next.js server uses to proxy API calls to the backend. Defaults to `http://backend:8000`. **Runtime variable — no rebuild needed.** | | `BACKEND_PORT` | repo-root `.env` or shell env before `docker compose up` | Host port used to expose the backend API for local diagnostics. Defaults to `8000`; set `BACKEND_PORT=8001` if port 8000 is already in use. Does not change Docker-internal `BACKEND_URL`. | +| `CARTO_API_KEY` | repo-root `.env` (passed to the frontend container by `docker-compose.yml`), or shell env | API key for the CARTO basemap tiles behind the DEFAULT dark/light map. CARTO now requires one; without it tiles still load but carry an "API KEY REQUIRED" watermark. Free at [carto.com/basemaps/apikey](https://carto.com/basemaps/apikey) (no account needed, 5M tiles/month). Served to the browser by the frontend-local `/api/basemap-config` route. **Runtime variable — no rebuild needed.** | **How it works:** The frontend proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. Browsers only talk to port 3000; the backend host port is only for local diagnostics. For local dev without Docker, `BACKEND_URL` defaults to `http://localhost:8000`. diff --git a/docker-compose.yml b/docker-compose.yml index 306e28a..093a609 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,9 @@ services: - BACKEND_URL=http://backend:8000 # Lets the server-side proxy authenticate protected local-node API calls. - ADMIN_KEY=${ADMIN_KEY:-} + # CARTO basemap tiles require an API key (free at https://carto.com/basemaps/apikey). + # Read at request time via /api/basemap-config, so no image rebuild is needed. + - CARTO_API_KEY=${CARTO_API_KEY:-} depends_on: backend: condition: service_healthy diff --git a/docs/OUTBOUND_DATA.md b/docs/OUTBOUND_DATA.md index e07a8da..afef72b 100644 --- a/docs/OUTBOUND_DATA.md +++ b/docs/OUTBOUND_DATA.md @@ -83,6 +83,7 @@ Shadowbroker is **self-hosted**: each install uses its own backend egress IP. Th - **Code:** `frontend/src/components/map/styles/mapStyles.ts`, `frontend/public/map-style.json` - **Hosts:** `*.basemaps.cartocdn.com`, `demotiles.maplibre.org` - **Exposure:** **Browser** loads tiles (client IP + pan/zoom), not the backend +- **API key:** CARTO requires a key for basemap tiles. `CARTO_API_KEY` is set on the frontend container and served to the browser by the frontend-local route `/api/basemap-config` (read at request time, never proxied to the backend). The browser then sends it to `*.basemaps.cartocdn.com` as a `?key=` query parameter on every tile request. Unset it to keep the previous unkeyed behavior (watermarked tiles). - **Mitigation:** Self-host raster tiles and point MapLibre `sources` at your tile server (operator choice; not required for core features) --- diff --git a/frontend/src/__tests__/map/basemapConfig.test.ts b/frontend/src/__tests__/map/basemapConfig.test.ts new file mode 100644 index 0000000..61e0558 --- /dev/null +++ b/frontend/src/__tests__/map/basemapConfig.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GET as getBasemapConfig } from '@/app/api/basemap-config/route'; +import { + buildBasemapStyle, + cartoTileUrls, + darkStyle, + lightStyle, +} from '@/components/map/styles/mapStyles'; + +describe('CARTO basemap API key plumbing', () => { + const originalKey = process.env.CARTO_API_KEY; + + beforeEach(() => { + delete process.env.CARTO_API_KEY; + }); + + afterEach(() => { + if (originalKey === undefined) delete process.env.CARTO_API_KEY; + else process.env.CARTO_API_KEY = originalKey; + }); + + describe('GET /api/basemap-config', () => { + it('reports unconfigured when CARTO_API_KEY is unset', async () => { + const res = await getBasemapConfig(); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toContain('no-store'); + expect(await res.json()).toEqual({ carto: { configured: false, key: '' } }); + }); + + it('returns the trimmed key read at request time', async () => { + process.env.CARTO_API_KEY = ' abc123 '; + const res = await getBasemapConfig(); + expect(await res.json()).toEqual({ carto: { configured: true, key: 'abc123' } }); + }); + }); + + describe('buildBasemapStyle', () => { + it('produces unkeyed CARTO tile URLs when no key is given', () => { + const style = buildBasemapStyle('dark'); + const source = style.sources['carto-dark']; + expect(source.tiles).toHaveLength(4); + for (const url of source.tiles) { + expect(url).toMatch(/^https:\/\/[abcd]\.basemaps\.cartocdn\.com\/rastertiles\/dark_all\//); + expect(url).not.toContain('?'); + } + expect(style.layers[0]).toMatchObject({ id: 'carto-dark-layer', source: 'carto-dark' }); + }); + + it('appends ?key= to every tile URL when a key is given', () => { + const style = buildBasemapStyle('light', 'my key'); + for (const url of style.sources['carto-light'].tiles) { + expect(url).toMatch(/\/rastertiles\/light_all\/\{z\}\/\{x\}\/\{y\}@2x\.png\?key=my%20key$/); + } + }); + + it('treats blank keys as unconfigured', () => { + expect(cartoTileUrls('dark', ' ')).toEqual(cartoTileUrls('dark')); + expect(cartoTileUrls('dark', null)).toEqual(cartoTileUrls('dark')); + }); + + it('keeps the key-less default exports in sync with the builder', () => { + expect(darkStyle).toEqual(buildBasemapStyle('dark')); + expect(lightStyle).toEqual(buildBasemapStyle('light')); + }); + }); +}); diff --git a/frontend/src/app/api/basemap-config/route.ts b/frontend/src/app/api/basemap-config/route.ts new file mode 100644 index 0000000..ba657c2 --- /dev/null +++ b/frontend/src/app/api/basemap-config/route.ts @@ -0,0 +1,41 @@ +/** + * Runtime basemap configuration for the browser map. + * + * CARTO_API_KEY is a plain server-side env var on the frontend container + * (see docker-compose.yml). Like BACKEND_URL it is read at request time, so + * operators running the prebuilt GHCR image can set it in .env without a + * rebuild. A NEXT_PUBLIC_ var would be baked in at image build time and + * therefore always empty for them. + * + * The key is not a secret in the usual sense — the browser sends it to + * CARTO on every tile request — but it is only returned to same-origin + * callers of this Next.js server, never proxied to the backend. + */ + +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const NO_STORE_HEADERS = { + 'Cache-Control': 'no-store, max-age=0', + Pragma: 'no-cache', +}; + +export type BasemapConfigResponse = { + carto: { + configured: boolean; + key: string; + }; +}; + +export function readCartoApiKey(): string { + return String(process.env.CARTO_API_KEY || '').trim(); +} + +export async function GET() { + const key = readCartoApiKey(); + const body: BasemapConfigResponse = { + carto: { configured: key.length > 0, key }, + }; + return NextResponse.json(body, { headers: NO_STORE_HEADERS }); +} diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 47a8fba..bcd3ab9 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -15,7 +15,8 @@ import Map, { } from 'react-map-gl/maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { computeNightPolygon } from '@/utils/solarTerminator'; -import { darkStyle, lightStyle } from '@/components/map/styles/mapStyles'; +import { buildBasemapStyle } from '@/components/map/styles/mapStyles'; +import { useBasemapConfig } from '@/hooks/useBasemapConfig'; import maplibregl from 'maplibre-gl'; import { AlertTriangle, Radio, Activity, Play, Satellite, ExternalLink, Info } from 'lucide-react'; import WikiImage from '@/components/WikiImage'; @@ -427,9 +428,11 @@ const MaplibreViewer = ({ const mapInitRef = useRef(false); const [mapReady, setMapReady] = useState(false); const { theme } = useTheme(); + const { cartoApiKey, loaded: basemapConfigLoaded } = useBasemapConfig(); const mapThemeStyle = useMemo( - () => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification, - [theme], + () => + buildBasemapStyle(theme === 'light' ? 'light' : 'dark', cartoApiKey) as maplibregl.StyleSpecification, + [theme, cartoApiKey], ); const initialViewState = useMemo( @@ -1886,6 +1889,9 @@ const MaplibreViewer = ({ className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint', 'gt_risk'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`} style={pinPlacementMode || sarAoiDropMode ? { cursor: 'crosshair' } : undefined} > + {/* Wait for /api/basemap-config so the first style load already carries the CARTO key + (avoids a burst of unkeyed, watermarked tile requests followed by a style swap). */} + {basemapConfigLoaded && ( + )} ); }; diff --git a/frontend/src/components/map/styles/mapStyles.ts b/frontend/src/components/map/styles/mapStyles.ts index e141d8f..cfd27a2 100644 --- a/frontend/src/components/map/styles/mapStyles.ts +++ b/frontend/src/components/map/styles/mapStyles.ts @@ -1,41 +1,54 @@ -export const darkStyle = { - version: 8, - glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [ - { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }, - { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }, - ], -}; +/** + * MapLibre basemap styles backed by CARTO raster tiles. + * + * CARTO now requires an API key for its basemap tiles; unkeyed requests + * return tiles stamped with an "API KEY REQUIRED" watermark. The key is + * read at request time by /api/basemap-config (CARTO_API_KEY on the + * frontend container) and threaded in here via `buildBasemapStyle`, so + * prebuilt Docker images pick it up without a rebuild. + * + * With no key configured the styles are unchanged from before, so existing + * deployments keep working exactly as they did (watermark included). + */ -export const lightStyle = { - version: 8, - glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', - sources: { - 'carto-light': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [ - { id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 }, - { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }, - ], +export type BasemapTheme = 'dark' | 'light'; + +const CARTO_SUBDOMAINS = ['a', 'b', 'c', 'd'] as const; +const CARTO_RASTER_STYLE: Record = { + dark: 'dark_all', + light: 'light_all', }; +const GLYPHS_URL = 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'; + +/** Tile URL templates for a CARTO raster style, keyed when a key is supplied. */ +export function cartoTileUrls(theme: BasemapTheme, cartoApiKey?: string | null): string[] { + const style = CARTO_RASTER_STYLE[theme]; + const key = (cartoApiKey || '').trim(); + const query = key ? `?key=${encodeURIComponent(key)}` : ''; + return CARTO_SUBDOMAINS.map( + (s) => `https://${s}.basemaps.cartocdn.com/rastertiles/${style}/{z}/{x}/{y}@2x.png${query}`, + ); +} + +export function buildBasemapStyle(theme: BasemapTheme, cartoApiKey?: string | null) { + const sourceId = `carto-${theme}`; + return { + version: 8, + glyphs: GLYPHS_URL, + sources: { + [sourceId]: { + type: 'raster', + tiles: cartoTileUrls(theme, cartoApiKey), + tileSize: 256, + }, + }, + layers: [ + { id: `${sourceId}-layer`, type: 'raster', source: sourceId, minzoom: 0, maxzoom: 22 }, + { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }, + ], + }; +} + +// Key-less defaults, kept for callers that do not need a CARTO key. +export const darkStyle = buildBasemapStyle('dark'); +export const lightStyle = buildBasemapStyle('light'); diff --git a/frontend/src/hooks/useBasemapConfig.ts b/frontend/src/hooks/useBasemapConfig.ts new file mode 100644 index 0000000..6925146 --- /dev/null +++ b/frontend/src/hooks/useBasemapConfig.ts @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { API_BASE } from '@/lib/api'; +import type { BasemapConfigResponse } from '@/app/api/basemap-config/route'; + +export type BasemapConfig = { + /** CARTO basemap API key, or null when none is configured / not yet loaded. */ + cartoApiKey: string | null; + /** True once the config request has settled (success or failure). */ + loaded: boolean; +}; + +const UNCONFIGURED: BasemapConfig = { cartoApiKey: null, loaded: true }; + +// One request per page load, shared by every map instance. +let configPromise: Promise | null = null; + +async function fetchBasemapConfig(): Promise { + try { + const res = await fetch(`${API_BASE}/api/basemap-config`, { cache: 'no-store' }); + if (!res.ok) return UNCONFIGURED; + const body = (await res.json()) as Partial; + const key = String(body?.carto?.key || '').trim(); + return { cartoApiKey: key || null, loaded: true }; + } catch { + // Static/desktop exports have no API routes; fall back to unkeyed tiles. + return UNCONFIGURED; + } +} + +/** Reset the shared request cache (tests only). */ +export function __resetBasemapConfigCache(): void { + configPromise = null; +} + +export function useBasemapConfig(): BasemapConfig { + const [config, setConfig] = useState({ cartoApiKey: null, loaded: false }); + + useEffect(() => { + let cancelled = false; + if (!configPromise) configPromise = fetchBasemapConfig(); + void configPromise.then((resolved) => { + if (!cancelled) setConfig(resolved); + }); + return () => { + cancelled = true; + }; + }, []); + + return config; +} From 71550b4adf868de4800bdc1244ee0a41f9880c77 Mon Sep 17 00:00:00 2001 From: C3B2W23 <217007207+C3B2W23@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:17:28 -0700 Subject: [PATCH 2/3] fix(basemap): trim header comments to match repo style Co-Authored-By: Claude Fable 5.1 --- .env.example | 6 ++---- frontend/src/app/api/basemap-config/route.ts | 14 +++----------- frontend/src/components/map/styles/mapStyles.ts | 14 +++----------- 3 files changed, 8 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 9f4cf28..fc868f0 100644 --- a/.env.example +++ b/.env.example @@ -27,10 +27,8 @@ AIS_API_KEY= # Windy Webcams global CCTV layer — free key from https://api.windy.com/webcams/docs # WINDY_API_KEY= -# CARTO basemap tiles (DEFAULT dark/light map). CARTO now requires an API key; -# without one the map still loads but every tile carries an "API KEY REQUIRED" -# watermark. Free key (no CARTO account needed, 5M tiles/month fair use): -# https://carto.com/basemaps/apikey — used by the frontend container only. +# CARTO basemap tiles (DEFAULT map) — free key from https://carto.com/basemaps/apikey +# Without it tiles render with an "API KEY REQUIRED" watermark. # CARTO_API_KEY= # Telegram OSINT map layer — scrapes public t.me/s channel previews (no bot token). diff --git a/frontend/src/app/api/basemap-config/route.ts b/frontend/src/app/api/basemap-config/route.ts index ba657c2..f82f2e4 100644 --- a/frontend/src/app/api/basemap-config/route.ts +++ b/frontend/src/app/api/basemap-config/route.ts @@ -1,15 +1,7 @@ /** - * Runtime basemap configuration for the browser map. - * - * CARTO_API_KEY is a plain server-side env var on the frontend container - * (see docker-compose.yml). Like BACKEND_URL it is read at request time, so - * operators running the prebuilt GHCR image can set it in .env without a - * rebuild. A NEXT_PUBLIC_ var would be baked in at image build time and - * therefore always empty for them. - * - * The key is not a secret in the usual sense — the browser sends it to - * CARTO on every tile request — but it is only returned to same-origin - * callers of this Next.js server, never proxied to the backend. + * Serves CARTO_API_KEY to the browser map. Read from the frontend container's + * environment at request time (like BACKEND_URL) so the prebuilt image needs + * no rebuild. Consumed by useBasemapConfig(). */ import { NextResponse } from 'next/server'; diff --git a/frontend/src/components/map/styles/mapStyles.ts b/frontend/src/components/map/styles/mapStyles.ts index cfd27a2..8acdcd8 100644 --- a/frontend/src/components/map/styles/mapStyles.ts +++ b/frontend/src/components/map/styles/mapStyles.ts @@ -1,14 +1,7 @@ /** - * MapLibre basemap styles backed by CARTO raster tiles. - * - * CARTO now requires an API key for its basemap tiles; unkeyed requests - * return tiles stamped with an "API KEY REQUIRED" watermark. The key is - * read at request time by /api/basemap-config (CARTO_API_KEY on the - * frontend container) and threaded in here via `buildBasemapStyle`, so - * prebuilt Docker images pick it up without a rebuild. - * - * With no key configured the styles are unchanged from before, so existing - * deployments keep working exactly as they did (watermark included). + * MapLibre basemap styles on CARTO raster tiles. CARTO requires an API key + * (unkeyed tiles are watermarked); MaplibreViewer passes one from + * useBasemapConfig() via buildBasemapStyle(). */ export type BasemapTheme = 'dark' | 'light'; @@ -49,6 +42,5 @@ export function buildBasemapStyle(theme: BasemapTheme, cartoApiKey?: string | nu }; } -// Key-less defaults, kept for callers that do not need a CARTO key. export const darkStyle = buildBasemapStyle('dark'); export const lightStyle = buildBasemapStyle('light'); From 667f51cb7aa1cd3a08e88a748857dcb8a68dba69 Mon Sep 17 00:00:00 2001 From: C3B2W23 <217007207+C3B2W23@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:01:39 -0700 Subject: [PATCH 3/3] fix(basemap): serve CARTO key from backend, bound the map gate, add source attribution Review follow-up: - Drop the Next.js route. CARTO_API_KEY is now a regular backend registry key (env, .env, or the API Keys panel) served by public GET /api/basemap-config. Every frontend mode already proxies /api/* to the backend (Next.js proxy in web mode, companion server in packaged desktop), so this covers web and desktop with one mechanism and leaves the static export untouched. Also removes the invalid non-handler export from the route module by removing the module. - useBasemapConfig: fail open to the unkeyed style after 3 s, abort the request at 15 s, apply a late key when it arrives, cache successes per page and retry failures on the next mount. - Declare OSM/CARTO attribution on the raster source (same markup as the viewer's existing AttributionControl so MapLibre de-duplicates it). - Tests: backend endpoint (unset / set+trimmed / persisted operator key / registry), hook behaviour (success, non-OK, network error, soft timeout then late key, hard abort, shared request and retry), attribution and gating source checks. - CARTO_API_KEY moves to the backend service in docker-compose.yml; docs updated accordingly. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- backend/main.py | 8 +- backend/services/api_settings.py | 16 ++ backend/services/config.py | 1 + backend/services/env_check.py | 1 + backend/tests/test_basemap_config.py | 55 +++++ docker-compose.yml | 4 +- docs/OUTBOUND_DATA.md | 2 +- .../src/__tests__/map/basemapConfig.test.ts | 207 ++++++++++++++---- frontend/src/app/api/basemap-config/route.ts | 33 --- frontend/src/components/MaplibreViewer.tsx | 4 +- .../src/components/map/styles/mapStyles.ts | 12 +- frontend/src/hooks/useBasemapConfig.ts | 57 +++-- 13 files changed, 306 insertions(+), 96 deletions(-) create mode 100644 backend/tests/test_basemap_config.py delete mode 100644 frontend/src/app/api/basemap-config/route.ts diff --git a/README.md b/README.md index 1217bce..38ec73e 100644 --- a/README.md +++ b/README.md @@ -1130,6 +1130,7 @@ OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID # Optional (enhances data quality) AIS_API_KEY=your_aisstream_key # Maritime vessel tracking (aisstream.io) — ships layer empty without it LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras +CARTO_API_KEY=your_carto_key # CARTO basemap tiles — DEFAULT map shows an "API KEY REQUIRED" watermark without it (free: carto.com/basemaps/apikey) SHODAN_API_KEY=your_shodan_key # Shodan device search overlay SH_CLIENT_ID=your_sentinel_hub_id # Copernicus CDSE Sentinel Hub imagery SH_CLIENT_SECRET=your_sentinel_hub_secret # Paired with Sentinel Hub Client ID @@ -1173,7 +1174,6 @@ Then confirm authenticated `GET /api/wormhole/status` or `GET /api/settings/worm |---|---|---| | `BACKEND_URL` | `environment` in `docker-compose.yml`, or shell env | URL the Next.js server uses to proxy API calls to the backend. Defaults to `http://backend:8000`. **Runtime variable — no rebuild needed.** | | `BACKEND_PORT` | repo-root `.env` or shell env before `docker compose up` | Host port used to expose the backend API for local diagnostics. Defaults to `8000`; set `BACKEND_PORT=8001` if port 8000 is already in use. Does not change Docker-internal `BACKEND_URL`. | -| `CARTO_API_KEY` | repo-root `.env` (passed to the frontend container by `docker-compose.yml`), or shell env | API key for the CARTO basemap tiles behind the DEFAULT dark/light map. CARTO now requires one; without it tiles still load but carry an "API KEY REQUIRED" watermark. Free at [carto.com/basemaps/apikey](https://carto.com/basemaps/apikey) (no account needed, 5M tiles/month). Served to the browser by the frontend-local `/api/basemap-config` route. **Runtime variable — no rebuild needed.** | **How it works:** The frontend proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. Browsers only talk to port 3000; the backend host port is only for local diagnostics. For local dev without Docker, `BACKEND_URL` defaults to `http://localhost:8000`. diff --git a/backend/main.py b/backend/main.py index e3b46a1..a099242 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9072,7 +9072,7 @@ async def api_sentinel_tile(request: Request): # --------------------------------------------------------------------------- # API Settings — key registry & management # --------------------------------------------------------------------------- -from services.api_settings import get_api_keys, get_env_path_info +from services.api_settings import get_api_keys, get_basemap_config, get_env_path_info from services.shodan_connector import ( ShodanConnectorError, count_shodan, @@ -9111,6 +9111,12 @@ async def api_get_keys_meta(request: Request): return get_env_path_info() +@app.get("/api/basemap-config") +@limiter.limit("60/minute") +async def api_basemap_config(request: Request): + return get_basemap_config() + + @app.get("/api/tools/shodan/status", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_shodan_status(request: Request): diff --git a/backend/services/api_settings.py b/backend/services/api_settings.py index e55e1bf..be65c7d 100644 --- a/backend/services/api_settings.py +++ b/backend/services/api_settings.py @@ -225,6 +225,15 @@ API_REGISTRY = [ "url": "https://dataspace.copernicus.eu/", "required": False, }, + { + "id": "carto_api_key", + "env_key": "CARTO_API_KEY", + "name": "CARTO Basemaps", + "description": "API key for the CARTO raster basemap behind the DEFAULT dark/light map. CARTO requires one; without it tiles still load but carry an \"API KEY REQUIRED\" watermark. Free at carto.com/basemaps/apikey (no CARTO account needed, 5M tiles/month). Unlike the other keys this one is sent to the browser (GET /api/basemap-config) because the browser passes it to CARTO on every tile request.", + "category": "Imagery", + "url": "https://carto.com/basemaps/apikey", + "required": False, + }, ] ALLOWED_ENV_KEYS = { @@ -391,6 +400,13 @@ def get_api_keys(): return result +def get_basemap_config() -> dict: + """Public config for the browser map: the CARTO key (or empty when unset).""" + load_persisted_api_keys_into_environ() + key = os.environ.get("CARTO_API_KEY", "").strip() + return {"carto": {"configured": bool(key), "key": key}} + + def save_api_keys(updates: dict[str, str]) -> dict: """Persist allowed API keys from a local operator request. diff --git a/backend/services/config.py b/backend/services/config.py index d22a910..fb24afe 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): OPENSKY_CLIENT_ID: str = "" OPENSKY_CLIENT_SECRET: str = "" LTA_ACCOUNT_KEY: str = "" + CARTO_API_KEY: str = "" # Basemap tiles; served to the browser via /api/basemap-config # Runtime CORS_ORIGINS: str = "" diff --git a/backend/services/env_check.py b/backend/services/env_check.py index 5eaad70..1cf3b89 100644 --- a/backend/services/env_check.py +++ b/backend/services/env_check.py @@ -49,6 +49,7 @@ _OPTIONAL = { "AISHUB_USERNAME": "AISHub REST backup when AISStream is silent (optional; free at aishub.net/api)", "GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)", "LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)", + "CARTO_API_KEY": "CARTO basemap tiles (DEFAULT map shows an API KEY REQUIRED watermark without it)", "PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)", } diff --git a/backend/tests/test_basemap_config.py b/backend/tests/test_basemap_config.py new file mode 100644 index 0000000..e03eddd --- /dev/null +++ b/backend/tests/test_basemap_config.py @@ -0,0 +1,55 @@ +"""GET /api/basemap-config serves the CARTO basemap key to the browser. + +The key is public by nature (the browser sends it to CARTO on every tile +request), so the endpoint needs no admin auth. It must read the key at +request time so Docker operators can set it without a rebuild, and it must +honor the persisted operator key file like every other registry key. +""" + +import pytest +from fastapi.testclient import TestClient + +from services import api_settings + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", tmp_path / "operator_api_keys.env") + monkeypatch.delenv("CARTO_API_KEY", raising=False) + import main + + return TestClient(main.app, raise_server_exceptions=False) + + +def test_unconfigured_when_env_unset(client): + r = client.get("/api/basemap-config") + assert r.status_code == 200 + assert r.json() == {"carto": {"configured": False, "key": ""}} + + +def test_returns_trimmed_key_without_admin_auth(client, monkeypatch): + monkeypatch.setenv("CARTO_API_KEY", " carto-test-key ") + r = client.get("/api/basemap-config") + assert r.status_code == 200 + assert r.json() == {"carto": {"configured": True, "key": "carto-test-key"}} + + +def test_reads_persisted_operator_key_file(client, tmp_path): + (tmp_path / "operator_api_keys.env").write_text("CARTO_API_KEY=persisted-key\n") + r = client.get("/api/basemap-config") + assert r.json()["carto"] == {"configured": True, "key": "persisted-key"} + + +def test_settings_model_exposes_carto_key(monkeypatch): + # env_check reads keys off Settings, so the field must exist there or the + # startup check would always report CARTO_API_KEY as unset. + from services.config import Settings + + monkeypatch.setenv("CARTO_API_KEY", "from-env") + assert Settings().CARTO_API_KEY == "from-env" + + +def test_carto_key_is_in_registry_and_saveable(): + assert "CARTO_API_KEY" in api_settings.ALLOWED_ENV_KEYS + entry = next(a for a in api_settings.API_REGISTRY if a["env_key"] == "CARTO_API_KEY") + assert entry["required"] is False diff --git a/docker-compose.yml b/docker-compose.yml index 093a609..9ded9c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - GFW_EVENTS_LOOKBACK_DAYS=${GFW_EVENTS_LOOKBACK_DAYS:-7} - GFW_EVENTS_TIMEOUT_S=${GFW_EVENTS_TIMEOUT_S:-90} - WINDY_API_KEY=${WINDY_API_KEY:-} + - CARTO_API_KEY=${CARTO_API_KEY:-} - ADMIN_KEY=${ADMIN_KEY:-} - FINNHUB_API_KEY=${FINNHUB_API_KEY:-} - AIRFRAMES_API_KEY=${AIRFRAMES_API_KEY:-} @@ -157,9 +158,6 @@ services: - BACKEND_URL=http://backend:8000 # Lets the server-side proxy authenticate protected local-node API calls. - ADMIN_KEY=${ADMIN_KEY:-} - # CARTO basemap tiles require an API key (free at https://carto.com/basemaps/apikey). - # Read at request time via /api/basemap-config, so no image rebuild is needed. - - CARTO_API_KEY=${CARTO_API_KEY:-} depends_on: backend: condition: service_healthy diff --git a/docs/OUTBOUND_DATA.md b/docs/OUTBOUND_DATA.md index afef72b..ab96f08 100644 --- a/docs/OUTBOUND_DATA.md +++ b/docs/OUTBOUND_DATA.md @@ -83,7 +83,7 @@ Shadowbroker is **self-hosted**: each install uses its own backend egress IP. Th - **Code:** `frontend/src/components/map/styles/mapStyles.ts`, `frontend/public/map-style.json` - **Hosts:** `*.basemaps.cartocdn.com`, `demotiles.maplibre.org` - **Exposure:** **Browser** loads tiles (client IP + pan/zoom), not the backend -- **API key:** CARTO requires a key for basemap tiles. `CARTO_API_KEY` is set on the frontend container and served to the browser by the frontend-local route `/api/basemap-config` (read at request time, never proxied to the backend). The browser then sends it to `*.basemaps.cartocdn.com` as a `?key=` query parameter on every tile request. Unset it to keep the previous unkeyed behavior (watermarked tiles). +- **API key:** CARTO requires a key for basemap tiles. `CARTO_API_KEY` lives with the other backend keys (env or the API Keys panel) and is served to the browser by the public backend route `GET /api/basemap-config` through the normal same-origin `/api/*` path (Next.js proxy in web mode, companion server in packaged desktop). The browser then sends it to `*.basemaps.cartocdn.com` as a `?key=` query parameter on every tile request, so it is not treated as a secret. Unset it to keep the previous unkeyed behavior (watermarked tiles). - **Mitigation:** Self-host raster tiles and point MapLibre `sources` at your tile server (operator choice; not required for core features) --- diff --git a/frontend/src/__tests__/map/basemapConfig.test.ts b/frontend/src/__tests__/map/basemapConfig.test.ts index 61e0558..5d47c8a 100644 --- a/frontend/src/__tests__/map/basemapConfig.test.ts +++ b/frontend/src/__tests__/map/basemapConfig.test.ts @@ -1,67 +1,194 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { GET as getBasemapConfig } from '@/app/api/basemap-config/route'; import { + CARTO_ATTRIBUTION_HTML, + OSM_ATTRIBUTION_HTML, buildBasemapStyle, cartoTileUrls, darkStyle, lightStyle, } from '@/components/map/styles/mapStyles'; +import { + BASEMAP_CONFIG_HARD_TIMEOUT_MS, + BASEMAP_CONFIG_SOFT_TIMEOUT_MS, + __resetBasemapConfigCache, + useBasemapConfig, +} from '@/hooks/useBasemapConfig'; -describe('CARTO basemap API key plumbing', () => { - const originalKey = process.env.CARTO_API_KEY; +const VIEWER_SRC = fs.readFileSync( + path.join(__dirname, '..', '..', 'components', 'MaplibreViewer.tsx'), + 'utf-8', +); + +describe('buildBasemapStyle', () => { + it('produces unkeyed CARTO tile URLs when no key is given', () => { + const style = buildBasemapStyle('dark'); + const source = style.sources['carto-dark']; + expect(source.tiles).toHaveLength(4); + for (const url of source.tiles) { + expect(url).toMatch(/^https:\/\/[abcd]\.basemaps\.cartocdn\.com\/rastertiles\/dark_all\//); + expect(url).not.toContain('?'); + } + expect(style.layers[0]).toMatchObject({ id: 'carto-dark-layer', source: 'carto-dark' }); + }); + + it('appends ?key= to every tile URL when a key is given', () => { + const style = buildBasemapStyle('light', 'my key'); + for (const url of style.sources['carto-light'].tiles) { + expect(url).toMatch(/\/rastertiles\/light_all\/\{z\}\/\{x\}\/\{y\}@2x\.png\?key=my%20key$/); + } + }); + + it('treats blank keys as unconfigured', () => { + expect(cartoTileUrls('dark', ' ')).toEqual(cartoTileUrls('dark')); + expect(cartoTileUrls('dark', null)).toEqual(cartoTileUrls('dark')); + }); + + it('keeps the key-less default exports in sync with the builder', () => { + expect(darkStyle).toEqual(buildBasemapStyle('dark')); + expect(lightStyle).toEqual(buildBasemapStyle('light')); + }); + + it('declares OpenStreetMap and CARTO attribution on the raster source, keyed or not', () => { + for (const style of [buildBasemapStyle('dark'), buildBasemapStyle('light', 'k')]) { + const source = Object.values(style.sources)[0]; + expect(source.attribution).toContain('openstreetmap.org/copyright'); + expect(source.attribution).toContain('carto.com/attribution'); + } + }); +}); + +describe('MaplibreViewer attribution and basemap gating', () => { + it('still renders the explicit AttributionControl with the same OSM/CARTO markup', () => { + // attributionControl={false} only disables the default control; the + // explicit child below it is the visible attribution and must survive. + expect(VIEWER_SRC).toContain(' { + expect(VIEWER_SRC).toContain('{basemapConfigLoaded && ('); + expect(VIEWER_SRC).toMatch(/const \{ cartoApiKey, loaded: basemapConfigLoaded \} = useBasemapConfig\(\)/); + expect(BASEMAP_CONFIG_SOFT_TIMEOUT_MS).toBeLessThan(BASEMAP_CONFIG_HARD_TIMEOUT_MS); + }); +}); + +describe('useBasemapConfig', () => { + const fetchMock = vi.fn(); beforeEach(() => { - delete process.env.CARTO_API_KEY; + vi.useFakeTimers(); + __resetBasemapConfigCache(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { - if (originalKey === undefined) delete process.env.CARTO_API_KEY; - else process.env.CARTO_API_KEY = originalKey; + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); }); - describe('GET /api/basemap-config', () => { - it('reports unconfigured when CARTO_API_KEY is unset', async () => { - const res = await getBasemapConfig(); - expect(res.status).toBe(200); - expect(res.headers.get('cache-control')).toContain('no-store'); - expect(await res.json()).toEqual({ carto: { configured: false, key: '' } }); - }); + function jsonResponse(body: unknown, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) } as Response); + } - it('returns the trimmed key read at request time', async () => { - process.env.CARTO_API_KEY = ' abc123 '; - const res = await getBasemapConfig(); - expect(await res.json()).toEqual({ carto: { configured: true, key: 'abc123' } }); + it('starts pending and resolves with the key', async () => { + fetchMock.mockReturnValue(jsonResponse({ carto: { configured: true, key: ' abc ' } })); + const { result } = renderHook(() => useBasemapConfig()); + expect(result.current).toEqual({ cartoApiKey: null, loaded: false }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); }); + expect(result.current).toEqual({ cartoApiKey: 'abc', loaded: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/api\/basemap-config$/); }); - describe('buildBasemapStyle', () => { - it('produces unkeyed CARTO tile URLs when no key is given', () => { - const style = buildBasemapStyle('dark'); - const source = style.sources['carto-dark']; - expect(source.tiles).toHaveLength(4); - for (const url of source.tiles) { - expect(url).toMatch(/^https:\/\/[abcd]\.basemaps\.cartocdn\.com\/rastertiles\/dark_all\//); - expect(url).not.toContain('?'); - } - expect(style.layers[0]).toMatchObject({ id: 'carto-dark-layer', source: 'carto-dark' }); + it('fails open with the unkeyed config on a non-OK response', async () => { + fetchMock.mockReturnValue(jsonResponse({ detail: 'nope' }, false)); + const { result } = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + }); - it('appends ?key= to every tile URL when a key is given', () => { - const style = buildBasemapStyle('light', 'my key'); - for (const url of style.sources['carto-light'].tiles) { - expect(url).toMatch(/\/rastertiles\/light_all\/\{z\}\/\{x\}\/\{y\}@2x\.png\?key=my%20key$/); - } + it('fails open on a network error', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + const { result } = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + }); - it('treats blank keys as unconfigured', () => { - expect(cartoTileUrls('dark', ' ')).toEqual(cartoTileUrls('dark')); - expect(cartoTileUrls('dark', null)).toEqual(cartoTileUrls('dark')); - }); + it('releases the map after the soft timeout, then applies a late key', async () => { + let resolveFetch: (value: Response) => void = () => {}; + fetchMock.mockReturnValue(new Promise((resolve) => (resolveFetch = resolve))); + const { result } = renderHook(() => useBasemapConfig()); - it('keeps the key-less default exports in sync with the builder', () => { - expect(darkStyle).toEqual(buildBasemapStyle('dark')); - expect(lightStyle).toEqual(buildBasemapStyle('light')); + await act(async () => { + await vi.advanceTimersByTimeAsync(BASEMAP_CONFIG_SOFT_TIMEOUT_MS); }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + + await act(async () => { + resolveFetch({ ok: true, json: () => Promise.resolve({ carto: { key: 'late' } }) } as Response); + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual({ cartoApiKey: 'late', loaded: true }); + }); + + it('aborts the request at the hard timeout and stays unkeyed', async () => { + fetchMock.mockImplementation( + (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }), + ); + const { result } = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(BASEMAP_CONFIG_HARD_TIMEOUT_MS + 1); + }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + }); + + it('shares one request across mounts and caches only successes', async () => { + fetchMock.mockReturnValue(jsonResponse({ carto: { key: 'shared' } })); + const a = renderHook(() => useBasemapConfig()); + const b = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(a.result.current.cartoApiKey).toBe('shared'); + expect(b.result.current.cartoApiKey).toBe('shared'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + cleanup(); + const c = renderHook(() => useBasemapConfig()); + expect(c.result.current).toEqual({ cartoApiKey: 'shared', loaded: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + __resetBasemapConfigCache(); + fetchMock.mockReturnValueOnce(jsonResponse({}, false)); + fetchMock.mockReturnValueOnce(jsonResponse({ carto: { key: 'second-try' } })); + cleanup(); + const d = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(d.result.current).toEqual({ cartoApiKey: null, loaded: true }); + cleanup(); + const e = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(e.result.current).toEqual({ cartoApiKey: 'second-try', loaded: true }); }); }); diff --git a/frontend/src/app/api/basemap-config/route.ts b/frontend/src/app/api/basemap-config/route.ts deleted file mode 100644 index f82f2e4..0000000 --- a/frontend/src/app/api/basemap-config/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Serves CARTO_API_KEY to the browser map. Read from the frontend container's - * environment at request time (like BACKEND_URL) so the prebuilt image needs - * no rebuild. Consumed by useBasemapConfig(). - */ - -import { NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; - -const NO_STORE_HEADERS = { - 'Cache-Control': 'no-store, max-age=0', - Pragma: 'no-cache', -}; - -export type BasemapConfigResponse = { - carto: { - configured: boolean; - key: string; - }; -}; - -export function readCartoApiKey(): string { - return String(process.env.CARTO_API_KEY || '').trim(); -} - -export async function GET() { - const key = readCartoApiKey(); - const body: BasemapConfigResponse = { - carto: { configured: key.length > 0, key }, - }; - return NextResponse.json(body, { headers: NO_STORE_HEADERS }); -} diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index bcd3ab9..328793c 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -1889,8 +1889,8 @@ const MaplibreViewer = ({ className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint', 'gt_risk'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`} style={pinPlacementMode || sarAoiDropMode ? { cursor: 'crosshair' } : undefined} > - {/* Wait for /api/basemap-config so the first style load already carries the CARTO key - (avoids a burst of unkeyed, watermarked tile requests followed by a style swap). */} + {/* Wait for /api/basemap-config so the first style load already carries the CARTO key. + Bounded: useBasemapConfig fails open to the unkeyed style after a short timeout. */} {basemapConfigLoaded && ( = { }; const GLYPHS_URL = 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'; +// Declared on the raster source so MapLibre's AttributionControl shows it +// even without the custom list in MaplibreViewer. Same markup as that list so +// the control de-duplicates instead of showing both. +export const OSM_ATTRIBUTION_HTML = + '© OpenStreetMap contributors'; +export const CARTO_ATTRIBUTION_HTML = + 'CARTO'; + /** Tile URL templates for a CARTO raster style, keyed when a key is supplied. */ export function cartoTileUrls(theme: BasemapTheme, cartoApiKey?: string | null): string[] { const style = CARTO_RASTER_STYLE[theme]; @@ -33,6 +42,7 @@ export function buildBasemapStyle(theme: BasemapTheme, cartoApiKey?: string | nu type: 'raster', tiles: cartoTileUrls(theme, cartoApiKey), tileSize: 256, + attribution: `${OSM_ATTRIBUTION_HTML} ${CARTO_ATTRIBUTION_HTML}`, }, }, layers: [ diff --git a/frontend/src/hooks/useBasemapConfig.ts b/frontend/src/hooks/useBasemapConfig.ts index 6925146..f7aae73 100644 --- a/frontend/src/hooks/useBasemapConfig.ts +++ b/frontend/src/hooks/useBasemapConfig.ts @@ -2,49 +2,78 @@ import { useEffect, useState } from 'react'; import { API_BASE } from '@/lib/api'; -import type { BasemapConfigResponse } from '@/app/api/basemap-config/route'; export type BasemapConfig = { /** CARTO basemap API key, or null when none is configured / not yet loaded. */ cartoApiKey: string | null; - /** True once the config request has settled (success or failure). */ + /** True once the map may render: config arrived, failed, or timed out. */ loaded: boolean; }; +type BasemapConfigResponse = { carto?: { configured?: boolean; key?: string } }; + +/** Render the unkeyed map if the config has not arrived by then. */ +export const BASEMAP_CONFIG_SOFT_TIMEOUT_MS = 3000; +/** Abort the config request outright after this long. */ +export const BASEMAP_CONFIG_HARD_TIMEOUT_MS = 15000; + +const PENDING: BasemapConfig = { cartoApiKey: null, loaded: false }; const UNCONFIGURED: BasemapConfig = { cartoApiKey: null, loaded: true }; -// One request per page load, shared by every map instance. -let configPromise: Promise | null = null; +// Successful responses are cached for the page lifetime and shared by every +// map instance. Failures are not cached so a later mount retries (the backend +// may still have been starting). +let cached: BasemapConfig | null = null; +let inflight: Promise | null = null; -async function fetchBasemapConfig(): Promise { +async function requestBasemapConfig(): Promise { + const controller = new AbortController(); + const hardTimer = setTimeout(() => controller.abort(), BASEMAP_CONFIG_HARD_TIMEOUT_MS); try { - const res = await fetch(`${API_BASE}/api/basemap-config`, { cache: 'no-store' }); + const res = await fetch(`${API_BASE}/api/basemap-config`, { + cache: 'no-store', + signal: controller.signal, + }); if (!res.ok) return UNCONFIGURED; - const body = (await res.json()) as Partial; + const body = (await res.json()) as BasemapConfigResponse; const key = String(body?.carto?.key || '').trim(); - return { cartoApiKey: key || null, loaded: true }; + cached = { cartoApiKey: key || null, loaded: true }; + return cached; } catch { - // Static/desktop exports have no API routes; fall back to unkeyed tiles. return UNCONFIGURED; + } finally { + clearTimeout(hardTimer); + inflight = null; } } -/** Reset the shared request cache (tests only). */ +/** Reset module state (tests only). */ export function __resetBasemapConfigCache(): void { - configPromise = null; + cached = null; + inflight = null; } export function useBasemapConfig(): BasemapConfig { - const [config, setConfig] = useState({ cartoApiKey: null, loaded: false }); + const [config, setConfig] = useState(() => cached ?? PENDING); useEffect(() => { + if (cached) { + setConfig(cached); + return; + } let cancelled = false; - if (!configPromise) configPromise = fetchBasemapConfig(); - void configPromise.then((resolved) => { + if (!inflight) inflight = requestBasemapConfig(); + // Fail open: a slow or hung config request must not hold the whole map. + // If the key arrives later the style is rebuilt with it, same as a theme switch. + const softTimer = setTimeout(() => { + if (!cancelled) setConfig((current) => (current.loaded ? current : UNCONFIGURED)); + }, BASEMAP_CONFIG_SOFT_TIMEOUT_MS); + void inflight.then((resolved) => { if (!cancelled) setConfig(resolved); }); return () => { cancelled = true; + clearTimeout(softTimer); }; }, []);