diff --git a/frontend/app/api/system/status/route.ts b/frontend/app/api/system/status/route.ts index b0aa7f19..6850de14 100644 --- a/frontend/app/api/system/status/route.ts +++ b/frontend/app/api/system/status/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { proxyBackendJsonGet } from "@/lib/api-proxy"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -11,11 +16,28 @@ export async function GET(req: NextRequest) { ); } - return proxyBackendJsonGet(req, { - cacheControl: "public, max-age=0, s-maxage=30, stale-while-revalidate=120", - detailLimit: 500, - publicMessage: "Failed to fetch system status", - revalidateSeconds: 30, - url: `${API_BASE}/api/system/status`, - }); + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const res = await fetch(`${API_BASE}/api/system/status`, { + cache: "no-store", + headers: auth.headers, + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Cloudflare-CDN-Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch system status", + }); + } } diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index cc551703..a906bec6 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -186,6 +186,7 @@ export function runTests() { "app/api/ops/users/route.ts", "app/api/ops/users/grant-points/route.ts", "app/api/ops/view-logs/route.ts", + "app/api/system/status/route.ts", ]) { const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); assert( @@ -211,14 +212,10 @@ export function runTests() { const optionalRefreshIndex = middlewareSource.indexOf( "function shouldRefreshOptionalSupabaseSession", ); - const systemStatusPublicIndex = middlewareSource.indexOf( - 'pathname === "/api/system/status"', - ); assert( - systemStatusPublicIndex >= 0 && - optionalRefreshIndex >= 0 && - systemStatusPublicIndex < optionalRefreshIndex, - "middleware must treat public system status as public API instead of optional Supabase session refresh", + optionalRefreshIndex >= 0 && + !middlewareSource.includes('pathname === "/api/system/status"'), + "middleware must not treat system status as public after it becomes an ops-only API", ); for (const route of paymentRoutes) { diff --git a/frontend/middleware.ts b/frontend/middleware.ts index b125a592..54b5cc2f 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -36,7 +36,6 @@ function isPublicApi(pathname: string) { pathname === "/api/cities" || pathname === "/api/payments/config" || pathname === "/api/scan/terminal" || - pathname === "/api/system/status" || pathname === "/api/vitals" || /^\/api\/city\/[^/]+$/i.test(pathname) || /^\/api\/city\/[^/]+\/summary$/i.test(pathname) || diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 34b7949c..b7d81b76 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -52,7 +52,18 @@ def test_healthz_keeps_liveness_200_when_db_health_is_degraded(monkeypatch): assert response.json()["status"] == "degraded" -def test_system_status_returns_summary_shape(): +def test_system_status_requires_ops_admin(): + response = client.get('/api/system/status') + assert response.status_code in {401, 403, 503} + + +def test_system_status_returns_summary_shape_for_ops_admin(monkeypatch): + monkeypatch.setattr( + routes, + "_require_ops_admin", + lambda request: {"user_id": "admin-user", "email": "admin@example.com"}, + ) + response = client.get('/api/system/status') assert response.status_code == 200 payload = response.json() @@ -93,12 +104,31 @@ def test_observation_freshness_accepts_epoch_seconds(): assert payload["observed_at"].startswith("2026-") -def test_metrics_endpoint_returns_prometheus_payload(): +def test_metrics_endpoint_requires_ops_admin(): + response = client.get('/metrics') + assert response.status_code in {401, 403, 503} + + +def test_metrics_endpoint_returns_prometheus_payload_for_ops_admin(monkeypatch): + monkeypatch.setattr( + routes, + "_require_ops_admin", + lambda request: {"user_id": "admin-user", "email": "admin@example.com"}, + ) + response = client.get('/metrics') assert response.status_code == 200 assert 'polyweather_http_requests_total' in response.text +def test_system_cache_status_requires_ops_admin(monkeypatch): + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + + response = client.get("/api/system/cache-status?cities=shanghai") + + assert response.status_code in {401, 403, 503} + + def test_standard_growth_funnel_events_are_trackable(): assert { "landing_view", diff --git a/web/routers/system.py b/web/routers/system.py index 3391d2a7..21606519 100644 --- a/web/routers/system.py +++ b/web/routers/system.py @@ -23,8 +23,8 @@ async def healthz(): @router.get("/api/system/status") -async def system_status(): - return await get_system_status_payload() +async def system_status(request: Request): + return await get_system_status_payload(request) @router.get("/api/system/cache-status") @@ -42,8 +42,8 @@ async def system_priority_warm( @router.get("/metrics", response_class=PlainTextResponse) -async def metrics(): - return get_prometheus_metrics_response() +async def metrics(request: Request): + return get_prometheus_metrics_response(request) @router.get("/api/dashboard/init") diff --git a/web/services/system_api.py b/web/services/system_api.py index 318b0328..334798bc 100644 --- a/web/services/system_api.py +++ b/web/services/system_api.py @@ -19,7 +19,8 @@ def get_health_payload() -> Dict[str, Any]: return build_health_payload() -async def get_system_status_payload() -> Dict[str, Any]: +async def get_system_status_payload(request: Request) -> Dict[str, Any]: + legacy_routes._require_ops_admin(request) payload = await run_in_threadpool(build_system_status_payload) payload["realtime"] = await run_in_threadpool(_realtime_status_payload) return payload @@ -52,7 +53,7 @@ def _realtime_status_payload() -> Dict[str, Any]: def get_system_cache_status(request: Request, cities: Optional[str] = None) -> Dict[str, Any]: - legacy_routes._assert_entitlement(request) + legacy_routes._require_ops_admin(request) selected = legacy_routes._normalize_city_list(cities) if not selected: selected = legacy_routes._normalize_city_list(None) @@ -118,7 +119,8 @@ def run_system_priority_warm( } -def get_prometheus_metrics_response() -> PlainTextResponse: +def get_prometheus_metrics_response(request: Request) -> PlainTextResponse: + legacy_routes._require_ops_admin(request) return PlainTextResponse( export_prometheus_metrics(), media_type="text/plain; version=0.0.4; charset=utf-8",