diff --git a/vehicle-data-platform/apps/web/package.json b/vehicle-data-platform/apps/web/package.json index d4103914..fba9796f 100644 --- a/vehicle-data-platform/apps/web/package.json +++ b/vehicle-data-platform/apps/web/package.json @@ -3,7 +3,7 @@ "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 20301", - "build": "tsc -b && vite build && node scripts/verify-build.mjs", + "build": "tsc -b && vite build && node scripts/prepare-dist.mjs && node scripts/verify-build.mjs", "test": "vitest run --exclude src/test/App.test.tsx", "test:legacy": "vitest run src/test/App.test.tsx", "test:all": "vitest run" diff --git a/vehicle-data-platform/apps/web/scripts/prepare-dist.mjs b/vehicle-data-platform/apps/web/scripts/prepare-dist.mjs new file mode 100644 index 00000000..db3ee6eb --- /dev/null +++ b/vehicle-data-platform/apps/web/scripts/prepare-dist.mjs @@ -0,0 +1,12 @@ +import { copyFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const source = resolve(process.cwd(), 'public/app-config.example.js'); +const destination = resolve(process.cwd(), 'dist/app-config.js'); + +// Vite copies public/app-config.js verbatim. That file is intentionally ignored +// because developers may keep local AMap credentials in it. Never let those +// machine-local values enter a production archive; the API renders the real +// runtime configuration at /app-config.js on the server. +copyFileSync(source, destination); + diff --git a/vehicle-data-platform/apps/web/scripts/verify-build.mjs b/vehicle-data-platform/apps/web/scripts/verify-build.mjs index e0586072..9cf2e843 100644 --- a/vehicle-data-platform/apps/web/scripts/verify-build.mjs +++ b/vehicle-data-platform/apps/web/scripts/verify-build.mjs @@ -1,10 +1,12 @@ -import { readdirSync, statSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; import { resolve } from 'node:path'; const assetsDirectory = resolve(process.cwd(), 'dist/assets'); const assets = readdirSync(assetsDirectory); const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.test(name)); const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name)); +const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8'); +const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8'); if (excelAssets.length !== 1) { throw new Error(`production build must contain exactly one ExcelJS asset, found ${excelAssets.length}: ${excelAssets.join(', ') || 'none'}`); @@ -12,7 +14,14 @@ if (excelAssets.length !== 1) { if (mileageWorkers.length !== 1) { throw new Error(`production build must contain exactly one mileage export worker, found ${mileageWorkers.length}: ${mileageWorkers.join(', ') || 'none'}`); } +if (runtimeConfig !== safeRuntimeTemplate) { + throw new Error('production dist/app-config.js must match the credential-free runtime template'); +} +const configuredSecurityCode = runtimeConfig.match(/["']?amapSecurityJsCode["']?\s*:\s*(["'])(.*?)\1/s)?.[2].trim(); +if (configuredSecurityCode) { + throw new Error('production dist/app-config.js must not contain an AMap security code'); +} const excelBytes = statSync(resolve(assetsDirectory, excelAssets[0])).size; const workerBytes = statSync(resolve(assetsDirectory, mileageWorkers[0])).size; -process.stdout.write(`web_build_gate=ok exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes}\n`); +process.stdout.write(`web_build_gate=ok exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} runtime_config_sanitized=1\n`); diff --git a/vehicle-data-platform/deploy/verify-web-release.sh b/vehicle-data-platform/deploy/verify-web-release.sh index 77469a1c..f3b622c3 100755 --- a/vehicle-data-platform/deploy/verify-web-release.sh +++ b/vehicle-data-platform/deploy/verify-web-release.sh @@ -56,6 +56,10 @@ fetch_exact / "$WEB_ROOT/index.html" 'root document' config_status=$(curl --silent --show-error --max-time "$CURL_TIMEOUT_SEC" --output "$response_file" --write-out '%{http_code}' "$BASE_URL/app-config.js" || true) test "$config_status" = 200 || fail "app config returned HTTP ${config_status:-000}" grep -q 'window\.__LINGNIU_APP_CONFIG__' "$response_file" || fail 'app config response does not expose the runtime configuration object' +if grep -q '"amapSecurityJsCode"' "$response_file"; then + fail 'app config response exposes the server-side AMap security code' +fi +grep -q '"amapSecurityServiceHost":"/_AMapService"' "$response_file" || fail 'app config response does not enable the server-side AMap proxy' current_count=$(verify_manifest "$CURRENT_MANIFEST" current) compatibility_count=0 diff --git a/vehicle-data-platform/deploy/verify-web-release.test.sh b/vehicle-data-platform/deploy/verify-web-release.test.sh index 5f8ccc31..b18da5ee 100755 --- a/vehicle-data-platform/deploy/verify-web-release.test.sh +++ b/vehicle-data-platform/deploy/verify-web-release.test.sh @@ -16,7 +16,7 @@ trap cleanup EXIT mkdir -p "$fixture/web/assets" printf '
\n' > "$fixture/web/index.html" -printf 'window.__LINGNIU_APP_CONFIG__ = {};\n' > "$fixture/web/app-config.js" +printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$fixture/web/app-config.js" printf 'console.log("new");\n' > "$fixture/web/assets/new.js" printf 'console.log("old");\n' > "$fixture/web/assets/old.js" printf 'new.js\n' > "$fixture/web/.release-assets" @@ -48,6 +48,21 @@ fi success_output=$("$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets") test "$success_output" = 'web_release_smoke=ok current_assets=1 compatibility_assets=1' +printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityJsCode":"must-not-ship"};\n' > "$fixture/served/app-config.js" +if "$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets" > "$fixture/exposed-config.out" 2>&1; then + printf 'expected an exposed AMap security code to fail\n' >&2 + exit 1 +fi +grep -q 'exposes the server-side AMap security code' "$fixture/exposed-config.out" + +printf 'window.__LINGNIU_APP_CONFIG__={};\n' > "$fixture/served/app-config.js" +if "$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets" > "$fixture/missing-proxy.out" 2>&1; then + printf 'expected a missing AMap security proxy to fail\n' >&2 + exit 1 +fi +grep -q 'does not enable the server-side AMap proxy' "$fixture/missing-proxy.out" +cp "$fixture/web/app-config.js" "$fixture/served/app-config.js" + rm "$fixture/web/assets/old.js" if "$SCRIPT_DIR/verify-web-release.sh" "$fixture/web" "http://127.0.0.1:$port" "$fixture/previous-assets" > "$fixture/missing.out" 2>&1; then printf 'expected a missing compatibility asset to fail\n' >&2 diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index 84536785..81d81e51 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -111,6 +111,8 @@ ALERT_STREAM_LATENESS_SEC=120 `AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration. +`apps/web/public/app-config.js` is ignored and may contain developer-local values. `pnpm build` always replaces its copied output with `app-config.example.js` before packaging, then verifies an exact byte match. This prevents a local security code from entering a release archive even though production requests are dynamically intercepted by the API. The release smoke gate separately rejects any `/app-config.js` response containing `amapSecurityJsCode` and requires `amapSecurityServiceHost=/_AMapService`. + `PLATFORM_RELEASE` is surfaced by `/api/ops/health` at `data.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment. All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development. diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 8df06467..ae24b962 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -94,6 +94,8 @@ Excel export previously produced two independent 940 KB ExcelJS assets because t The Alert Center previously mounted every tab's data dependencies together: the default event view fetched the metric catalog even though only the rule editor consumes it, while a direct notifications entry fetched both the unread-only collection and the full notification collection. Queries are now gated by the active workspace. Events load events, summary, rule names and the unread badge; rules add the metric catalog only when opened; notifications load one full collection and derive the unread badge from it. This follows TanStack Query's lazy-query `enabled` model and preserves Grafana's separation between alert triage, rule configuration and notification handling: , . Request-count tests protect both the default event route and a direct notifications entry. +Vite previously copied the ignored developer-local `public/app-config.js` into `dist` unchanged. The API correctly intercepted production `/app-config.js` requests, but a local AMap security code still entered release archives and would become exposed if that interception ever regressed. The production build now overwrites the copied file with the credential-free example and verifies an exact byte match. The ECS release smoke gate independently requires the server-side `/_AMapService` host and rejects the security-code property, so both the artifact and the served runtime surface fail closed. This matches AMap's production guidance, which strongly recommends server proxy forwarding and explicitly classifies browser plaintext configuration as unsafe: . + The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: and . ### Remaining audit queue