From 91996fc8fbca4d250a369c4ed79c815cee251196 Mon Sep 17 00:00:00 2001 From: Fabio Pietrosanti Date: Tue, 29 Sep 2026 12:44:21 +0200 Subject: [PATCH] Keyless open basemap: OSM primary + Esri reserve, runtime failover, no CARTO_KEY CARTO put its raster basemaps behind API keys (watermark rollout 2026-08-28, enforcement 2026-09-23). The current answer - a CARTO_KEY GitHub secret injected at deploy time - has structural downsides on a static site: the key is world-readable in the served JS (anyone can copy it and burn the quota), forks and local checkouts render watermark tiles, deploys hard-fail when the secret is missing, and the project gains a billing dependency. This PR makes the basemap fully open and keyless: - Primary: OpenStreetMap standard raster (OSMF, native z19). The familiar positron-like light-gray canvas is recreated with a CSS grayscale filter (.basemap-muted). - Labels above polygons: OSM bakes labels into the raster, so the SAME tiles are re-drawn on a dedicated pane blended with mix-blend-mode:darken + a midtone-crushing filter - only place names and admin borders emerge above the data. Same URLs => served from the browser HTTP cache, zero extra tile requests to OSM. @supports guard: browsers without blending simply hide the overlay. - Runtime failover: after 8 tileerror events the map swaps to the keyless Esri World Light Gray reserve (Base + Reference labels). Encoded traps: Esri axis order is /tile/{z}/{y}/{x} (INVERTED vs slippy) and native tiles stop at z16 (maxNativeZoom). - HQ PNG export follows the active basemap (template-based tile fetching; the label pass uses canvas globalCompositeOperation=darken to match the on-screen look) and updates its attribution line. - deploy.yml: CARTO_KEY injection step removed - deploys need no secret, forks render exactly what production renders. - tests/test_basemap.py: functional battery. Structural: keyless-only templates, axis-order rule per host, blend CSS contract, failover wiring, preconnect alignment. Functional: real LAND tiles (Rome, Milan) for primary AND reserve - HTTP 200 + image/* + minimum size + the anti-placeholder check (two coordinates MUST return different bytes; a watermark is identical everywhere - the only check that catches breakage hidden behind HTTP 200). Battle-tested: mxmap.it (the Italian Osservatorio fork) runs exactly this stack in production since 2026-09-29, with the battery green in CI and a daily scheduled run. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yml | 10 +- css/map.css | 21 ++++ datenschutz.html | 2 +- js/map-shared.js | 138 +++++++++++++++++++++--- providers.html | 5 +- security.html | 5 +- tests/test_basemap.py | 199 +++++++++++++++++++++++++++++++++++ 7 files changed, 348 insertions(+), 32 deletions(-) create mode 100644 tests/test_basemap.py 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:

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)