# 08 — API y Endpoints

> **No existe una API REST.** Kuorum es una aplicación server-rendered. Lo que aquí se documenta son:
> 1. El **contrato de URL** que rige las 458 acciones de controlador.
> 2. Los **endpoints que devuelven JSON** (los únicos consumibles programáticamente).
> 3. Los **endpoints invocables sin sesión** (superficie pública).
>
> Solo se documentan endpoints verificados en el código.

---

## 1. Contrato general de URL

```
{base_url}/?c={Controlador}&a={accion}[&param=valor…][#ancla]
```

| Elemento | Regla |
|---|---|
| Método | Cualquiera. **El framework no distingue GET de POST** salvo donde el código lo comprueba explícitamente |
| `c` | Nombre del controlador sin sufijo `Controller`. Se le aplica `ucfirst()`. Por defecto `Home` |
| `a` | Nombre de la acción sin sufijo `Action`. Por defecto, el `$ActionDefault` del controlador (normalmente `list`) |
| Autenticación | Determinada por `loadAccessControl()`: `'*'` público, `'@'` requiere sesión + permiso, ausente = denegado |
| Respuesta de éxito | HTML completo (con layout) en la mayoría de casos; JSON en los endpoints de §3 |
| Respuesta de error de autorización | **HTTP 302** a `DIR_INDEX`, no 401/403 |
| Respuesta a controlador o acción inexistente | **HTTP 302** a `home` con un flash de error, no 404 |
| CSRF | Requerido en `remove` (siempre) y en todo `Model::save()` sobre POST. Muchas acciones lo validan además a mano |
| Codificación | `Content-Type: text/html; charset=utf-8` por defecto (fijado en `Controller::__construct()`) |

### Cabeceras de seguridad en toda respuesta

Emitidas por `core/AutoLoad.php` si `!headers_sent()`:

```
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer-when-downgrade
Content-Security-Policy: default-src 'self' 'unsafe-inline' data:;
X-XSS-Protection: 1; mode=block
Strict-Transport-Security: max-age=31536000; includeSubDomains   (solo bajo HTTPS)
```

---

## 2. Endpoints públicos (sin sesión)

Son las únicas acciones declaradas con `'*'` en algún `loadAccessControl()`. **Es la superficie de ataque de la aplicación.**

| Método | URL | Controlador::acción | Protección adicional |
|---|---|---|---|
| GET | `?c=login&a=admin` | `LoginController::adminAction` | Redirige a `home` si ya hay sesión |
| POST | `?c=login&a=validate` | `LoginController::validateAction` | Exige POST + CSRF + rate limit (10/900 s) |
| GET | `?c=public&a=index` | `PublicController::indexAction` | Formulario de login del portal |
| GET | `?c=public&a=index1` | `PublicController::index1Action` | ⚠️ Declarada en `AccessControl` pero **el método no existe** → `NO_ACTION` → redirección |
| POST | `?c=public&a=validate` | `PublicController::validateAction` | POST + CSRF + rate limit (10/900 s) |
| GET | `?c=public&a=validated` | `PublicController::validatedAction` | Callback OAuth de Microsoft. Valida `state` en sesión **y** en cookie |
| GET | `?c=public&a=home` | `PublicController::homeAction` | |
| GET | `?c=public&a=manual` | `PublicController::manualAction` | Contenido legado (ver [04_MODULES.md](04_MODULES.md) §22) |
| GET | `?c=public&a=vacantesPublicas` | `PublicController::vacantesPublicasAction` | Bolsa de empleo pública |
| POST | `?c=public&a=postularVacante` | `PublicController::postularVacanteAction` | Postulación externa |
| GET | `?c=api&a=main` | `ApiController::mainAction` | Devuelve `["API KLEE"]` |
| GET | `?c=LineaEticaPublica&a=crear` | `LineaEticaPublicaController::crearAction` | Formulario de denuncia anónima |
| POST | `?c=LineaEticaPublica&a=guardar` | `…::guardarAction` | Radica el caso y devuelve código + PIN |
| GET/POST | `?c=LineaEticaPublica&a=seguimiento` | `…::seguimientoAction` | Pide código + PIN |
| GET/POST | `?c=LineaEticaPublica&a=verCaso` | `…::verCasoAction` | Requiere código + PIN válidos |
| POST | `?c=LineaEticaPublica&a=agregarMensaje` | `…::agregarMensajeAction` | Requiere código + PIN válidos |
| GET | `?c=ComunicacionInterna&a=portalNoticias` | `ComunicacionInternaController::portalNoticiasAction` | Noticias del portal |
| GET | `?c=Demo&a=index` | `DemoController::indexAction` | **Bloqueado salvo `DEMO_MODE_ENABLED=true`** |
| GET | `?c=Demo&a=salir` | `DemoController::salirAction` | |
| GET/POST | `?c=AlertasEmail&a=sendCron` | `AlertasEmailController::sendCronAction` | **`CRON_TOKEN`** o sesión con permiso `send` |
| GET/POST | `?c=AlertasEmail&a=sendCronSolicitudAdmin` | `…::sendCronSolicitudAdminAction` | Idem |

