fix(security): escape API strings, pin CDN scripts, add CSP (FO-SEC-01/02/03)

The tickets code escaped HTML, but the logistics + fuel renderers and the error
banners interpolated API strings straight into innerHTML. Fuel Log fields
(driver, department, fuel_type, plate) come from WhatsApp messages and
vehicle/driver names from the Tracksolid registry — both user-controlled — so
this was a stored-XSS path into every dispatcher's browser.

- Hoist escapeHtml into HELPERS + add esc(); route every logistics/fuel renderer
  and the three error banners through it (21 -> 37 escaped call sites).
- SRI integrity + crossorigin on Chart.js 4.4.1 and maplibre-gl 4.7.1 JS/CSS.
- Caddyfile: CSP (self + pinned CDNs + CARTO basemap + the two fleet APIs),
  X-Content-Type-Options, Referrer-Policy, frame-ancestors 'none', -Server.
  Validated with `caddy validate` inside the deployed image.
- loadLive(): check r.ok; pause the 15s live poll while hidden or off the Tickets
  tab, refresh immediately on return (FO-BUG-01/02).
- Missing-API_BASE fallback flipped staging -> prod, matching the documented
  design (FO-OPS-01).

Inline app script passes `node --check`. Audit + plan + work log in docs/260702_*.
Local only; not deployed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-07-02 09:47:40 +03:00
parent 9fb39aa992
commit a0022fbeaf
5 changed files with 186 additions and 23 deletions

View file

