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