From db181043ca96e90ebc5245d9a49290a29bb4bfa6 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Mon, 11 May 2026 00:42:12 +0300 Subject: [PATCH] [AZ-450] [AZ-451] [AZ-452] [AZ-454] Externalize URLs + accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor batch 1 of 2 for the 01-testability-refactoring epic (AZ-447). Minimal-surgical edits to make the UI's external dependencies overridable for the test profiles in _docs/02_document/tests/environment.md. - AZ-450 (C03): tile URLs externalized to VITE_OSM_TILE_URL and VITE_ESRI_TILE_URL with the production strings as defaults. Fixes the AC-N3 / NFT-RES-03 air-gap regression and lets the e2e profile's tile-stub serve tiles deterministically. - AZ-451 (C04): Leaflet marker icon imported from the pinned leaflet package as a Vite asset; removes the unpkg.com CDN reference (also fixes the leaflet@1.7.1 vs ^1.9.4 mismatch). - AZ-452 (C05): getApiBase() accessor reading VITE_API_BASE_URL. request(), refreshToken(), and createSSE() prepend the prefix. Default '' preserves every call site. - AZ-454 (C07): JSDoc on setToken/getToken documenting the test-override intent so future cleanup doesn't delete them. Also: .env.example documents every VITE_* var; vite-env.d.ts declares ImportMetaEnv; .gitignore now excludes /dist and *.tsbuildinfo (tracked-by-mistake cache file removed). Build verification: `bunx tsc -b --noEmit` passes. No tests in this run (testability-run exemption per refactor SKILL — tests land in autodev Step 6). Co-authored-by: Cursor --- .env.example | 35 ++++++++++++++++++++++++++++++++ .gitignore | 4 ++++ src/api/client.ts | 35 +++++++++++++++++++++++++++++--- src/api/sse.ts | 7 +++++-- src/features/flights/mapIcons.ts | 3 ++- src/features/flights/types.ts | 10 +++++++-- src/vite-env.d.ts | 12 +++++++++++ tsconfig.tsbuildinfo | 1 - 8 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 .env.example delete mode 100644 tsconfig.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08d7d29 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Azaion UI — Vite build-time environment variables. +# +# All variables here are read at build time via `import.meta.env.VITE_*` and +# inlined by Vite. Copy this file to `.env.local` (gitignored) for local +# dev; CI / Docker pass the same variables through the build environment. +# +# Every variable is OPTIONAL. When unset, the SPA falls back to production- +# default behavior: +# - VITE_API_BASE_URL : '' (relative paths; SPA and suite share nginx) +# - VITE_OWM_API_KEY : undefined → getWeatherData returns null +# - VITE_OWM_BASE_URL : https://api.openweathermap.org/data/2.5 +# - VITE_OSM_TILE_URL : https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png +# - VITE_ESRI_TILE_URL : https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x} + +# Prefix for every API request (production: empty; tests / alt deployments: set). +# A trailing slash is stripped automatically. +# Example: VITE_API_BASE_URL=http://azaion-ui:80 +VITE_API_BASE_URL= + +# OpenWeatherMap API key. Required for the FlightsPage weather feature. +# Leave unset in CI tests — the e2e profile routes to owm-stub. +VITE_OWM_API_KEY= + +# OpenWeatherMap REST base URL. Default targets the public endpoint; tests +# override to point at the owm-stub service. +# Example for the e2e profile: http://owm-stub:8081/data/2.5 +VITE_OWM_BASE_URL= + +# OSM map tile URL template (Leaflet TileLayer.url). +# Example for the e2e profile: http://tile-stub:8082/{z}/{x}/{y}.png +VITE_OSM_TILE_URL= + +# Esri satellite tile URL template (Leaflet TileLayer.url for the satellite layer). +# Example for the e2e profile: http://tile-stub:8082/sat/{z}/{y}/{x} +VITE_ESRI_TILE_URL= diff --git a/.gitignore b/.gitignore index 250cbfb..e05879e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ node_modules/ # production /build +/dist + +# TypeScript build cache +*.tsbuildinfo # misc .DS_Store diff --git a/src/api/client.ts b/src/api/client.ts index c71a76c..21048f3 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,13 +1,41 @@ let accessToken: string | null = null +/** + * Single source of truth for the in-memory bearer token. + * + * The bearer is intentionally held in module scope (NOT in localStorage / + * sessionStorage / cookies) per AC-02 / restriction O2 — the only way to + * survive a page reload is through the HttpOnly refresh cookie + the + * `refreshToken()` round-trip in `request()` below. + * + * Tests override this hook to seed a bearer without going through the full + * login flow. See `_docs/02_document/tests/test-data.md` ("Stubbed bearer / + * cookie in test helpers"). DO NOT delete this accessor as "dead code" — + * it is reflectively used by the test harness; the import looks dead at + * static-grep time but is not. + */ export function setToken(token: string | null) { accessToken = token } +/** Read-side companion to {@link setToken}. */ export function getToken() { return accessToken } +/** + * Resolve the base URL prefix for every API request. Returns `''` (no + * prefix) on production builds where the SPA and the suite share the same + * nginx (E2); tests + alternative deployments override via the Vite env + * var `VITE_API_BASE_URL`. A trailing slash is stripped so a value of + * `http://host/` does not produce `http://host//api/...`. + */ +export function getApiBase(): string { + const raw = import.meta.env.VITE_API_BASE_URL + if (!raw) return '' + return raw.replace(/\/+$/, '') +} + async function handleResponse(res: Response): Promise { if (res.status === 204) return undefined as T if (!res.ok) { @@ -22,13 +50,14 @@ async function request(url: string, options: RequestInit = {}): Promise { if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`) if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json') - let res = await fetch(url, { ...options, headers }) + const fullUrl = getApiBase() + url + let res = await fetch(fullUrl, { ...options, headers }) if (res.status === 401 && accessToken) { const refreshed = await refreshToken() if (refreshed) { headers.set('Authorization', `Bearer ${accessToken}`) - res = await fetch(url, { ...options, headers }) + res = await fetch(fullUrl, { ...options, headers }) } else { setToken(null) window.location.href = '/login' @@ -41,7 +70,7 @@ async function request(url: string, options: RequestInit = {}): Promise { async function refreshToken(): Promise { try { - const res = await fetch('/api/admin/auth/refresh', { method: 'POST', credentials: 'include' }) + const res = await fetch(getApiBase() + '/api/admin/auth/refresh', { method: 'POST', credentials: 'include' }) if (!res.ok) return false const data = await res.json() setToken(data.token) diff --git a/src/api/sse.ts b/src/api/sse.ts index 6283c8e..6a2ac85 100644 --- a/src/api/sse.ts +++ b/src/api/sse.ts @@ -1,4 +1,4 @@ -import { getToken } from './client' +import { getApiBase, getToken } from './client' export function createSSE( url: string, @@ -6,7 +6,10 @@ export function createSSE( onError?: (err: Event) => void, ): () => void { const token = getToken() - const fullUrl = token ? `${url}${url.includes('?') ? '&' : '?'}access_token=${token}` : url + const prefixedUrl = getApiBase() + url + const fullUrl = token + ? `${prefixedUrl}${prefixedUrl.includes('?') ? '&' : '?'}access_token=${token}` + : prefixedUrl const source = new EventSource(fullUrl) diff --git a/src/features/flights/mapIcons.ts b/src/features/flights/mapIcons.ts index 0629398..61440be 100644 --- a/src/features/flights/mapIcons.ts +++ b/src/features/flights/mapIcons.ts @@ -1,4 +1,5 @@ import L from 'leaflet' +import markerIcon from 'leaflet/dist/images/marker-icon.png' function pinIcon(color: string) { return L.divIcon({ @@ -15,7 +16,7 @@ export const pointIconBlue = pinIcon('#228be6') export const pointIconRed = pinIcon('#fa5252') export const defaultIcon = new L.Icon({ - iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png', + iconUrl: markerIcon, iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], diff --git a/src/features/flights/types.ts b/src/features/flights/types.ts index 5eeb403..fe5f1bc 100644 --- a/src/features/flights/types.ts +++ b/src/features/flights/types.ts @@ -52,7 +52,13 @@ export const PURPOSES = [ export const COORDINATE_PRECISION = 8 +const trimTrailingSlash = (s: string) => s.replace(/\/+$/, '') + export const TILE_URLS = { - classic: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + classic: + trimTrailingSlash(import.meta.env.VITE_OSM_TILE_URL ?? '') || + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + satellite: + trimTrailingSlash(import.meta.env.VITE_ESRI_TILE_URL ?? '') || + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', } as const diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 9dccf71..df49b0b 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,3 +2,15 @@ declare module '*.css' declare module 'leaflet-polylinedecorator' + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string + readonly VITE_OWM_API_KEY?: string + readonly VITE_OWM_BASE_URL?: string + readonly VITE_OSM_TILE_URL?: string + readonly VITE_ESRI_TILE_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo deleted file mode 100644 index b457a5c..0000000 --- a/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"} \ No newline at end of file