@ -8,6 +8,19 @@
root * /srv root * /srv
encode zstd gzip encode zstd gzip
# Security headers (FO-SEC-03). CSP allows self + the two pinned CDNs, the
# CARTO basemap (styles/tiles/fonts) and the fleet APIs; SRI in index.html
# pins the CDN payloads themselves. frame-ancestors 'none' = no clickjacking.
# script-src keeps 'unsafe-inline' because the whole app is one inline
# <script> block the CSP's job here is limiting external script origins
# and exfil targets (connect-src), not inline policy.
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: blob: https://*.cartocdn.com; font-src https://*.cartocdn.com; connect-src 'self' https://fleetapi.rahamafresh.com https://fleetapi.fivetitude.com https://*.cartocdn.com; worker-src blob:; frame-ancestors 'none'"
-Server
}
# Health endpoint for Coolify / Traefik probes. # Health endpoint for Coolify / Traefik probes.
handle /healthz { handle /healthz {
respond "ok" 200 respond "ok" 200

View file

@ -0,0 +1,67 @@
# FleetOps — Platform Audit Report (2026-07-02)
Part of the 2026-07-02 cross-repo audit (tracksolid + fleettickets + fleetops; see
the tracksolid repo's `docs/reports/260702_platform_audit_report.md` for the
DB/host-wide findings). Scope here: the SPA (`src/index.html` + `src/env.js`),
the Caddy/Docker serving layer, and the two deployed containers on twala.
Companion docs: `260702_fix_plan.md`, `260702_work_done.md`.
---
## High
### FO-SEC-01 — Stored XSS: API strings rendered into `innerHTML` unescaped
The tickets explorer and map popups escape correctly (`escapeHtml`), but the
**logistics tables (`renderVehicles`, `renderDrivers`), the fuel banner notes, and
all three Fuel Log tables (`renderFuelVehicles`, `renderFuelDepartments`,
`renderFuelRecent`)** interpolate API strings straight into `innerHTML`. The Fuel
Log fields (`driver`, `department`, `fuel_type`, `plate`) originate from **WhatsApp
messages** — genuinely user-generated content — and vehicle/driver names come from
the Tracksolid registry, editable by any fleet-console user. A crafted name like
`<img src=x onerror=…>` executes in every dispatcher's browser. Error banners also
render `e.message` unescaped (server-influenced text).
### FO-SEC-02 — CDN scripts loaded without Subresource Integrity
Chart.js (jsdelivr) and MapLibre (unpkg) load with no `integrity`/`crossorigin`
attributes — a compromised or MITM'd CDN response executes arbitrary script with
access to the fleet APIs.
### FO-SEC-03 — No security headers from Caddy
No `Content-Security-Policy`, `X-Content-Type-Options`, `Referrer-Policy`, or
frame-ancestors protection. Combined with FO-SEC-01 this leaves injected markup
free to load external script and exfiltrate anywhere.
## Medium
### FO-BUG-01 — `loadLive()` parses the response without checking `r.ok`
A 4xx/5xx from `/webhook/live-positions` throws inside `r.json()` on non-JSON
bodies with an unhelpful error; harmless but noisy.
### FO-BUG-02 — Live-position poll never pauses
The 15-second `/webhook/live-positions` poll starts when the Tickets map is first
opened and then runs forever — including when the user is on the Logistics/Fuel
tabs and when the browser tab is hidden overnight. A dashboard left open ≈ 5,760
wasted API calls/day per viewer.
### FO-OPS-01 — Fallback API base pointed at *staging*
`index.html` fell back to `https://fleetapi.fivetitude.com` when `env.js` isn't
templated. CLAUDE.md documents the intended fallback as the **prod** API. A prod
pod missing `API_BASE` would silently lean on the staging bridge (and fail CORS
confusingly).
## Notes / by design
- **FO-OPS-02** — the prod container (`API_BASE=fleetapi.rahamafresh.com`) runs
commit `21bca24`, ~19 commits behind HEAD — this is the documented frozen-prod
staging-umbrella pattern, not drift. But note: **promotion must pair the SPA
deploy with the prod `dashboard_api` bridge redeploy** — prod's bridge currently
lacks all `/webhook/{inc,crq}-*` and `/analytics/fuel-fills` routes the newer
SPA calls (see the tracksolid audit, OPS-01).
- **FO-OPS-03** — untracked scratch files at the repo root
(`marker-preview.html`, `tracksolid_db_connection.md`, `webook_instructions.txt`).
`tracksolid_db_connection.md` documents the public `twala:5433` superuser
connection pattern that the tracksolid audit is eliminating — update or remove
once the SSH-tunnel workflow lands.
- The serving layer is otherwise good: Caddyfile validated at build time,
healthcheck endpoint, runtime `API_BASE` injection via `templates`, `no-store`
on the shell and env.js.

27
docs/260702_fix_plan.md Normal file
View file

@ -0,0 +1,27 @@
# FleetOps — Fix Plan (2026-07-02)
Companion to `260702_audit_report.md` and `260702_work_done.md`.
## Phase A — repo changes (implemented in this session)
| Step | Finding | Change |
|---|---|---|
| A1 | FO-SEC-01 | Hoist `escapeHtml` into the HELPERS section, add an `esc()` convenience (escapes or em-dashes), and route every API string in the logistics + fuel renderers and the three error banners through it. The tickets code already escaped — now the whole file is consistent. |
| A2 | FO-SEC-02 | Add `integrity="sha384-…" crossorigin="anonymous"` to the three CDN assets (hashes computed from the exact pinned files; recompute command documented inline). |
| A3 | FO-SEC-03 | Caddyfile: `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `frame-ancestors 'none'`, and a CSP restricting script/style/img/font/connect origins to self + the two pinned CDNs + CARTO + the two fleet APIs. `script-src` keeps `'unsafe-inline'` (the app is one inline script block); the CSP's value here is limiting external script origins and exfil targets. |
| A4 | FO-BUG-01/02 | `loadLive()` checks `r.ok`, and skips while `document.hidden` or when the Tickets tab isn't active; switching back to Tickets (or the page becoming visible) triggers an immediate refresh so markers don't wait out the 15 s interval. |
| A5 | FO-OPS-01 | Fallback API base flipped to the prod bridge (`fleetapi.rahamafresh.com`), matching the documented design. |
## Phase B — operational (needs operator decision)
| # | Item |
|---|---|
| B1 | Promotion pairing: when this branch is promoted to prod (Coolify deploy of fleetops.rahamafresh.com), redeploy the prod `dashboard_api` bridge first — it currently lacks the `/webhook/{inc,crq}-*` + `/analytics/fuel-fills` routes the SPA calls. |
| B2 | Clean up untracked root scratch files; rewrite `tracksolid_db_connection.md` for the SSH-tunnel workflow once the DB port closes. |
| B3 | Longer-term: consider vendoring Chart.js/MapLibre into `src/` (removes the CDN trust surface entirely and lets the CSP drop jsdelivr/unpkg). |
## Verification
- Extracted inline script passes `node --check`.
- Caddyfile passes `caddy validate` (run inside the deployed fleetops image).
- Manual: staging deploy → open all four tabs, confirm charts/map/popups render,
check DevTools console for CSP violations before promoting.

32
docs/260702_work_done.md Normal file
View file

@ -0,0 +1,32 @@
# FleetOps — Work Done (2026-07-02)
Execution log for `260702_fix_plan.md` Phase A. **Local changes only — nothing
committed, pushed, or deployed.**
## Changes
| Finding | Files | What changed |
|---|---|---|
| FO-SEC-01 | `src/index.html` | `escapeHtml` moved to HELPERS with a new `esc()` shorthand; all previously-unescaped API strings now escape: `renderVehicles` (vehicle_number, cost_centre, assigned_city), `renderDrivers` (driver_name, assigned_city), `renderFuel` banner notes, `renderFuelVehicles`, `renderFuelDepartments`, `renderFuelRecent` (plate, department, driver, fuel_type), and the three catch-block error banners (`e.message`). WhatsApp-sourced Fuel Log strings can no longer inject markup. |
| FO-SEC-02 | `src/index.html` | SRI `integrity` + `crossorigin="anonymous"` on Chart.js 4.4.1, maplibre-gl 4.7.1 JS and CSS (sha384 computed from the exact CDN payloads; recompute one-liner documented inline for future bumps). |
| FO-SEC-03 | `Caddyfile` | Security header block: `nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, CSP (self + pinned CDNs + CARTO basemap + the two fleet APIs; `worker-src blob:` for MapLibre; `frame-ancestors 'none'`), `-Server`. `script-src` retains `'unsafe-inline'` because the app is a single inline script block. |
| FO-BUG-01 | `src/index.html` | `loadLive()` now throws on non-OK responses instead of parsing error bodies. |
| FO-BUG-02 | `src/index.html` | The 15 s live-position poll skips while `document.hidden` or when the active tab isn't Tickets; switching back to Tickets or re-focusing the page triggers an immediate refresh. Cuts thousands of pointless API calls/day per idle viewer. |
| FO-OPS-01 | `src/index.html` | Missing-`API_BASE` fallback now targets the prod bridge (`fleetapi.rahamafresh.com`) per the documented design (was the staging bridge). |
## Verification
- Inline app script extracted and passed `node --check` (no syntax errors).
- Modified Caddyfile passed `caddy validate` inside the deployed fleetops image on
twala ("Valid configuration").
- 37 `esc(`/`escapeHtml(` call sites after the change (was 21, tickets-only).
## NOT done — operational
1. Deploy flows through the Forgejo → Coolify webhook on push (staging first).
Before **prod** promotion, redeploy the prod `dashboard_api` bridge — it lacks
the INC/CRQ/fuel-fills routes this SPA version calls.
2. After the staging deploy, click through all four tabs with DevTools open to
confirm zero CSP violations before promoting.
3. Root scratch files (`marker-preview.html`, `tracksolid_db_connection.md`,
`webook_instructions.txt`) left untouched — decide keep/commit/delete;
`tracksolid_db_connection.md` should be rewritten for the SSH-tunnel workflow
once the public DB port closes.

View file

@ -20,10 +20,18 @@
--> -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" /> <link rel="preconnect" href="https://cdn.jsdelivr.net" />
<script src="/env.js"></script> <script src="/env.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <!-- SRI pins (FO-SEC-02): a compromised/MITM'd CDN can't inject script. When
bumping a CDN version, recompute: curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"
integrity="sha384-9nhczxUqK87bcKHh20fSQcTGD4qq5GhayNYSYWqwBkINBhOfQLg/P5HG5lF1urn4"
crossorigin="anonymous"></script>
<!-- MapLibre GL — Tickets tab map (FleetNow-style live map + INC/CRQ layers). --> <!-- MapLibre GL — Tickets tab map (FleetNow-style live map + INC/CRQ layers). -->
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" /> <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> integrity="sha384-MinO0mNliZ3vwppuPOUnGa+iq619pfMhLVUXfC4LHwSCvF9H+6P/KO4Q7qBOYV5V"
crossorigin="anonymous" />
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"
integrity="sha384-SYKAG6cglRMN0RVvhNeBY0r3FYKNOJtznwA0v7B5Vp9tr31xAHsZC0DqkQ/pZDmj"
crossorigin="anonymous"></script>
<style> <style>
:root { :root {
/* Shared with FleetNow (warm dark ops palette) */ /* Shared with FleetNow (warm dark ops palette) */
@ -464,7 +472,8 @@
// ============================================================================ // ============================================================================
const API_BASE = (window.FLEETOPS_API_BASE && /^https?:\/\//.test(window.FLEETOPS_API_BASE)) const API_BASE = (window.FLEETOPS_API_BASE && /^https?:\/\//.test(window.FLEETOPS_API_BASE))
? window.FLEETOPS_API_BASE.replace(/\/$/, '') ? window.FLEETOPS_API_BASE.replace(/\/$/, '')
: 'https://fleetapi.fivetitude.com'; // staging default : 'https://fleetapi.rahamafresh.com'; // prod fallback — a prod pod missing API_BASE
// must not silently lean on the staging bridge
// ============================================================================ // ============================================================================
// HELPERS // HELPERS
@ -472,6 +481,12 @@ const API_BASE = (window.FLEETOPS_API_BASE && /^https?:\/\//.test(window.FLEETOP
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const num = (v, d = 0) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en', { minimumFractionDigits: d, maximumFractionDigits: d }); const num = (v, d = 0) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en', { minimumFractionDigits: d, maximumFractionDigits: d });
const intg = (v) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en'); const intg = (v) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en');
// FO-SEC-01: every API string rendered via innerHTML goes through this. Fleet
// names come from the Tracksolid registry and fuel rows from WhatsApp messages —
// both are effectively user input, so unescaped interpolation is stored XSS.
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const esc = (v, dash = '—') => (v == null ? dash : escapeHtml(v));
// Which tab owns the shared header KPI strip right now — guards late async // Which tab owns the shared header KPI strip right now — guards late async
// loaders (e.g. boot loadAll finishing after the user switched tabs) from // loaders (e.g. boot loadAll finishing after the user switched tabs) from
// clobbering another tab's header. // clobbering another tab's header.
@ -606,9 +621,9 @@ function renderVehicles(rows) {
if (!rows.length) { $('veh-wrap').innerHTML = '<div class="empty">No trips in range.</div>'; return; } if (!rows.length) { $('veh-wrap').innerHTML = '<div class="empty">No trips in range.</div>'; return; }
const body = rows.map(r => ` const body = rows.map(r => `
<tr> <tr>
<td class="plate">${r.vehicle_number ?? '—'}</td> <td class="plate">${esc(r.vehicle_number)}</td>
<td class="dim">${r.cost_centre ?? '—'}</td> <td class="dim">${esc(r.cost_centre)}</td>
<td class="dim">${r.assigned_city ?? '—'}</td> <td class="dim">${esc(r.assigned_city)}</td>
<td>${intg(r.trips)}</td> <td>${intg(r.trips)}</td>
<td>${num(r.total_km, 1)}</td> <td>${num(r.total_km, 1)}</td>
<td>${num(r.driving_hours, 1)}</td> <td>${num(r.driving_hours, 1)}</td>
@ -632,8 +647,8 @@ function renderDrivers(d) {
} }
const body = rows.map(r => ` const body = rows.map(r => `
<tr> <tr>
<td class="plate">${r.driver_name ?? '—'}</td> <td class="plate">${esc(r.driver_name)}</td>
<td class="dim">${r.assigned_city ?? '—'}</td> <td class="dim">${esc(r.assigned_city)}</td>
<td>${intg(r.active_days)}</td> <td>${intg(r.active_days)}</td>
<td>${num(r.total_km, 0)}</td> <td>${num(r.total_km, 0)}</td>
<td>${intg(r.trips)}</td> <td>${intg(r.trips)}</td>
@ -656,7 +671,7 @@ function renderFuel(d) {
$('fuel-count').textContent = rows.length ? `(${rows.length})` : ''; $('fuel-count').textContent = rows.length ? `(${rows.length})` : '';
let html = ''; let html = '';
if (!ds.actual_fuel_available && !ds.estimated_fuel_available) { if (!ds.actual_fuel_available && !ds.estimated_fuel_available) {
html += `<div class="banner">No fuel figures yet.<ul>${(ds.notes || []).map(n => `<li>${n}</li>`).join('')}</ul></div>`; html += `<div class="banner">No fuel figures yet.<ul>${(ds.notes || []).map(n => `<li>${escapeHtml(n)}</li>`).join('')}</ul></div>`;
} }
const actual = rows.reduce((s, r) => s + Number(r.actual_fuel_l || 0), 0); const actual = rows.reduce((s, r) => s + Number(r.actual_fuel_l || 0), 0);
const est = rows.reduce((s, r) => s + Number(r.estimated_fuel_l || 0), 0); const est = rows.reduce((s, r) => s + Number(r.estimated_fuel_l || 0), 0);
@ -695,7 +710,7 @@ async function loadAll() {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
$('main').querySelectorAll('.tbl-scroll, #fuel-wrap').forEach(el => $('main').querySelectorAll('.tbl-scroll, #fuel-wrap').forEach(el =>
el.innerHTML = `<div class="banner error">${e.message || 'Failed to load. Is the API reachable?'}</div>`); el.innerHTML = `<div class="banner error">${escapeHtml(e.message || 'Failed to load. Is the API reachable?')}</div>`);
} finally { } finally {
$('main').classList.remove('loading'); $('main').classList.remove('loading');
} }
@ -771,9 +786,9 @@ function renderFuelVehicles(rows) {
if (!rows.length) { $('fv-wrap').innerHTML = '<div class="empty">No fills in range.</div>'; return; } if (!rows.length) { $('fv-wrap').innerHTML = '<div class="empty">No fills in range.</div>'; return; }
const body = rows.map(r => ` const body = rows.map(r => `
<tr> <tr>
<td class="plate">${r.vehicle_number ?? r.plate ?? '—'}</td> <td class="plate">${esc(r.vehicle_number ?? r.plate)}</td>
<td class="dim">${r.cost_centre ?? '—'}</td> <td class="dim">${esc(r.cost_centre)}</td>
<td class="dim">${r.assigned_city ?? '—'}</td> <td class="dim">${esc(r.assigned_city)}</td>
<td>${num(r.litres, 1)}</td> <td>${num(r.litres, 1)}</td>
<td>${intg(r.spend_kes)}</td> <td>${intg(r.spend_kes)}</td>
<td>${intg(r.fills)}</td> <td>${intg(r.fills)}</td>
@ -791,7 +806,7 @@ function renderFuelDepartments(rows) {
if (!rows.length) { $('fd-wrap').innerHTML = '<div class="empty">No data.</div>'; return; } if (!rows.length) { $('fd-wrap').innerHTML = '<div class="empty">No data.</div>'; return; }
const body = rows.map(r => ` const body = rows.map(r => `
<tr> <tr>
<td>${r.department ?? '—'}</td> <td>${esc(r.department)}</td>
<td>${num(r.litres, 0)}</td> <td>${num(r.litres, 0)}</td>
<td>${intg(r.spend_kes)}</td> <td>${intg(r.spend_kes)}</td>
<td>${intg(r.fills)}</td> <td>${intg(r.fills)}</td>
@ -813,12 +828,12 @@ function renderFuelRecent(rows) {
const body = rows.map(r => ` const body = rows.map(r => `
<tr> <tr>
<td class="dim">${dt(r.record_datetime)}</td> <td class="dim">${dt(r.record_datetime)}</td>
<td class="plate">${r.vehicle_number ?? r.plate ?? '—'}</td> <td class="plate">${esc(r.vehicle_number ?? r.plate)}</td>
<td class="dim">${r.department ?? '—'}</td> <td class="dim">${esc(r.department)}</td>
<td class="dim">${r.driver ?? '—'}</td> <td class="dim">${esc(r.driver)}</td>
<td>${num(r.liters, 1)}</td> <td>${num(r.liters, 1)}</td>
<td>${intg(r.amount)}</td> <td>${intg(r.amount)}</td>
<td class="dim">${r.fuel_type ?? '—'}</td> <td class="dim">${esc(r.fuel_type)}</td>
<td class="dim">${r.odometer != null ? intg(r.odometer) : '—'}</td> <td class="dim">${r.odometer != null ? intg(r.odometer) : '—'}</td>
</tr>`).join(''); </tr>`).join('');
$('fr-wrap').innerHTML = `<table> $('fr-wrap').innerHTML = `<table>
@ -844,7 +859,7 @@ async function loadFuel() {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
$('fuel-main').querySelectorAll('.tbl-scroll').forEach(el => $('fuel-main').querySelectorAll('.tbl-scroll').forEach(el =>
el.innerHTML = `<div class="banner error">${e.message || 'Failed to load. Is the API reachable?'}</div>`); el.innerHTML = `<div class="banner error">${escapeHtml(e.message || 'Failed to load. Is the API reachable?')}</div>`);
} finally { } finally {
$('fuel-main').classList.remove('loading'); $('fuel-main').classList.remove('loading');
} }
@ -875,6 +890,7 @@ function switchTab(name) {
$('kpis').innerHTML = ''; // INC metrics live in the INC overview card, not the header $('kpis').innerHTML = ''; // INC metrics live in the INC overview card, not the header
loadInc(); // (re)load INC data first — independent of the basemap loadInc(); // (re)load INC data first — independent of the basemap
initIncMap(); // then build the map (lazy) / just resize if built initIncMap(); // then build the map (lazy) / just resize if built
if (tkMap) loadLive(); // instant marker refresh — the poll pauses off-tab
} else if (name === 'fuel') { } else if (name === 'fuel') {
if (lastFuelTotals) renderFuelKpis(lastFuelTotals); if (lastFuelTotals) renderFuelKpis(lastFuelTotals);
if (!fuelLoaded) loadFuel(); // lazy — first open if (!fuelLoaded) loadFuel(); // lazy — first open
@ -950,8 +966,7 @@ const COST_CENTRE_COLORS = {
'planning': '#06b6d4', 'deliveries': '#84cc16', 'qehs': '#14b8a6', 'airtel': '#ef4444', 'planning': '#06b6d4', 'deliveries': '#84cc16', 'qehs': '#14b8a6', 'airtel': '#ef4444',
}; };
const CC_PALETTE = ['#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981']; const CC_PALETTE = ['#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981'];
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, // escapeHtml/esc live in HELPERS (top of script) — used by every renderer.
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function ccColor(cc) { function ccColor(cc) {
if (!cc) return '#9ca3af'; if (!cc) return '#9ca3af';
const key = String(cc).trim().toLowerCase(); const key = String(cc).trim().toLowerCase();
@ -1297,15 +1312,19 @@ async function loadInc() {
buildIncLayers(); buildIncLayers();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
$('tk-metrics').innerHTML = `<div class="banner error">${e.message || `Failed to load the ${DS.toUpperCase()} dashboard. Is the API reachable?`}</div>`; $('tk-metrics').innerHTML = `<div class="banner error">${escapeHtml(e.message || `Failed to load the ${DS.toUpperCase()} dashboard. Is the API reachable?`)}</div>`;
} finally { } finally {
$('tk-main').classList.remove('loading'); $('tk-main').classList.remove('loading');
} }
} }
async function loadLive() { async function loadLive() {
// Pause the 15s poll while the page is hidden or another tab owns the screen —
// no point hammering the API for markers nobody can see.
if (document.hidden || activeTab() !== 'tickets') return;
try { try {
const r = await fetch(`${API_BASE}/webhook/live-positions`, { headers: { 'Accept': 'application/json' } }); const r = await fetch(`${API_BASE}/webhook/live-positions`, { headers: { 'Accept': 'application/json' } });
if (!r.ok) throw new Error(`live-positions → HTTP ${r.status}`);
const j = await r.json(); const j = await r.json();
const feats = (j.geojson && j.geojson.features) || []; const feats = (j.geojson && j.geojson.features) || [];
const seen = new Set(); const seen = new Set();
@ -1414,6 +1433,11 @@ function buildIncLayers() {
})); }));
} }
// Catch up as soon as the page becomes visible again (poll skips while hidden).
document.addEventListener('visibilitychange', () => {
if (!document.hidden && activeTab() === 'tickets' && tkMap) loadLive();
});
// ============================================================================ // ============================================================================
// BOOT // BOOT
// ============================================================================ // ============================================================================