mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-09 23:05:34 +00:00
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:
co-authored by
Claude Opus 5
parent
589fa9878d
commit
7aa60c0350
+2
-1
@@ -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.",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user