Merge pull request #558 from C3B2W23/fix/carto-api-key

fix(basemap): add runtime CARTO_API_KEY for CARTO tiles
This commit is contained in:
Shadowbroker
2026-09-15 01:51:43 -06:00
committed by GitHub
13 changed files with 427 additions and 44 deletions
+4
View File
@@ -27,6 +27,10 @@ AIS_API_KEY=
# Windy Webcams global CCTV layer — free key from https://api.windy.com/webcams/docs
# WINDY_API_KEY=
# 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).
# TELEGRAM_OSINT_ENABLED=true
# TELEGRAM_OSINT_CHANNELS=osintdefender,insiderpaper,aljazeeraenglish,nexta_live,war_monitor
+2 -1
View File
@@ -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.).
@@ -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
+7 -1
View File
@@ -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):
+16
View File
@@ -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.
+1
View File
@@ -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 = ""
+1
View File
@@ -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)",
}
+55
View File
@@ -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
+1
View File
@@ -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:-}
+1
View File
@@ -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` 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)
---
@@ -0,0 +1,194 @@
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 {
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';
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('<AttributionControl');
expect(VIEWER_SRC).toContain(OSM_ATTRIBUTION_HTML);
expect(VIEWER_SRC).toContain(CARTO_ATTRIBUTION_HTML);
});
it('gates the map on the bounded basemap config, not on an open-ended request', () => {
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(() => {
vi.useFakeTimers();
__resetBasemapConfigCache();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function jsonResponse(body: unknown, ok = true) {
return Promise.resolve({ ok, json: () => Promise.resolve(body) } as Response);
}
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$/);
});
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('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('releases the map after the soft timeout, then applies a late key', async () => {
let resolveFetch: (value: Response) => void = () => {};
fetchMock.mockReturnValue(new Promise<Response>((resolve) => (resolveFetch = resolve)));
const { result } = renderHook(() => useBasemapConfig());
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<Response>((_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 });
});
});
+10 -3
View File
@@ -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';
@@ -428,9 +429,11 @@ const MaplibreViewer = ({
const mapInitRef = useRef(false);
const [mapReady, setMapReady] = useState(false);
const { theme } = useTheme();
const { cartoApiKey, loaded: basemapConfigLoaded } = useBasemapConfig();
const mapThemeStyle = useMemo<maplibregl.StyleSpecification>(
() => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification,
[theme],
() =>
buildBasemapStyle(theme === 'light' ? 'light' : 'dark', cartoApiKey) as maplibregl.StyleSpecification,
[theme, cartoApiKey],
);
const initialViewState = useMemo<ViewState>(
@@ -1898,6 +1901,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.
Bounded: useBasemapConfig fails open to the unkeyed style after a short timeout. */}
{basemapConfigLoaded && (
<Map
ref={mapRef}
reuseMaps
@@ -6670,6 +6676,7 @@ const MaplibreViewer = ({
<MeasurementLayers measurePoints={measurePoints} />
</Map>
)}
</div>
);
};
+54 -39
View File
@@ -1,41 +1,56 @@
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 on CARTO raster tiles. CARTO requires an API key
* (unkeyed tiles are watermarked); MaplibreViewer passes one from
* useBasemapConfig() via buildBasemapStyle(). The key is served by the
* backend at GET /api/basemap-config.
*/
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<BasemapTheme, string> = {
dark: 'dark_all',
light: 'light_all',
};
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 =
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a>';
export const CARTO_ATTRIBUTION_HTML =
'<a href="https://carto.com/attribution" target="_blank" rel="noopener">CARTO</a>';
/** 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,
attribution: `${OSM_ATTRIBUTION_HTML} ${CARTO_ATTRIBUTION_HTML}`,
},
},
layers: [
{ id: `${sourceId}-layer`, type: 'raster', source: sourceId, minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } },
],
};
}
export const darkStyle = buildBasemapStyle('dark');
export const lightStyle = buildBasemapStyle('light');
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { useEffect, useState } from 'react';
import { API_BASE } from '@/lib/api';
export type BasemapConfig = {
/** CARTO basemap API key, or null when none is configured / not yet loaded. */
cartoApiKey: string | null;
/** 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 };
// 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<BasemapConfig> | null = null;
async function requestBasemapConfig(): Promise<BasemapConfig> {
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',
signal: controller.signal,
});
if (!res.ok) return UNCONFIGURED;
const body = (await res.json()) as BasemapConfigResponse;
const key = String(body?.carto?.key || '').trim();
cached = { cartoApiKey: key || null, loaded: true };
return cached;
} catch {
return UNCONFIGURED;
} finally {
clearTimeout(hardTimer);
inflight = null;
}
}
/** Reset module state (tests only). */
export function __resetBasemapConfigCache(): void {
cached = null;
inflight = null;
}
export function useBasemapConfig(): BasemapConfig {
const [config, setConfig] = useState<BasemapConfig>(() => cached ?? PENDING);
useEffect(() => {
if (cached) {
setConfig(cached);
return;
}
let cancelled = false;
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);
};
}, []);
return config;
}