fix: support Carto basemap API key (tiles are watermarked without one) (#1919)

## Problem

Carto began requiring an API key for its raster basemaps in 2026-08.
Requests without a key still return `HTTP 200`, but the returned PNG is
stamped `API KEY REQUIRED / carto.com/basemaps/apikey`. Because
`carto-dark` / `carto-light` are the built-in defaults, **every
CoreScope instance that has not changed tile providers now renders
watermarked maps.**

Reproduce without involving CoreScope at all — the watermark is baked
into the bytes Carto serves:

```bash
curl -o tile.png https://a.basemaps.cartocdn.com/light_all/11/1152/683.png
# HTTP 200, and tile.png carries the watermark. (11/1152/683 is Lublin, PL.)
```

## Why `carto.domain` cannot express this

Carto takes the key as a query parameter on the **same** host:

```
https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png?key=YOUR_KEY
```

The existing `tiles.providers.carto.domain` option builds
`https://{s}.<domain>.cartocdn.com` — an enterprise subdomain, which is
a different mechanism. There was no way to supply a key, so I added one
rather than repurposing `domain`.

## What this changes

Adds `tiles.providers.carto.key`, appended as `?key=` (URL-encoded) to
all five Carto styles: `carto-dark`, `carto-light`, `carto-voyager`,
`carto-voyager-dark`, `positron-dark`.

```json
"carto": {
  "enabled": true,
  "key": "",
  "domain": ""
}
```

When no key is set the emitted URLs are byte-identical to today's, so
this is a no-op for anyone who has not requested one. `key` and `domain`
compose, so enterprise users keep their subdomain and gain the key.

Free keys are available at <https://carto.com/basemaps/apikey> (5M
tiles/month, non-commercial tier).

**Security note:** like the existing OSM and Stamen tokens, this key
reaches the browser — unavoidable for raster tiles. The `_comment_carto`
text in `config.example.json` tells operators to restrict the key by
domain in the Carto dashboard.

## Tests

5 new cases in `test-issue-1420-tile-providers.js` — **38 passed, 0
failed**:

1. no `?key=` emitted when no key is configured (guards the no-op claim)
2. all five Carto styles append the key when it is set
3. the key is URL-encoded
4. the key does not leak into OSM / Esri provider URLs
5. `key` coexists with the enterprise `domain` option

Also ran the required frontend suite: `test-packet-filter.js` (92
passed), `test-aging.js` (18 passed), `test-frontend-helpers.js` (625
passed, 2 failed). **The 2 failures are pre-existing on `master`** — I
ran the same file from a clean `origin/master` worktree and got an
identical 625/2. They are `favStar returns filled star for favorite` /
`... for non-favorite`, unrelated to tiles.

## Performance

Not a hot path. `url()` returns a Leaflet URL *template* that is built
once per tile-layer construction (`map.js:289`, `live.js:1422`), not
once per tile — `_getCartoKey()` is called at exactly the same frequency
as the existing `_getCartoBase()`, and does one property read plus one
`encodeURIComponent`.

## Validation