También accesible sin pasar por el router: **`public/check_session.php`** (GET) → devuelve `1` si hay sesión, otra cosa si no.

---

## 3. Endpoints JSON

### 3.1 `dataListAjax` — DataTables server-side

Existe en todo controlador que declare `'dataListAjax' => '@'`.

```
Método         POST (lo emite DataTables)
URL            ?c={Controlador}&a=dataListAjax
Autenticación  '@' — sesión + permiso {Modulo}.list (y {Modulo}.view para los enlaces)
Controller     Controller::dataListAjaxAction()  (o su sobrescritura)
Service        ListaAjax::generateDataListAjax()
Modelo         El fijado con $table->setModel('XxxModel'); lee de $VIEW_NAME
```

**Request** (parámetros estándar de DataTables):

| Parámetro | Tipo | Uso |
|---|---|---|
| `draw` | int | Se devuelve como `draw + 1` |
| `start` | int | Offset → `LIMIT START` |
| `length` | int | Tamaño de página → `LIMIT END`. `-1` = sin límite |
| `search[value]` | string | Búsqueda global; se parte por espacios y se aplica `LIKE '%término%'` con `OR` sobre **todas** las columnas de `fieldsShow` |
| `order[0][column]` | int | Índice de columna en `fieldsShow` |
| `order[0][dir]` | `asc`\|`desc` | |
| `criteriaExt` | array | 🔴 **Criterio arbitrario inyectado por el cliente.** Ver §6 |
| *(filtros propios)* | string | Los declarados con `$table->setFilters([...])` |

**Response 200:**

```json
{
  "draw": 2,
  "recordsTotal": 120,
  "recordsFiltered": 8,
  "data": [
    { "Nombre": "<a href='…'>Liderazgo</a>", "Categoria": "Gerencial",
      "Global": "Sí", "Nivel": "3", "_Acciones": "<div>…botones HTML…</div>" }
  ]
}
```

Las claves de cada fila son los nombres de `fieldsShow`, más `_Acciones` si hay acciones. **Los valores contienen HTML ya renderizado.**

**Response de error** (en los controladores que envuelven la llamada en `try/catch`):

```json
{ "draw": 2, "recordsTotal": 0, "recordsFiltered": 0, "data": [],
  "error": "No fue posible cargar … en este momento." }
```

### 3.2 `?c=api&a=quickSearch`

```
Método         GET
URL            ?c=api&a=quickSearch&q={texto}&limit={1..20}
Autenticación  '@'
Controller     ApiController::quickSearchAction()
Modelo         QuickSearchModel::search($query, $limit)
Catálogo       app/config/QuickActionsConfig::getActions()
```

| Parámetro | Tipo | Por defecto | Notas |
|---|---|---|---|
| `q` | string | `''` | Se normaliza antes de puntuar |
| `limit` | int | 8 | Fuera de `[1,20]` se fuerza a 8 |

**Response 200:**

```json
{
  "query": "roles",
  "results": [
    { "id": "roles-listar", "label": "Roles - Listar", "module": "Configuración",
      "keywords": ["perfiles","roles list","roles listar"],
      "path": "…url…", "command": "roles", "has_deep_link": false }
  ],
  "blockedExactMatch": false,
  "message": "",
  "suggestions": [ … ]
}
```

`blockedExactMatch: true` + `message: "Sin permisos"` cuando el término coincide exactamente con una acción del catálogo que el usuario no puede ejecutar.

**Response 500:**

```json
{ "error": true, "message": "Error al procesar quick-search", "detail": "…" }
```

> ⚠️ El campo `detail` expone el mensaje de la excepción.

### 3.3 `?c=api&a=main`

```
Método   GET       Autenticación   '*'      Response 200   ["API KLEE"]
```

Endpoint de comprobación de vida. No recibe parámetros.

### 3.4 Asistente IA

