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 <noreply@anthropic.com>
This commit is contained in:
C3B2W23
2026-09-13 14:54:01 -07:00
co-authored by Claude Fable 5.1
parent a5fb1c392e
commit 8f169f1ecc
9 changed files with 234 additions and 43 deletions
@@ -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'));
});
});
});
@@ -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 });
}
+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';
@@ -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<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>(
@@ -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 && (
<Map
ref={mapRef}
reuseMaps
@@ -6658,6 +6664,7 @@ const MaplibreViewer = ({
<MeasurementLayers measurePoints={measurePoints} />
</Map>
)}
</div>
);
};
+52 -39
View File
@@ -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<BasemapTheme, string> = {
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');
+52
View File
@@ -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<BasemapConfig> | null = null;
async function fetchBasemapConfig(): Promise<BasemapConfig> {
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<BasemapConfigResponse>;
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<BasemapConfig>({ 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;
}