diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index d787287..fc9115e 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -21,13 +21,9 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v6
- - name: Inject CARTO basemap key
- env:
- CARTO_KEY: ${{ secrets.CARTO_KEY }}
- run: |
- test -n "$CARTO_KEY" || { echo "CARTO_KEY secret is not set"; exit 1; }
- sed -i "s|__CARTO_KEY__|$CARTO_KEY|" js/map-shared.js
- grep -q "$CARTO_KEY" js/map-shared.js
+ # No basemap key injection anymore: the basemap is keyless (OSM primary
+ # + Esri reserve, see js/map-shared.js). Forks and local checkouts render
+ # exactly what production renders, and no CARTO_KEY secret is needed.
- uses: actions/upload-pages-artifact@v4
with:
path: .
diff --git a/css/map.css b/css/map.css
index 28281a9..a9d86f8 100644
--- a/css/map.css
+++ b/css/map.css
@@ -157,3 +157,24 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden;
.signal-text { max-width: 140px; }
.signal-item { gap: 4px; padding-left: 4px; }
}
+
+/* ── Keyless basemap (OSM primary, Esri reserve) ─────────────────────────────
+ .basemap-muted recreates the light-gray "positron" canvas from the raw
+ OSM tiles (Leaflet puts a tileLayer's className on the LAYER CONTAINER,
+ not on the tile
s — target both for robustness).
+ .leaflet-basemap-labels-pane re-draws the SAME tiles above the data
+ polygons and keeps only their dark pixels (place names, admin borders)
+ via mix-blend-mode: darken + a midtone-crushing filter. Hidden entirely
+ where blending is unsupported, so it can never cover the data. */
+.leaflet-layer.basemap-muted,
+.leaflet-tile.basemap-muted {
+ filter: saturate(0) brightness(1.06) contrast(0.9);
+}
+.leaflet-basemap-labels-pane { display: none; pointer-events: none; }
+@supports (mix-blend-mode: darken) {
+ .leaflet-basemap-labels-pane {
+ display: block;
+ mix-blend-mode: darken;
+ filter: saturate(0) brightness(1.35) contrast(1.6);
+ }
+}
diff --git a/datenschutz.html b/datenschutz.html
index f0e42f1..18418a5 100644
--- a/datenschutz.html
+++ b/datenschutz.html
@@ -33,7 +33,7 @@
3. Externe Ressourcen
Zur Darstellung der Karten werden externe Ressourcen von Drittanbietern geladen. Dabei wird Ihre IP-Adresse an die jeweiligen Server übermittelt:
unpkg.com — Leaflet-Kartenbibliothek und Gemeindegrenzen (TopoJSON)
- basemaps.cartocdn.com — Kartenkacheln (CARTO)
+ tile.openstreetmap.org — Kartenkacheln (OpenStreetMap)
4. Cookies
diff --git a/js/map-shared.js b/js/map-shared.js
index 5e5e19d..9c2bf35 100644
--- a/js/map-shared.js
+++ b/js/map-shared.js
@@ -1,6 +1,52 @@
/* map-shared.js — shared utilities for map pages */
-var CARTO_KEY = '__CARTO_KEY__'; // injected from the CARTO_KEY GitHub secret at deploy time
+/* Basemap: open & keyless providers only — no API keys, no accounts, no
+ * deploy-time secret injection. Rationale: on a static site a "secret"
+ * basemap key ends up world-readable in the served JS anyway (anyone can
+ * copy it and burn the quota), forks and local checkouts render broken
+ * tiles, and the deploy gains a billing dependency. CARTO put its raster
+ * basemaps behind API keys (watermark rollout 2026-08-28, enforcement
+ * 2026-09-23), so the keyless CARTO URLs this file used are dead.
+ *
+ * Primary: OpenStreetMap standard raster (OSMF community infrastructure,
+ * native up to z19). The familiar light-gray "positron" look is recreated
+ * with a CSS grayscale filter (.basemap-muted, css/map.css); the labels
+ * that OSM bakes into the raster are lifted ABOVE the data polygons by
+ * re-drawing the same tiles on a dedicated pane blended with
+ * mix-blend-mode: darken (same URLs -> served from the browser HTTP
+ * cache, zero extra tile requests; see css/map.css).
+ *
+ * Fallback (runtime failover): Esri World Light Gray, also keyless. Two
+ * Esri traps encoded here: the axis order is /tile/{z}/{y}/{x} — INVERTED
+ * vs slippy — and native tiles stop at z16 (maxNativeZoom upscales
+ * beyond). A wrong axis order renders the wrong place on Earth with zero
+ * errors, so tests/test_basemap.py pins the rule per host.
+ */
+var BASEMAP = {
+ base: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution: '© OpenStreetMap contributors',
+ exportAttribution: '© OpenStreetMap contributors',
+ maxZoom: 19,
+ fallbackBase: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
+ fallbackLabels: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
+ fallbackAttribution: '© OSM · Tiles © Esri',
+ fallbackExportAttribution: '© OpenStreetMap · Tiles © Esri',
+ fallbackMaxNativeZoom: 16,
+ failoverThreshold: 8
+};
+
+/* Active basemap state — the PNG export reads this too, so a failover
+ * changes both the on-screen map and exported images coherently. */
+var activeBasemap = {
+ base: BASEMAP.base,
+ labels: BASEMAP.base, // OSM: labels are the same tiles, blended
+ exportAttribution: BASEMAP.exportAttribution,
+ exportMaxZoom: 18
+};
+
+function tileUrlFromTemplate(template, z, x, y) {
+ return template.replace('{z}', z).replace('{x}', x).replace('{y}', y);
+}
function escapeHtml(str) {
var el = document.createElement('span');
@@ -21,20 +67,68 @@ function initMap(elementId, options) {
renderer: L.canvas()
});
- L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png?key=' + CARTO_KEY, {
- attribution: '© OSM © CARTO',
- subdomains: 'abcd',
+ // Dedicated pane for the label overlay: above the data polygons in the
+ // overlayPane (z 400), below markers (z 600). CSS in css/map.css gives it
+ // mix-blend-mode: darken + a midtone-crushing filter so only the dark
+ // pixels (place names, admin borders) emerge above the colored polygons,
+ // and hides it entirely where blending is unsupported (@supports guard).
+ map.createPane('basemap-labels');
+ map.getPane('basemap-labels').style.zIndex = 450;
+ map.getPane('basemap-labels').style.pointerEvents = 'none';
+
+ var baseLayer = L.tileLayer(activeBasemap.base, {
+ attribution: BASEMAP.attribution,
maxZoom: 19,
+ className: 'basemap-muted',
crossOrigin: ''
}).addTo(map);
- L.tileLayer('https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png?key=' + CARTO_KEY, {
- subdomains: 'abcd',
+ var labelLayer = L.tileLayer(activeBasemap.labels, {
maxZoom: 19,
- pane: 'shadowPane',
+ pane: 'basemap-labels',
crossOrigin: ''
}).addTo(map);
+ // Runtime failover: OSMF is community infrastructure without an SLA. If
+ // the base layer accumulates tileerror events (outage, throttling), swap
+ // both layers to the keyless Esri reserve. The reserve is exercised daily
+ // by tests/test_basemap.py so it cannot rot unnoticed.
+ var osmTileErrors = 0;
+ var failoverDone = false;
+ function activateBasemapFailover(reason) {
+ if (failoverDone) return;
+ failoverDone = true;
+ console.warn('[basemap] primary basemap failing (' + reason + '): switching to Esri World Light Gray reserve');
+ map.removeLayer(baseLayer);
+ map.removeLayer(labelLayer);
+ activeBasemap.base = BASEMAP.fallbackBase;
+ activeBasemap.labels = BASEMAP.fallbackLabels;
+ activeBasemap.exportAttribution = BASEMAP.fallbackExportAttribution;
+ activeBasemap.exportMaxZoom = BASEMAP.fallbackMaxNativeZoom;
+ baseLayer = L.tileLayer(BASEMAP.fallbackBase, {
+ attribution: BASEMAP.fallbackAttribution,
+ maxNativeZoom: BASEMAP.fallbackMaxNativeZoom,
+ maxZoom: 19,
+ className: 'basemap-muted',
+ crossOrigin: ''
+ }).addTo(map);
+ // Esri Reference is a transparent label-only layer: on the blend pane
+ // the darken blend keeps the labels above the polygons, like OSM.
+ labelLayer = L.tileLayer(BASEMAP.fallbackLabels, {
+ maxNativeZoom: BASEMAP.fallbackMaxNativeZoom,
+ maxZoom: 19,
+ pane: 'basemap-labels',
+ crossOrigin: ''
+ }).addTo(map);
+ }
+ baseLayer.on('tileerror', function () {
+ osmTileErrors += 1;
+ if (osmTileErrors >= BASEMAP.failoverThreshold) {
+ activateBasemapFailover(osmTileErrors + ' tileerror');
+ }
+ });
+ window.__forceBasemapFailover = function () { activateBasemapFailover('manual'); };
+
var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
@@ -167,19 +261,27 @@ function handleLoadError(err) {
}
}
-function fetchTileLayer(ctx, layer, zoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY) {
+function fetchTileLayer(ctx, template, zoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY, opts) {
+ opts = opts || {};
var promises = [];
for (var tx = minTX; tx <= maxTX; tx++) {
for (var ty = minTY; ty <= maxTY; ty++) {
(function (tx, ty) {
- var sub = 'abcd'.charAt(Math.abs(tx + ty) % 4);
- var url = 'https://' + sub + '.basemaps.cartocdn.com/' + layer + '/' + zoom + '/' + tx + '/' + ty + '.png?key=' + CARTO_KEY;
+ var url = tileUrlFromTemplate(template, zoom, tx, ty);
promises.push(
fetch(url, { mode: 'cors' })
.then(function (r) { return r.blob(); })
.then(function (b) { return createImageBitmap(b); })
.then(function (bmp) {
+ // Replicate the on-screen look: the base pass is muted like
+ // .basemap-muted; the label pass uses the same darken blend
+ // as the .leaflet-basemap-labels-pane CSS, so exports match
+ // the map (labels above polygons, gray canvas below).
+ ctx.save();
+ if (opts.composite) ctx.globalCompositeOperation = opts.composite;
+ if (opts.filter && 'filter' in ctx) ctx.filter = opts.filter;
ctx.drawImage(bmp, tx * tileSize - originX, ty * tileSize - originY, tileSize, tileSize);
+ ctx.restore();
bmp.close();
})
.catch(function () {})
@@ -301,9 +403,11 @@ function renderLegendToCanvas(legendEl, scale) {
function exportMapImage(map, filename, onDone) {
map.closePopup();
- // Render at current zoom + 2 for 4× tile detail in each dimension
+ // Render at current zoom + 2 for 4× tile detail in each dimension.
+ // Cap at the active basemap's export ceiling (18 for OSM; the Esri
+ // reserve has no native tiles beyond 16).
var viewZoom = map.getZoom();
- var exportZoom = Math.min(Math.round(viewZoom) + 2, 18);
+ var exportZoom = Math.min(Math.round(viewZoom) + 2, activeBasemap.exportMaxZoom);
var bounds = map.getBounds();
// Project bounds to pixel coordinates at export zoom
@@ -316,7 +420,7 @@ function exportMapImage(map, filename, onDone) {
// Cap at 8192 — fall back to zoom+1 if too large
if (w > 8192 || h > 8192) {
- exportZoom = Math.min(Math.round(viewZoom) + 1, 18);
+ exportZoom = Math.min(Math.round(viewZoom) + 1, activeBasemap.exportMaxZoom);
nw = map.project(bounds.getNorthWest(), exportZoom);
se = map.project(bounds.getSouthEast(), exportZoom);
originX = Math.floor(nw.x);
@@ -346,10 +450,12 @@ function exportMapImage(map, filename, onDone) {
var legendPromise = renderLegendToCanvas(legendEl, legendScale);
// 1. Base tiles → 2. GeoJSON features → 3. Label tiles → 4. Legend + attribution
- fetchTileLayer(ctx, 'light_nolabels', exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY)
+ fetchTileLayer(ctx, activeBasemap.base, exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY,
+ { filter: 'saturate(0) brightness(1.06) contrast(0.9)' })
.then(function () {
drawMapFeatures(map, ctx, exportZoom, originX, originY);
- return fetchTileLayer(ctx, 'light_only_labels', exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY);
+ return fetchTileLayer(ctx, activeBasemap.labels, exportZoom, minTX, maxTX, minTY, maxTY, tileSize, originX, originY,
+ { filter: 'saturate(0) brightness(1.35) contrast(1.6)', composite: 'darken' });
})
.then(function () { return legendPromise; })
.then(function (legendImg) {
@@ -360,7 +466,7 @@ function exportMapImage(map, filename, onDone) {
// Attribution
var fontSize = Math.max(13, Math.round(w / 300));
ctx.font = fontSize + 'px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
- var attrText = '\u00a9 OpenStreetMap \u00a9 CARTO';
+ var attrText = activeBasemap.exportAttribution;
var tw = ctx.measureText(attrText).width;
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fillRect(0, h - fontSize * 2.2, tw + fontSize * 2, fontSize * 2.2);
diff --git a/providers.html b/providers.html
index 23ef502..abcb33a 100644
--- a/providers.html
+++ b/providers.html
@@ -10,10 +10,7 @@
-
-
-
-
+
Email Providers of DACH Municipalities
diff --git a/security.html b/security.html
index 4117166..9fcd750 100644
--- a/security.html
+++ b/security.html
@@ -10,10 +10,7 @@
-
-
-
-
+
Email Security of DACH Municipalities
diff --git a/tests/test_basemap.py b/tests/test_basemap.py
new file mode 100644
index 0000000..88a7247
--- /dev/null
+++ b/tests/test_basemap.py
@@ -0,0 +1,199 @@
+"""Functional battery for the keyless basemap (js/map-shared.js).
+
+Context: CARTO put its raster basemaps behind API keys (watermark rollout
+2026-08-28, enforcement 2026-09-23). The keyless URLs kept answering
+**HTTP 200** while serving a fixed "API KEY REQUIRED" watermark tile —
+the map broke *silently*: no 4xx, no console error, invisible to any
+status check. This battery is the tripwire for that failure class, on
+two levels:
+
+STRUCTURAL (offline, deterministic) — the basemap config stays coherent:
+keyless templates only, the axis-order rule per host (Esri is
+``/tile/{z}/{y}/{x}``, INVERTED vs slippy ``/{z}/{x}/{y}`` — a mismatch
+renders the wrong place on Earth with zero errors), the label-overlay
+blend CSS, the runtime failover wiring, preconnects aligned.
+
+FUNCTIONAL (network) — real tiles actually arrive, from the primary AND
+from the reserve (the fallback must not turn out dead the day it is
+needed): HTTP 200 + image/* + a minimum size on LAND tiles (Rome, Milan
+— never open sea, which yields tiny uniform tiles) + the decisive
+ANTI-PLACEHOLDER check: two different coordinates MUST return different
+bytes. A watermark is identical everywhere; real tiles never are.
+"""
+
+from __future__ import annotations
+
+import re
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+MAP_JS = ROOT / "js" / "map-shared.js"
+MAP_CSS = ROOT / "css" / "map.css"
+UA = "secassure2026-basemap-battery/1.0 (+https://github.com/mxmap/secassure2026)"
+
+# Sample tiles at z=6 on European LAND (Rome, Milan), slippy order (z, x, y).
+SAMPLE_TILES = [(6, 34, 23), (6, 33, 22)]
+
+# A real land base tile at z=6 weighs >5 KB; the CARTO "API KEY REQUIRED"
+# placeholder was 2049 fixed bytes. Label-only tiles are sparse -> lower bar.
+MIN_BYTES_BASE = 3000
+MIN_BYTES_LABELS = 800
+
+BASEMAP_FIELD_RE = re.compile(r"^\s*(base|fallbackBase|fallbackLabels):\s*'([^']+)'", re.M)
+
+
+@pytest.fixture(scope="module")
+def map_js() -> str:
+ return MAP_JS.read_text(encoding="utf-8")
+
+
+@pytest.fixture(scope="module")
+def templates(map_js: str) -> dict[str, str]:
+ found = dict(BASEMAP_FIELD_RE.findall(map_js))
+ assert set(found) == {"base", "fallbackBase", "fallbackLabels"}, (
+ f"BASEMAP templates not found in js/map-shared.js: {sorted(found)}"
+ )
+ return found
+
+
+def _host(template: str) -> str:
+ return urlsplit(template.replace("{s}", "a")).hostname or ""
+
+
+def _tile_url(template: str, z: int, x: int, y: int) -> str:
+ return (
+ template.replace("{s}", "a")
+ .replace("{r}", "")
+ .replace("{z}", str(z))
+ .replace("{x}", str(x))
+ .replace("{y}", str(y))
+ )
+
+
+def _fetch(url: str, retries: int = 2) -> tuple[int, str, bytes]:
+ """GET with retry/backoff on transient errors only (network, 5xx)."""
+ last: Exception | None = None
+ for attempt in range(retries + 1):
+ try:
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
+ with urllib.request.urlopen(req, timeout=25) as r:
+ return r.status, r.headers.get("Content-Type", ""), r.read()
+ except urllib.error.HTTPError as e:
+ if e.code >= 500 and attempt < retries:
+ last = e
+ time.sleep(5 * (attempt + 1))
+ continue
+ return e.code, e.headers.get("Content-Type", ""), e.read()
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
+ last = e
+ if attempt < retries:
+ time.sleep(5 * (attempt + 1))
+ pytest.fail(f"unreachable after {retries + 1} attempts: {url} — {last!r}")
+
+
+def _assert_axis_rule(template: str) -> None:
+ host = _host(template)
+ for var in ("{z}", "{x}", "{y}"):
+ assert var in template, f"variable {var} missing in template {template}"
+ if host.endswith("arcgisonline.com"):
+ assert "/tile/{z}/{y}/{x}" in template, (
+ f"Esri uses /tile/{{z}}/{{y}}/{{x}} (inverted vs slippy), got: {template}"
+ )
+ assert "{s}" not in template and "{r}" not in template, (
+ f"Esri supports neither {{s}} subdomains nor {{r}} retina: {template}"
+ )
+ elif host.endswith("cartocdn.com") or host.endswith("openstreetmap.org"):
+ assert "/{z}/{x}/{y}" in template, f"{host} uses slippy /{{z}}/{{x}}/{{y}} order, got: {template}"
+
+
+def _assert_real_tiles(template: str, min_bytes: int) -> None:
+ payloads = []
+ for z, x, y in SAMPLE_TILES:
+ url = _tile_url(template, z, x, y)
+ status, ctype, body = _fetch(url)
+ assert status == 200, f"tile {url}: HTTP {status}"
+ assert ctype.startswith("image/"), f"tile {url}: Content-Type '{ctype}'"
+ assert len(body) >= min_bytes, (
+ f"tile {url}: {len(body)} bytes < {min_bytes} — likely a placeholder "
+ "or error hidden behind HTTP 200 (the CARTO incident class)"
+ )
+ payloads.append(body)
+ assert payloads[0] != payloads[1], (
+ f"ANTI-PLACEHOLDER: {template} returns IDENTICAL bytes for Rome and "
+ "Milan — that is an everywhere-identical watermark (e.g. 'API KEY "
+ "REQUIRED'), not a real basemap"
+ )
+
+
+# ── STRUCTURAL (offline) ─────────────────────────────────────────────────────
+
+
+def test_templates_are_keyless(templates: dict[str, str], map_js: str) -> None:
+ for name, template in templates.items():
+ assert "key=" not in template and "apikey" not in template.lower(), (
+ f"basemap template {name} must not require a key: {template}"
+ )
+ assert "cartocdn.com" not in map_js, (
+ "keyless CARTO reintroduced: dead since 2026-09 (serves only an 'API KEY REQUIRED' watermark, with HTTP 200!)"
+ )
+ assert "CARTO_KEY" not in map_js, "CARTO_KEY machinery must stay removed"
+
+
+def test_axis_order_per_host(templates: dict[str, str]) -> None:
+ for template in templates.values():
+ _assert_axis_rule(template)
+
+
+def test_label_overlay_css_contract() -> None:
+ """The blend pane is what keeps place names ABOVE the data polygons;
+ without it OSM's baked-in labels are covered. Hidden by default +
+ @supports guard so unsupported browsers never get opaque tiles over
+ the data."""
+ css = MAP_CSS.read_text(encoding="utf-8")
+ for marker in (
+ ".leaflet-basemap-labels-pane",
+ "mix-blend-mode: darken",
+ "@supports (mix-blend-mode: darken)",
+ ".leaflet-layer.basemap-muted",
+ ):
+ assert marker in css, f"basemap CSS contract: missing '{marker}' in css/map.css"
+
+
+def test_failover_wiring_present(map_js: str) -> None:
+ for marker in (
+ "createPane('basemap-labels')",
+ "on('tileerror'",
+ "failoverThreshold",
+ "activateBasemapFailover",
+ "window.__forceBasemapFailover",
+ ):
+ assert marker in map_js, f"basemap failover: missing '{marker}' in js/map-shared.js"
+
+
+def test_preconnects_aligned() -> None:
+ for page in ("providers.html", "security.html"):
+ html = (ROOT / page).read_text(encoding="utf-8")
+ assert 'rel="preconnect" href="https://tile.openstreetmap.org"' in html, (
+ f"{page}: missing preconnect to the tile host"
+ )
+ assert "cartocdn.com" not in html, f"{page}: stale cartocdn preconnect"
+
+
+# ── FUNCTIONAL (network) ─────────────────────────────────────────────────────
+
+
+def test_primary_tiles_are_real(templates: dict[str, str]) -> None:
+ _assert_real_tiles(templates["base"], MIN_BYTES_BASE)
+
+
+def test_fallback_tiles_are_real(templates: dict[str, str]) -> None:
+ """The reserve is exercised with the same criteria as the primary, so
+ it cannot rot unnoticed until the day the failover actually fires."""
+ _assert_real_tiles(templates["fallbackBase"], MIN_BYTES_BASE)
+ _assert_real_tiles(templates["fallbackLabels"], MIN_BYTES_LABELS)