#### `?c=AsistenteIA&a=ask`

```
Método         POST
Autenticación  '@' + validateCsrfTokenNoRotate(_csrf_token)
Controller     AsistenteIAController::askAction()
Service        AsistenteIAModel::ask() → AsistenteIAService::processQuestion()
```

| Parámetro | Tipo | Notas |
|---|---|---|
| `_csrf_token` | string | Obligatorio. **No rota** (permite varias preguntas en la misma página) |
| `question` | string | Obligatorio, no vacío. Se trunca a 1 200 caracteres |

**Response 200:**

```json
{ "ok": true, "answer": "Hay 20 colaboradores activos.",
  "sql": "SELECT COUNT(*) …", "elapsed_ms": 2140, "cached": false }
```

**Errores:**

| Código | Cuerpo | Causa |
|---|---|---|
| 405 | `{"ok":false,"message":"Metodo no permitido."}` | No es POST |
| 403 | `{"ok":false,"message":"Token de seguridad invalido."}` | CSRF |
| 422 | `{"ok":false,"message":"Pregunta vacia."}` | `question` vacía |
| 500 | `{"ok":false,"message":"No fue posible procesar la consulta…"}` | Excepción; se registra con `Logger::error` |

Además, con 200 y `ok:false` cuando el SQL generado no es un `SELECT` seguro:
`"La consulta fue bloqueada por politicas de seguridad (solo lectura)."`

#### `?c=AsistenteIA&a=clearHistory`

```
Método POST · '@' + CSRF sin rotación · Response {"ok": true}
```

### 3.5 `?c=ajax&a=…`

`AjaxController` define **11 métodos `*Action`** pero solo declara **6** en `loadAccessControl()`. Los otros 5 son **inalcanzables** (el framework los deniega por omisión).

| Acción | Declarada | Método | Estado |
|---|---|---|---|
| `getAll` | ✅ `'@'` | GET | Requiere cabecera `X-Requested-With: XMLHttpRequest` |
| `setAlertaVista` | ✅ `'@'` | POST | Marca alertas como vistas |
| `subirImagenBase64` | ✅ `'@'` | POST | Firma: exige AJAX + POST + CSRF + rol `Administrador` |
| `textoAImagenBase64` | ✅ `'@'` | POST | Idem. Usa las fuentes de `app/fonts/` |
| `getEstadosPais` | ✅ `'@'` | GET | Catálogo geográfico |
| `getCiudadesEstado` | ✅ `'@'` | GET | Catálogo geográfico |
| `update` | ❌ | — | **Inalcanzable.** Referencia `LaborDocenteModel`, que no existe |
| `getResumen` | ❌ | — | **Inalcanzable.** Referencia `FacultadesModel`, que no existe |
| `getDocente` | ❌ | — | **Inalcanzable.** Legado académico |
| `getDocentes` | ❌ | — | **Inalcanzable.** Legado académico |
| `getProductosIntelectuales` | ❌ | — | **Inalcanzable.** Legado académico |

#### `?c=ajax&a=getAll`

```
Método         GET con X-Requested-With: XMLHttpRequest
Autenticación  '@'
Parámetros     model, fields[], q, sede, facultad, group_by (según el modelo)
```

Saneamiento (introducido por el commit `fix: validar entradas dinámicas de ajax`, cubierto por `tests/MutationSecurityRegressionTest`):

| Helper | Regla |
|---|---|
| `getSafeSearchTerm($key, 80)` | Solo `\p{L}\p{N}\s.@-`, máximo 80 caracteres |
| `getSafeIdentifier($key, 80)` | Solo `A-Za-z0-9._-` |
| `getSafePositiveId($key)` | `ctype_digit` y `>= 1` |
| `getSafeFields()` | Cada campo debe cumplir `\A(?:\*\|[A-Za-z_][A-Za-z0-9_]*)\z`; máximo 30 campos |

> 🔴 **Los cuatro valores de `model` que acepta (`Asignaturas`, `Estudiantes`, `Facultades`, `Programas`) instancian modelos que NO EXISTEN en `app/models/`.** El endpoint está saneado pero, en la práctica, cualquier llamada real produce un fatal de clase no encontrada. Es código muerto de un producto académico anterior.

#### `?c=ajax&a=subirImagenBase64` y `textoAImagenBase64`

```
Método         POST + X-Requested-With: XMLHttpRequest
Autenticación  '@' + validateCsrfTokenNoRotate + UsuariosModel::getUserTypeName() === 'Administrador'
Error          403 { … } vía sendAjaxError()
```

