diff --git a/ai/tasks/done/TASK-224-full-application-request-audit.md b/ai/tasks/done/TASK-224-full-application-request-audit.md new file mode 100644 index 0000000..c3e58b4 --- /dev/null +++ b/ai/tasks/done/TASK-224-full-application-request-audit.md @@ -0,0 +1,167 @@ +--- +id: TASK-224 +title: Full application request audit +status: done +type: research +team: Analista +supporting_teams: + - Backend Senior + - Frontend Senior + - Arquitecto Python + - Arquitecto de Base de Datos +roadmap_item: foundation +priority: high +--- + +# TASK-224 - Full application request audit + +## Goal + +Discover, classify, measure and document all relevant public application requests across frontend and backend/API without applying functional fixes. + +## Context + +Recent public ranking/snapshot optimizations were followed by a regression in `historico-partida.html` and `/api/historical/matches/detail`. Production profiling showed the real bottleneck was storage initialization during a public read path: + +- `get_rcon_historical_match_detail()`: 14.193s +- `get_materialized_rcon_match_detail()`: 14.167s +- `initialize_postgres_rcon_storage()`: 14.147s +- `initialize_rcon_materialized_storage()`: 14.133s +- storage read after initialization: 0.224s +- payload build: 0.014s + +This task audits the complete public request surface to find other endpoints with similar cold-read initialization, DDL/bootstrap, direct live network calls or heavyweight fallback behavior. + +## Steps + +1. Inspect repository context, architecture notes and relevant orchestrator role guidance. +2. Review `backend/app/routes.py` and extract all public GET routes. +3. Review `backend/app/payloads.py` and storage/read-model modules reached by payload builders. +4. Search backend for `initialize_*`, `ensure_*`, DDL, migration, bootstrap, fallback, direct RCON and external HTTP paths. +5. Review public frontend HTML and JavaScript request construction. +6. Create an executable public request audit script using stdlib only. +7. Run the audit against production when available and document measured results. +8. Write the full request matrix and prioritized follow-up tasks. + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/analyst.md` +- `ai/orchestrator/backend-senior.md` +- `ai/orchestrator/frontend-senior.md` +- `backend/app/routes.py` +- `backend/app/payloads.py` +- `frontend/*.html` +- `frontend/assets/js/*.js` + +## Expected Files to Modify + +- `ai/tasks/done/TASK-224-full-application-request-audit.md` +- `docs/FULL_APPLICATION_REQUEST_AUDIT.md` +- `scripts/audit_public_requests.py` +- `tmp/public_request_audit.json` +- `tmp/audit_prod_stdout.txt` +- `tmp/audit_prod_stderr.txt` + +## Constraints + +- Do not run `ai-platform run`. +- Do not commit. +- Do not push. +- Do not apply functional fixes. +- Do not optimize production code except for audit tooling. +- Do not touch weapon assets, clan assets, SVGs or physical images. +- Do not modify `ai/system-metrics.md`. +- Do not reactivate Elo/MMR. +- Do not add unrelated prior changes. + +## Validation + +Executed: + +```powershell +python -m compileall backend\app +python -m py_compile scripts\audit_public_requests.py +cd backend +python -m unittest tests.test_rcon_materialization_pipeline +``` + +Results: + +- `python -m compileall backend\app`: passed. +- `python -m py_compile scripts\audit_public_requests.py`: passed. +- `python -m unittest tests.test_rcon_materialization_pipeline`: passed, 9 tests in 0.588s. Existing tests emitted `ResourceWarning` for unclosed SQLite connections but did not fail. + +Runtime audit: + +- Local backend health check against `http://127.0.0.1:8000/health`: unavailable from host, so local endpoint matrix was not launched. +- Production audit against `https://comunidadhll.devzamode.es`: launched 191 probes and wrote `tmp/public_request_audit.json`. +- Manual player-dependent probes launched after deriving `player_id=76561198092154180` from ranking data. + +Production automatic summary: + +- OK: 77 +- WARNING: 110 +- CRITICAL: 4 +- HTTP 200: 187 +- HTTP 500: 1 +- timeout/error without HTTP status: 3 + +Manual player-dependent summary: + +- OK: 0 +- WARNING: 2 +- CRITICAL: 2 + +Combined measured summary: + +- OK: 77 +- WARNING: 112 +- CRITICAL: 6 + +## Outcome + +Created `docs/FULL_APPLICATION_REQUEST_AUDIT.md` with: + +- Executive summary. +- Backend route inventory. +- Frontend fetch inventory. +- Full request matrix with public backend routes, frontend fetches, static assets and internal runners. +- Endpoint severity tables. +- Initialization-in-read detection. +- Heavy fallback detection. +- Frontend loading/error/timeout audit. +- Top risks and prioritized recommendations. +- Proposed follow-up tasks. +- Exact commands to rerun the audit locally, in production and from the backend container. + +Created `scripts/audit_public_requests.py` with: + +- Configurable base URL and timeout. +- Representative public GET probes. +- HTTP status, elapsed time, response size and JSON metadata extraction. +- Non-fatal endpoint failures. +- JSON output to `tmp/public_request_audit.json` by default. +- Static discovery for backend routes, frontend API literals, `fetch(` occurrences and localhost references. + +Key findings: + +1. `/api/stats/players/search` times out at 30s in production. +2. `/api/stats/players/{player_id}` weekly/monthly times out at 30s in production. +3. `/api/current-match/kills` times out or returns 500. +4. `/api/current-match/players` times out for `comunidad-hispana-01`. +5. `/api/servers` can spend 4.3s refreshing RCON live data during a public GET. +6. `/api/historical/matches/detail` responded OK for the known real match in this audit, but still has a static initialization-in-read chain and needs later hardening. +7. 13 frontend localhost/127.0.0.1 references remain in public HTML/JS, mitigated by `config.js` but still worth hardening. + +Next recommended fix: + +1. Fix `/api/stats/players/search` and `/api/stats/players/{player_id}` first by making the public read path strict read-only, without `initialize_*` or runtime fallback. +2. Fix `/api/current-match/kills` and `/api/current-match/players` next. +3. Harden `/api/historical/matches/detail` after those, unless new profiling shows it regressed again. + +## Change Budget + +Files changed by this task are audit/documentation/support files only. No product logic files were modified. diff --git a/docs/FULL_APPLICATION_REQUEST_AUDIT.md b/docs/FULL_APPLICATION_REQUEST_AUDIT.md new file mode 100644 index 0000000..2ad7445 --- /dev/null +++ b/docs/FULL_APPLICATION_REQUEST_AUDIT.md @@ -0,0 +1,386 @@ +# Full Application Request Audit + +Fecha: 2026-06-10 +Task: `TASK-224-full-application-request-audit` +Alcance: auditoria de peticiones publicas frontend/backend/API sin aplicar fixes funcionales. + +## Resumen ejecutivo + +La auditoria encontro 36 patrones HTTP publicos en `backend/app/routes.py`, 12 ocurrencias `fetch(` en JavaScript publico, 15 literales `/api/...` en frontend y 13 referencias `localhost`/`127.0.0.1` en HTML/JS publico. No se encontro `XMLHttpRequest`. + +Se ejecuto la auditoria contra `https://comunidadhll.devzamode.es` con 191 probes automaticos y 4 probes manuales dependientes de `player_id`. Resultado combinado: + +| Severidad | Cantidad | +| --- | ---: | +| OK | 77 | +| WARNING | 112 | +| CRITICAL | 6 | + +Los fallos criticos actuales no estan en rankings/snapshots ni en el match detail historico probado. Estan en: + +- `/api/stats/players/search?q=Medu&server_id=all&limit=10`: timeout a 30s. +- `/api/stats/players/{player_id}?timeframe=weekly|monthly&server_id=all`: timeout a 30s. +- `/api/current-match/kills?server=comunidad-hispana-01&limit=30`: timeout a 30s. +- `/api/current-match/players?server=comunidad-hispana-01`: timeout a 30s. +- `/api/current-match/kills?server=comunidad-hispana-02&limit=30`: 500 en una ejecucion y timeout en una repeticion manual. + +`/api/historical/matches/detail` con el match real `comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare` respondio `200` en `120.47 ms` y `125580 B`. Sigue teniendo deuda de inicializacion en lectura en la cadena estatica, pero ya no es el endpoint publico mas critico segun la medicion de produccion de esta auditoria. + +Siguiente fix prioritario recomendado: aislar o eliminar inicializacion/fallback runtime de `/api/stats/players/search` y `/api/stats/players/{player_id}`. Despues, corregir `/api/current-match/kills` y `/api/current-match/players`. `/api/historical/matches/detail` debe quedar en cola de hardening para hacerlo estrictamente read-only, pero no aparece como el primer incendio operativo en esta medicion. + +## Evidencia ejecutada + +Comandos ejecutados: + +```powershell +python -m compileall backend\app +python -m py_compile scripts\audit_public_requests.py +cd backend +python -m unittest tests.test_rcon_materialization_pipeline +``` + +Resultado: + +- `compileall`: OK. +- `py_compile`: OK. +- `unittest`: OK, 9 tests en 0.588s. El test existente emitio `ResourceWarning` por conexiones SQLite sin cerrar, sin fallo de test. + +Auditoria local: + +- `http://127.0.0.1:8000/health` no estaba disponible desde el host, por lo que no se lanzo la matriz completa local. + +Auditoria produccion: + +```powershell +python scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --output tmp\public_request_audit.json +``` + +Resultado automatico: + +- Probes lanzados: 191. +- `OK`: 77. +- `WARNING`: 110. +- `CRITICAL`: 4. +- HTTP 200: 187. +- HTTP 500: 1. +- Timeout/error sin status: 3. +- Warnings con `fallback_used=true`: 109. +- Snapshots con `snapshot_status=missing`: 75. + +Probes manuales adicionales dependientes de `player_id`: + +| Endpoint | Resultado | Tiempo | Severidad | +| --- | ---: | ---: | --- | +| `/api/stats/players/76561198092154180?timeframe=weekly&server_id=all` | timeout | 30094.15 ms | CRITICAL | +| `/api/stats/players/76561198092154180?timeframe=monthly&server_id=all` | timeout | 30046.21 ms | CRITICAL | +| `/api/historical/player-profile?player=76561198092154180` | 200 | 4736.64 ms | WARNING | +| `/api/historical/elo-mmr/player?server=all-servers&player=76561198092154180` | 200 | 46.87 ms | WARNING | + +El `player_id` se obtuvo desde `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=1`. + +## Inventario backend + +Rutas HTTP publicas extraidas de `backend/app/routes.py`: 36 patrones. + +| ID | Metodo | Path | Handler/payload builder | Parametros | Fuente/modelo esperado | +| --- | --- | --- | --- | --- | --- | +| B001 | GET | `/health` | `build_health_payload` | ninguno | config/runtime status | +| B002 | GET | `/api/community` | `build_community_payload` | ninguno | payload estatico | +| B003 | GET | `/api/trailer` | `build_trailer_payload` | ninguno | payload estatico | +| B004 | GET | `/api/discord` | `build_discord_payload` | ninguno | payload estatico | +| B005 | GET | `/api/servers` | `build_servers_payload` | ninguno | latest snapshots; si stale refresca RCON/A2S | +| B006 | GET | `/api/servers/latest` | `build_server_latest_payload` | ninguno | `list_latest_snapshots` | +| B007 | GET | `/api/servers/history` | `build_server_history_payload` | `limit` | `list_snapshot_history` | +| B008 | GET | `/api/servers/{id}/history` | `build_server_detail_history_payload` | `id`, `limit` | `list_server_history` | +| B009 | GET | `/api/stats/players/search` | `build_stats_player_search_payload` | `q`, `server_id/server`, `limit` | player search read model; fallback runtime RCON materialized | +| B010 | GET | `/api/stats/rankings/annual` | `build_annual_ranking_snapshot_payload` | `year`, `metric=kills`, `server_id/server`, `limit` | annual ranking snapshot | +| B011 | GET | `/api/ranking` | `build_global_ranking_payload` | `timeframe`, `server_id/server`, `metric`, `limit`, `year` for annual | ranking snapshots; annual snapshots | +| B012 | GET | `/api/current-match` | `build_current_match_payload` | `server` | direct RCON sample; fallback `/api/servers` path | +| B013 | GET | `/api/current-match/kills` | `build_current_match_kill_feed_payload` | `server`, `limit`, `since_event_id` | AdminLog storage | +| B014 | GET | `/api/current-match/players` | `build_current_match_player_stats_payload` | `server` | AdminLog storage | +| B015 | GET | `/api/stats/players/{player_id}` | `build_stats_player_profile_payload` | `player_id`, `timeframe`, `server_id/server` | player period stats read model; fallback runtime | +| B016 | GET | `/api/historical/weekly-top-kills` | `build_weekly_top_kills_payload` | `limit`, `server` | legacy historical storage | +| B017 | GET | `/api/historical/leaderboard` | `build_historical_leaderboard_payload` | `limit`, `server`, `metric`, `timeframe` | RCON read model plus public-scoreboard fallback | +| B018 | GET | `/api/historical/weekly-leaderboard` | `build_weekly_leaderboard_payload` | `limit`, `server`, `metric` | legacy weekly storage | +| B019 | GET | `/api/historical/monthly-leaderboard` | `build_monthly_leaderboard_payload` | `limit`, `server`, `metric` | legacy monthly storage | +| B020 | GET | `/api/historical/monthly-mvp` | `build_monthly_mvp_payload` | `limit`, `server` | legacy monthly storage | +| B021 | GET | `/api/historical/monthly-mvp-v2` | `build_monthly_mvp_v2_payload` | `limit`, `server` | legacy monthly storage | +| B022 | GET | `/api/historical/player-events` | `build_player_event_payload` | `limit`, `server`, `view` | legacy player event storage | +| B023 | GET | `/api/historical/snapshots/leaderboard` | `build_leaderboard_snapshot_payload` | `limit`, `server`, `metric`, `timeframe` | displayed historical snapshots | +| B024 | GET | `/api/historical/snapshots/monthly-leaderboard` | `build_monthly_leaderboard_snapshot_payload` | `limit`, `server`, `metric` | displayed historical snapshots | +| B025 | GET | `/api/historical/snapshots/monthly-mvp` | `build_monthly_mvp_snapshot_payload` | `limit`, `server` | displayed historical snapshots | +| B026 | GET | `/api/historical/snapshots/monthly-mvp-v2` | `build_monthly_mvp_v2_snapshot_payload` | `limit`, `server` | displayed historical snapshots | +| B027 | GET | `/api/historical/snapshots/player-events` | `build_player_event_snapshot_payload` | `limit`, `server`, `view` | displayed historical snapshots | +| B028 | GET | `/api/historical/snapshots/weekly-leaderboard` | `build_weekly_leaderboard_snapshot_payload` | `limit`, `server`, `metric` | displayed historical snapshots | +| B029 | GET | `/api/historical/recent-matches` | `build_recent_historical_matches_payload` | `limit`, `server` | RCON read model plus scoreboard merge | +| B030 | GET | `/api/historical/snapshots/recent-matches` | `build_recent_historical_matches_snapshot_payload` | `limit`, `server` | displayed historical snapshots | +| B031 | GET | `/api/historical/matches/detail` | `build_historical_match_detail_payload` | `server`, `match` | RCON materialized detail; fallback scoreboard detail | +| B032 | GET | `/api/historical/server-summary` | `build_historical_server_summary_payload` | `server` | RCON read model plus fallback | +| B033 | GET | `/api/historical/snapshots/server-summary` | `build_historical_server_summary_snapshot_payload` | `server` | displayed historical snapshots | +| B034 | GET | `/api/historical/player-profile` | `build_historical_player_profile_payload` | `player` | legacy historical profile | +| B035 | GET | `/api/historical/elo-mmr/leaderboard` | `build_elo_mmr_leaderboard_payload` | `limit`, `server` | Elo/MMR engine/read storage, currently fallback/paused in public payload | +| B036 | GET | `/api/historical/elo-mmr/player` | `build_elo_mmr_player_payload` | `player`, `server` | Elo/MMR engine/read storage, currently fallback/paused in public payload | + +## Matriz completa de peticiones + +La tabla usa rangos cuando una ruta se lanzo con varias combinaciones representativas. El JSON probe-a-probe esta en `tmp/public_request_audit.json`. + +| ID | Tipo | Archivo origen | Pagina/contexto | Metodo | Endpoint/path | Parametros requeridos | Ejemplo de URL real | Handler/backend function | Fuente de datos | Usa snapshot/read-model/materialized/legacy/RCON/directo | Ejecuta initialize/ensure/bootstrap en lectura publica | Riesgo fallback pesado | Riesgo timeout | Loading/error frontend | Validacion ejecutada | Resultado HTTP | Tiempo medido | Tamano respuesta | Observaciones | Severidad | Recomendacion | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| M001 | backend-api | `backend/app/routes.py` | health | GET | `/health` | ninguno | `/health` | `build_health_payload` | config | status directo | no | no | bajo | n/a | produccion | 200 | 210.37 ms | 264 B | OK | OK | mantener | +| M002 | backend-api | `backend/app/routes.py` | landing metadata | GET | `/api/community` | ninguno | `/api/community` | `build_community_payload` | estatico | directo | no | no | bajo | n/a | produccion | 200 | 22.33 ms | 203 B | OK | OK | mantener | +| M003 | backend-api | `backend/app/routes.py` | trailer | GET | `/api/trailer` | ninguno | `/api/trailer` | `build_trailer_payload` | estatico | directo | no | no | bajo | main usa error controlado | produccion | 200 | 33.08 ms | 139 B | OK | OK | mantener | +| M004 | backend-api | `backend/app/routes.py` | discord | GET | `/api/discord` | ninguno | `/api/discord` | `build_discord_payload` | estatico | directo | no | no | bajo | n/a | produccion | 200 | 47.26 ms | 137 B | OK | OK | mantener | +| M005 | backend-api | `backend/app/routes.py` | server cards | GET | `/api/servers` | ninguno | `/api/servers` | `build_servers_payload` | latest snapshots + live refresh | snapshot/RCON/A2S | no DDL, pero red externa en lectura si stale | si | medio | main tiene fallback visual | produccion | 200 | 4311.38 ms | 1751 B | `source=real-time-rcon-refresh` | WARNING | servir snapshot estricto y mover refresh fuera de request | +| M006 | backend-api | `backend/app/routes.py` | server history | GET | `/api/servers/latest` | ninguno | `/api/servers/latest` | `build_server_latest_payload` | local snapshot storage | snapshot legacy | posible `initialize_storage` por storage local | no | bajo | n/a | produccion | 200 | 42.17 ms | 184 B | OK | OK | mantener | +| M007 | backend-api | `backend/app/routes.py` | server history | GET | `/api/servers/history` | `limit` | `/api/servers/history?limit=20` | `build_server_history_payload` | local snapshot storage | snapshot legacy | posible `initialize_storage` por storage local | no | bajo | n/a | produccion | 200 | 40.59 ms | 167 B | OK | OK | mantener | +| M008 | backend-api | `backend/app/routes.py` | server history detail | GET | `/api/servers/{id}/history` | `id`, `limit` | `/api/servers/comunidad-hispana-01/history?limit=20` | `build_server_detail_history_payload` | local snapshot storage | snapshot legacy | posible `initialize_storage` por storage local | no | bajo | n/a | produccion | 200 | 46.53-78.34 ms | 194 B | OK | OK | mantener | +| M009 | backend-api | `backend/app/routes.py` | stats search | GET | `/api/stats/players/search` | `q`, `server_id/server`, `limit` | `/api/stats/players/search?q=Medu&server_id=all&limit=10` | `build_stats_player_search_payload` | player search index | read-model + runtime materialized fallback | si: `initialize_player_search_index_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage` | si | alto | stats puede quedar esperando hasta timeout navegador | produccion | timeout | 30019.46 ms | 0 B | probe CRITICAL | CRITICAL | primer fix: read-only estricto, sin inicializar ni fallback runtime | +| M010 | backend-api | `backend/app/routes.py` | stats annual ranking | GET | `/api/stats/rankings/annual` | `year`, `metric=kills`, `server_id/server`, `limit` | `/api/stats/rankings/annual?year=2026&metric=kills&server_id=all&limit=10` | `build_annual_ranking_snapshot_payload` | annual ranking snapshot | snapshot | no en lectura normal | no | bajo | stats tiene error controlado | produccion | 200 | 60.07 ms | 5236 B | OK | OK | mantener | +| M011 | backend-api | `backend/app/routes.py` | ranking | GET | `/api/ranking` | `timeframe`, `server_id/server`, `metric`, `limit`, `year` anual | `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=20` | `build_global_ranking_payload` | ranking snapshots | snapshot/read-model | no en lectura snapshot; fallback runtime opcional por env | si si env fallback esta activo | bajo | ranking usa AbortController/requestId | produccion | 200 | 31.40-129.56 ms | 755-5668 B | 55 probes OK | OK | mantener fallback runtime desactivado en publico | +| M012 | backend-api | `backend/app/routes.py` | partida actual resumen | GET | `/api/current-match` | `server` | `/api/current-match?server=comunidad-hispana-01` | `build_current_match_payload` | RCON live | directo RCON + fallback `/api/servers` | no DDL | si, por fallback live | medio | partida actual tiene in-flight guard, sin timeout | produccion | 200 | 1242.08-2155.20 ms | 808-820 B | OK pero red externa en request | OK | mover a snapshot/read-model publico | +| M013 | backend-api | `backend/app/routes.py` | partida actual kills | GET | `/api/current-match/kills` | `server`, `limit`, `since_event_id` | `/api/current-match/kills?server=comunidad-hispana-01&limit=30` | `build_current_match_kill_feed_payload` | AdminLog | materialized AdminLog | si: `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | desconocido | alto | polling 1.5s con in-flight guard, sin timeout | produccion | timeout/500 | 6268.78-30024.86 ms | 0-58 B | CH01 timeout, CH02 500 y luego timeout manual | CRITICAL | segundo fix: read-only connection y query/index audit | +| M014 | backend-api | `backend/app/routes.py` | partida actual jugadores | GET | `/api/current-match/players` | `server` | `/api/current-match/players?server=comunidad-hispana-01` | `build_current_match_player_stats_payload` | AdminLog | materialized AdminLog | si: `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | desconocido | alto | polling 3s con in-flight guard, sin timeout | produccion | timeout/200 | 1090.32-30096.62 ms | 0-72280 B | CH01 timeout; CH02 OK | CRITICAL | segundo fix junto con kills | +| M015 | backend-api | `backend/app/routes.py` | stats player detail | GET | `/api/stats/players/{player_id}` | `player_id`, `timeframe`, `server_id/server` | `/api/stats/players/76561198092154180?timeframe=weekly&server_id=all` | `build_stats_player_profile_payload` | player period stats | read-model + runtime fallback | si: `initialize_player_period_stats_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage` | si | alto | stats sin AbortController/timeout | manual produccion | timeout | 30046.21-30094.15 ms | 0 B | weekly/monthly CRITICAL | CRITICAL | primer fix junto con player search | +| M016 | backend-api | `backend/app/routes.py` | legacy historical top kills | GET | `/api/historical/weekly-top-kills` | `limit`, `server` | `/api/historical/weekly-top-kills?server=all-servers&limit=10` | `build_weekly_top_kills_payload` | historical storage | legacy/fallback | si en Postgres display fallback paths | si | bajo | n/a | produccion | 200 | 53.72-75.73 ms | 806 B | `fallback_used=true` | WARNING | convertir a snapshot estricto o marcar legacy | +| M017 | backend-api | `backend/app/routes.py` | legacy historical leaderboard | GET | `/api/historical/leaderboard` | `limit`, `server`, `metric`, `timeframe` | `/api/historical/leaderboard?server=all-servers&timeframe=weekly&metric=kills&limit=10` | `build_historical_leaderboard_payload` | RCON/historical storage | read-model + legacy fallback | si en legacy display paths | si | bajo | n/a | produccion | 200 | 38-132 ms | ~1375-1414 B | `fallback_used=true` en muchos probes | WARNING | documentar deprecacion o hacer snapshot-only | +| M018 | backend-api | `backend/app/routes.py` | legacy weekly leaderboard | GET | `/api/historical/weekly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/weekly-leaderboard?server=comunidad-hispana-01&metric=kills&limit=10` | `build_weekly_leaderboard_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 55-58 ms | ~1380 B | fallback | WARNING | snapshot-only o mantener como legacy controlado | +| M019 | backend-api | `backend/app/routes.py` | legacy monthly leaderboard | GET | `/api/historical/monthly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/monthly-leaderboard?server=comunidad-hispana-02&metric=kills&limit=10` | `build_monthly_leaderboard_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 57-116 ms | ~1360 B | fallback | WARNING | snapshot-only o mantener como legacy controlado | +| M020 | backend-api | `backend/app/routes.py` | monthly MVP | GET | `/api/historical/monthly-mvp` | `limit`, `server` | `/api/historical/monthly-mvp?server=all-servers&limit=10` | `build_monthly_mvp_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 43-58 ms | 1470 B | fallback | WARNING | snapshot-only | +| M021 | backend-api | `backend/app/routes.py` | monthly MVP V2 | GET | `/api/historical/monthly-mvp-v2` | `limit`, `server` | `/api/historical/monthly-mvp-v2?server=all-servers&limit=10` | `build_monthly_mvp_v2_payload` | historical storage | legacy | si en Postgres display paths | si | bajo | n/a | produccion | 200 | 40-72 ms | 1509 B | fallback | WARNING | snapshot-only | +| M022 | backend-api | `backend/app/routes.py` | player events | GET | `/api/historical/player-events` | `limit`, `server`, `view` | `/api/historical/player-events?server=comunidad-hispana-01&view=duels&limit=10` | `build_player_event_payload` | player event storage | legacy | posible initialize legacy storage | si | bajo | n/a | produccion | 200 | 38-67 ms | ~1144-1156 B | fallback | WARNING | snapshot-only | +| M023 | backend-api | `backend/app/routes.py` | snapshot leaderboard | GET | `/api/historical/snapshots/leaderboard` | `limit`, `server`, `metric`, `timeframe` | `/api/historical/snapshots/leaderboard?server=all-servers&timeframe=weekly&metric=kills&limit=10` | `build_leaderboard_snapshot_payload` | displayed snapshots | snapshot | si en Postgres: `get_snapshot` -> `initialize_postgres_display_storage` | no pesado hoy | bajo | historico muestra fallback visual | produccion | 200 | 40-110 ms | ~1596-1646 B | muchos `snapshot_status=missing` | WARNING | quitar DDL/init de `get_snapshot`; completar snapshots | +| M024 | backend-api | `backend/app/routes.py` | snapshot monthly leaderboard | GET | `/api/historical/snapshots/monthly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/snapshots/monthly-leaderboard?server=comunidad-hispana-02&metric=kills&limit=10` | `build_monthly_leaderboard_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 41-116 ms | ~1612 B | snapshot missing/fallback | WARNING | completar snapshots y read-only | +| M025 | backend-api | `backend/app/routes.py` | snapshot monthly MVP | GET | `/api/historical/snapshots/monthly-mvp` | `limit`, `server` | `/api/historical/snapshots/monthly-mvp?server=all-servers&limit=10` | `build_monthly_mvp_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 42-78 ms | 1506 B | snapshot missing/fallback | WARNING | completar snapshots | +| M026 | backend-api | `backend/app/routes.py` | snapshot monthly MVP V2 | GET | `/api/historical/snapshots/monthly-mvp-v2` | `limit`, `server` | `/api/historical/snapshots/monthly-mvp-v2?server=all-servers&limit=10` | `build_monthly_mvp_v2_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 40-41 ms | 1539 B | snapshot missing/fallback | WARNING | completar snapshots | +| M027 | backend-api | `backend/app/routes.py` | snapshot player events | GET | `/api/historical/snapshots/player-events` | `limit`, `server`, `view` | `/api/historical/snapshots/player-events?server=all-servers&view=duels&limit=10` | `build_player_event_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 40-57 ms | ~1176-1188 B | snapshot missing/fallback | WARNING | completar snapshots | +| M028 | backend-api | `backend/app/routes.py` | snapshot weekly leaderboard | GET | `/api/historical/snapshots/weekly-leaderboard` | `limit`, `server`, `metric` | `/api/historical/snapshots/weekly-leaderboard?server=comunidad-hispana-01&metric=kills&limit=10` | `build_weekly_leaderboard_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no pesado hoy | bajo | n/a | produccion | 200 | 44-70 ms | ~1610 B | snapshot missing/fallback | WARNING | completar snapshots | +| M029 | backend-api | `backend/app/routes.py` | historico recent matches legacy | GET | `/api/historical/recent-matches` | `limit`, `server` | `/api/historical/recent-matches?server=all-servers&limit=20` | `build_recent_historical_matches_payload` | RCON read model + scoreboard merge | read-model/legacy merge | posible init en fallback storage | si | medio | n/a | produccion | 200 | 278.44-1286.47 ms | ~24885 B | OK pero mas lento que snapshot | OK | preferir snapshot en frontend | +| M030 | backend-api | `backend/app/routes.py` | historico recent snapshot | GET | `/api/historical/snapshots/recent-matches` | `limit`, `server` | `/api/historical/snapshots/recent-matches?server=all-servers&limit=20` | `build_recent_historical_matches_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no | bajo | historico handles error | produccion | 200 | 48-82 ms | ~24185 B | OK | OK | mantener, quitar init display despues | +| M031 | backend-api | `backend/app/routes.py` | historico match detail | GET | `/api/historical/matches/detail` | `server`, `match` | `/api/historical/matches/detail?server=comunidad-hispana-01&match=comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare` | `build_historical_match_detail_payload` | RCON materialized detail; scoreboard fallback | materialized/read-model + fallback legacy | si: `get_materialized_rcon_match_detail` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage`; fallback display init | si | medio | historico-partida muestra error, sin timeout | produccion | 200 | 120.47 ms | 125580 B | hoy OK, deuda de init persiste | OK | hardening posterior read-only estricto | +| M032 | backend-api | `backend/app/routes.py` | historico server summary legacy | GET | `/api/historical/server-summary` | `server` | `/api/historical/server-summary?server=all-servers` | `build_historical_server_summary_payload` | RCON read model | read-model/fallback | posible init en fallback storage | si | bajo | n/a | produccion | 200 | 95.82-145.29 ms | ~2480 B | OK | OK | mantener | +| M033 | backend-api | `backend/app/routes.py` | historico server summary snapshot | GET | `/api/historical/snapshots/server-summary` | `server` | `/api/historical/snapshots/server-summary?server=all-servers` | `build_historical_server_summary_snapshot_payload` | displayed snapshots | snapshot | si en Postgres display | no | bajo | historico handles missing | produccion | 200 | 50.03-62.60 ms | ~1042 B | `fallback_used=true` | WARNING | completar snapshot/read-only | +| M034 | backend-api | `backend/app/routes.py` | historical player profile | GET | `/api/historical/player-profile` | `player` | `/api/historical/player-profile?player=76561198092154180` | `build_historical_player_profile_payload` | legacy historical profile | legacy | si en Postgres display/historical fallback | si | medio | n/a | manual produccion | 200 | 4736.64 ms | no capturado | fallback true | WARNING | no cargar de inicio; optimizar si se mantiene publico | +| M035 | backend-api | `backend/app/routes.py` | Elo/MMR leaderboard | GET | `/api/historical/elo-mmr/leaderboard` | `limit`, `server` | `/api/historical/elo-mmr/leaderboard?server=all-servers&limit=10` | `build_elo_mmr_leaderboard_payload` | Elo/MMR storage | legacy/paused | posible `initialize_elo_mmr_storage` si engine carga | si | bajo | no usado por frontend actual | produccion | 200 | 58.35 ms | 2257 B | fallback/paused | WARNING | no reactivar; mantener fuera de UI | +| M036 | backend-api | `backend/app/routes.py` | Elo/MMR player | GET | `/api/historical/elo-mmr/player` | `player`, `server` | `/api/historical/elo-mmr/player?server=all-servers&player=76561198092154180` | `build_elo_mmr_player_payload` | Elo/MMR storage | legacy/paused | posible `initialize_elo_mmr_storage` si engine carga | si | bajo | no usado por frontend actual | manual produccion | 200 | 46.87 ms | no capturado | fallback true | WARNING | no reactivar; mantener fuera de UI | +| M037 | frontend-fetch | `frontend/assets/js/main.js` | `index.html` | GET | `/health` | ninguno | `${backendBaseUrl}/health` | backend B001 | config status | directo | no | no | bajo | usa `try/catch`; no bloquea pagina completa | codigo + produccion | 200 | 210.37 ms | 264 B | usado para estado backend | OK | mantener | +| M038 | frontend-fetch | `frontend/assets/js/main.js` | `index.html` | GET | `/api/trailer` | ninguno | `${backendBaseUrl}/api/trailer` | backend B003 | estatico | directo | no | no | bajo | error controlado | codigo + produccion | 200 | 33.08 ms | 139 B | OK | OK | mantener | +| M039 | frontend-fetch | `frontend/assets/js/main.js` | `index.html` | GET | `/api/servers` | ninguno | `${backendBaseUrl}/api/servers` | backend B005 | snapshots/live | RCON refresh posible | no DDL, si red live | si | medio | fallback visual; `Promise.allSettled` | codigo + produccion | 200 | 4311.38 ms | 1751 B | puede ralentizar landing | WARNING | no refrescar live en request publica | +| M040 | frontend-fetch | `frontend/assets/js/historico.js` | `historico.html` | GET | `/api/historical/snapshots/server-summary` | `server` | `/api/historical/snapshots/server-summary?server=comunidad-hispana-01` | backend B033 | snapshot | snapshot | si display init | no | bajo | requestId/cache/error | codigo + produccion | 200 | 50-63 ms | ~1042 B | snapshot missing/fallback | WARNING | completar snapshot y read-only | +| M041 | frontend-fetch | `frontend/assets/js/historico.js` | `historico.html` | GET | `/api/historical/snapshots/recent-matches` | `server`, `limit` | `/api/historical/snapshots/recent-matches?server=comunidad-hispana-01&limit=20` | backend B030 | snapshot | snapshot | si display init | no | bajo | requestId/cache/error | codigo + produccion | 200 | 48-82 ms | ~24 KB | OK | OK | mantener | +| M042 | frontend-fetch | `frontend/assets/js/historico.js` | `historico.html` | GET | `/api/historical/snapshots/leaderboard` | `server`, `timeframe`, `metric`, `limit` | `/api/historical/snapshots/leaderboard?server=comunidad-hispana-01&timeframe=weekly&metric=kills&limit=10` | backend B023 | snapshot | snapshot | si display init | no | bajo | requestId/cache/error | codigo + produccion | 200 | 40-110 ms | ~1.6 KB | snapshot missing/fallback | WARNING | completar snapshots | +| M043 | frontend-fetch | `frontend/assets/js/historico-recent-live.js` | `historico.html` | GET | `/api/historical/snapshots/recent-matches` | `server`, `limit` | `/api/historical/snapshots/recent-matches?server=comunidad-hispana-02&limit=20` | backend B030 | snapshot | snapshot | si display init | no | bajo | `try/catch`; cache no-store | codigo + produccion | 200 | 48-82 ms | ~24 KB | duplicable con historico.js | OK | revisar si ambos scripts piden lo mismo | +| M044 | frontend-fetch | `frontend/assets/js/historico-partida.js` | `historico-partida.html` | GET | `/api/historical/matches/detail` | `server`, `match` | `/api/historical/matches/detail?server=...&match=...` | backend B031 | materialized/fallback | materialized RCON | si | si | medio | error visual; sin timeout/abort | codigo + produccion | 200 | 120.47 ms | 125580 B | fallback localhost corregido previamente | OK | hardening read-only despues | +| M045 | frontend-fetch | `frontend/assets/js/partida-actual.js` | `partida-actual.html` | GET | `/api/current-match` | `server` | `/api/current-match?server=comunidad-hispana-01` | backend B012 | RCON live | directo RCON | no | si | medio | in-flight guard; sin timeout | codigo + produccion | 200 | 1242-2155 ms | ~820 B | polling | OK | mover a snapshot | +| M046 | frontend-fetch | `frontend/assets/js/partida-actual.js` | `partida-actual.html` | GET | `/api/current-match/kills` | `server`, `limit`, `since_event_id` | `/api/current-match/kills?server=comunidad-hispana-01&limit=30` | backend B013 | AdminLog | materialized | si | desconocido | alto | in-flight guard; sin timeout | codigo + produccion | timeout/500 | 6269-30025 ms | 0-58 B | polling frecuente | CRITICAL | corregir backend y agregar timeout frontend | +| M047 | frontend-fetch | `frontend/assets/js/partida-actual.js` | `partida-actual.html` | GET | `/api/current-match/players` | `server` | `/api/current-match/players?server=comunidad-hispana-01` | backend B014 | AdminLog | materialized | si | desconocido | alto | in-flight guard; sin timeout | codigo + produccion | timeout/200 | 1090-30097 ms | 0-72 KB | polling frecuente | CRITICAL | corregir backend y agregar timeout frontend | +| M048 | frontend-fetch | `frontend/assets/js/ranking.js` | `ranking.html` | GET | `/api/ranking` | `timeframe`, `server_id`, `metric`, `limit`, `year` | `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=100` | backend B011 | ranking snapshots | snapshot | no en fast path | no | bajo | AbortController + requestId + error | codigo + produccion | 200 | 31-130 ms | variable | OK | OK | mantener | +| M049 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/health` | ninguno | `${backendBaseUrl}/health` | backend B001 | config status | directo | no | no | bajo | error controlado | codigo + produccion | 200 | 210 ms | 264 B | OK | OK | mantener | +| M050 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/api/stats/players/search` | `q`, opcional `server_id`, `limit` | `/api/stats/players/search?q=Medu&server_id=all&limit=10` | backend B009 | player read model | read-model/fallback | si | si | alto | sin abort/timeout; loading puede durar mucho | codigo + produccion | timeout | 30019 ms | 0 B | CRITICAL | CRITICAL | primer fix | +| M051 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/api/stats/rankings/annual` | `year`, `metric`, `server_id`, `limit` | `/api/stats/rankings/annual?year=2026&metric=kills&server_id=all&limit=10` | backend B010 | annual snapshot | snapshot | no | no | bajo | error controlado | codigo + produccion | 200 | 60 ms | 5236 B | OK | OK | mantener | +| M052 | frontend-fetch | `frontend/assets/js/stats.js` | `stats.html` | GET | `/api/stats/players/{player_id}` | `player_id`, `timeframe`, `server_id` | `/api/stats/players/76561198092154180?timeframe=weekly&server_id=all` | backend B015 | player period stats | read-model/fallback | si | si | alto | sin abort/timeout; puede quedarse cargando hasta timeout navegador | codigo + manual produccion | timeout | 30046-30094 ms | 0 B | CRITICAL | CRITICAL | primer fix | +| M053 | static-asset | `frontend/*.html`, `frontend/assets/css`, `frontend/assets/js` | paginas publicas | GET | assets estaticos | path estatico | `/assets/js/ranking.js` | servidor estatico | filesystem/web server | n/a | no | no | bajo | navegador gestiona error de asset | no medida HTTP individual | n/a | n/a | n/a | no se tocaron assets | OK | mantener cache headers fuera de esta task | +| M054 | static-asset | `frontend/assets/img/**` | imagenes publicas | GET | assets imagen | path estatico | `/assets/img/...` | servidor estatico | filesystem/web server | n/a | no | no | bajo | navegador gestiona error de asset | no medida HTTP individual | n/a | n/a | n/a | no se tocaron imagenes, SVGs, weapons ni clans | OK | sin cambios | +| M055 | internal-runner | `backend/app/historical_runner.py` | worker historico | n/a | no publico | args CLI/env | n/a | runner | RCON/public scoreboard/storage | ingestion/fallback | si, por diseno de worker | si | n/a | n/a | lectura estatica | n/a | n/a | n/a | fuera de superficie publica | OK | no mezclar con fixes publicos | +| M056 | internal-runner | `backend/app/historical_ingestion.py` | ingestion historica | n/a | no publico | args CLI/env | n/a | runner | public scoreboard/RCON | ingestion/fallback | si, por diseno de worker | si | n/a | n/a | lectura estatica | n/a | n/a | n/a | fuera de superficie publica | OK | no ejecutar desde request publica | +| M057 | internal-runner | `backend/app/database_maintenance.py` | mantenimiento DB | n/a | no publico | args CLI/env | n/a | runner | DB | maintenance | si, por diseno de worker | n/a | n/a | n/a | lectura estatica | n/a | n/a | n/a | fuera de superficie publica | OK | mantener separado de GET | +| M058 | internal-runner | `scripts/audit_public_requests.py` | auditoria publica | GET | endpoints publicos | `--base-url`, `--timeout` | produccion/local | script stdlib | HTTP publico | n/a | no modifica app | no | configurable | n/a | ejecutado | 191 probes | ver JSON | ver JSON | artefacto de auditoria | OK | usar antes/despues de fixes | + +## Endpoints OK + +| Endpoint/familia | Evidencia | +| --- | --- | +| `/health`, `/api/community`, `/api/trailer`, `/api/discord` | 200, menos de 211 ms | +| `/api/servers/latest`, `/api/servers/history`, `/api/servers/{id}/history` | 200, 40-78 ms | +| `/api/ranking` semanal/mensual/anual, metricas `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`, scopes `all`, `comunidad-hispana-01`, `comunidad-hispana-02` | 55 probes OK, 31-130 ms | +| `/api/stats/rankings/annual` | 200, 60.07 ms | +| `/api/current-match` | 200, 1.2-2.2 s; OK funcional, riesgo arquitectonico por RCON directo | +| `/api/historical/recent-matches` | 200, 278-1286 ms | +| `/api/historical/snapshots/recent-matches` | 200, 48-82 ms | +| `/api/historical/matches/detail` con match conocido | 200, 120.47 ms | +| `/api/historical/server-summary` | 200, 95-145 ms | + +## Endpoints WARNING + +| Endpoint/familia | Motivo | +| --- | --- | +| `/api/servers` | 200 pero 4311.38 ms y `source=real-time-rcon-refresh`: red live durante lectura publica | +| `/api/historical/weekly-top-kills` | `fallback_used=true` | +| `/api/historical/leaderboard`, `/weekly-leaderboard`, `/monthly-leaderboard` | `fallback_used=true` y legacy fallback | +| `/api/historical/monthly-mvp`, `/monthly-mvp-v2` | `fallback_used=true` | +| `/api/historical/player-events` | `fallback_used=true` | +| `/api/historical/snapshots/*` salvo recent-matches | `snapshot_status=missing` frecuente, `fallback_used=true` | +| `/api/historical/player-profile` | 200 pero 4736.64 ms manual y fallback | +| `/api/historical/elo-mmr/*` | endpoints expuestos pero fallback/pausados; no reactivar ni poner en UI | +| frontend `data-backend-base-url`/fallback local | 13 referencias a localhost/127.0.0.1 en HTML/JS publico; `config.js` mitiga en produccion pero el residuo existe | + +## Endpoints CRITICAL + +| Endpoint | Evidencia | Recomendacion | +| --- | --- | --- | +| `/api/stats/players/search?q=Medu&server_id=all&limit=10` | timeout 30019.46 ms | read-only estricto del player search index; no inicializar storage ni fallback runtime en request publica | +| `/api/stats/players/{player_id}?timeframe=weekly&server_id=all` | timeout 30094.15 ms | igual que search; revisar `player_period_stats` | +| `/api/stats/players/{player_id}?timeframe=monthly&server_id=all` | timeout 30046.21 ms | igual que search; revisar `player_period_stats` | +| `/api/current-match/kills?server=comunidad-hispana-01&limit=30` | timeout 30024.86 ms | quitar init de AdminLog en lectura; revisar indices/query | +| `/api/current-match/players?server=comunidad-hispana-01` | timeout 30096.62 ms | quitar init de AdminLog en lectura; revisar ventana actual e indices | +| `/api/current-match/kills?server=comunidad-hispana-02&limit=30` | 500 en 6268.78 ms; repeticion manual hizo timeout | capturar error backend y estabilizar query | + +## Inicializacion en lectura publica + +Rutas donde una peticion publica puede acabar ejecutando inicializacion, DDL o bootstrap de storage: + +| Ruta publica | Cadena | Tiempo observado/potencial | Estado | Recomendacion | +| --- | --- | --- | --- | --- | +| `/api/historical/matches/detail` | `build_historical_match_detail_payload` -> `get_rcon_historical_match_detail` -> `get_materialized_rcon_match_detail` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage` | historico reciente midio 14s antes; esta auditoria 120 ms para match conocido | funcional pero no limpio | mantener como fix posterior: conexion read-only sin init y fallback estricto | +| `/api/stats/players/search` | `build_stats_player_search_payload` -> `search_rcon_materialized_players` -> `_search_player_search_index` -> `initialize_player_search_index_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage`; fallback `_search_rcon_materialized_players_runtime` | 30s timeout | CRITICAL | primer fix | +| `/api/stats/players/{player_id}` | `build_stats_player_profile_payload` -> `get_rcon_materialized_player_stats` -> `_get_player_period_stats_read_model` -> `initialize_player_period_stats_storage` -> `initialize_rcon_materialized_storage` -> `initialize_postgres_rcon_storage`; fallback runtime | 30s timeout | CRITICAL | primer fix junto a search | +| `/api/current-match/kills` | `build_current_match_kill_feed_payload` -> `list_current_match_kill_feed` -> `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | 30s timeout / 500 | CRITICAL | segundo fix | +| `/api/current-match/players` | `build_current_match_player_stats_payload` -> `list_current_match_player_stats` -> `initialize_rcon_admin_log_storage` -> `initialize_postgres_rcon_storage` | 30s timeout en CH01 | CRITICAL | segundo fix | +| `/api/historical/snapshots/*` | snapshot builder -> `get_historical_snapshot` -> Postgres `get_snapshot` -> `initialize_postgres_display_storage` | rapido hoy, 40-116 ms | WARNING | read-only snapshot connection | +| legacy `/api/historical/*` | payloads -> `historical_storage` -> Postgres display fallback functions | rapido hoy salvo player-profile 4.7s | WARNING | snapshot-only o legacy explicit | +| `/api/ranking` | weekly/monthly fast path uses read-only snapshot connection; `initialize_ranking_snapshot_storage` solo deberia ocurrir en generation/fallback. Runtime fallback existe por env | 31-130 ms | OK con condicion | mantener fallback runtime apagado en publico | +| annual ranking | `get_annual_ranking_snapshot` usa read connection | 31-88 ms | OK | mantener | + +## Fallbacks pesados detectados + +| Endpoint | Fallback | Activacion | Coste estimado | Lo muestra frontend | Recomendacion | +| --- | --- | --- | --- | --- | --- | +| `/api/stats/players/search` | runtime search en `rcon_match_player_stats` | indice vacio/no disponible/error | alto, timeout 30s | no claramente | eliminar fallback runtime en publico o devolver estado `snapshot_missing` rapido | +| `/api/stats/players/{player_id}` | runtime player stats desde materialized matches | `player_period_stats` vacio/no disponible | alto, timeout 30s | no claramente | eliminar fallback runtime publico | +| `/api/current-match/kills/players` | AdminLog init + queries sobre ventana actual | siempre entra por helper actual | alto en CH01 | UI queda esperando | read-only + indices; respuesta vacia rapida si no hay ventana | +| `/api/servers` | RCON live refresh y A2S fallback si snapshots stale | snapshots ausentes/stale | medio, 4.3s medido | no como fallback tecnico | snapshot estricto en publico; refresh asincrono | +| `/api/historical/recent-matches` | merge con public-scoreboard persisted fallback | RCON insuficiente | medio, 1.3s max | no | frontend ya debe preferir snapshot | +| legacy `/api/historical/*` | public-scoreboard/display fallback | RCON no soporta o snapshot missing | bajo hoy pero acoplado | parcialmente por metadata | deprecar o snapshot-only | +| `/api/historical/matches/detail` | public-scoreboard detail fallback | RCON detail no encontrado | potencial alto si display init se repite | no destacado | fallback estricto y rapido | + +## Auditoria frontend + +Paginas publicas revisadas: + +- `frontend/index.html` +- `frontend/historico.html` +- `frontend/historico-partida.html` +- `frontend/partida-actual.html` +- `frontend/ranking.html` +- `frontend/stats.html` + +Scripts publicos revisados: + +- `frontend/assets/js/config.js` +- `frontend/assets/js/main.js` +- `frontend/assets/js/historico.js` +- `frontend/assets/js/historico-recent-live.js` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/js/partida-actual.js` +- `frontend/assets/js/ranking.js` +- `frontend/assets/js/stats.js` + +Hallazgos frontend: + +| Pagina | Fetchs | Loading/error | Timeout/abort/requestId | Riesgo | +| --- | --- | --- | --- | --- | +| `index.html` | `/health`, `/api/trailer`, `/api/servers` | error controlado; no bloquea todo por `Promise.allSettled` | sin timeout | WARNING por `/api/servers` lento | +| `historico.html` | snapshots server-summary/recent/leaderboard y recent-live | requestId/cache/error visual | requestId; sin timeout generico | WARNING por snapshots missing/fallback | +| `historico-partida.html` | `/api/historical/matches/detail` | error visual si falla | sin timeout/abort | OK hoy, deuda si endpoint vuelve lento | +| `partida-actual.html` | current match, kills, players | in-flight guards; no abort | sin timeout; polling frecuente | CRITICAL por endpoints kills/players | +| `ranking.html` | `/api/ranking` | loading/error correcto | AbortController + requestId | OK | +| `stats.html` | `/health`, player search, annual ranking, player profile | errores visibles pero search/profile pueden esperar demasiado | sin AbortController/requestId robusto para search/profile; sin timeout | CRITICAL por search/profile | + +Referencias localhost/127.0.0.1 en frontend publico: + +- `frontend/assets/js/config.js`: default dev backend y deteccion localhost. +- `frontend/assets/js/historico-recent-live.js` +- `frontend/assets/js/historico.js` +- `frontend/assets/js/main.js` +- `frontend/assets/js/partida-actual.js` +- `frontend/assets/js/ranking.js` +- `frontend/assets/js/stats.js` +- `frontend/historico.html` +- `frontend/index.html` +- `frontend/partida-actual.html` +- `frontend/ranking.html` +- `frontend/stats.html` + +`frontend/historico-partida.html` ya no conserva `data-backend-base-url` local. La mitigacion en `config.js` evita que un host no local use el default dev, pero la deuda sigue existiendo porque hay HTML/JS publico con fallback local. + +## Top 10 riesgos actuales + +1. `/api/stats/players/search` hace inicializacion/read-model/fallback runtime en lectura publica y vence a 30s. +2. `/api/stats/players/{player_id}` hace inicializacion/read-model/fallback runtime en lectura publica y vence a 30s. +3. `/api/current-match/kills` vence a 30s o devuelve 500 segun servidor. +4. `/api/current-match/players` vence a 30s en `comunidad-hispana-01`. +5. `/api/servers` puede hacer refresh RCON en request publica y tarda 4.3s. +6. `get_snapshot` de snapshots historicos ejecuta `initialize_postgres_display_storage` en lectura. +7. Muchos snapshots historicos estan `missing`, generando `fallback_used=true` en 75+ probes. +8. Hay residuos de fallback `127.0.0.1`/`localhost` en HTML/JS publico. +9. `stats.js`, `partida-actual.js` e `historico-partida.js` no tienen timeout HTTP propio. +10. Endpoints legacy historicos siguen expuestos con fallback dinamico aunque el frontend ya usa snapshots para lo principal. + +## Top 10 recomendaciones priorizadas + +1. Crear task backend para `/api/stats/players/search` y `/api/stats/players/{player_id}`: read-only estricto, sin `initialize_*` ni fallback runtime en GET publico. +2. Crear task backend para `/api/current-match/kills` y `/api/current-match/players`: quitar init de AdminLog en lectura, revisar indices y devolver payload vacio rapido si no hay ventana actual. +3. Crear task backend para `/api/servers`: no hacer refresh RCON/A2S desde GET publico; responder snapshot y dejar refresh a worker. +4. Crear task frontend para `stats.js`: AbortController/requestId/timeout y estado de error rapido para search/profile. +5. Crear task frontend para `partida-actual.js`: timeout/abort por request y backoff si kills/players fallan. +6. Crear task backend para `historical_snapshot_storage`/`postgres_display_storage.get_snapshot`: conexion read-only sin DDL/init en lectura. +7. Crear task de generacion/validacion snapshots historicos faltantes: reducir `snapshot_status=missing`. +8. Crear task de limpieza de fallbacks locales en frontend publico, manteniendo soporte dev explicito sin contaminar produccion. +9. Crear task backend para hardening de `/api/historical/matches/detail`: read-only estricto y fallback rapido aunque hoy responda OK. +10. Crear task de deprecacion/control de endpoints legacy `/api/historical/*` no usados por frontend o exponerlos solo como legacy con limites claros. + +## Fixes propuestos como tasks pequenas + +1. `TASK-225-stats-player-read-model-public-fast-path`: `/api/stats/players/search` y `/api/stats/players/{player_id}` sin inicializacion ni fallback runtime en lectura publica. +2. `TASK-226-current-match-adminlog-public-fast-path`: `/api/current-match/kills` y `/api/current-match/players` read-only, indices y errores controlados. +3. `TASK-227-servers-public-snapshot-only`: `/api/servers` no refresca RCON/A2S durante request publica. +4. `TASK-228-frontend-stats-request-timeouts`: timeout/abort/requestId para `stats.js`. +5. `TASK-229-frontend-current-match-request-timeouts`: timeout/backoff para `partida-actual.js`. +6. `TASK-230-historical-snapshot-storage-read-only`: quitar `initialize_postgres_display_storage` del path de lectura de snapshots. +7. `TASK-231-historical-snapshot-coverage-refresh`: completar snapshots faltantes usados por `historico.html`. +8. `TASK-232-frontend-public-backend-url-hardening`: eliminar fallbacks locales de HTML/JS publico de produccion. +9. `TASK-233-historical-match-detail-read-only-hardening`: hardening de `/api/historical/matches/detail`. +10. `TASK-234-legacy-historical-endpoint-policy`: inventario/limites/deprecacion de legacy endpoints publicos. + +## Comandos de auditoria + +Desde host contra produccion: + +```powershell +python scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --output tmp\public_request_audit.json +``` + +Desde host contra backend local: + +```powershell +python scripts\audit_public_requests.py --base-url http://127.0.0.1:8000 --timeout 30 --output tmp\public_request_audit.json +``` + +Dentro del contenedor backend desde PowerShell, sin copiar archivos al contenedor: + +```powershell +Get-Content scripts\audit_public_requests.py | docker compose exec -T backend python - --base-url http://127.0.0.1:8000 --timeout 30 --output /app/data/public_request_audit.json +``` + +Equivalente shell: + +```bash +cat scripts/audit_public_requests.py | docker compose exec -T backend python - --base-url http://127.0.0.1:8000 --timeout 30 --output /app/data/public_request_audit.json +``` + +Compilacion del script: + +```powershell +python -m py_compile scripts\audit_public_requests.py +``` + +## Notas de alcance + +- No se ejecuto `ai-platform run`. +- No se hizo commit. +- No se hizo push. +- No se aplicaron fixes funcionales. +- No se modifico logica productiva backend ni frontend. +- No se tocaron assets, SVGs, imagenes fisicas, `frontend/assets/img/weapons/`, `frontend/assets/img/clans/` ni `ai/system-metrics.md`. +- No se reactivo Elo/MMR. +- No se agrego ningun servidor nuevo. diff --git a/scripts/audit_public_requests.py b/scripts/audit_public_requests.py new file mode 100644 index 0000000..e04ab61 --- /dev/null +++ b/scripts/audit_public_requests.py @@ -0,0 +1,676 @@ +#!/usr/bin/env python3 +"""Audit public HTTP requests exposed by the HLL Vietnam application.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +DEFAULT_BASE_URL = "http://127.0.0.1:8000" +DEFAULT_TIMEOUT_SECONDS = 25.0 +KNOWN_MATCH_ID = "comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare" +KNOWN_PLAYER_SEARCH = "Medu" + +RANKING_METRICS = ( + "kills", + "deaths", + "teamkills", + "matches_considered", + "kd_ratio", + "kills_per_match", +) +RANKING_SCOPES = ("all", "comunidad-hispana-01", "comunidad-hispana-02") +HISTORICAL_SCOPES = ("all-servers", "comunidad-hispana-01", "comunidad-hispana-02") +HISTORICAL_LEGACY_METRICS = ("kills", "deaths", "support", "matches_over_100_kills") +PLAYER_EVENT_VIEWS = ("most-killed", "death-by", "duels", "weapon-kills", "teamkills") + + +@dataclass(frozen=True) +class ProbeSpec: + id: str + kind: str + context: str + method: str + path: str + parameters: str + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Measure public HLL Vietnam frontend/backend request surfaces." + ) + parser.add_argument( + "--base-url", + default=DEFAULT_BASE_URL, + help=f"Base URL to audit. Default: {DEFAULT_BASE_URL}", + ) + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_TIMEOUT_SECONDS, + help=f"Per-request timeout in seconds. Default: {DEFAULT_TIMEOUT_SECONDS}", + ) + parser.add_argument( + "--output", + default=None, + help="JSON output path. Default: tmp/public_request_audit.json.", + ) + parser.add_argument( + "--repo-root", + default=None, + help="Repository root for static discovery metadata. Default: auto/cwd.", + ) + parser.add_argument( + "--max-probes", + type=int, + default=0, + help="Optional cap for smoke runs. 0 means all probes.", + ) + args = parser.parse_args() + + base_url = normalize_base_url(args.base_url) + repo_root = resolve_repo_root(args.repo_root) + output_path = resolve_output_path(args.output) + + specs = build_probe_specs() + if args.max_probes and args.max_probes > 0: + specs = specs[: args.max_probes] + + results: list[dict[str, Any]] = [] + dynamic_player_id: str | None = None + index = 0 + while index < len(specs): + spec = specs[index] + result = run_probe(base_url=base_url, spec=spec, timeout_seconds=args.timeout) + results.append(result) + print_result_row(result) + + if spec.id == "stats-player-search-medu" and dynamic_player_id is None: + dynamic_player_id = extract_first_player_id(result.get("json")) + if dynamic_player_id: + specs.extend(build_player_dependent_specs(dynamic_player_id)) + + index += 1 + + payload = { + "audit": { + "base_url": base_url, + "timeout_seconds": args.timeout, + "generated_at_epoch": time.time(), + "probe_count": len(results), + }, + "summary": summarize_results(results), + "discovery": discover_static_requests(repo_root), + "results": strip_json_payloads(results), + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + + print() + print_summary(payload["summary"]) + print(f"Saved JSON results to {output_path}") + return 0 + + +def build_probe_specs() -> list[ProbeSpec]: + specs: list[ProbeSpec] = [] + seen: set[str] = set() + + def add( + id_: str, + path: str, + *, + kind: str = "backend-api", + context: str = "backend", + parameters: str = "", + ) -> None: + if id_ in seen: + return + seen.add(id_) + specs.append( + ProbeSpec( + id=id_, + kind=kind, + context=context, + method="GET", + path=path, + parameters=parameters, + ) + ) + + add("health", "/health", context="health", parameters="none") + add("api-community", "/api/community", context="home", parameters="none") + add("api-trailer", "/api/trailer", context="home", parameters="none") + add("api-discord", "/api/discord", context="home", parameters="none") + add("api-servers", "/api/servers", context="home", parameters="none") + add("api-servers-latest", "/api/servers/latest", context="home", parameters="none") + add("api-servers-history", "/api/servers/history?limit=20", context="server-history", parameters="limit=20") + for server in ("comunidad-hispana-01", "comunidad-hispana-02"): + add( + f"api-servers-{server}-history", + f"/api/servers/{server}/history?limit=20", + context="server-history", + parameters="server path, limit=20", + ) + + for timeframe in ("weekly", "monthly", "annual"): + for server in RANKING_SCOPES: + for metric in RANKING_METRICS: + params = { + "timeframe": timeframe, + "server_id": server, + "metric": metric, + "limit": "20", + } + if timeframe == "annual": + params["year"] = "2026" + query = "&".join(f"{key}={quote(value)}" for key, value in params.items()) + add( + f"ranking-{timeframe}-{server}-{metric}", + f"/api/ranking?{query}", + context="ranking.html", + parameters="timeframe, server_id, metric, limit, year for annual", + ) + + add( + "ranking-weekly-all-servers-alias", + "/api/ranking?timeframe=weekly&server_id=all-servers&metric=kills&limit=20", + context="ranking.html", + parameters="server_id=all-servers alias", + ) + add( + "stats-player-search-medu", + f"/api/stats/players/search?q={quote(KNOWN_PLAYER_SEARCH)}&server_id=all&limit=10", + context="stats.html", + parameters="q=Medu, server_id=all, limit=10", + ) + add( + "stats-annual-ranking", + "/api/stats/rankings/annual?year=2026&server_id=all&metric=kills&limit=20", + context="stats.html", + parameters="year=2026, server_id=all, metric=kills, limit=20", + ) + + for server in ("comunidad-hispana-01", "comunidad-hispana-02"): + add( + f"current-match-{server}", + f"/api/current-match?server={server}", + context="partida-actual.html", + parameters="server", + ) + add( + f"current-match-kills-{server}", + f"/api/current-match/kills?server={server}&limit=30", + context="partida-actual.html", + parameters="server, limit=30", + ) + add( + f"current-match-players-{server}", + f"/api/current-match/players?server={server}", + context="partida-actual.html", + parameters="server", + ) + + for server in HISTORICAL_SCOPES: + add( + f"historical-server-summary-{server}", + f"/api/historical/server-summary?server={server}", + context="historico.html legacy", + parameters="server", + ) + add( + f"historical-recent-matches-{server}", + f"/api/historical/recent-matches?server={server}&limit=20", + context="historico.html legacy", + parameters="server, limit=20", + ) + add( + f"snapshot-server-summary-{server}", + f"/api/historical/snapshots/server-summary?server={server}", + context="historico.html", + parameters="server", + ) + add( + f"snapshot-recent-matches-{server}", + f"/api/historical/snapshots/recent-matches?server={server}&limit=100", + context="historico.html", + parameters="server, limit=100", + ) + add( + f"weekly-top-kills-{server}", + f"/api/historical/weekly-top-kills?server={server}&limit=10", + context="historical legacy API", + parameters="server, limit=10", + ) + + for timeframe in ("weekly", "monthly"): + for metric in HISTORICAL_LEGACY_METRICS: + add( + f"historical-leaderboard-{timeframe}-{server}-{metric}", + ( + "/api/historical/leaderboard?" + f"server={server}&timeframe={timeframe}&metric={metric}&limit=10" + ), + context="historical legacy API", + parameters="server, timeframe, metric, limit=10", + ) + add( + f"snapshot-leaderboard-{timeframe}-{server}-{metric}", + ( + "/api/historical/snapshots/leaderboard?" + f"server={server}&timeframe={timeframe}&metric={metric}&limit=10" + ), + context="historico.html", + parameters="server, timeframe, metric, limit=10", + ) + + add( + f"historical-weekly-leaderboard-{server}", + f"/api/historical/weekly-leaderboard?server={server}&metric=kills&limit=10", + context="historical legacy API", + parameters="server, metric=kills, limit=10", + ) + add( + f"historical-monthly-leaderboard-{server}", + f"/api/historical/monthly-leaderboard?server={server}&metric=kills&limit=10", + context="historical legacy API", + parameters="server, metric=kills, limit=10", + ) + add( + f"snapshot-weekly-leaderboard-{server}", + f"/api/historical/snapshots/weekly-leaderboard?server={server}&metric=kills&limit=10", + context="historico.html", + parameters="server, metric=kills, limit=10", + ) + add( + f"snapshot-monthly-leaderboard-{server}", + f"/api/historical/snapshots/monthly-leaderboard?server={server}&metric=kills&limit=10", + context="historico.html", + parameters="server, metric=kills, limit=10", + ) + add( + f"monthly-mvp-{server}", + f"/api/historical/monthly-mvp?server={server}&limit=10", + context="historical public API", + parameters="server, limit=10", + ) + add( + f"monthly-mvp-v2-{server}", + f"/api/historical/monthly-mvp-v2?server={server}&limit=10", + context="historical public API", + parameters="server, limit=10", + ) + add( + f"snapshot-monthly-mvp-{server}", + f"/api/historical/snapshots/monthly-mvp?server={server}&limit=10", + context="historical snapshots", + parameters="server, limit=10", + ) + add( + f"snapshot-monthly-mvp-v2-{server}", + f"/api/historical/snapshots/monthly-mvp-v2?server={server}&limit=10", + context="historical snapshots", + parameters="server, limit=10", + ) + for view in PLAYER_EVENT_VIEWS: + add( + f"player-events-{server}-{view}", + f"/api/historical/player-events?server={server}&view={view}&limit=10", + context="historical public API", + parameters="server, view, limit=10", + ) + add( + f"snapshot-player-events-{server}-{view}", + f"/api/historical/snapshots/player-events?server={server}&view={view}&limit=10", + context="historical snapshots", + parameters="server, view, limit=10", + ) + + add( + "historical-match-detail-known", + ( + "/api/historical/matches/detail?" + "server=comunidad-hispana-01&match=" + f"{quote(KNOWN_MATCH_ID, safe='')}" + ), + context="historico-partida.html", + parameters="server, match", + ) + + add( + "elo-mmr-leaderboard-paused", + "/api/historical/elo-mmr/leaderboard?server=all-servers&limit=10", + context="paused public API", + parameters="server, limit=10", + ) + return specs + + +def build_player_dependent_specs(player_id: str) -> list[ProbeSpec]: + quoted_player = quote(player_id, safe="") + return [ + ProbeSpec( + id="stats-player-profile-weekly", + kind="backend-api", + context="stats.html", + method="GET", + path=f"/api/stats/players/{quoted_player}?timeframe=weekly&server_id=all", + parameters="player_id from search, timeframe=weekly, server_id=all", + ), + ProbeSpec( + id="stats-player-profile-monthly", + kind="backend-api", + context="stats.html", + method="GET", + path=f"/api/stats/players/{quoted_player}?timeframe=monthly&server_id=all", + parameters="player_id from search, timeframe=monthly, server_id=all", + ), + ProbeSpec( + id="historical-player-profile", + kind="backend-api", + context="historical public API", + method="GET", + path=f"/api/historical/player-profile?player={quoted_player}", + parameters="player from search", + ), + ProbeSpec( + id="elo-mmr-player-paused", + kind="backend-api", + context="paused public API", + method="GET", + path=f"/api/historical/elo-mmr/player?server=all-servers&player={quoted_player}", + parameters="player from search, server=all-servers", + ), + ] + + +def run_probe(*, base_url: str, spec: ProbeSpec, timeout_seconds: float) -> dict[str, Any]: + url = f"{base_url}{spec.path}" + started = time.perf_counter() + status_code: int | None = None + body = b"" + error: str | None = None + headers: dict[str, str] = {} + + try: + request = Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "HLL-Vietnam-Public-Request-Audit/1.0", + }, + method=spec.method, + ) + with urlopen(request, timeout=timeout_seconds) as response: + status_code = int(response.status) + headers = {key.lower(): value for key, value in response.headers.items()} + body = response.read() + except HTTPError as exc: + status_code = int(exc.code) + headers = {key.lower(): value for key, value in exc.headers.items()} + body = exc.read() + error = f"HTTPError: {exc.code}" + except URLError as exc: + error = f"URLError: {exc.reason}" + except TimeoutError: + error = "TimeoutError" + except Exception as exc: # noqa: BLE001 - audit must continue + error = f"{type(exc).__name__}: {exc}" + + elapsed_ms = round((time.perf_counter() - started) * 1000, 2) + parsed_json = parse_json_body(body) + summary = summarize_json(parsed_json) + severity = classify_result( + status_code=status_code, + elapsed_ms=elapsed_ms, + error=error, + fallback_used=summary.get("fallback_used"), + snapshot_status=summary.get("snapshot_status"), + ) + return { + **asdict(spec), + "url": url, + "status_code": status_code, + "elapsed_ms": elapsed_ms, + "response_size_bytes": len(body), + "content_type": headers.get("content-type"), + "json_status": summary.get("json_status"), + "data_source": summary.get("data_source"), + "fallback_used": summary.get("fallback_used"), + "fallback_reason": summary.get("fallback_reason"), + "snapshot_status": summary.get("snapshot_status"), + "item_count": summary.get("item_count"), + "found": summary.get("found"), + "severity": severity, + "error": error, + "json": parsed_json, + } + + +def parse_json_body(body: bytes) -> Any: + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except Exception: + return None + + +def summarize_json(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + source_payload = data.get("source") if isinstance(data.get("source"), dict) else {} + items = data.get("items") + if isinstance(items, list): + item_count = len(items) + elif isinstance(data.get("item"), dict): + item_count = 1 + elif isinstance(data.get("profile"), dict): + item_count = 1 + else: + item_count = None + return { + "json_status": payload.get("status"), + "data_source": data.get("source") + if isinstance(data.get("source"), str) + else source_payload.get("read_model") or source_payload.get("primary_source"), + "fallback_used": data.get("fallback_used") + if "fallback_used" in data + else source_payload.get("fallback_used"), + "fallback_reason": data.get("fallback_reason") + if "fallback_reason" in data + else source_payload.get("fallback_reason"), + "snapshot_status": data.get("snapshot_status"), + "item_count": item_count, + "found": data.get("found"), + } + + +def classify_result( + *, + status_code: int | None, + elapsed_ms: float, + error: str | None, + fallback_used: Any, + snapshot_status: Any, +) -> str: + if error or status_code is None or status_code >= 500 or elapsed_ms >= 10000: + return "CRITICAL" + if status_code >= 400 or elapsed_ms >= 2500 or fallback_used is True: + return "WARNING" + if str(snapshot_status or "").lower() == "missing": + return "WARNING" + return "OK" + + +def extract_first_player_id(payload: Any) -> str | None: + if not isinstance(payload, dict): + return None + data = payload.get("data") + if not isinstance(data, dict): + return None + items = data.get("items") + if not isinstance(items, list): + return None + for item in items: + if isinstance(item, dict) and str(item.get("player_id") or "").strip(): + return str(item["player_id"]).strip() + return None + + +def discover_static_requests(repo_root: Path) -> dict[str, Any]: + backend_paths: set[str] = set() + frontend_literals: set[str] = set() + fetch_occurrences: list[dict[str, Any]] = [] + localhost_refs: list[dict[str, Any]] = [] + + routes_path = repo_root / "backend" / "app" / "routes.py" + if routes_path.exists(): + text = routes_path.read_text(encoding="utf-8") + for value in re.findall(r"['\"](/(?:api/[^'\"]+|health))['\"]", text): + backend_paths.add(value) + + frontend_root = repo_root / "frontend" + if frontend_root.exists(): + for path in sorted(frontend_root.rglob("*")): + if path.suffix.lower() not in {".html", ".js"}: + continue + text = path.read_text(encoding="utf-8", errors="replace") + for value in re.findall(r"/api/[A-Za-z0-9_./?=&%${}()+:,\\-]+|/health", text): + frontend_literals.add(value) + for line_number, line in enumerate(text.splitlines(), start=1): + if "fetch(" in line: + fetch_occurrences.append( + { + "file": str(path.relative_to(repo_root)), + "line": line_number, + "text": line.strip(), + } + ) + if "127.0.0.1" in line or "localhost" in line: + localhost_refs.append( + { + "file": str(path.relative_to(repo_root)), + "line": line_number, + "text": line.strip(), + } + ) + + return { + "repo_root": str(repo_root), + "backend_route_paths": sorted(backend_paths), + "frontend_api_literals": sorted(frontend_literals), + "frontend_fetch_occurrences": fetch_occurrences, + "frontend_localhost_references": localhost_refs, + } + + +def summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]: + severity_counts = {"OK": 0, "WARNING": 0, "CRITICAL": 0} + status_counts: dict[str, int] = {} + launched = 0 + for result in results: + launched += 1 + severity = str(result.get("severity") or "WARNING") + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + status_key = str(result.get("status_code")) + status_counts[status_key] = status_counts.get(status_key, 0) + 1 + slowest = sorted( + ( + { + "id": result.get("id"), + "path": result.get("path"), + "elapsed_ms": result.get("elapsed_ms"), + "status_code": result.get("status_code"), + "severity": result.get("severity"), + } + for result in results + ), + key=lambda item: float(item.get("elapsed_ms") or 0), + reverse=True, + )[:10] + return { + "launched": launched, + "severity_counts": severity_counts, + "status_counts": status_counts, + "slowest": slowest, + } + + +def strip_json_payloads(results: list[dict[str, Any]]) -> list[dict[str, Any]]: + stripped = [] + for result in results: + item = dict(result) + item.pop("json", None) + stripped.append(item) + return stripped + + +def print_result_row(result: dict[str, Any]) -> None: + status = result.get("status_code") + elapsed = result.get("elapsed_ms") + severity = result.get("severity") + size = result.get("response_size_bytes") + fallback = result.get("fallback_used") + print( + f"{severity:8} {str(status):>4} {elapsed:>9.2f} ms {size:>9} B " + f"fallback={str(fallback):<5} {result['id']}" + ) + + +def print_summary(summary: dict[str, Any]) -> None: + print("Summary") + print(f" launched: {summary['launched']}") + print(f" OK: {summary['severity_counts'].get('OK', 0)}") + print(f" WARNING: {summary['severity_counts'].get('WARNING', 0)}") + print(f" CRITICAL: {summary['severity_counts'].get('CRITICAL', 0)}") + print(" slowest:") + for item in summary.get("slowest", []): + print( + " " + f"{item.get('elapsed_ms')} ms {item.get('status_code')} " + f"{item.get('severity')} {item.get('id')}" + ) + + +def normalize_base_url(value: str) -> str: + return str(value or DEFAULT_BASE_URL).strip().rstrip("/") + + +def resolve_repo_root(raw_value: str | None) -> Path: + if raw_value: + return Path(raw_value).resolve() + cwd = Path.cwd().resolve() + if (cwd / "backend" / "app" / "routes.py").exists(): + return cwd + script_path = Path(__file__).resolve() + for candidate in (script_path.parent, *script_path.parents): + if (candidate / "backend" / "app" / "routes.py").exists(): + return candidate + return cwd + + +def resolve_output_path(raw_value: str | None) -> Path: + if raw_value: + return Path(raw_value) + tmp_path = Path("tmp") / "public_request_audit.json" + try: + tmp_path.parent.mkdir(parents=True, exist_ok=True) + return tmp_path + except OSError: + return Path("docs") / "public_request_audit_results.example.json" + + +if __name__ == "__main__": + sys.exit(main())