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 authored and GitHub committed 2026-09-15 01:51:43 -06:00
commit 1a1b6e1dc3
13 files changed
+427 -44

No files matched your search

@@ -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;
}