Generan/actualizan la imagen de **firma** de un usuario. El comentario del código es explícito: *"Las cabeceras AJAX no son una medida de seguridad."*

### 3.6 `?c=ElFinder&a=conector`

```
Método         GET/POST (protocolo elFinder)
Autenticación  '@' (cualquier usuario autenticado)
Controller     ElFinderController::conectorAction()
Librería       vendor/studio-42/elfinder/php/
Raíz           files/
Response       JSON del protocolo elFinder
Error          503 "El gestor de archivos no está disponible." si falta la librería
```

⚠️ Hace `error_reporting(0)` al inicio.

---

## 4. Endpoints de tareas programadas (cron)

```
GET|POST ?c=AlertasEmail&a=sendCron
GET|POST ?c=AlertasEmail&a=sendCronSolicitudAdmin
```

**Autenticación** (`AlertasEmailController::guardCronAccess()`, corregida por el hallazgo HR-025):

```mermaid
flowchart TD
    A["Petición a sendCron*"] --> B{"¿Hay sesión activa?"}
    B -- sí --> C{"¿Permission['AlertasEmail']['send']\no usuario == getUserAccess()?"}
    C -- sí --> OK["Continuar"]
    C -- no --> F403["403 'No tienes permiso para ejecutar envios programados.'"]
    B -- no --> D{"¿CRON_TOKEN configurado?"}
    D -- no --> F503["503 'CRON_TOKEN no configurado.'"]
    D -- sí --> E["token = X-Cron-Token  ó  ?token="]
    E --> G{"hash_equals(esperado, recibido)"}
    G -- no --> F403b["403 'Token invalido.'"]
    G -- sí --> OK
```

Formas de autenticación anónima aceptadas:
- Cabecera **`X-Cron-Token: <valor de CRON_TOKEN>`**
- Parámetro **`?token=<valor de CRON_TOKEN>`**

Ambos se comparan con `hash_equals()`. Si `CRON_TOKEN` está vacío, la invocación anónima se rechaza con **503** (decisión deliberada: *"es preferible que el envío no salga a que lo dispare cualquiera"*).

**Comportamiento de `sendCron`:**
1. Si `configuraciones['DetenerEnvioNotificaciones']` no está vacío, no hace nada.
2. Selecciona correos de `alertas_email` con `EstadoEnvio = 1` y `Destinatarios != ''`.
3. Límite por ejecución = `configuraciones['EnvioMaximoNotificaciones']` (si es numérico y > 0), si no **2**.
4. Envía uno a uno con `sleep(3)` entre correos.

**Ejemplo de invocación desde crontab:**

```cron
*/10 * * * * curl -fsS -H "X-Cron-Token: $CRON_TOKEN" "https://host/?c=AlertasEmail&a=sendCron" >/dev/null 2>&1
```

> No hay ningún otro endpoint pensado para cron. Ver [15_DEPLOYMENT.md](15_DEPLOYMENT.md).

---

## 5. Acciones destacadas por módulo

Todas siguen el contrato `?c=…&a=…` y devuelven HTML. Inventario completo de acciones por controlador en [04_MODULES.md](04_MODULES.md).

### Acciones CRUD convencionales

Presentes en la mayoría de módulos, con semántica fija:

| Acción | Método esperado | Notas |
|---|---|---|
| `list` | GET | Renderiza `{ViewFolder}/list.php` con `$listaHtml` |
| `dataListAjax` | POST | JSON de DataTables |
| `view` / `ver` | GET + `Id` | Detalle |
| `create` / `crear` | GET (formulario) / POST (guardar) | Detecta el envío con `isset($_POST[get_class($model)])` |
| `edit` / `editar` | GET + `Id` / POST | Igual |
| `remove` | **POST + `_csrf_token` obligatorios** | El framework lo impone en `Controller::process()` |

### Acciones de cambio de estado

Patrón común en los módulos con flujo: `aprobar`, `rechazar`, `cancelar`, `cambiarEstado`, `cambiarPrioridad`, `publicar`, `archivar`, `activar`, `inactivar`, `entregar`, `cerrar`, `asignar`.

Todas **deben** ser POST con CSRF. Ejemplo real (`VacacionesController::aprobarAction`):

```php
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
    || !Controller::validateCsrfToken($_POST['_csrf_token'] ?? null)) {
    UserFlash::setFlash('Error', 'Token de seguridad inválido.');
    ROUTER::redirect_to_action('Vacaciones', 'list');
    return;
}
```

