diff --git a/config.example.json b/config.example.json
index b6f967315..191431af4 100644
--- a/config.example.json
+++ b/config.example.json
@@ -78,10 +78,11 @@
"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. From August 2026 CARTO REQUIRES a Basemaps API key on raster tile requests: without a key here, every Carto layer (map, live map, node detail, geo-filter maps and the standalone geofilter-builder) still loads, but the tiles come back stamped 'API KEY REQUIRED -- carto.com/basemapsapikey'. Get a free key at carto.com/basemapsapikey and put it in 'key' (e.g. 'YOUR_CARTO_BASEMAP_KEY'); the field name matches upstream CoreScope so the same config works on both. WARNING: the key is sent to the browser. Follow any domain/referrer restrictions offered when CARTO issues the key, use a key dedicated to this deployment, monitor its usage, and rotate it if abused. Optional: 'domain' for Carto enterprise -- dot-separated enterprise SUBDOMAIN LABELS only, e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com'. A value containing a scheme, '/', '?', '#' or whitespace, or with empty/over-long labels, is ignored and the public Carto base is used instead (it would otherwise move the tile host and send 'key' there). NOTE on 'enabled': false -- it removes Carto from the registered main-map / layer-picker styles only (enable another provider below to replace them). It does NOT stop all Carto use: the dedicated geo-filter maps (the Customize geo-filter tab and modal, and the standalone geofilter-builder page) call Carto directly and still need 'key' set to avoid the watermark.",
"carto": {
"enabled": true,
- "domain": ""
+ "domain": "",
+ "key": ""
},
"_comment_osm": "OSM providers: 'mapbox', 'thunderforest', 'maptiler'. WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions in your provider dashboard.",
"osm": {
diff --git a/public/customize-v2.js b/public/customize-v2.js
index d1d844cfd..35512bfa1 100644
--- a/public/customize-v2.js
+++ b/public/customize-v2.js
@@ -1835,7 +1835,10 @@
var modalClosingLine = null;
_gfModalMap = L.map(mapDiv, { zoomControl: true });
- L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
+ // #7: resolve through the shared helper so the CARTO Basemaps API key
+ // (map.tiles.providers.carto.key) is applied here too. The modal is
+ // opened by user action, long after /api/config/client has landed.
+ L.tileLayer(window.MC_getCartoTileUrl('/light_all/{z}/{x}/{y}{r}.png'), {
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
}).addTo(_gfModalMap);
@@ -2044,7 +2047,8 @@
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', {
+ // #7: same shared-helper resolution as the geo-filter modal above.
+ L.tileLayer(window.MC_getCartoTileUrl('/light_all/{z}/{x}/{y}{r}.png'), {
attribution: '© OpenStreetMap © CartoDB', maxZoom: 19
}).addTo(_gfMap);
diff --git a/public/geofilter-builder.html b/public/geofilter-builder.html
index 2a55ca840..6b1dad1df 100644
--- a/public/geofilter-builder.html
+++ b/public/geofilter-builder.html
@@ -85,13 +85,40 @@
GeoFilter Builder
+
+
', start));
+ const bootstrap = inline.slice(0, inline.indexOf('let points'));
+ assert.ok(bootstrap.indexOf('MC_getCartoTileUrl') >= 0, 'fixture extraction failed');
+
+ const ctx = makeSandbox();
+ loadProviders(ctx);
+ const added = [];
+ ctx.L = {
+ map: () => ({ setView: () => ({}), on() {}, removeLayer() {}, addLayer() {} }),
+ tileLayer: (url) => ({ addTo() { added.push(url); return this; }, setUrl() { return this; } }),
+ marker: () => ({ addTo() { return this; }, on() { return this; } }),
+ polygon: () => ({ addTo() { return this; } }),
+ polyline: () => ({ addTo() { return this; } }),
+ };
+ ctx.window.L = ctx.L;
+ let resolveFetch;
+ const gate = new Promise((r) => { resolveFetch = r; });
+ ctx.fetch = () => gate.then(() => ({ json: () => Promise.resolve(CFG_TOKEN) }));
+ ctx.window.fetch = ctx.fetch;
+ vm.runInContext(bootstrap, ctx, { filename: 'geofilter-builder.html (inline)' });
+
+ assert.deepStrictEqual(added, [],
+ 'builder must not add any tile layer before /api/config/client settles; added: ' + JSON.stringify(added));
+
+ resolveFetch();
+ for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
+
+ assert.strictEqual(added.length, 1, 'exactly one layer after config; got ' + added.length);
+ assert.strictEqual(added[0], 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=' + FAKE_TOKEN,
+ 'builder first (and only) request must already be keyed; got: ' + added[0]);
+ });
+
+ await atest('geofilter-builder falls back to a keyless layer when the config fetch fails', async () => {
+ const html = fs.readFileSync(P('geofilter-builder.html'), 'utf8');
+ const OPEN = '', start));
+ const bootstrap = inline.slice(0, inline.indexOf('let points'));
+
+ const ctx = makeSandbox();
+ loadProviders(ctx);
+ const added = [];
+ ctx.L = {
+ map: () => ({ setView: () => ({}), on() {}, removeLayer() {}, addLayer() {} }),
+ tileLayer: (url) => ({ addTo() { added.push(url); return this; }, setUrl() { return this; } }),
+ marker: () => ({ addTo() { return this; }, on() { return this; } }),
+ polygon: () => ({ addTo() { return this; } }),
+ polyline: () => ({ addTo() { return this; } }),
+ };
+ ctx.window.L = ctx.L;
+ ctx.fetch = () => Promise.reject(new Error('offline'));
+ ctx.window.fetch = ctx.fetch;
+ vm.runInContext(bootstrap, ctx, { filename: 'geofilter-builder.html (inline)' });
+ for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
+
+ assert.strictEqual(added.length, 1, 'builder must still get exactly one layer when the fetch fails');
+ assert.strictEqual(added[0], 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ 'offline builder must fall back to keyless tiles, not an empty map');
+ });
+
+ await atest('MC_whenTileConfigReady fires once, on settle (resolve AND reject), never twice', async () => {
+ // resolve
+ const a = withCarto({ enabled: true, key: FAKE_TOKEN });
+ let aN = 0;
+ let resA; a.window.MeshConfigReady = new Promise(r => { resA = r; });
+ a.window.MC_whenTileConfigReady(() => { aN++; });
+ assert.strictEqual(aN, 0, 'must not fire before the promise settles');
+ resA(); await new Promise(r => setTimeout(r, 0));
+ assert.strictEqual(aN, 1, 'must fire exactly once on resolve');
+
+ // reject
+ const b = withCarto({ enabled: true, key: FAKE_TOKEN });
+ let bN = 0;
+ let rejB; b.window.MeshConfigReady = new Promise((_, rj) => { rejB = rj; });
+ b.window.MeshConfigReady.catch(() => {});
+ b.window.MC_whenTileConfigReady(() => { bN++; });
+ rejB(new Error('x')); await new Promise(r => setTimeout(r, 0));
+ assert.strictEqual(bN, 1, 'must fire exactly once on reject too (settled, not fulfilled)');
+
+ // absent → synchronous
+ const c = withCarto({ enabled: true, key: FAKE_TOKEN });
+ let cN = 0;
+ c.window.MC_whenTileConfigReady(() => { cN++; });
+ assert.strictEqual(cN, 1, 'must fire synchronously when there is no MeshConfigReady');
+ });
+
+ await atest('test-carto-basemap-key.js is registered exactly once in test-all.sh', async () => {
+ const sh = fs.readFileSync(path.join(__dirname, 'test-all.sh'), 'utf8');
+ const lines = sh.split('\n').filter(l => l.trim() === 'node test-carto-basemap-key.js');
+ assert.strictEqual(lines.length, 1,
+ 'expected exactly one registration line in test-all.sh, got ' + lines.length);
+ assert.ok(/^set -e$/m.test(sh), 'test-all.sh must keep its set -e semantics');
+ const idx = sh.indexOf('node test-carto-basemap-key.js');
+ const tileIdx = sh.indexOf('node test-issue-1420-tile-providers.js');
+ assert.ok(tileIdx > 0 && Math.abs(sh.slice(0, idx).split('\n').length - sh.slice(0, tileIdx).split('\n').length) <= 3,
+ 'should sit next to the existing tile-provider tests');
+ });
+
+ await atest('roles.js TILE_DARK/TILE_LIGHT pick up the key once config lands', async () => {
+ const { ctx, landConfig } = loadRolesStack(CLIENT_CFG_WITH_KEY);
+ const beforeDark = ctx.window.TILE_DARK;
+ await landConfig();
+ assert.strictEqual(ctx.window.TILE_DARK,
+ 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=' + FAKE_TOKEN);
+ assert.strictEqual(ctx.window.TILE_LIGHT,
+ 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=' + FAKE_TOKEN);
+ assert.notStrictEqual(ctx.window.TILE_DARK, beforeDark, 'the keyless value must not stay frozen');
+ });
+
+ await atest('getTileUrl() returns a keyed URL after config lands (dark + light)', async () => {
+ const dark = loadRolesStack(CLIENT_CFG_WITH_KEY, 'dark');
+ await dark.landConfig();
+ const d = dark.ctx.window.getTileUrl();
+ assert.ok(d.indexOf('?key=' + FAKE_TOKEN) >= 0, 'dark getTileUrl must be keyed; got: ' + d);
+
+ const light = loadRolesStack(CLIENT_CFG_WITH_KEY, 'light');
+ await light.landConfig();
+ const l = light.ctx.window.getTileUrl();
+ assert.ok(l.indexOf('?key=' + FAKE_TOKEN) >= 0, 'light getTileUrl must be keyed; got: ' + l);
+ });
+
+ await atest('an explicit darkUrl/lightUrl override still wins over the CARTO derivation', async () => {
+ const CUSTOM_D = 'https://tiles.example.com/dark/{z}/{x}/{y}.png';
+ const CUSTOM_L = 'https://tiles.example.com/light/{z}/{x}/{y}.png';
+ const { ctx, landConfig } = loadRolesStack({
+ map: { tiles: { darkUrl: CUSTOM_D, lightUrl: CUSTOM_L, providers: { carto: { enabled: true, key: FAKE_TOKEN } } } }
+ });
+ await landConfig();
+ assert.strictEqual(ctx.window.TILE_DARK, CUSTOM_D, 'explicit darkUrl override must be honoured');
+ assert.strictEqual(ctx.window.TILE_LIGHT, CUSTOM_L, 'explicit lightUrl override must be honoured');
+ });
+
+ await atest('roles.js stays keyless (no bare "?") when the server sends no token', async () => {
+ const { ctx, landConfig } = loadRolesStack({ map: { tiles: { providers: { carto: { enabled: true } } } } });
+ await landConfig();
+ assert.strictEqual(ctx.window.TILE_DARK, 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
+ assert.ok(ctx.window.TILE_DARK.indexOf('?') < 0, 'no bare "?" without a token');
+ });
+
+ // ─── carto.domain hardening ──────────────────────────────────────────────
+ // `domain` is concatenated straight into the host, so an unvalidated value
+ // escapes the host and (with a key set) sends the key somewhere else.
+ // Upstream Kpa-clawbot/CoreScope#1919 has the same unvalidated _getCartoBase.
+
+ test('a valid enterprise domain still builds the documented host', () => {
+ const ctx = withCarto({ enabled: true, domain: 'mycompany' });
+ const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png');
+ assert.strictEqual(u, 'https://{s}.mycompany.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
+ });
+
+ test('a dotted enterprise domain is still accepted', () => {
+ const ctx = withCarto({ enabled: true, domain: 'eu.mycompany' });
+ assert.ok(ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png')
+ .indexOf('https://{s}.eu.mycompany.cartocdn.com/') === 0);
+ });
+
+ test('domain is trimmed', () => {
+ const ctx = withCarto({ enabled: true, domain: ' mycompany ' });
+ assert.strictEqual(ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'),
+ 'https://{s}.mycompany.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
+ });
+
+ test('a domain that would move the tile host is ignored', () => {
+ const BAD = ['evil.com/x?a=b', 'foo?a=b', 'foo#frag', 'https://evil.com',
+ 'foo bar', 'evil.com@real', '../evil', 'foo/', '?a=b', '//evil.com'];
+ for (const d of BAD) {
+ const ctx = withCarto({ enabled: true, domain: d });
+ const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png');
+ assert.strictEqual(u, 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ 'domain ' + JSON.stringify(d) + ' must fall back to the public base, got: ' + u);
+ }
+ });
+
+ test('an injected domain can never receive the key', () => {
+ for (const d of ['evil.com/x?a=b', 'https://evil.com', 'foo?a=b']) {
+ const ctx = withCarto({ enabled: true, domain: d, key: FAKE_TOKEN });
+ const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png');
+ const host = u.replace('{s}', 'a').split('/')[2];
+ assert.strictEqual(host, 'a.basemaps.cartocdn.com',
+ 'key must never be sent to an injected host, got: ' + u);
+ assert.ok(u.indexOf('?key=' + FAKE_TOKEN) > 0, 'the key still reaches the real host: ' + u);
+ assert.strictEqual(u.split('?').length, 2, 'exactly one querystring: ' + u);
+ }
+ });
+
+ test('a valid domain and the key compose on every style', () => {
+ const ctx = withCarto({ enabled: true, domain: 'mycompany', key: FAKE_TOKEN });
+ for (const id of ALL_CARTO_IDS) {
+ const u = urlFor(ctx, id);
+ assert.ok(u.indexOf('https://{s}.mycompany.cartocdn.com/') === 0, id + ': ' + u);
+ assert.ok(u.indexOf('?key=' + FAKE_TOKEN) > 0, id + ' missing key: ' + u);
+ assert.strictEqual(u.split('?').length, 2, id + ' has more than one querystring: ' + u);
+ }
+ });
+
+ // ─── clean rename: token -> key (no permanent dual support) ──────────────
+
+ test('the legacy carto.token field is NOT honoured', () => {
+ const ctx = withCarto({ enabled: true, token: FAKE_TOKEN });
+ const u = ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png');
+ assert.strictEqual(u, 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ 'carto.token must be inert after the rename to carto.key, got: ' + u);
+ });
+
+ test('production source no longer reads carto.token', () => {
+ const src = readPub('map-tile-providers.js');
+ assert.ok(src.indexOf('carto.token') < 0, 'map-tile-providers.js still mentions carto.token');
+ assert.ok(/providers\.carto\.key/.test(src) || /_cfg\.providers\.carto\) \? _cfg\.providers\.carto\.key/.test(src),
+ 'map-tile-providers.js must read carto.key');
+ });
+
+ test('config.example.json exposes key and no longer exposes token', () => {
+ const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8'));
+ const carto = cfg.map.tiles.providers.carto;
+ assert.ok(Object.prototype.hasOwnProperty.call(carto, 'key'), 'carto.key must exist');
+ assert.ok(!Object.prototype.hasOwnProperty.call(carto, 'token'), 'carto.token must be gone');
+ assert.strictEqual(carto.key, '', 'the shipped example must carry an EMPTY key');
+ const cmt = cfg.map.tiles.providers._comment_carto;
+ assert.ok(/'key'/.test(cmt), 'the comment must name the key field');
+ assert.ok(!/restrict it by origin\/referrer/i.test(cmt),
+ 'the comment must not claim CARTO basemap keys can be origin/referrer restricted');
+ assert.ok(/SUBDOMAIN LABEL/i.test(cmt), 'the comment must document the domain restriction');
+ });
+
+ // ─── domain length contract (DNS) ────────────────────────────────────────
+
+ test('a 63-character label is accepted, 64 is not', () => {
+ const ok = 'a'.repeat(63), tooLong = 'a'.repeat(64);
+ assert.strictEqual(withCarto({ enabled: true, domain: ok })
+ .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'),
+ 'https://{s}.' + ok + '.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
+ assert.strictEqual(withCarto({ enabled: true, domain: tooLong })
+ .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'),
+ 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ 'a 64-character label exceeds the DNS label limit and must be ignored');
+ });
+
+ test('an over-long overall domain is ignored', () => {
+ const many = Array(60).fill('abcd').join('.'); // 299 chars
+ assert.ok(many.length > 238);
+ assert.strictEqual(withCarto({ enabled: true, domain: many })
+ .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'),
+ 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
+ });
+
+ test('empty labels, leading/trailing dots and hyphens are ignored', () => {
+ for (const d of ['.mycompany', 'mycompany.', 'my..company', '-mycompany',
+ 'mycompany-', 'eu.-my', 'eu.my-', '.', '..']) {
+ assert.strictEqual(withCarto({ enabled: true, domain: d })
+ .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'),
+ 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ 'domain ' + JSON.stringify(d) + ' must be ignored');
+ }
+ });
+
+ test('an internal hyphen is still allowed', () => {
+ assert.strictEqual(withCarto({ enabled: true, domain: 'my-company' })
+ .window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'),
+ 'https://{s}.my-company.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
+ });
+
+ // ─── the warning must not echo operator input, and fires once ────────────
+
+ test('the invalid-domain warning never reprints the rejected value', () => {
+ const SECRETISH = 'https://evil.example/leak?token=SUPERSECRET';
+ const ctx = makeSandbox();
+ const seen = [];
+ ctx.console = Object.assign({}, ctx.console, { warn: (...a) => seen.push(a.map(String).join(' ')) });
+ loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, domain: SECRETISH } } } });
+ ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png');
+ const joined = seen.join('\n');
+ assert.ok(joined.indexOf('carto.domain') >= 0, 'expected a warning, got: ' + JSON.stringify(seen));
+ assert.ok(joined.indexOf(SECRETISH) < 0, 'the warning must not echo the value: ' + joined);
+ assert.ok(joined.indexOf('SUPERSECRET') < 0, 'no fragment of the value may leak: ' + joined);
+ assert.ok(joined.indexOf('evil.example') < 0, 'no fragment of the value may leak: ' + joined);
+ assert.ok(/mycompany/.test(joined), 'the warning should show the expected form');
+ });
+
+ test('the invalid-domain warning is emitted at most once', () => {
+ const ctx = makeSandbox();
+ let n = 0;
+ ctx.console = Object.assign({}, ctx.console, { warn: (...a) => { if (/carto\.domain/.test(a.map(String).join(' '))) n++; } });
+ loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, domain: 'evil.com/x' } } } });
+ for (let i = 0; i < 12; i++) ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png');
+ for (const id of ALL_CARTO_IDS) urlFor(ctx, id);
+ assert.strictEqual(n, 1, 'expected exactly one warning, got ' + n);
+ });
+
+ test('a blank or whitespace-only domain warns not at all', () => {
+ for (const d of ['', ' ', '\t']) {
+ const ctx = makeSandbox();
+ let n = 0;
+ ctx.console = Object.assign({}, ctx.console, { warn: (...a) => { if (/carto\.domain/.test(a.map(String).join(' '))) n++; } });
+ loadProviders(ctx, { tiles: { providers: { carto: { enabled: true, domain: d } } } });
+ assert.strictEqual(ctx.window.MC_getCartoTileUrl('/dark_all/{z}/{x}/{y}{r}.png'),
+ 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
+ assert.strictEqual(n, 0, 'blank domain must not warn (' + JSON.stringify(d) + ')');
+ }
+ });
+
+ // ─── static guards: carto.token must not come back ───────────────────────
+
+ test('no production source mentions carto.token or _getCartoToken', () => {
+ for (const f of ['map-tile-providers.js', 'roles.js', 'customize-v2.js', 'map.js', 'live.js', 'geofilter-builder.html']) {
+ const src = readPub(f);
+ assert.ok(src.indexOf('carto.token') < 0, f + ' still mentions carto.token');
+ assert.ok(src.indexOf('_getCartoToken') < 0, f + ' still defines/uses _getCartoToken');
+ }
+ });
+
+ test('config.example.json carries no carto token field or wording', () => {
+ const raw = fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8');
+ const cfg = JSON.parse(raw);
+ const carto = cfg.map.tiles.providers.carto;
+ assert.ok(!Object.prototype.hasOwnProperty.call(carto, 'token'), 'carto.token must be gone');
+ assert.ok(Object.prototype.hasOwnProperty.call(carto, 'key'), 'carto.key must exist');
+ const cmt = cfg.map.tiles.providers._comment_carto;
+ assert.ok(!/\btoken\b/i.test(cmt), 'the carto comment must not say "token": ' + cmt.slice(0, 120));
+ // OSM/Stamen keep their own token wording — make sure we did not over-rename.
+ assert.ok(/token/i.test(cfg.map.tiles.providers._comment_osm), 'the OSM comment should still say token');
+ });
+
+ test('the example makes no unverifiable claim about key restrictions', () => {
+ const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8'));
+ const cmt = cfg.map.tiles.providers._comment_carto;
+ assert.ok(!/cannot be restricted/i.test(cmt), 'must not assert restrictions are unavailable');
+ assert.ok(!/restrict it by origin\/referrer in the CARTO dashboard/i.test(cmt),
+ 'must not assert restrictions are available either');
+ assert.ok(/Follow any domain\/referrer restrictions offered when CARTO issues the key/.test(cmt),
+ 'expected the neutral guidance');
+ assert.ok(/rotate it if abused/i.test(cmt), 'expected rotation guidance');
+ });
+
+ console.log('\n#7 CARTO Basemaps API key: ' + passed + ' passed, ' + failed + ' failed');
+ process.exit(failed === 0 ? 0 : 1);
+})();