fix(map): apply the CARTO key on every map surface (rebase of #1916 onto #1919) (#1926)

Continues #1916 by @nullrouten0. The commit is theirs, authorship
unchanged; I rebased it onto master and resolved the fallout from #1919.
Opening it here rather than force-pushing to someone else's branch.

## Why this is needed after #1919

#1919 shipped the Carto key, but only for the five `BASE_STYLES` entries
in `map-tile-providers.js`. Four map surfaces still request unkeyed
tiles and get them back stamped `API KEY REQUIRED` at HTTP 200:

- `public/roles.js` — `getTileUrl()` returns the bare `TILE_LIGHT`
constant in light mode and never consults the registry (the dark branch
has consulted it since #1461). Affects `analytics.js:2200` and
`nodes.js:94`, so the analytics map and the node-detail map stay
watermarked in light theme even with a key configured.
- `public/customize-v2.js:1838` and `:2047` — the two geo-filter maps in
the customizer.
- `public/geofilter-builder.html` — standalone page, outside the SPA, so
it fetches `/api/config/client` itself.

@nullrouten0 had already found and fixed all four, plus written the
docs, before #1919 was merged. Their diagnosis of the light-mode branch
is in the PR verbatim.

## What I changed while rebasing

1. **`carto.token` → `carto.key`.** #1919 shipped `key` and operators
already have it in their configs; renaming now would break them
silently. All of #1916's code, tests and docs follow suit.
2. **Dropped the duplicate key getter.** `_getCartoKeyParam` did the
same job as master's `_getCartoKey`; kept master's.
3. **Merged the two test suites.** Master's five cases plus four of
#1916's that master does not cover: the `api_key`-is-ignored assertion,
lazy resolution after async config, and both `MC_tileUrlById` cases. 42
passed, 0 failed.
4. **Corrected one factual claim in the docs.** #1916 stated CARTO
offers no referrer or origin restriction. CARTO does ask for a domain
when the key is issued. Whether that is enforced per request is not
something I verified, so the note now says exactly that rather than
asserting either way.
5. Merged the two config comments, keeping the operationally useful
part: unkeyed tiles return **200** with a watermark, so nothing errors
and no healthcheck fires. Verify by looking at a tile, not at a status
code.

## Verification

Rebased onto `7aa60c03`. No regressions:

| | base 7aa60c03 | this branch |
|---|---|---|
| `test-issue-1420-tile-providers.js` | 38 passed, 0 failed | **42
passed, 0 failed** |
| `test-issue-1614-tile-url-function.js` | 3 passed, 0 failed | 3
passed, 0 failed |
| `test-issue-1470-node-tile-helper.js` | 3 passed, 3 failed | 3 passed,
3 failed (pre-existing) |
| `test-frontend-helpers.js` | 625 passed, 2 failed | 625 passed, 2
failed (pre-existing) |

Also verified end to end on a live instance running this change with a
real key: the tile that came back carried the `API KEY REQUIRED`
watermark before and came back clean after.

@nullrouten0 — this is your work and I would rather you had the credit
and the merge. Say the word and I will close this and hand the rebase
back to you, or push it to your branch instead if you prefer #1916 to
stay the vehicle.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Wzwr3eXseyNM7Xj598djjE

Co-authored-by: nullrouten <nullrouten@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efiten
2026-09-01 11:50:45 +02:00
committed by GitHub
co-authored by nullrouten Claude Opus 5
parent 7aa60c0350
commit 8ce5291b7c
7 changed files with 119 additions and 4 deletions
+1 -1
View File
@@ -78,7 +78,7 @@
"darkDefault": "carto-dark",
"lightDefault": "carto-light",
"providers": {
"_comment_carto": "Carto is the default provider. Since 2026-08 Carto requires an API key - without one the tiles come back stamped 'API KEY REQUIRED'. Get a free key at https://carto.com/basemaps/apikey and put it in 'key'. WARNING: the key is sent to the browser; restrict it by domain in the Carto dashboard. Optional: specify 'domain' for Carto enterprise (e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com').",
"_comment_carto": "Carto is the default provider. Since 2026-08 CARTO requires an API key on its raster basemaps: without one every tile is served stamped 'API KEY REQUIRED' with a 200 status, so nothing errors and no healthcheck fires - verify by looking at a tile, not at a status code. Get a free key (no account, no card, 5M tile requests/month) at https://carto.com/basemaps/apikey and put it in 'key'. The query parameter is 'key'; 'api_key' is silently ignored and still serves the watermarked tile. WARNING: the key is sent to the browser; restrict it by domain in the CARTO dashboard. Optional: specify 'domain' for Carto enterprise (e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com').",
"carto": {
"enabled": true,
"key": "",
+38
View File
@@ -188,6 +188,44 @@ See `config.example.json` in the repository for all available options including:
Map tile providers are enabled and configured via the `config.json` file. You can provide your custom API credentials (e.g. `osm_url`, `stamen_api_key`, `mapbox_api_key`) to activate external tile services. Once configured on the server, users can select their preferred tile provider from the Customizer UI on the client, and their choice will be persisted automatically.
#### CARTO now requires an API key
CARTO's raster basemaps (`carto-dark`, `carto-light`, `carto-voyager`,
`carto-voyager-dark`, `positron-dark`) require an API key. Without one, tiles
are still returned with a **200 status** but every one of them is stamped
`API KEY REQUIRED` — so nothing errors, no healthcheck fires, and the map just
quietly looks broken.
Get a free key at <https://carto.com/basemaps/apikey> — no CARTO account, no
credit card, emailed back immediately, 5 million tile requests per calendar
month across raster and vector. Then set it as `key`:
```json
"map": {
"tiles": {
"providers": {
"carto": { "enabled": true, "key": "YOUR_CARTO_KEY" }
}
}
}
```
Notes:
- The query parameter is `key`. **`api_key` is silently ignored** and still
serves the watermarked tile.
- The key is sent to the browser, so treat it as public. CARTO asks for a domain
when the key is issued, but whether that restriction is enforced on every
request has not been verified here. Assume anyone can read the key from
devtools and spend your quota. The 5M/month fair-use ceiling makes that
low-impact for most deployments, but it is worth knowing.
- A wrong or expired key fails the same silent way as no key at all. Verify by
**looking at a tile**, not by checking for a 200.
- CARTO has said it is considering freezing data updates to the raster
basemaps. If you would rather not depend on them, set
`"carto": { "enabled": false }` and use another provider — `esri` needs no
key at all, and `osm`/`stamen` accept their own tokens.
---
## MQTT Setup
+6 -2
View File
@@ -1835,7 +1835,9 @@
var modalClosingLine = null;
_gfModalMap = L.map(mapDiv, { zoomControl: true });
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
L.tileLayer(window.MC_tileUrlById
? window.MC_tileUrlById('carto-light', 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png')
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
}).addTo(_gfModalMap);
@@ -2044,7 +2046,9 @@
if (!mapEl || typeof L === 'undefined') return;
_gfMap = L.map(mapEl, { zoomControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, touchZoom: false });
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
L.tileLayer(window.MC_tileUrlById
? window.MC_tileUrlById('carto-light', 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png')
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
}).addTo(_gfMap);
+17 -1
View File
@@ -85,14 +85,30 @@
</div>
<script src="geofilter-draft.js"></script>
<script src="map-tile-providers.js"></script>
<script>
const map = L.map('map').setView([50.5, 4.4], 8);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
const DEFAULT_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const tiles = L.tileLayer(DEFAULT_DARK_TILES, {
attribution: '© OpenStreetMap © CartoDB',
maxZoom: 19
}).addTo(map);
// This page is standalone (not the SPA), so it loads the server's map config
// itself and re-resolves the tile URL through the shared registry. Without
// this, CARTO serves every tile stamped 'API KEY REQUIRED' — with a 200.
fetch('/api/config/client')
.then(function (r) { return r.json(); })
.then(function (cfg) {
window.MC_MAP_CFG = (cfg && cfg.map) || { tiles: { providers: {} } };
if (typeof window.MC_initTileRegistry === 'function') window.MC_initTileRegistry();
if (typeof window.MC_tileUrlById === 'function') {
tiles.setUrl(window.MC_tileUrlById('carto-dark', DEFAULT_DARK_TILES));
}
})
.catch(function () { /* keep the unkeyed default */ });
let points = [];
let markers = [];
let polygon = null;
+13
View File
@@ -192,6 +192,19 @@
window.MC_setServerDefaultLightTileProvider = setServerDefaultLight;
window.MC_applyTileFilter = applyTileFilter;
/**
* Resolve a registry id to a concrete tile-URL string, invoking function-typed
* `url` builders so callers never hand L.tileLayer a function (#1614). Returns
* `fallback` when the id isn't in the active registry (e.g. provider disabled).
* Lets non-map pages request a specific style without duplicating the URL.
*/
window.MC_tileUrlById = function (id, fallback) {
var p = _hasId(id) ? REGISTRY[id] : null;
var u = p && (p.url || p.baseUrl);
if (!u) return fallback;
return (typeof u === 'function') ? u() : u;
};
/**
* Build and attach a Leaflet layer control listing "Auto (follows theme)"
+9
View File
@@ -587,6 +587,15 @@
if (cfg.map.tiles.lightUrl) window.TILE_LIGHT = cfg.map.tiles.lightUrl;
}
if (typeof window.MC_initTileRegistry === 'function') window.MC_initTileRegistry(true);
// CARTO raster needs an API key now, so the bare TILE_DARK/TILE_LIGHT
// fallbacks have to pick it up as well — getTileUrl() returns TILE_LIGHT
// directly in light mode and never consults the registry. Explicit
// darkUrl/lightUrl overrides above still win.
if (typeof window.MC_tileUrlById === 'function') {
var _tOv = (cfg.tiles || (cfg.map && cfg.map.tiles) || {});
if (!_tOv.dark && !_tOv.darkUrl) window.TILE_DARK = window.MC_tileUrlById('carto-dark', window.TILE_DARK);
if (!_tOv.light && !_tOv.lightUrl) window.TILE_LIGHT = window.MC_tileUrlById('carto-light', window.TILE_LIGHT);
}
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
+35
View File
@@ -564,6 +564,41 @@ test('Carto key coexists with the enterprise domain option', () => {
assert.ok(u.endsWith('?key=abc123'), 'key lost: ' + u);
});
test('Carto uses ?key= and never ?api_key= (CARTO ignores api_key and still watermarks)', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, key: 'abc123' } } } });
const url = ctx.window.MC_TILE_PROVIDERS['carto-dark'].url();
assert.ok(url.endsWith('?key=abc123'), 'should end with ?key=abc123: ' + url);
assert.ok(url.indexOf('api_key') < 0, 'must not use the api_key param: ' + url);
});
test('Carto key resolves lazily - set after load, picked up on re-init', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true } } } });
assert.ok(ctx.window.MC_TILE_PROVIDERS['carto-dark'].url().indexOf('key=') < 0, 'no key before config');
ctx.window.MC_MAP_CFG = { tiles: { providers: { carto: { enabled: true, key: 'late' } } } };
ctx.window.MC_initTileRegistry(true);
assert.ok(ctx.window.MC_TILE_PROVIDERS['carto-dark'].url().endsWith('?key=late'), 'key picked up after async config');
});
// --- MC_tileUrlById -------------------------------------------------------
test('MC_tileUrlById resolves a function-typed url to a string', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, key: 'k' } } } });
const url = ctx.window.MC_tileUrlById('carto-light', 'FALLBACK');
assert.strictEqual(typeof url, 'string', 'must return a string, not a function');
assert.ok(url.endsWith('?key=k'), 'carries the key: ' + url);
});
test('MC_tileUrlById returns the fallback for a disabled or unknown provider', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: false } } } });
assert.strictEqual(ctx.window.MC_tileUrlById('carto-dark', 'FALLBACK'), 'FALLBACK', 'disabled provider falls back');
assert.strictEqual(ctx.window.MC_tileUrlById('nope', 'FALLBACK'), 'FALLBACK', 'unknown id falls back');
});
process.on('beforeExit', () => {
console.log('');
console.log(' ' + passed + ' passed, ' + failed + ' failed');