# 12 — Integraciones Externas

> ⚠️ **Este documento nunca contiene credenciales reales.** Todos los secretos viven en el fichero `.env`, que no se versiona. Aquí solo se documentan los **nombres** de las variables.

---

## Resumen

| # | Integración | Tipo | Protocolo | Estado | Dónde |
|---|---|---|---|---|---|
| 1 | [SMTP saliente](#1-smtp--correo-saliente) | Correo | SMTP | ✅ En uso | `AlertasEmailController`, `PlanesCarreraController` |
| 2 | [Microsoft Entra ID](#2-microsoft-entra-id--oauth-20) | Identidad | OAuth 2.0 / HTTPS | ✅ Implementada, requiere configuración | `PublicController::validatedAction()` |
| 3 | [Ollama (LLM local)](#3-ollama--modelo-de-lenguaje-local) | IA | HTTP JSON | ✅ En uso si `IA_ENABLED` | `services/llm/OllamaProvider.php` |
| 4 | [OpenAI / ChatGPT](#4-openai--chatgpt) | IA | HTTPS | 🔴 Código muerto | `SincronizacionController` |
| 5 | [mPDF](#5-mpdf--generación-de-pdf) | Librería | — | ✅ En uso | `CapacitacionPortalController` |
| 6 | [elFinder](#6-elfinder--gestor-de-ficheros) | Librería | JSON | ✅ En uso | `ElFinderController` |
| 7 | [Metronic 8](#7-metronic-8--plantilla-de-interfaz) | Assets | — | ✅ En uso | `public/metronic_html_v8.0.23_demo1/` |
| 8 | [CDNs externos](#8-cdns-externos) | Assets | HTTPS | ✅ En uso | Layouts, `ListaAjax` |
| 9 | [LDAP / Directorio Activo](#9-ldap--directorio-activo) | Identidad | LDAP | 🔴 Driver presente, modelo ausente | `core/db/LdapPDO.php` |
| 10 | [Oracle](#10-oracle) | Base de datos | OCI | ⚠️ Driver incompleto, sin uso | `core/db/OraclePDO.php` |
| 11 | [Planificador externo (cron)](#11-planificador-externo-cron) | Sistema | HTTP | ✅ Implementado | `AlertasEmailController::sendCron*` |

---

## 1. SMTP — correo saliente

### Qué hace
Envía las alertas por correo encoladas en la tabla `alertas_email`, y notificaciones puntuales desde el módulo de Planes de Carrera.

### Cómo se conecta
**PHPMailer 6.8** (`phpmailer/phpmailer` en `composer.json`), en modo SMTP.

```php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host       = $email_send["EmailHost"];
$mail->SMTPSecure = $email_send["EmailEncripcion"];   // 'tls' o 'ssl'
$mail->Port       = $email_send["EmailPuerto"];
// … usuario y contraseña desde $email_send …
```

### Dónde está implementada
| Fichero | Uso |
|---|---|
| `app/controllers/AlertasEmailController.php` (líneas 178-190) | Envío manual (`send`) y programado (`sendCron`, `sendCronSolicitudAdmin`) |
| `app/controllers/PlanesCarreraController.php` | Notificaciones del módulo de carrera |

### Configuración
La construye `Config::buildEmailSendConfig()` y `Controller::__construct()` la asigna a `$this->email_send`:

| Clave de `$email_send` | Variable `.env` | Notas |
|---|---|---|
| `EmailNombre` | `MAIL_NOMBRE` | Nombre del remitente |
| `EmailHost` | `MAIL_HOST` | Servidor SMTP |
| `EmailPuerto` | `MAIL_PORT` | Por defecto 587 |
| `EmailUsuario` | `MAIL_USER` | |
| `EmailContrasena` | `MAIL_PASSWORD` | 🔒 Secreto |
| `EmailEncripcion` | `MAIL_ENCRYPTION` | `tls` para 587 (STARTTLS), `ssl` para 465. **No los mezcles** |
| `EmailEncabezado` | *(literal en el código)* | `<p>SISTEMA DE MENSAJERIA</p>` |
| `EmailPie` | *(literal en el código)* | Pie fijo |

### Control de volumen
| Clave | Origen | Efecto |
|---|---|---|
| `configuraciones['DetenerEnvioNotificaciones']` | BD | Interruptor de emergencia: si no está vacío, el cron no envía nada |
| `configuraciones['EnvioMaximoNotificaciones']` | BD | Correos por ejecución de cron. Si no es numérico o es ≤ 0 → **2** |
| `MAIL_DIARIOS_MAX` → `Config::$correos_diarios` | `.env` | 🔴 **Declarada pero nunca consultada en el código** |

Entre correo y correo hay un `sleep(3)`.

### Errores y diagnóstico
`PHPMailer(true)` lanza `PHPMailer\PHPMailer\Exception`. Comprueba: `MAIL_HOST` alcanzable, coherencia puerto/cifrado, credenciales, y que el remitente esté autorizado en el servidor SMTP.

### Formato de datos
`alertas_email(Id, Tipo, Asunto, Descripcion, Destinatarios, TotalDestinatarios, Comentarios, EstadoEnvio, FechaRegistro, UsuarioRegistro)`. `Destinatarios` es texto; `EstadoEnvio = 1` marca la alerta como pendiente.

---

## 2. Microsoft Entra ID — OAuth 2.0

### Qué hace
Permite iniciar sesión con la cuenta corporativa de Microsoft en lugar de con usuario y contraseña locales.

### Cómo se conecta
Flujo **Authorization Code** con cURL, sin librería intermedia. Endpoints usados:

| Paso | URL |
|---|---|
| Autorización | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` |
| Token | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` |
| Perfil | Microsoft Graph `/me` |

Ámbito solicitado: **`openid profile User.Read`** — el mínimo. No se piden refresh tokens ni acceso al buzón.

### Dónde está implementada
`app/controllers/PublicController.php` → `validatedAction()` (líneas ~713-810), más los helpers `setMicrosoftOAuthStateCookie()`, `getMicrosoftOAuthStateCookieName()`, `clearMicrosoftOAuthStateCookie()`.

Endpoint público: `?c=public&a=validated` (declarado `'*'`).

### Configuración

| Variable `.env` | Notas |
|---|---|
| `MICROSOFT_TENANT_ID` | Id del tenant de Entra ID |
| `MICROSOFT_CLIENT_ID` | Id de la aplicación registrada |
| `MICROSOFT_CLIENT_SECRET` | 🔒 Secreto |
| `MICROSOFT_REDIRECT_URI` | **Debe coincidir exactamente** con la URL de retorno registrada en Entra ID |

Se cargan en `Config::$microsoft_login` desde `syncConfigFromEnv()`. Si **cualquiera** de las cuatro está vacía, la acción aborta con *"El inicio de sesión institucional no está configurado en este entorno."*

### Medidas de seguridad implementadas
- `state` de 32 bytes (`bin2hex(random_bytes(32))`), guardado **en sesión y en una cookie temporal `Lax`** con 600 s de vida.
  - Motivo documentado en el código: la cookie de sesión es `SameSite=Strict` y **no viaja** en el callback cross-site de Microsoft; la cookie `Lax` conserva la correlación.
  - Si llegan ambas fuentes, deben coincidir (`hash_equals`).
  - Si no llega ninguna, o falta el `state` recibido, se aborta.
- Comparación con `hash_equals()`.
- Comprobación de `function_exists('curl_init')` antes de usar cURL.
- Los parámetros se codifican con `http_build_query(..., PHP_QUERY_RFC3986)` y `rawurlencode($tenantId)`.

### Errores
| Mensaje | Causa |
|---|---|
| *"El inicio de sesión institucional no está configurado en este entorno."* | Falta alguna de las 4 variables |
| *"No fue posible iniciar el inicio de sesión institucional. Intenta nuevamente."* | No se pudo fijar la cookie de `state` |
| *"La respuesta del inicio de sesión institucional no es válida. Intenta nuevamente."* | `state` ausente o discordante |
| *"El servidor no tiene habilitado el cliente requerido para el inicio institucional."* | Falta la extensión cURL |

---

## 3. Ollama — modelo de lenguaje local

### Qué hace
Alimenta el **Asistente IA**: convierte preguntas en lenguaje natural en consultas `SELECT` sobre la base de datos de Kuorum y redacta la respuesta.

### Cómo se conecta
HTTP `POST {endpoint}/api/chat` con cuerpo JSON. Implementado en `services/llm/OllamaProvider.php`, que cumple la interfaz `LLMInterface`.

```json
{
  "model": "<IA_MODEL>",
  "stream": false,
  "think": false,
  "messages": [ … ],
  "options": { "temperature": 0.1, "num_predict": 800 }
}
```

Características del cliente:
- **Múltiples endpoints**: el principal más una lista de respaldo (`fallback_endpoints`, hoy siempre vacía). Se prueban en orden.
- **Reintentos** por endpoint: `IA_RETRY_ATTEMPTS + 1` intentos.
- Timeouts separados de conexión y de lectura.
- Endpoint por defecto si no hay ninguno configurado: `http://127.0.0.1:11434`.
- Temperatura fija en 0.1 (respuestas deterministas) salvo que se pase otra en `$options`.

### Dónde está implementada

```
app/controllers/AsistenteIAController.php   ← index / ask / clearHistory
app/models/AsistenteIAModel.php             ← historial y configuración por usuario
services/asistente_ia/AsistenteIAService.php ← orquestación
services/asistente_ia/DatabaseContext.php    ← esquema resumido de la BD
services/asistente_ia/SQLGenerator.php       ← genera y valida el SQL (1 456 líneas)
services/asistente_ia/ResponseFormatter.php  ← redacta la respuesta
services/llm/LLMInterface.php                ← contrato
services/llm/OllamaProvider.php              ← cliente HTTP
services/prompt/PromptBuilder.php            ← construcción de prompts
app/layouts/_asistente_ia_widget.php         ← widget flotante en todo el backoffice
```

### Configuración

| Variable `.env` | Por defecto | Efecto |
|---|---|---|
| `IA_ENABLED` | `false` | Interruptor general |
| `IA_CONNECTION_NAME` | `klee` | Conexión de BD que se consulta |
| `IA_OLLAMA_ENDPOINT` | *(vacío)* | URL base de Ollama |
| `IA_MODEL` | `qwen3:14b` | Modelo |
| `IA_TIMEOUT_SECONDS` | 45 | Timeout de lectura |
| `IA_CONNECT_TIMEOUT_SECONDS` | 12 | Timeout de conexión |
| `IA_RETRY_ATTEMPTS` | 2 | Reintentos por endpoint |
| `IA_QUERY_CACHE_TTL` | 180 | Segundos de caché por hash de SQL |
| `IA_MAX_HISTORY_MESSAGES` | 10 | Mensajes de historial enviados |
| `IA_MAX_SCHEMA_TABLES` | 50 | Tablas incluidas en el contexto |
| `IA_MAX_SCHEMA_COLUMNS_PER_TABLE` | 12 | Columnas por tabla |
| `IA_MAX_SCHEMA_CHARS` | 5 000 | Tamaño máximo del contexto de esquema |

### Salvaguardas
- `SQLGenerator::isReadOnlySelect($sql)` bloquea todo lo que no sea un `SELECT` seguro. Cubierto por `tests/SQLGeneratorTest`: `testAllowsARegularReadOnlyQuery` y `testRejectsUnsafeOrResourceIntensiveSql`.
- Un bloqueo se registra con `Logger::warning('AsistenteIA bloqueo SQL inseguro', ['sql' => …])`.
- La pregunta se trunca a 1 200 caracteres.
- Los resultados se cachean por `sha1($sql)` en el namespace `AsistenteIA`.
- Si la consulta falla, se intenta un SQL de respaldo (`buildMainIndicatorsFallbackSql()`).

### 🔴 Riesgo documentado
El propio `.env.example` advierte:

> *"Usa `https://` en producción: por HTTP viajan en claro el esquema de la base de datos y resultados con datos personales y salariales."*

Además, el asistente consulta la base con la **conexión de la aplicación**, sin restricción de columnas: puede devolver salarios, documentos de identidad y datos de casos éticos a cualquier usuario con permiso `AsistenteIA`.

---

## 4. OpenAI / ChatGPT

### Qué hacía
Clasificar automáticamente comentarios de evaluación docente en `POSITIVO`, `NEUTRAL`, `OPORTUNIDAD DE MEJORA` o `NA`, y generar resúmenes.

### Cómo se conecta
Librería `sgraaf/chatgpt-php ^0.1.0`:

```php
$client  = new ChatGPT\Client(static::$OPENAI_API_KEY);
$message = $client->chat($texto);
```

### Dónde está
`app/controllers/SincronizacionController.php` → `openIAClasificacionAction()` (línea 944) y `openIAResumenAction()`.

### Configuración
`OPENAI_API_KEY` en `.env` → `Config::$OPENAI_API_KEY`.

El propio código deja constancia de una corrección de seguridad:

```php
// La clave vive en el .env (OPENAI_API_KEY) y la inyecta
// Config::syncConfigFromEnv() en la estática heredada $OPENAI_API_KEY.
// No reintroducir aquí una constante con el valor en claro.
```

### 🔴 Estado: código muerto
Ambas acciones operan sobre **`EstudiantesEvaluacionesModel`**, una clase que **no existe en `app/models/`**. Cualquier invocación real produciría un error fatal de clase no encontrada.

Es legado de un producto de **evaluación docente**, no de Kuorum. Ver §12 y [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md).

---

## 5. mPDF — generación de PDF

### Qué hace
Genera el **certificado de finalización de curso** en PDF.

### Cómo se usa
```php
$autoloadPath = BASE_PATH . 'vendor/autoload.php';
if (!class_exists('\Mpdf\Mpdf') && file_exists($autoloadPath)) {
    require_once $autoloadPath;
}
if (class_exists('\Mpdf\Mpdf')) {
    try {
        $mpdf = new \Mpdf\Mpdf(array('format' => 'A4-L'));   // A4 apaisado
        $mpdf->WriteHTML($html);
        $mpdf->Output('certificado_' . $certificado['Codigo'] . '.pdf', 'I');
        exit;
    } catch (Throwable $e) {
        // fallback: se devuelve el HTML sin convertir
    }
}
```

### Dónde está
`app/controllers/CapacitacionPortalController.php` → `descargarCertificadoAction()` (líneas ~520-540).
Plantilla HTML: `app/views/capacitacion_certificados/plantilla.php`.
Hoja de estilo auxiliar: `app/views/archivos/pdf.css`.

### Dependencias
`mpdf/mpdf ^8.2`. Requiere las extensiones PHP `gd` y `mbstring` (verificadas en el arranque por `core/AutoLoad.php`).

### Comportamiento ante fallo
Si la clase no existe o `WriteHTML` lanza, **el certificado se entrega como HTML** en lugar de PDF. Degradación silenciosa: no hay mensaje al usuario ni entrada de log.

---

## 6. elFinder — gestor de ficheros

### Qué hace
Explorador de ficheros web sobre el directorio `files/`.

### Cómo se conecta
Protocolo propio de elFinder sobre HTTP/JSON. El controlador carga las clases desde la copia gestionada por Composer:

```php
$elFinderPhpPath = BASE_PATH . 'vendor/studio-42/elfinder/php/';
$elFinderFiles = array(
    'elFinderConnector.class.php',
    'elFinder.class.php',
    'elFinderVolumeDriver.class.php',
    'elFinderVolumeLocalFileSystem.class.php',
);
```

El comentario del código explica la decisión: *"El conector debe cargar la versión auditada que gestiona Composer, no una copia pública que puede quedar desactualizada en despliegues."*

### Dónde está
`app/controllers/ElFinderController.php` → `conectorAction()`. Endpoint: `?c=ElFinder&a=conector`, acceso `'@'` (cualquier usuario autenticado).

### Dependencia
`studio-42/elfinder ^2.1.70`.

### Errores
Si falta cualquiera de los cuatro ficheros de la librería, responde **503** con *"El gestor de archivos no está disponible."*

### ⚠️ Observaciones
- La acción empieza con `error_reporting(0)`.
- El acceso es `'@'` sin distinción de rol: **cualquier usuario autenticado accede al gestor de ficheros completo**.
- Los drivers de Dropbox, FTP y MySQL están comentados en el código.

---

## 7. Metronic 8 — plantilla de interfaz

### Qué es
Plantilla comercial de administración (**Metronic 8.0.23, Demo 1**), incluida en el repositorio.

### Dónde está
`public/metronic_html_v8.0.23_demo1/` — 275 páginas HTML de demostración, `assets/` con CSS, JS, fuentes, iconos e ilustraciones.

De todo eso, la aplicación **solo usa `assets/`**:

```
assets/plugins/global/plugins.bundle.{css,js}
assets/css/style.bundle.css
assets/js/scripts.bundle.js
assets/plugins/custom/datatables/datatables.bundle.{css,js}
assets/media/illustrations/…
```

### Personalización
Un único fichero propio: `public/assets/kuorum-metronic-demo1.css` (7 329 B), cargado después de los bundles.

### Licencia
`[NO DETERMINADO EN EL CÓDIGO]` — Metronic es un producto comercial de KeenThemes. No hay fichero de licencia en el repositorio. **Verificar la licencia antes de distribuir el código.**

---

## 8. CDNs externos

La aplicación carga tres recursos desde Internet. Sin ellos, la interfaz se degrada.

| Recurso | URL | Usado en | Impacto si falla |
|---|---|---|---|
| Fuente Poppins | `https://fonts.googleapis.com/css?family=Poppins:300,400,500,600,700` | `metronic.php`, `metronic_public.php` | Se usa la fuente de respaldo |
| Idioma de DataTables | `https://cdn.datatables.net/plug-ins/1.11.3/i18n/es-mx.json` | `core/ListaAjax.php` | **Los listados quedan en inglés** |

> ⚠️ La CSP declarada es `default-src 'self' 'unsafe-inline' data:;`. **No incluye `fonts.googleapis.com`, `fonts.gstatic.com` ni `cdn.datatables.net`.** Un navegador que aplique la CSP estrictamente bloqueará esos recursos. En la práctica funciona porque `default-src` con `'self'` no siempre se aplica a subrecursos de la manera esperada, pero es una incoherencia real que conviene resolver.

---

## 9. LDAP / Directorio Activo

### Estado: **driver presente, integración rota**

| Elemento | Estado |
|---|---|
| `core/db/LdapPDO.php` | ✅ Existe |
| Soporte en `DB::createConnection()` para `driver == 'ldap'` | ✅ Existe |
| `Config::$directorioActivo` ← `.env DIRECTORIO_ACTIVO` | ✅ Existe |
| Rama en `UsuariosModel::validateUser()` que llama a `DirectorioActivoModel::autentication()` | ✅ Existe |
| **Clase `DirectorioActivoModel`** | 🔴 **NO EXISTE en `app/models/`** |
| Conexión LDAP en `DB_CONNECTIONS` | ❌ No configurada |

### 🔴 Consecuencia

```php
} elseif (Config::$directorioActivo) {
    if (DirectorioActivoModel::autentication($user, $pass)) {   // ← clase inexistente
```

Con `DIRECTORIO_ACTIVO=true` y una contraseña local incorrecta, la aplicación produce un **error fatal de clase no encontrada**. La variable **debe permanecer en `false`** salvo que se implemente `DirectorioActivoModel`.

Configuración de conexión LDAP, si algún día se implementa (parámetros que espera `DB::createConnection()`): `driver`, `host`, `port`, `user`, `password`, `dbname` (usado como DN).

---

## 10. Oracle

`core/db/OraclePDO.php` implementa `KleePDO` para conexiones OCI. `DB::createConnection()` construye un TNS a partir de `host`, `port`, `dbname` (SID) y `owner`.

**Estado:** sin uso. El fichero contiene marcadores `//TODO` y dos métodos sin implementar:

```php
public function deleteByCriteria(...) { /* TODO: Implement deleteByCriteria() method. */ }
public function updateCriteria(...)   { /* TODO: Implement updateCriteria() method. */ }
```

Si alguna instalación necesitara Oracle, se declararía en `ConfigEnv.php`:

```php
self::$DB_CONNECTIONS['legacy'] = array(
    'name' => 'legacy', 'driver' => 'oracle',
    'dbname' => '<SID>', 'host' => '…', 'port' => 1521,
    'owner' => '…', 'user' => '…', 'password' => '…', 'instance' => null,
);
```

(El propio `ConfigEnv.php` incluye este ejemplo comentado.)

---

## 11. Planificador externo (cron)

### Qué hace
Dispara el envío de correos encolados sin necesidad de sesión.

### Endpoints
```
?c=AlertasEmail&a=sendCron
?c=AlertasEmail&a=sendCronSolicitudAdmin
```

### Autenticación
Token compartido, comparado con `hash_equals()`:
- Cabecera **`X-Cron-Token`**, o
- Parámetro **`?token=`**

Variable: **`CRON_TOKEN`** en `.env`. Se genera con:

```bash
php -r "echo bin2hex(random_bytes(32));"
```

Si `CRON_TOKEN` está vacío, la invocación anónima se rechaza con **503**. Una sesión con el permiso `AlertasEmail.send` también sirve (para el disparo manual desde la interfaz).

### Ejemplo de crontab

```cron
*/10 * * * * curl -fsS -H "X-Cron-Token: ${CRON_TOKEN}" \
    "https://kuorum.ejemplo.com/?c=AlertasEmail&a=sendCron" >/dev/null 2>&1
```

> **No hay ningún otro proceso programado.** Tareas que un sistema de RR. HH. normalmente automatizaría —vencimiento de inscripciones de capacitación, recálculo de SLA de tickets abiertos, generación de saldos de vacaciones al cambiar de año, alertas de contratos por vencer— **no tienen implementación de cron**. Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md).

---

## 12. 🔴 Clases referenciadas que NO existen

Durante el análisis se detectaron **20 clases `*Model` invocadas en el código que no tienen fichero en `app/models/`**. Cualquier ruta que las alcance produce un error fatal.

| Clase ausente | Referenciada desde |
|---|---|
| `AsignaturasModel` | `AjaxController`, `SincronizacionController` |
| `DebugModel` | `TestController` |
| `DirectoresEvaluacionesModel` | `NotificacionesController` |
| `DirectoresModel` | `AlertasController`, `AjaxController`, `SincronizacionController`, `PublicModel`, `app/views/alertas/view.php` |
| `DirectorioActivoModel` | `PublicModel`, `UsuariosModel` |
| `DocentesAsignaturasModel` | `SincronizacionController` |
| `DocentesEvaluacionesModel` | `NotificacionesController` |
| `DocentesModel` | `AlertasController`, `AjaxController`, `SincronizacionController`, `PublicModel`, `app/views/alertas/view.php` |
| `EstudiantesAsignaturasModel` | `SincronizacionController` |
| `EstudiantesEvaluacionesModel` | `NotificacionesController`, `AjaxController`, `SincronizacionController` |
| `EstudiantesModel` | `AlertasController`, `AjaxController`, `SincronizacionController`, `PublicModel`, `app/views/alertas/view.php` |
| `EstudiantesRespuestasModel` | `AjaxController` |
| `FacultadesModel` | `AjaxController` |
| `LaborDocenteModel` | `AjaxController`, `SincronizacionController` |
| `OdsDocentesModel` | `AjaxController`, `SincronizacionController` |
| `PostulacionesModel` | `AjaxController` |
| `ProduccionIntelectualModel` | `AjaxController` |
| `ProgramasModel` | `AjaxController`, `SincronizacionController`, `BeneficiosPortalController`, `BeneficiosController`, `BeneficiosCatalogoProgramasModel` |
| `ProyectosDocentesModel` | `SincronizacionController` |
| `ResumenEvaluacionModel` | `AjaxController`, `SincronizacionController` |

**Origen:** casi todas pertenecen a un producto anterior de **evaluación docente universitaria** (estudiantes, docentes, facultades, programas, asignaturas). El código que las invoca sobrevivió a la conversión del producto en un HRIS.

**Ficheros más afectados:** `SincronizacionController` (1 255 líneas, prácticamente todo su contenido es inalcanzable), `AjaxController` (5 de sus 11 acciones), `AlertasController`, `NotificacionesController`, `PublicModel`, `TestController`, `app/views/alertas/view.php`.

> ⚠️ **Antes de "arreglar" cualquiera de estos ficheros, comprueba si la funcionalidad tiene sentido en Kuorum.** En la mayoría de los casos la respuesta correcta es eliminar el código, no crear la clase que falta.

---

## 13. Cómo añadir una integración nueva

Siguiendo las convenciones del proyecto:

1. **Los secretos van al `.env`**, nunca al código. Documenta la variable en `.env.example` con un comentario.
2. **Cárgala en `Config::syncConfigFromEnv()`**, en la sección correspondiente:
   ```php
   self::$MI_SERVICIO = array(
       'enabled'  => Env::bool('MISERVICIO_ENABLED', false),
       'endpoint' => Env::get('MISERVICIO_ENDPOINT', ''),
       'api_key'  => (string)Env::get('MISERVICIO_API_KEY', ''),
       'timeout'  => Env::int('MISERVICIO_TIMEOUT', 30),
   );
   ```
   ⚠️ **Nunca redeclares la propiedad en `ConfigEnv.php`**: PHP crearía un almacenamiento separado y el `.env` sería ignorado en silencio.
3. **Aísla el cliente en `services/`** con una interfaz, como se hizo con `LLMInterface` / `OllamaProvider`. Si creas un subdirectorio nuevo, añádelo a `$autoloadSearchPaths` en `core/AutoLoad.php`.
4. **Degrada con elegancia**: si el servicio no está configurado o falla, la aplicación debe seguir funcionando. Comprueba `enabled` y las credenciales antes de intentar la conexión.
5. **Registra los fallos** con `Logger::error()` o `Logger::warning()`, **no** con `echo`.
6. **Nunca devuelvas el mensaje de la excepción al usuario final.**
7. **Si es un endpoint invocable sin sesión**, protégelo con un token compartido comparado con `hash_equals()`, siguiendo el patrón de `guardCronAccess()`.
8. **Usa HTTPS**, y déjalo escrito en `.env.example`.

---

## 14. Inventario de secretos

Todas las variables sensibles, en un solo sitio. **Ninguna aparece con su valor en esta documentación ni debe aparecer en el repositorio.**

| Variable | Servicio | Rotación recomendada |
|---|---|---|
| `DB_PASSWORD` | MySQL | Al cambiar de entorno |
| `MAIL_PASSWORD` | SMTP | Al cambiar de proveedor |
| `MICROSOFT_CLIENT_SECRET` | Entra ID | Según la caducidad de Entra ID |
| `OPENAI_API_KEY` | OpenAI | Si se filtra (hoy: integración muerta, se puede dejar vacía) |
| `CRON_TOKEN` | Cron interno | Al cambiar de planificador |
| `APP_ID` | Sesión | ⚠️ Cambiarla **invalida todas las sesiones abiertas** |

El fichero `.env` está en `.gitignore`. El `.htaccess` de la raíz bloquea su acceso por HTTP como **mitigación**; la solución correcta es que el `DocumentRoot` apunte a `public/` (hallazgo **HR-014**).

---

## Documentos relacionados
- [13_CONFIGURATION.md](13_CONFIGURATION.md)
- [15_DEPLOYMENT.md](15_DEPLOYMENT.md)
- [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md)