Verified against a live instance (<https://analyzer.marwoj.net>) running
this logic as a mounted patch, with a real Carto key:

- **before** — `light_all` and `dark_all` tiles both watermarked
- **after** — both clean, with the key restricted to the instance's
domain in the Carto dashboard
- confirmed the key survives `config.json` → `/api/config/client` →
`MC_MAP_CFG` and reaches the tile URLs

## Heads-up for maintainers

Carto is retiring raster basemaps and steering users to vector, so this
fix has a shelf life. It restores the default experience now; a vector
migration is a larger, separate piece of work.

I am not sure whether you want an issue filed to track that — happy to
open one if it helps.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marek Wojtaszek
2026-08-31 16:23:04 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 589fa9878d
commit 7aa60c0350
3 changed files with 60 additions and 6 deletions
+2 -1
View File
@@ -78,9 +78,10 @@
"darkDefault": "carto-dark",
"lightDefault": "carto-light",
"providers": {
"_comment_carto": "Carto is the default free-tier provider. 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 - 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').",
"carto": {
"enabled": true,
"key": "",
"domain": ""
},
"_comment_osm": "OSM providers: 'mapbox', 'thunderforest', 'maptiler'. WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions in your provider dashboard.",
+13 -5
View File
@@ -2,6 +2,8 @@
*
* Scope:
* - Multiple providers: Carto (default), OSM, Stamen, Esri.
* - Carto requires an API key since 2026-08. `tiles.providers.carto.key` is
* appended as `?key=`; without it Carto returns watermarked tiles.
* - MC_setDarkTileProvider(id) / MC_setLightTileProvider(id) persist per-browser
* to localStorage and dispatch `mc-tile-provider-changed`.
* - Resolves localStorage → server default → 'carto-dark' / 'carto-light'.
@@ -29,6 +31,12 @@
var _cfg = null;
var _getCartoBase = function() { return (_cfg && _cfg.providers && _cfg.providers.carto && _cfg.providers.carto.domain) ? 'https://{s}.' + _cfg.providers.carto.domain + '.cartocdn.com' : 'https://{s}.basemaps.cartocdn.com'; };
// Carto started requiring an API key in 2026-08: unauthenticated tiles come back
// stamped "API KEY REQUIRED". The key is a query param on the same host, so it is
// appended to every Carto style URL. Free keys: https://carto.com/basemaps/apikey
// NOTE: like the OSM/Stamen tokens above, this reaches the browser - restrict it
// by domain in the Carto dashboard.
var _getCartoKey = function() { return (_cfg && _cfg.providers && _cfg.providers.carto && _cfg.providers.carto.key) ? '?key=' + encodeURIComponent(_cfg.providers.carto.key) : ''; };
var _getStamenUrl = function() { return 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png' + ((_cfg && _cfg.providers && _cfg.providers.stamen && _cfg.providers.stamen.token) ? '?api_key=' + encodeURIComponent(_cfg.providers.stamen.token) : ''); };
var _getOsmUrl = function() {
if (_cfg && _cfg.providers && _cfg.providers.osm && _cfg.providers.osm.provider && _cfg.providers.osm.token) {
@@ -42,11 +50,11 @@
};
var BASE_STYLES = {
'carto-dark': { provider: 'carto', label: 'Carto Dark', url: function() { return _getCartoBase() + '/dark_all/{z}/{x}/{y}{r}.png'; }, invertFilter: null, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-light': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png'; }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-voyager': { provider: 'carto', label: 'Carto Voyager', url: function() { return _getCartoBase() + '/rastertiles/voyager/{z}/{x}/{y}{r}.png'; }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-voyager-dark': { provider: 'carto', label: 'Carto Voyager', url: function() { return _getCartoBase() + '/rastertiles/voyager/{z}/{x}/{y}{r}.png'; }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'positron-dark': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png'; }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-dark': { provider: 'carto', label: 'Carto Dark', url: function() { return _getCartoBase() + '/dark_all/{z}/{x}/{y}{r}.png' + _getCartoKey(); }, invertFilter: null, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-light': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png' + _getCartoKey(); }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-voyager': { provider: 'carto', label: 'Carto Voyager', url: function() { return _getCartoBase() + '/rastertiles/voyager/{z}/{x}/{y}{r}.png' + _getCartoKey(); }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'carto-voyager-dark': { provider: 'carto', label: 'Carto Voyager', url: function() { return _getCartoBase() + '/rastertiles/voyager/{z}/{x}/{y}{r}.png' + _getCartoKey(); }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'positron-dark': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png' + _getCartoKey(); }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 },
'osm-standard': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: null, type: 'light', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 },
'osm-dark': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 },
'stamen-toner-lite': { provider: 'stamen', label: 'Stamen Toner Lite', url: _getStamenUrl, invertFilter: null, type: 'light', attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', maxZoom: 20 },
+45
View File
@@ -519,6 +519,51 @@ test('MC_createLayerControl handles Auto mode and explicit layers correctly', ()
assert.strictEqual(ev.detail.auto, true, 'event detail.auto should be true');
});
test('Carto URLs carry no ?key= when no key is configured', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true } } } });
const reg = ctx.window.MC_TILE_PROVIDERS;
for (const id of ALL_CARTO_IDS) {
const u = reg[id].url();
assert.ok(u.indexOf('?key=') === -1, id + ' should have no key param: ' + u);
assert.ok(u.indexOf('basemaps.cartocdn.com') !== -1, id + ' unexpected host: ' + u);
}
});
test('Every Carto style appends ?key= when carto.key is set', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, key: 'abc123' } } } });
const reg = ctx.window.MC_TILE_PROVIDERS;
for (const id of ALL_CARTO_IDS) {
assert.ok(/\.png\?key=abc123$/.test(reg[id].url()), id + ' bad URL: ' + reg[id].url());
}
});
test('Carto key is URL-encoded', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, key: 'a b&c=d' } } } });
const u = ctx.window.MC_TILE_PROVIDERS['carto-dark'].url();
assert.ok(u.endsWith('?key=a%20b%26c%3Dd'), 'bad encoding: ' + u);
});
test('Carto key does not leak into non-Carto providers', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, key: 'abc123' }, osm: { enabled: true } } } });
const reg = ctx.window.MC_TILE_PROVIDERS;
for (const id of ['osm-standard', 'osm-dark', 'esri-darkgray-labels']) {
if (!reg[id]) continue;
assert.ok(reg[id].url().indexOf('abc123') === -1, id + ' leaked the key: ' + reg[id].url());
}
});
test('Carto key coexists with the enterprise domain option', () => {
const ctx = makeSandbox();
loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, domain: 'mycompany', key: 'abc123' } } } });
const u = ctx.window.MC_TILE_PROVIDERS['carto-dark'].url();
assert.ok(u.indexOf('mycompany.cartocdn.com') !== -1, 'domain lost: ' + u);
assert.ok(u.endsWith('?key=abc123'), 'key lost: ' + u);
});
process.on('beforeExit', () => {
console.log('');
console.log(' ' + passed + ' passed, ' + failed + ' failed');