diff --git a/config.example.json b/config.example.json index b5c0f55b..c5de20a0 100644 --- a/config.example.json +++ b/config.example.json @@ -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.", diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index 6266b81f..ec2385d5 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -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 }, diff --git a/test-issue-1420-tile-providers.js b/test-issue-1420-tile-providers.js index c70edb36..560721d4 100644 --- a/test-issue-1420-tile-providers.js +++ b/test-issue-1420-tile-providers.js @@ -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');