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); }; }, []);