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: