Harden current match and servers public endpoints

This commit is contained in:
devRaGonSa
2026-06-10 19:48:12 +02:00
parent ee76bfde1c
commit ac1816f688
6 changed files with 369 additions and 48 deletions

View File

@@ -0,0 +1,136 @@
---
id: TASK-226
title: Harden current-match and servers public endpoints
status: done
type: backend
team: Backend Senior
supporting_teams:
- Analista
- Arquitecto Python
roadmap_item: foundation
priority: high
---
# TASK-226 - Harden current-match and servers public endpoints
## Goal
Harden the public endpoints still affected by RCON/live dependencies after `TASK-225`:
- `/api/current-match/kills`
- `/api/current-match/players`
- `/api/servers`
The objective is not to redesign RCON connectivity. Public HTTP reads must not hang indefinitely or return uncontrolled 500 responses when live RCON/AdminLog data is slow, unavailable or missing.
## Context
`TASK-225` stabilized:
- `/api/stats/players/search`
- `/api/stats/players/{player_id}`
- `/api/historical/matches/detail`
Production validation after `TASK-225` showed those endpoints responding OK without fallback. Remaining audit debt was RCON/live:
- `/api/current-match/kills` timed out or returned 500.
- `/api/current-match/players` timed out for `comunidad-hispana-01`.
- `/api/servers` could spend about 4.3s refreshing RCON from a public GET.
RCON timeout in the current test environment must not be interpreted automatically as wrong configuration. Backend/RCON `127.0.0.1`, hosts, ports and `27001` may be correct in final production and must not be changed in this task.
## Steps
1. Inspect public route and payload chains.
2. Make current-match AdminLog reads public read-only and controlled.
3. Make `/api/servers` return configuration/cache/snapshot data without live refresh in the request.
4. Add focused tests for controlled degradation and no public live refresh.
5. Validate and document the outcome.
## Files to Read First
- `ai/architecture-index.md`
- `ai/repo-context.md`
- `ai/orchestrator/backend-senior.md`
- `backend/app/routes.py`
- `backend/app/payloads.py`
- `backend/app/rcon_admin_log_storage.py`
- `backend/app/rcon_client.py`
- `backend/app/providers/rcon_provider.py`
- `docs/FULL_APPLICATION_REQUEST_AUDIT.md`
- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md`
## Expected Files to Modify
- `backend/app/payloads.py`
- `backend/app/rcon_admin_log_storage.py`
- `backend/tests/test_current_match_payload.py`
- `docs/FULL_APPLICATION_REQUEST_AUDIT.md`
- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md`
- `ai/tasks/done/TASK-226-harden-current-match-and-servers-public-endpoints.md`
## Constraints
- Do not run `ai-platform run`.
- Do not commit or push.
- Do not touch RCON hosts, ports, credentials, environment variables, Docker networking or server connection configuration.
- Do not change `27001`.
- Do not replace backend/RCON `127.0.0.1`; it may be correct in production.
- Do not touch frontend assets, weapon assets, clan assets, SVGs or physical images.
- Do not modify `frontend/assets/img/weapons/` or `frontend/assets/img/clans/`.
- Do not modify `ai/system-metrics.md`.
- Do not reactivate Elo/MMR.
- Do not reintroduce Comunidad Hispana #03.
## Validation
Required:
- `python -m compileall backend/app`
- `python -m unittest tests.test_current_match_payload`
- current-match related tests if present
- payload related tests if present
- partial production audit if possible:
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp/task226_current_match_audit.json`
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp/task226_servers_audit.json`
## Outcome
Implemented:
- `/api/current-match/kills` now calls AdminLog storage in read-only mode with `ensure_storage=False`. If the AdminLog read model is missing, slow or raises, the public payload returns `status: ok`, `items: []`, `confidence: unavailable`, and source metadata with `fallback_used: true` and an `admin-log-*` reason.
- `/api/current-match/players` uses the same read-only/degrade pattern and returns an empty controlled current-match player stats payload instead of propagating storage exceptions as uncontrolled 500s.
- `rcon_admin_log_storage` gained read-only SQLite opening for current-match public reads so missing local storage does not create files or initialize schemas during GET handling.
- `/api/servers` no longer calls `_try_collect_real_time_snapshot()` from the public payload builder. It serves persisted snapshots only, marks stale/fresh state, and returns `refresh_status: "cache-only"`.
- Focused unit tests were added under `backend/tests/test_current_match_payload.py` for kill feed degradation, player stats degradation, and ensuring `/api/servers` does not trigger live refresh in a public GET.
Not changed:
- RCON hosts, ports, credentials, environment variables and server configuration.
- `27001`.
- Frontend JS, because existing `partida-actual.js` catches endpoint errors and clears loading states for kills/players.
- Assets, SVGs and physical images.
- Elo/MMR and Comunidad Hispana #03.
Validation performed:
- `python -m compileall backend/app`: OK.
- `cd backend; python -m unittest tests.test_current_match_payload`: OK, 3 tests from `CurrentMatchPublicEndpointHardeningTests`.
- Related current-match tests: no previous test modules were present in this repo checkout.
- Related payload tests: no previous payload-specific test modules were present in this repo checkout.
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp/task226_current_match_audit.json`: executed against current production deployment. It still shows the pre-deploy failures: kills CH01 timeout 30054.96 ms, players CH01 timeout 30024.09 ms, kills CH02 timeout 30068.39 ms, players CH02 500 in 7497.46 ms. Re-run after deployment is required.
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp/task226_servers_audit.json`: executed against current production deployment. `/api/servers` still measured 200 in 4249.71 ms, consistent with pre-deploy live refresh behavior. Re-run after deployment is required.
Risks:
- `/api/current-match` still has direct RCON sample behavior by design and remains separate architectural debt.
- `/api/servers` freshness now depends on an external runner/cache refresh path being active.
- Production latency must be confirmed after deploy because local tests validate behavior, not live infrastructure timing.
## Recovery Note
During recovery, an accidental root-level `tests/` package left by the interrupted session was folded into the existing backend test module and removed from the task scope. The repository convention for this coverage is `backend/tests`.
## Change Budget
The task intentionally exceeds five files only because it includes required tests, documentation and the done-task record. Product/frontend assets and configuration remain untouched.