> ⚠️ Esta comprobación **no está centralizada**: cada acción la repite. `Controller::process()` solo la impone para `remove`. Una acción de mutación nueva que la olvide queda expuesta a CSRF si la cookie `SameSite=Strict` no basta.

### Acciones de exportación

`exportar` / `exportCsv` en `AdelantosNominaController`, `BeneficiosSolicitudesController`, `TicketsController`, `CapacitacionInscripcionesController`, `PotencialesController`, `NominaController`, `EvaluacionesController`. Devuelven CSV con cabeceras de descarga.

### Acciones de impresión / PDF

| Endpoint | Salida |
|---|---|
| `?c=Nomina&a=comprobante&…` | HTML con layout `impresiones` |
| `?c=Evaluaciones&a=imprimir&…` | HTML imprimible |
| `?c=CapacitacionPortal&a=descargarCertificado&…` | **PDF vía mPDF** |
| `?c=AdelantosNominaPortal&a=certificadoLaboral` | Certificado laboral |

---

## 6. 🔴 Riesgos del contrato actual

| # | Riesgo | Detalle |
|---|---|---|
| 1 | **`criteriaExt` en `dataListAjax`** | `ListaAjax::loadCriteria()` acepta `$_REQUEST['criteriaExt']` como array de criterio y lo pasa tal cual a `criteriaToSql()`, que no escapa. Un usuario autenticado con permiso `list` sobre **cualquier** módulo puede construir cláusulas WHERE arbitrarias |
| 2 | **`search[value]` sin escapar** | Se concatena en `LIKE '%…%'` sin `addslashes` ni parámetros |
| 3 | **Sin verbos HTTP** | Salvo donde el código lo comprueba a mano, una acción de mutación puede invocarse por GET |
| 4 | **CSRF no centralizado** | Solo `remove` y `Model::save()` lo imponen. Las demás mutaciones dependen de que el autor lo recuerde |
| 5 | **Sin códigos de estado** | Un cliente programático no puede distinguir "sin permiso" de "redirección normal": ambos son 302 |
| 6 | **Trazas en la respuesta** | El `catch` global de `AutoLoad.php` hace `echo` de la excepción completa, independientemente de `APP_DEBUG` |
| 7 | **`detail` en errores de quickSearch** | Expone el mensaje de la excepción |

Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md) para la clasificación por severidad.

---

## 7. Cómo añadir un endpoint JSON

```php
// 1. Declararlo en loadAccessControl()
protected function loadAccessControl()
{
    $this->AccessControl = array(
        'list'     => '@',
        'miAjax'   => '@',       // ← sin esto, es inalcanzable
    );
}

// 2. Implementar la acción
public function miAjaxAction()
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return $this->jsonResponse(array('ok' => false, 'message' => 'Metodo no permitido.'), 405);
    }
    if (!Controller::validateCsrfTokenNoRotate($_POST['_csrf_token'] ?? null)) {
        return $this->jsonResponse(array('ok' => false, 'message' => 'Token invalido.'), 403);
    }

    $id = (int)($_POST['Id'] ?? 0);          // ← castear SIEMPRE
    if ($id <= 0) {
        return $this->jsonResponse(array('ok' => false, 'message' => 'Parametros invalidos.'), 422);
    }

    $datos = MiModeloModel::getById($id);
    return $this->jsonResponse(array('ok' => true, 'data' => $datos));
}

// 3. Helper de respuesta (copiar el de AsistenteIAController)
private function jsonResponse(array $payload, $status = 200)
{
    http_response_code((int)$status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($payload, JSON_UNESCAPED_UNICODE);
    return;
}
```

**Reglas:**
1. Usar `validateCsrfTokenNoRotate()` si la página puede llamarlo varias veces; `validateCsrfToken()` si es una sola vez.
2. Castear o sanear **todo** parámetro antes de meterlo en un criterio.
3. No devolver el mensaje de la excepción al cliente; registrarlo con `Logger::error()`.
4. Si el layout no debe intervenir, declarar `protected $MyLayout = 'empty';` o simplemente hacer `echo` y `return`.

---

## Documentos relacionados
- [04_MODULES.md](04_MODULES.md) — inventario completo de acciones
- [07_FRONTEND.md](07_FRONTEND.md) — cómo se consumen desde el navegador
- [09_AUTHENTICATION_AUTHORIZATION.md](09_AUTHENTICATION_AUTHORIZATION.md)
- [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md)
