From 711b68d5a3a998e64ff518df46898befb7803d54 Mon Sep 17 00:00:00 2001
From: munisp <155237317+munisp@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:30:38 -0400
Subject: [PATCH 01/14] Phase 17 (B1): add shared geo helpers + CSP parser with
unit tests
---
client/src/components/Map.test.tsx | 25 +++++
client/src/hooks/useFluvioFeed.test.ts | 33 +++++++
client/src/lib/geo.test.ts | 116 +++++++++++++++++++++++
client/src/lib/geo.ts | 126 +++++++++++++++++++++++++
server/_core/csp.test.ts | 29 ++++++
server/_core/csp.ts | 16 ++++
6 files changed, 345 insertions(+)
create mode 100644 client/src/components/Map.test.tsx
create mode 100644 client/src/hooks/useFluvioFeed.test.ts
create mode 100644 client/src/lib/geo.test.ts
create mode 100644 client/src/lib/geo.ts
create mode 100644 server/_core/csp.test.ts
create mode 100644 server/_core/csp.ts
diff --git a/client/src/components/Map.test.tsx b/client/src/components/Map.test.tsx
new file mode 100644
index 00000000..b6e2003a
--- /dev/null
+++ b/client/src/components/Map.test.tsx
@@ -0,0 +1,25 @@
+// @vitest-environment jsdom
+/**
+ * Phase 17 (G8) — MapView smoke test: without a configured Google Maps key
+ * the component must fail fast into the honest "Map unavailable" fallback
+ * instead of hanging on a spinner (and must never attempt a script injection).
+ */
+import { describe, it, expect } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { MapView } from "./Map";
+
+describe("MapView", () => {
+ it("renders the honest fallback when no maps API key is configured", async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText("Map unavailable")).toBeTruthy();
+ });
+ // Fail-closed: no runtime CDN script was injected into the document
+ expect(document.querySelector("script[src*='maps/api/js']")).toBeNull();
+ });
+
+ it("exposes an accessible map region while loading", () => {
+ const { container } = render();
+ expect(container.querySelector('[role="region"][aria-label="Interactive map"]')).toBeTruthy();
+ });
+});
diff --git a/client/src/hooks/useFluvioFeed.test.ts b/client/src/hooks/useFluvioFeed.test.ts
new file mode 100644
index 00000000..f11f8ff4
--- /dev/null
+++ b/client/src/hooks/useFluvioFeed.test.ts
@@ -0,0 +1,33 @@
+/**
+ * Phase 17 (G4/G8) — WS URL resolution tests for useFluvioFeed.
+ */
+import { describe, it, expect } from "vitest";
+import { resolveFluvioWsUrl } from "./useFluvioFeed";
+
+const httpsLoc = { protocol: "https:", host: "trade.gov.ng" };
+const httpLoc = { protocol: "http:", host: "localhost:5173" };
+
+describe("resolveFluvioWsUrl", () => {
+ it("honours an explicit env URL", () => {
+ expect(resolveFluvioWsUrl("wss://fluvio.internal/ws", httpsLoc)).toBe("wss://fluvio.internal/ws");
+ });
+
+ it('"off" disables the feed (honest disabled state)', () => {
+ expect(resolveFluvioWsUrl("off", httpsLoc)).toBeNull();
+ expect(resolveFluvioWsUrl("OFF", httpsLoc)).toBeNull();
+ });
+
+ it("defaults to same-origin wss on https (never ws://localhost)", () => {
+ expect(resolveFluvioWsUrl(undefined, httpsLoc)).toBe("wss://trade.gov.ng/ws");
+ });
+
+ it("uses ws: only on http dev origins", () => {
+ expect(resolveFluvioWsUrl("", httpLoc)).toBe("ws://localhost:5173/ws");
+ });
+
+ it("never returns a hardcoded localhost URL in production", () => {
+ const url = resolveFluvioWsUrl(undefined, httpsLoc);
+ expect(url).not.toContain("localhost");
+ expect(url!.startsWith("wss://")).toBe(true);
+ });
+});
diff --git a/client/src/lib/geo.test.ts b/client/src/lib/geo.test.ts
new file mode 100644
index 00000000..bb5b090d
--- /dev/null
+++ b/client/src/lib/geo.test.ts
@@ -0,0 +1,116 @@
+/**
+ * Phase 17 (G8) — unit tests for the shared geospatial helpers that back the
+ * map components (GeospatialPortal 2D/3D, AIS layer, track replay, heatmap).
+ */
+import { describe, it, expect } from "vitest";
+import {
+ toMapVessel,
+ vesselsToGeoJSON,
+ trackToLineString,
+ isFeedStale,
+ lowDataRasterStyle,
+ resolveMapStyleUrl,
+ resolveCesiumIonToken,
+ VesselTrackingRow,
+} from "./geo";
+
+const row: VesselTrackingRow = {
+ id: 1,
+ mmsi: "657123456",
+ vesselName: "MT LAGOS STAR",
+ imoNumber: "9074729",
+ latitude: 6.42,
+ longitude: 3.31,
+ speed: 12.5,
+ heading: 270,
+ destinationPort: "NGAPP",
+ eta: new Date("2026-01-01T00:00:00Z"),
+ cargoType: "container",
+ flagCountry: "NGA",
+ recordedAt: new Date("2025-12-31T12:00:00Z"),
+};
+
+describe("toMapVessel", () => {
+ it("maps a DB row to a GeoVessel with ISO eta", () => {
+ const v = toMapVessel(row);
+ expect(v).not.toBeNull();
+ expect(v!.mmsi).toBe("657123456");
+ expect(v!.lat).toBeCloseTo(6.42);
+ expect(v!.eta).toBe("2026-01-01T00:00:00.000Z");
+ });
+
+ it("drops rows with out-of-range coordinates (fail-closed)", () => {
+ expect(toMapVessel({ ...row, latitude: 91 })).toBeNull();
+ expect(toMapVessel({ ...row, longitude: -181 })).toBeNull();
+ expect(toMapVessel({ ...row, latitude: NaN })).toBeNull();
+ });
+});
+
+describe("vesselsToGeoJSON", () => {
+ it("builds a FeatureCollection keyed [lng, lat]", () => {
+ const fc = vesselsToGeoJSON([toMapVessel(row)!]);
+ expect(fc.type).toBe("FeatureCollection");
+ expect(fc.features[0].geometry.coordinates).toEqual([3.31, 6.42]);
+ expect(fc.features[0].properties.mmsi).toBe("657123456");
+ });
+});
+
+describe("trackToLineString", () => {
+ it("returns a LineString for 2+ valid points", () => {
+ const line = trackToLineString([
+ { latitude: 6.0, longitude: 3.0 },
+ { latitude: 6.4, longitude: 3.3 },
+ { latitude: NaN, longitude: 3.4 }, // dropped
+ { latitude: 6.45, longitude: 3.39 },
+ ]);
+ expect(line).not.toBeNull();
+ expect(line!.geometry.coordinates).toHaveLength(3);
+ });
+
+ it("returns null for fewer than 2 valid points (honest no-track)", () => {
+ expect(trackToLineString([{ latitude: 6.0, longitude: 3.0 }])).toBeNull();
+ expect(trackToLineString([])).toBeNull();
+ });
+});
+
+describe("isFeedStale", () => {
+ const now = Date.parse("2026-01-01T00:00:00Z");
+ it("flags missing/old data as stale", () => {
+ expect(isFeedStale(null, now)).toBe(true);
+ expect(isFeedStale("2025-12-31T23:57:00Z", now)).toBe(true);
+ });
+ it("accepts fresh data", () => {
+ expect(isFeedStale("2025-12-31T23:59:30Z", now)).toBe(false);
+ });
+});
+
+describe("lowDataRasterStyle", () => {
+ it("is a self-contained raster style (no remote style JSON fetch)", () => {
+ const style = lowDataRasterStyle();
+ expect(style.version).toBe(8);
+ expect(style.sources.osm.type).toBe("raster");
+ expect(style.layers).toHaveLength(1);
+ });
+});
+
+describe("resolveMapStyleUrl", () => {
+ it("uses the env URL when it is a valid http(s) URL", () => {
+ expect(resolveMapStyleUrl("https://tiles.internal/styles/liberty")).toBe(
+ "https://tiles.internal/styles/liberty"
+ );
+ });
+ it("falls back to OpenFreeMap for empty/garbage values", () => {
+ expect(resolveMapStyleUrl(undefined)).toContain("openfreemap");
+ expect(resolveMapStyleUrl("javascript:alert(1)")).toContain("openfreemap");
+ });
+});
+
+describe("resolveCesiumIonToken", () => {
+ it("returns null when unset — never an empty-string token", () => {
+ expect(resolveCesiumIonToken(undefined)).toBeNull();
+ expect(resolveCesiumIonToken(" ")).toBeNull();
+ });
+ it("returns a configured token", () => {
+ expect(resolveCesiumIonToken(" tok123 ")).toBe("tok123");
+ });
+});
diff --git a/client/src/lib/geo.ts b/client/src/lib/geo.ts
new file mode 100644
index 00000000..e7b39736
--- /dev/null
+++ b/client/src/lib/geo.ts
@@ -0,0 +1,126 @@
+/**
+ * Phase 17 — shared geospatial helpers (pure, unit-tested).
+ *
+ * Fail-closed doctrine: every helper returns explicit "unavailable" signals
+ * (null / empty / stale flags) instead of fabricating coordinates. Map style
+ * and tile origins are env-driven so operators can point at a same-origin
+ * tile proxy in sovereign deployments (see CSP_*_EXTRA server env vars).
+ */
+
+export interface GeoVessel {
+ id: string;
+ name: string;
+ mmsi: string;
+ imo: string;
+ lat: number;
+ lng: number;
+ heading: number;
+ speed: number;
+ status: string;
+ cargo_type: string;
+ declaration_ref?: string;
+ eta?: string;
+ destination_port?: string;
+}
+
+/** Row shape returned by trpc.geospatial.listVessels / getVesselTrack. */
+export interface VesselTrackingRow {
+ id: number;
+ mmsi: string;
+ vesselName: string | null;
+ imoNumber: string | null;
+ latitude: number;
+ longitude: number;
+ speed: number | null;
+ heading: number | null;
+ destinationPort: string | null;
+ eta: Date | string | null;
+ cargoType: string | null;
+ flagCountry: string | null;
+ recordedAt: Date | string;
+}
+
+/** Convert a DB vessel row to a map vessel; invalid coordinates are dropped. */
+export function toMapVessel(row: VesselTrackingRow): GeoVessel | null {
+ if (!Number.isFinite(row.latitude) || !Number.isFinite(row.longitude)) return null;
+ if (Math.abs(row.latitude) > 90 || Math.abs(row.longitude) > 180) return null;
+ return {
+ id: String(row.id),
+ name: row.vesselName ?? row.mmsi,
+ mmsi: row.mmsi,
+ imo: row.imoNumber ?? "",
+ lat: row.latitude,
+ lng: row.longitude,
+ heading: row.heading ?? 0,
+ speed: row.speed ?? 0,
+ status: "underway",
+ cargo_type: row.cargoType ?? "unknown",
+ declaration_ref: row.destinationPort ?? undefined,
+ destination_port: row.destinationPort ?? undefined,
+ eta: row.eta ? new Date(row.eta).toISOString() : undefined,
+ };
+}
+
+export function vesselsToGeoJSON(vessels: GeoVessel[]) {
+ return {
+ type: "FeatureCollection" as const,
+ features: vessels.map(v => ({
+ type: "Feature" as const,
+ geometry: { type: "Point" as const, coordinates: [v.lng, v.lat] },
+ properties: { ...v },
+ })),
+ };
+}
+
+/** Vessel track (oldest → newest) as a GeoJSON LineString for replay overlays. */
+export function trackToLineString(
+ rows: Array<{ latitude: number; longitude: number }>,
+) {
+ const coords = rows
+ .filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude))
+ .map(r => [r.longitude, r.latitude] as [number, number]);
+ if (coords.length < 2) return null;
+ return {
+ type: "Feature" as const,
+ geometry: { type: "LineString" as const, coordinates: coords },
+ properties: {},
+ };
+}
+
+/** Honest staleness: true when the last AIS update is older than maxAgeMs. */
+export function isFeedStale(lastUpdated: Date | string | null, now = Date.now(), maxAgeMs = 90_000): boolean {
+ if (!lastUpdated) return true;
+ const t = new Date(lastUpdated).getTime();
+ if (!Number.isFinite(t)) return true;
+ return now - t > maxAgeMs;
+}
+
+/** A raster-only MapLibre style for low-bandwidth / reduced-data mode. */
+export function lowDataRasterStyle(tileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png") {
+ return {
+ version: 8 as const,
+ name: "low-data-raster",
+ sources: {
+ osm: {
+ type: "raster" as const,
+ tiles: [tileUrl],
+ tileSize: 256,
+ attribution: "© OpenStreetMap contributors",
+ },
+ },
+ layers: [{ id: "osm", type: "raster" as const, source: "osm" }],
+ };
+}
+
+/** Resolve the 2D vector style URL; env first, OpenFreeMap liberty as default. */
+export function resolveMapStyleUrl(envValue: string | undefined): string {
+ const raw = (envValue ?? "").trim();
+ if (/^https?:\/\/.+/.test(raw)) return raw;
+ return "https://tiles.openfreemap.org/styles/liberty";
+}
+
+/** Resolve the Cesium Ion token; empty means "Ion disabled" — never send empty-token requests. */
+export function resolveCesiumIonToken(envValue: string | undefined): string | null {
+ const raw = (envValue ?? "").trim();
+ return raw.length > 0 ? raw : null;
+}
diff --git a/server/_core/csp.test.ts b/server/_core/csp.test.ts
new file mode 100644
index 00000000..e84434fd
--- /dev/null
+++ b/server/_core/csp.test.ts
@@ -0,0 +1,29 @@
+/**
+ * Phase 17 (G1/G8) — CSP origin whitelist parsing tests.
+ */
+import { describe, it, expect } from "vitest";
+import { parseCspOrigins } from "./csp";
+
+describe("parseCspOrigins", () => {
+ it("parses comma-separated https origins", () => {
+ expect(parseCspOrigins("https://tiles.openfreemap.org, https://tile.openstreetmap.org")).toEqual([
+ "https://tiles.openfreemap.org",
+ "https://tile.openstreetmap.org",
+ ]);
+ });
+
+ it("returns empty for unset/blank env (fail-closed)", () => {
+ expect(parseCspOrigins(undefined)).toEqual([]);
+ expect(parseCspOrigins(" ")).toEqual([]);
+ });
+
+ it("drops wildcards, http, and malformed entries — a typo can never widen CSP", () => {
+ expect(parseCspOrigins("*, https://ok.example.com, http://insecure.example.com, notaurl")).toEqual([
+ "https://ok.example.com",
+ ]);
+ });
+
+ it("allows data:/blob: tokens for inline assets", () => {
+ expect(parseCspOrigins("data:,blob:")).toEqual(["data:", "blob:"]);
+ });
+});
diff --git a/server/_core/csp.ts b/server/_core/csp.ts
new file mode 100644
index 00000000..c94092ea
--- /dev/null
+++ b/server/_core/csp.ts
@@ -0,0 +1,16 @@
+/**
+ * Phase 17 (G1) — env-driven CSP origin whitelist parsing.
+ *
+ * Production CSP stays fail-closed (same-origin only) unless operators
+ * explicitly whitelist tile/style/glyph origins via:
+ * CSP_SCRIPT_SRC_EXTRA / CSP_CONNECT_SRC_EXTRA / CSP_IMG_SRC_EXTRA
+ * (comma-separated https origins). Malformed entries are dropped so a typo
+ * can never widen the policy to a wildcard.
+ */
+
+export function parseCspOrigins(raw: string | undefined): string[] {
+ return (raw ?? "")
+ .split(",")
+ .map(s => s.trim())
+ .filter(s => /^https:\/\/[a-z0-9.-]+(?::\d+)?(\/[^\s,]*)?$/i.test(s) || s === "data:" || s === "blob:");
+}
From 75ef58cb0ee172cc15626f2d97ee259979089bee Mon Sep 17 00:00:00 2001
From: munisp <155237317+munisp@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:31:48 -0400
Subject: [PATCH 02/14] Phase 17 (G4): env-driven Fluvio WS URL with honest
disabled state
---
client/src/hooks/useFluvioFeed.ts | 32 ++++++++++++++++++++++++++++---
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/client/src/hooks/useFluvioFeed.ts b/client/src/hooks/useFluvioFeed.ts
index 71cf50cc..d1dc6263 100644
--- a/client/src/hooks/useFluvioFeed.ts
+++ b/client/src/hooks/useFluvioFeed.ts
@@ -42,11 +42,31 @@ export interface FluvioEvent {
payload: VesselPosition | Record;
}
-export type FeedStatus = "connecting" | "connected" | "paused" | "reconnecting" | "error";
+export type FeedStatus = "connecting" | "connected" | "paused" | "reconnecting" | "error" | "disabled";
// ── Constants ─────────────────────────────────────────────────────────────────
-const FLUVIO_WS_URL = "ws://localhost:8085/ws";
+/**
+ * Phase 17 (G4): the Fluvio WS endpoint is env-driven, never hardcoded to
+ * localhost. Resolution order:
+ * 1. VITE_FLUVIO_WS_URL (full ws:// or wss:// URL; "off" disables the feed)
+ * 2. same-origin default: wss:///ws (ws: on http: dev origins)
+ * When disabled, the hook reports status "disabled" and never opens a socket.
+ */
+export function resolveFluvioWsUrl(
+ envValue: string | undefined,
+ locationLike?: { protocol: string; host: string },
+): string | null {
+ const raw = (envValue ?? "").trim();
+ if (/^off$/i.test(raw)) return null;
+ if (/^wss?:\/\/.+/.test(raw)) return raw;
+ const loc = locationLike ?? (typeof window !== "undefined" ? window.location : undefined);
+ if (!loc) return null;
+ const scheme = loc.protocol === "https:" ? "wss:" : "ws:";
+ return `${scheme}//${loc.host}/ws`;
+}
+
+const FLUVIO_WS_URL = resolveFluvioWsUrl(import.meta.env.VITE_FLUVIO_WS_URL);
const MAX_EVENTS = 500; // ring buffer size
const RECONNECT_DELAY_MS = 3000; // 3 s between reconnect attempts
const MAX_RECONNECT_ATTEMPTS = 10;
@@ -65,7 +85,7 @@ export function useFluvioFeed(options?: {
} = options ?? {};
const [events, setEvents] = useState([]);
- const [status, setStatus] = useState("connecting");
+ const [status, setStatus] = useState(FLUVIO_WS_URL ? "connecting" : "disabled");
const [lastUpdated, setLastUpdated] = useState(null);
const [reconnectCount, setReconnectCount] = useState(0);
@@ -77,6 +97,11 @@ export function useFluvioFeed(options?: {
const connect = useCallback(() => {
if (!mountedRef.current) return;
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
+ if (!FLUVIO_WS_URL) {
+ // Honest disabled state: feed not configured for this deployment.
+ setStatus("disabled");
+ return;
+ }
setStatus("connecting");
@@ -168,6 +193,7 @@ export function useFluvioFeed(options?: {
events,
vesselPositions,
status,
+ feedUrl: FLUVIO_WS_URL,
lastUpdated,
reconnectCount,
pause,
From 4979fd57c1eb16111042a99b5e30d97817174021 Mon Sep 17 00:00:00 2001
From: munisp <155237317+munisp@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:34:09 -0400
Subject: [PATCH 03/14] Phase 17 (G9/G12 + heatmap lib): Map.tsx visualization
library, env mapId, aria region
---
client/src/components/Map.tsx | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/client/src/components/Map.tsx b/client/src/components/Map.tsx
index d3c2465c..265f2af5 100644
--- a/client/src/components/Map.tsx
+++ b/client/src/components/Map.tsx
@@ -37,7 +37,7 @@
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
- * if (status === "OK" && results[0]) {
+ * if (status === "OK" && results[0].geometry) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
@@ -101,7 +101,10 @@ function loadMapScript(): Promise {
return;
}
const script = document.createElement("script");
- script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
+ // visualization is required by the heatmap layers in PortHeatmap /
+ // CargoTrackingMap — it was missing, so google.maps.visualization was
+ // undefined at runtime.
+ script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry,visualization`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
@@ -152,7 +155,8 @@ export function MapView({
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
- mapId: "DEMO_MAP_ID",
+ // G9: mapId is operator-configurable; DEMO_MAP_ID only as a dev default.
+ mapId: import.meta.env.VITE_GOOGLE_MAPS_MAP_ID || "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
@@ -173,6 +177,12 @@ export function MapView({
}
return (
-
+ // G12: expose the map region to assistive technology.
+
);
}
From 9309b1ec63e4bf87688067fcf40aea9e9008e4f0 Mon Sep 17 00:00:00 2001
From: munisp <155237317+munisp@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:35:43 -0400
Subject: [PATCH 04/14] Phase 17 (G5/G8): vite cesium bundling plugins + vitest
tsx include
---
vite.config.ts | 37 ++++++++++++++++++++++++++++++++++++-
vitest.config.ts | 5 ++++-
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/vite.config.ts b/vite.config.ts
index e621b450..da8a1b13 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,38 @@ import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig, type PluginOption } from "vite";
import tailwindcss from "@tailwindcss/vite";
+// Phase 17 (G5): Cesium is a pinned npm dependency bundled at build time
+// (static assets copied into dist) — no runtime cesium.com CDN injection.
+import cesium from "vite-plugin-cesium";
+import fs from "node:fs";
+
+// Strip vite-plugin-cesium's eager