View File

@@ -118,48 +118,33 @@ def build_discord_payload() -> dict[str, object]:
def build_servers_payload() -> dict[str, object]:
"""Return current server status, refreshing stale snapshots before responding."""
"""Return current server status from persisted snapshots only."""
max_snapshot_age_seconds = get_refresh_interval_seconds()
persisted_items = _select_primary_snapshot_items(
_enrich_server_items(list_latest_snapshots())
)
persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items)
persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at)
refresh_attempted = _should_refresh_snapshot(
persisted_items,
persisted_snapshot_age_seconds,
max_snapshot_age_seconds,
)
refresh_errors: list[dict[str, object]] = []
refresh_source_policy = build_source_policy(
primary_source=get_live_data_source_kind(),
selected_source="none",
selected_source="persisted-snapshot" if persisted_items else "none",
fallback_reason=None,
source_attempts=[],
source_attempts=[
build_source_attempt(
source="persisted-snapshot",
role="served-response",
status="success" if persisted_items else "empty",
reason="public-servers-read-is-cache-only",
)
],
)
if refresh_attempted:
refreshed_items, refresh_errors, refresh_source_policy = _try_collect_real_time_snapshot()
if refreshed_items:
refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items)
refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at)
return _build_servers_response(
items=refreshed_items,
response_source=_build_live_response_source(refresh_source_policy),
last_snapshot_at=refreshed_snapshot_at,
snapshot_age_seconds=refreshed_snapshot_age_seconds,
max_snapshot_age_seconds=max_snapshot_age_seconds,
refresh_attempted=True,
refresh_status="success",
refresh_errors=refresh_errors,
source_policy=refresh_source_policy,
)
if persisted_items:
refresh_status = "failed" if refresh_attempted else "not-needed"
response_source = (
"persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot"
"persisted-stale-snapshot"
if _is_snapshot_stale(persisted_snapshot_age_seconds, max_snapshot_age_seconds)
else "persisted-fresh-snapshot"
)
return _build_servers_response(
items=persisted_items,
@@ -167,12 +152,12 @@ def build_servers_payload() -> dict[str, object]:
last_snapshot_at=persisted_snapshot_at,
snapshot_age_seconds=persisted_snapshot_age_seconds,
max_snapshot_age_seconds=max_snapshot_age_seconds,
refresh_attempted=refresh_attempted,
refresh_status=refresh_status,
refresh_attempted=False,
refresh_status="cache-only",
refresh_errors=refresh_errors,
source_policy=_infer_live_source_policy_from_items(
persisted_items,
refresh_attempted=refresh_attempted,
refresh_attempted=False,
refresh_errors=refresh_errors,
),
)
@@ -189,8 +174,8 @@ def build_servers_payload() -> dict[str, object]:
"max_snapshot_age_seconds": max_snapshot_age_seconds,
"is_stale": True,
"freshness": "stale",
"refresh_attempted": refresh_attempted,
"refresh_status": "failed" if refresh_attempted else "not-needed",
"refresh_attempted": False,
"refresh_status": "cache-only",
"refresh_errors": refresh_errors,
**refresh_source_policy,
"items": [],
@@ -415,16 +400,27 @@ def build_current_match_kill_feed_payload(
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
feed = list_current_match_kill_feed(
server_key=origin.slug,
limit=limit,
since_event_id=since_event_id,
)
try:
feed = list_current_match_kill_feed(
server_key=origin.slug,
limit=limit,
since_event_id=since_event_id,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
feed = _empty_current_match_kill_feed_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
return {
"status": "ok",
"data": {
"server_slug": origin.slug,
"server_name": origin.display_name,
**source_policy,
**feed,
},
}
@@ -435,17 +431,86 @@ def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, o
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
stats = list_current_match_player_stats(server_key=origin.slug)
try:
stats = list_current_match_player_stats(
server_key=origin.slug,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
stats = _empty_current_match_player_stats_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
return {
"status": "ok",
"data": {
"server_slug": origin.slug,
"server_name": origin.display_name,
**source_policy,
**stats,
},
}
def _empty_current_match_kill_feed_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"stale_events_filtered": 0,
"items": [],
}
def _empty_current_match_player_stats_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"source": "rcon-admin-log-current-match-summary",
"updated_at": None,
"stale_events_filtered": 0,
"items": [],
}
def _build_current_match_admin_log_source_policy(
*,
status: str,
error_reason: str | None = None,
message: str | None = None,
) -> dict[str, object]:
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source="rcon-admin-log",
fallback_used=status != "success",
fallback_reason=error_reason,
source_attempts=[
build_source_attempt(
source="rcon-admin-log",
role="read-model",
status=status,
reason=error_reason,
message=message,
)
],
)
def _public_error_reason(error: Exception) -> str:
if isinstance(error, FileNotFoundError):
return "admin-log-read-model-unavailable"
if isinstance(error, TimeoutError):
return "admin-log-read-timeout"
message = str(error).lower()
if "timeout" in message or "timed out" in message:
return "admin-log-read-timeout"
if "does not exist" in message or "no such table" in message:
return "admin-log-read-model-unavailable"
return "admin-log-read-failed"
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
"""Read one configured trusted RCON target for the current-match view."""
try:
@@ -2314,6 +2379,13 @@ def _should_refresh_snapshot(
return snapshot_age_seconds > max_snapshot_age_seconds
def _is_snapshot_stale(
snapshot_age_seconds: int | None,
max_snapshot_age_seconds: int,
) -> bool:
return snapshot_age_seconds is None or snapshot_age_seconds > max_snapshot_age_seconds
def _try_collect_real_time_snapshot() -> tuple[
list[dict[str, object]],
list[dict[str, object]],

View File

@@ -348,16 +348,21 @@ def list_current_match_kill_feed(
since_event_id: str | None = None,
db_path: Path | None = None,
now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Return safe recent kill rows for one AdminLog server window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
since_row_id = _parse_current_match_event_row_id(since_event_id)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(sqlite3.connect(resolved_path))
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
@@ -469,15 +474,20 @@ def list_current_match_player_stats(
server_key: str,
db_path: Path | None = None,
now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]:
"""Return current-match participants and partial stats from the safe AdminLog window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(sqlite3.connect(resolved_path))
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
@@ -592,6 +602,14 @@ def list_current_match_player_stats(
}
def _connect_admin_log_sqlite_read(db_path: Path) -> sqlite3.Connection:
"""Open AdminLog storage for read without creating files during public GETs."""
if not db_path.exists():
raise FileNotFoundError(f"AdminLog storage is not available: {db_path}")
uri = f"file:{db_path.as_posix()}?mode=ro"
return sqlite3.connect(uri, uri=True)
def _resolve_current_match_window(
connection: sqlite3.Connection | object,
*,

View File

@@ -1,7 +1,9 @@
from http import HTTPStatus
from datetime import datetime, timezone
import unittest
from unittest.mock import patch
from app import payloads
from app.payloads import build_current_match_payload
from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
from app.rcon_client import RconServerTarget
@@ -494,6 +496,72 @@ def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
assert stats["items"] == []
class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase):
def test_kill_feed_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_kill_feed",
side_effect=TimeoutError("read timed out"),
):
result = payloads.build_current_match_kill_feed_payload(
server_slug="comunidad-hispana-01",
limit=30,
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["confidence"], "unavailable")
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-timeout")
def test_player_stats_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_player_stats",
side_effect=RuntimeError("no such table: rcon_admin_log_events"),
):
result = payloads.build_current_match_player_stats_payload(
server_slug="comunidad-hispana-01",
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["updated_at"], None)
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-model-unavailable")
def test_servers_payload_does_not_refresh_live_on_public_get(self) -> None:
stale_snapshot = {
"server_name": "Comunidad Hispana #01",
"external_server_id": "comunidad-hispana-01",
"captured_at": "2020-01-01T00:00:00Z",
"snapshot_origin": "real-rcon",
"current_map": "carentan",
}
fake_live_source = type(
"FakeLiveSource",
(),
{"build_target_index": lambda self: {}},
)()
with (
patch.object(payloads, "list_latest_snapshots", return_value=[stale_snapshot]),
patch.object(payloads, "get_live_data_source", return_value=fake_live_source),
patch.object(payloads, "_try_collect_real_time_snapshot") as refresh,
):
result = payloads.build_servers_payload()
refresh.assert_not_called()
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
self.assertEqual(data["refresh_attempted"], False)
self.assertEqual(data["refresh_status"], "cache-only")
self.assertEqual(data["source"], "persisted-stale-snapshot")
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
with (
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),

View File

@@ -83,6 +83,31 @@ Estado de severidad esperado tras deploy:
Siguiente fix prioritario actualizado: Fase 2 de `TASK-225` en task separada o continuacion autorizada, centrada en hardening de `/api/current-match/kills` y `/api/current-match/players` sin cambiar configuracion RCON. La validacion final debe repetirse en el entorno donde RCON este local.
## Estado post-fix TASK-226
Fecha: 2026-06-10
Alcance aplicado: hardening de endpoints publicos RCON/live pendientes, sin cambiar hosts, puertos, `27001`, variables de entorno ni configuracion de servidores.
Cambios de codigo aplicados:
- `/api/current-match/kills`: el payload usa AdminLog en modo lectura publica sin inicializar storage (`ensure_storage=False`). Si falta el read model, hay timeout o falla la lectura, devuelve `status: ok`, `items: []`, `confidence: unavailable`, `fallback_used: true` y `fallback_reason` controlado (`admin-log-read-model-unavailable`, `admin-log-read-timeout` o `admin-log-read-failed`).
- `/api/current-match/players`: aplica el mismo patron de degradacion controlada sobre player stats de AdminLog. Una excepcion de storage ya no se propaga como 500 sin cuerpo.
- `/api/servers`: el builder publico deja de llamar `_try_collect_real_time_snapshot()` y no refresca RCON/A2S en cada GET publico. Sirve snapshots persistidos, marca `freshness`/`is_stale` y expone `refresh_status: "cache-only"`.
Estado de severidad esperado tras deploy:
| Endpoint | Severidad TASK-224 | Estado TASK-226 en codigo | Severidad esperada tras deploy |
| --- | --- | --- | --- |
| `/api/current-match/kills` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
| `/api/current-match/players` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
| `/api/servers` | WARNING | Snapshot/cache-only; sin refresh RCON live en request publica | OK o WARNING si snapshot stale |
Notas:
- `/api/current-match` conserva deuda separada porque todavia puede intentar muestra RCON directa antes de caer a snapshot.
- La frescura de `/api/servers` depende ahora del proceso/runner que mantenga snapshots calientes fuera del request publico.
- Auditoria parcial contra produccion ejecutada el 2026-06-10 antes de desplegar este codigo: `current-match` todavia mostro los timeouts/500 previos y `/api/servers` midio 4249.71 ms. Repetir tras deploy para confirmar los tiempos post-fix reales.
## Evidencia ejecutada
Comandos ejecutados:

View File

@@ -6,12 +6,14 @@ Esta auditoria revisa las rutas publicas que alimentan `ranking`, `stats`, `hist
Actualizacion TASK-225, 2026-06-10: la deuda P1 de `stats search` y `stats player profile` fue corregida en codigo para que los GET publicos usen `player_search_index` y `player_period_stats` en modo read-only estricto, sin inicializar storage ni caer a runtime fallback pesado. La medicion HTTP final requiere redeploy. `current-match` sigue pendiente porque depende de RCON live y debe tratarse como hardening/degradacion sin cambiar hosts, puertos ni configuracion RCON.
Actualizacion TASK-226, 2026-06-10: `/api/current-match/kills` y `/api/current-match/players` pasan a usar AdminLog en lectura publica sin inicializar storage y con degradacion JSON controlada si el read model no esta disponible o falla. `/api/servers` pasa a ser snapshot/cache-only en el GET publico y ya no dispara refresh RCON/A2S live durante la lectura. La deuda restante de `current-match` queda en `/api/current-match`, que todavia puede intentar una muestra RCON directa antes de caer a snapshot.
Conclusiones principales:
- El backend de `ranking` ya no muestra el cuello de botella grave del ranking anual. La evidencia mas fuerte es el test `backend/tests/test_annual_ranking_payload.py`, que confirma que la lectura anual en PostgreSQL ya no inicializa storage en request publico.
- El cuello de botella visible actual mas claro esta en frontend: `frontend/assets/js/ranking.js` y `frontend/assets/js/stats.js` bloquean la carga principal detras de `/health`, y `ranking.js` no tiene proteccion contra request race ni limpieza robusta del estado de carga.
- `historico.js` es hoy la referencia mas sana del frontend publico: usa snapshots, cache TTL, deduplicacion de peticiones y `requestId` para ignorar respuestas obsoletas.
- `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario.
- `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario. Tras TASK-226, kills/players degradan desde backend en JSON controlado.
- En backend siguen existiendo fallbacks runtime publicos sobre tablas materializadas grandes para `ranking`, `stats search` y `stats player profile`. Son mejores que consultar RCON directo, pero siguen rompiendo la meta de servir lecturas publicas desde read models dedicados.
- Las queries runtime de leaderboard y player stats usan patrones como `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))` y agregaciones sobre `rcon_match_player_stats`, lo que aumenta riesgo de scans y de uso parcial de indices.
- `current-match` sigue consultando RCON directo en request publico cuando hay target confiable. Eso contradice la regla objetivo de esta auditoria y debe tratarse como deuda arquitectonica explicita aunque hoy sea un requisito funcional de la pagina live.
@@ -29,7 +31,7 @@ Flujo observado:
- `ranking` y `stats` mezclan frontend secuencial con backend que aun puede caer a runtime sobre tablas materializadas si falta snapshot o read model.
- `historico` ya prioriza snapshots y fallback controlado.
- `current-match` expone una excepcion relevante: consulta RCON directo en la ruta publica cuando encuentra target valido.
- `current-match` expone una excepcion relevante: `/api/current-match` consulta RCON directo en la ruta publica cuando encuentra target valido. Kills/players ya leen AdminLog sin inicializar storage en request publico.
## Inventario de Endpoints Publicos
@@ -43,9 +45,9 @@ Flujo observado:
| `/api/historical/snapshots/recent-matches` | `frontend/assets/js/historico.js` | `build_recent_historical_matches_snapshot_payload()` | Snapshot publico de recent matches | Segun source kind puede usar RCON read model o fallback publico | Si | P2 |
| `/api/historical/matches/detail` | `frontend/assets/js/historico-partida.js` | `build_historical_match_detail_payload()` | Read model detalle de partida | Intenta `get_rcon_historical_match_detail()`, luego fallback a storage historico publico | Si | P2 |
| `/api/current-match` | `frontend/assets/js/partida-actual.js` | `build_current_match_payload()` | Read model live propio | Primero intenta `_query_current_match_rcon_sample()` directo; luego fallback a `/api/servers` snapshot | Si, y toca RCON directo | P0 |
| `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | `list_current_match_kill_feed()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 |
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | `list_current_match_player_stats()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 |
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot local/live payload | Sin fallback costoso visible | P2 |
| `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | AdminLog materializado en modo read-only publico | Degradacion JSON controlada si falla read model | P2 |
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | AdminLog materializado en modo read-only publico | Degradacion JSON controlada si falla read model | P2 |
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot/cache persistido | Sin refresh live en GET publico | P2 |
| `/health` | `frontend/assets/js/main.js`, `ranking.js`, `stats.js` | `build_health_payload()` | N/A | Check tecnico | No aplica | P1 por bloqueo UI, no por backend |
Notas: