# 13 — Configuración

> ⚠️ **Este documento nunca contiene valores reales de secretos.** Solo nombres de variables, tipos, valores por defecto y efecto.

---

## 1. Dónde vive la configuración

Hay **cuatro** lugares, con precedencia definida:

```mermaid
flowchart LR
    A["1. Variables reales del entorno\ngetenv / \$_ENV / \$_SERVER"] --> B["2. Fichero .env"]
    B --> C["3. app/config/ConfigEnv[.local].php\n(sobrescrituras por instalación)"]
    C --> D["4. app/config/Config.php\n(valores de respaldo en código)"]
    D --> E["5. Tabla configuraciones\n(parámetros de negocio en caliente)"]
```

| # | Origen | Contenido | Versionado |
|---|---|---|---|
| 1 | Entorno del proceso | Cualquier variable. **Máxima prioridad** | — |
| 2 | `.env` en la raíz | Credenciales, endpoints, banderas | ❌ `.gitignore` |
| 3 | `app/config/ConfigEnv.php` / `ConfigEnv.local.php` | Conexiones adicionales, sobrescrituras estructurales | ❌ `.gitignore` |
| 4 | `app/config/Config.php` | Layouts, filtros de sesión, listas de clases generales, valores de respaldo | ✅ Sí |
| 5 | Tabla `configuraciones` | Parámetros de negocio editables desde la interfaz | En BD |

### El orden de carga importa

`core/AutoLoad.php`, en este orden exacto:

```php
require "Env.php";
Env::load(BASE_PATH . ".env");                     // 1) el .env, primero

require BASE_PATH . "app/config/Config.php";       // 2) la clase base

if (file_exists(".../ConfigEnv.local.php") && host es localhost/127.0.0.1) {
    require ".../ConfigEnv.local.php";             // 3a) entorno local
} else {
    require ".../ConfigEnv.php";                   // 3b) entorno remoto
}

Config::syncConfigFromEnv();                       // 4) inyecta el .env en las estáticas

require "Controller.php";                          // 5) lee Controller::$debug
```

> 🔴 **`Config::syncConfigFromEnv()` es el ÚNICO punto por el que el `.env` entra en la aplicación.** Una variable que no se lea ahí, no existe para el código.

### 🔴 La trampa de PHP con las propiedades estáticas

El propio `ConfigEnv.php` lo documenta:

> *No vuelvas a declarar aquí ninguna propiedad que ya gestione el `.env`. En PHP, redeclarar una propiedad estática en la clase hija crea un almacenamiento **SEPARADO**: la asignación que hace `syncConfigFromEnv()` sobre `Config` dejaría de verse desde `ConfigEnv` y desde `Controller`, y el valor del `.env` sería ignorado en silencio.*

`syncConfigFromEnv()` asigna con **`self::`** deliberadamente, para que los valores aterricen en el almacenamiento de `Config` y los compartan `Config`, `ConfigEnv` y `Controller` (la cadena es `Controller extends ConfigEnv extends Config`).

---

## 2. El fichero `.env`

Copia la plantilla y rellena:

```bash
cp .env.example .env
```

### Parser (`core/Env.php`)

| Característica | Comportamiento |
|---|---|
| Comentarios | Líneas que empiezan por `#` o `;` |
| Prefijo shell | Se admite y elimina `export ` |
| Comillas | `unquote()` elimina comillas simples o dobles envolventes |
| Fichero ausente | **No falla**: la aplicación funciona solo con variables reales del entorno |
| Precedencia | `getenv()` → `$_ENV` → `$_SERVER` → fichero `.env` → valor por defecto |
| Valores vacíos | Se tratan como **ausentes** (se devuelve el valor por defecto) |

### `Env::bool()` — cuidado con los booleanos

```php
Env::bool('APP_DEBUG', false);
```

`getenv()` siempre devuelve cadenas, y en PHP la cadena `"false"` es **verdadera**. **Nunca castees el valor crudo**; usa siempre `Env::bool()`.

| Se interpretan como `false` | Se interpretan como `true` |
|---|---|
| `false`, `0`, `off`, `no`, `null`, cadena vacía | `true`, `1`, `on`, `yes` |

Cualquier otro valor devuelve el valor por defecto.

---

## 3. Catálogo completo de variables de entorno

Extraído de `.env.example` y de `Config::syncConfigFromEnv()`.

### Aplicación

| Variable | Tipo | Por defecto | Obligatoria | Efecto |
|---|---|---|---|---|
| `APP_ID` | string | `Kuorum` | Recomendada | Clave raíz de `$_SESSION[APP_ID]`. ⚠️ **Cambiarla invalida todas las sesiones abiertas** |
| `APP_NAME` | string | `Kuorum` | No | Marca visible (`site_name`) en los layouts |
| `APP_START_URL` | string | `https://kleesoftware.com` | No | URL de inicio de la marca |
| `APP_INSTITUCION` | string | `Kuorum` | No | Nombre de la institución en layouts y pie |
| `APP_DEBUG` | bool | `false` | Sí en producción | Carga `ErrorHandler`, activa `LogsConsole` con cada SQL y los avisos de campo inválido. 🔴 **Nunca `true` en producción** |
| `APP_PUBLICADO` | bool | `true` | No | Consultada solo por `PublicController` |
| `APP_NO_PAYMENT` | bool | `false` | No | 🔴 Sin efecto: nada la consulta |

### Base de datos — **obligatorio**

| Variable | Tipo | Por defecto | Obligatoria | Efecto |
|---|---|---|---|---|
| `DB_DRIVER` | string | `mysql` | Sí | Único valor probado: `mysql` |
| `DB_HOST` | string | `localhost` | Sí | |
| `DB_NAME` | string | *(vacío)* | **Sí** | 🔴 **Si está vacía, `Config::$DB_CONNECTIONS['klee']` no se define y `DB::getConnection('klee')` lanza `'No existe la configuración de conexión'`** |
| `DB_USER` | string | *(vacío)* | **Sí** | |
| `DB_PASSWORD` | string | *(vacío)* | Sí | 🔒 Secreto |

La conexión se registra bajo el nombre **`klee`** (es el `$CONNECTION_NAME` por defecto de todos los modelos).

### Correo saliente (SMTP)

| Variable | Tipo | Por defecto | Efecto |
|---|---|---|---|
| `MAIL_NOMBRE` | string | *(vacío)* | Nombre del remitente |
| `MAIL_HOST` | string | *(vacío)* | Servidor SMTP |
| `MAIL_PORT` | int | `587` | Puerto |
| `MAIL_USER` | string | *(vacío)* | |
| `MAIL_PASSWORD` | string | *(vacío)* | 🔒 Secreto |
| `MAIL_ENCRYPTION` | string | `tls` | `tls` para 587 (STARTTLS), `ssl` para 465. **No mezclar** |
| `MAIL_DIARIOS_MAX` | int | `20` | 🔴 Sin efecto: `Config::$correos_diarios` nunca se consulta |

### Autenticación

| Variable | Tipo | Por defecto | Efecto real |
|---|---|---|---|
| `LOGIN_MANUAL` | bool | `false` | Permite entrar usando el número de documento como contraseña. Solo la consulta `PublicModel`. **Déjala en `false`** |
| `LOGIN_AUTOMATICO` | bool | `false` | 🔴 Sin efecto |
| `CAMBIO_ROL` | bool | `false` | 🔴 Sin efecto |
| `CSRF_STRICT` | bool | `false` | 🔴 **Sin efecto.** Ninguna comprobación de CSRF la consulta. El `.env.example` la recomienda en `true`, lo que induce a error |
| `IP_BLOCKING` | bool | `false` | 🔴 **Sin efecto.** La tabla `ips_autorizadas` y su CRUD existen, pero nada comprueba la IP del cliente |
| `DIRECTORIO_ACTIVO` | bool | `false` | ⚠️ Activa una rama de `UsuariosModel::validateUser()` que invoca `DirectorioActivoModel`, **clase que no existe**. Con `true` y contraseña incorrecta → error fatal. **Déjala en `false`** |

### Registro y auditoría

| Variable | Tipo | Por defecto | Efecto |
|---|---|---|---|
| `LOG_ACTIONS` | bool | `true` | Habilita `log_acceso` y `log_urls` |
| `LOG_MODULES` | bool | `true` | Habilita `log_modules`. **Además, el modelo debe tener `$LOG = true`** (solo `ColaboradoresModel` y `UsuariosModel`) |
| `LOG_ACCESS` | bool | `true` | Consultada por `LogAccionesModel` y `LogUrlsModel` |

### Interfaz

| Variable | Tipo | Por defecto | Efecto |
|---|---|---|---|
| `VALUES_TO_UPPER` | bool | `false` | Convierte a mayúsculas todo valor recibido en `Atributo::receiveData()` |
| `NO_COPY` | bool | `false` | 🔴 Sin efecto |
| `DROP_FILES` | bool | `false` | Consultada solo por `ElFinderController` |

### Reglas de negocio

| Variable | Tipo | Por defecto | Efecto |
|---|---|---|---|
| `VACACIONES_BLOQUEAR_SOLICITADAS` | bool | `true` | Si es `true`, las solicitudes en estado `SOLICITADA` también bloquean el cruce de fechas de una nueva solicitud |
| `METAS_FRECUENCIA_PERIODO_DEFAULT` | string | `anual` | Frecuencia de metas cuando no hay valor en `configuraciones` ni en el periodo. Valores aceptados: `anual`, `semestral` |

### Asistente IA (Ollama)

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

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

### Terceros

| Variable | Tipo | Efecto |
|---|---|---|
| `OPENAI_API_KEY` | string | 🔒 Secreto. Solo la usa `SincronizacionController`, que es código muerto. Puede quedar vacía |
| `MICROSOFT_TENANT_ID` | string | Entra ID |
| `MICROSOFT_CLIENT_ID` | string | Entra ID |
| `MICROSOFT_CLIENT_SECRET` | string | 🔒 Secreto |
| `MICROSOFT_REDIRECT_URI` | string | Debe coincidir **exactamente** con lo registrado en Entra ID |

Si cualquiera de las cuatro variables de Microsoft está vacía, el inicio institucional se desactiva con un mensaje claro.

### Tareas programadas

| Variable | Tipo | Efecto |
|---|---|---|
| `CRON_TOKEN` | string | 🔒 Secreto. Autoriza `?c=AlertasEmail&a=sendCron` y `sendCronSolicitudAdmin` sin sesión. **Vacía = los endpoints anónimos responden 503** |

Genera uno con:
```bash
php -r "echo bin2hex(random_bytes(32));"
```

### Modo demo

| Variable | Tipo | Por defecto | Efecto |
|---|---|---|---|
| `DEMO_MODE_ENABLED` | bool | `false` | 🔴 **Con `true`, `?c=Demo&a=index` siembra datos en la base REAL y crea una sesión suplantando a un colaborador, relajando la autorización sobre 13 módulos. Solo para la instalación de demostración** |

---

## 4. Variables mínimas para arrancar

```ini
APP_ID=Kuorum
APP_DEBUG=false
DB_DRIVER=mysql
DB_HOST=localhost
DB_NAME=kuorum
DB_USER=<usuario>
DB_PASSWORD=<contraseña>
```

Sin `DB_NAME`, `DB_USER` y `DB_PASSWORD` la aplicación no conecta. Todo lo demás tiene valor por defecto.

### Configuración recomendada para producción

```ini
APP_DEBUG=false               # obligatorio
DEMO_MODE_ENABLED=false       # obligatorio
DIRECTORIO_ACTIVO=false       # hasta que exista DirectorioActivoModel
LOGIN_MANUAL=false
IP_BLOCKING=false             # no tiene efecto; no da falsa seguridad
LOG_ACTIONS=true
LOG_MODULES=true
LOG_ACCESS=true
CRON_TOKEN=<64 hex>           # si se usa el envío programado
IA_OLLAMA_ENDPOINT=https://…  # si IA_ENABLED=true
```

---

## 5. `app/config/Config.php` — configuración estructural

Lo que **no** debe ir al `.env` porque es código, no configuración de despliegue.

### Mapa de layouts

```php
public $layout = array(
    'admin'          => 'metronic',
    'adminLogin'     => 'metronic_empty',
    'impresion'      => 'impresiones',
    'impresionPOS'   => 'impresionesPOS',   // ⚠️ el fichero NO existe
    'externo'        => 'metronic_empty',
    'empty'          => 'empty',
    'metronic'       => 'metronic',
    'metronicPublic' => 'metronic_public',
    'metronicEmpty'  => 'metronic_empty',
    'demo'           => 'demo',
);
protected $MyLayout = 'metronic';   // por defecto
```

### Filtros globales de sesión

```php
public static $FILTERS_SESION = array(
    'Estado' => array(
        'name' => 'Estado', 'tags' => array('Estado','IdEstado'),
        'form' => false, 'valueDefault' => 1,
    ),
    'Periodo' => array(
        'name' => 'Periodo', 'model' => 'PeriodosModel',
        'tags' => array('Periodo','IdPeriodo'),
        'form' => true, 'valueDefault' => 29,
    ),
);
```

| Clave | Significado |
|---|---|
| `name` | Nombre del filtro en `$_SESSION[APP_ID]['filtersSesion']` |
| `tags` | Nombres de columna que activan el filtro si el modelo los tiene |
| `type` | *(opcional)* Tipo de atributo exigido |
| `model` | Modelo que provee las opciones (`getAllFiltersSesion()`) |
| `form` | Si `true`, se muestra el `<select>` en la barra superior |
| `valueDefault` | Valor inicial cuando el modelo no aporta opciones |

Se aplican como `FIND_IN_SET('<valor>', <columna>)`.

> ⚠️ `'valueDefault' => 29` para `Periodo` es un **Id concreto codificado a fuego**. En una instalación nueva ese periodo puede no existir.

### Clases y acciones exentas de permisos

```php
public static $classesGeneral = array(
    'Public','Home','Ajax','Log','Login','Perfil',
    'CarritoFacturacion','CarritoDevolucion','CarritoCotizacion'   // ← residuos de otro producto
);
public static $actionsGeneral = array('dataListAjax','testDB');
```

> 🔴 `dataListAjax` exento significa que **cualquier usuario autenticado puede consultar el endpoint de datos de cualquier módulo**, tenga o no permiso `list`.

### Superusuario

```php
public static function getUserAccess() { return 1; }
```

El usuario con `usuarios.Id = 1` tiene acceso total incondicional.

### Helpers de negocio

| Método | Qué hace |
|---|---|
| `getFrecuenciasMetasDisponibles()` | Lee `configuraciones['metas.frecuencias_disponibles']`; acepta solo `Anual` y `Semestral`; respaldo `['Anual','Semestral']` |
| `getFrecuenciaMetasPeriodoActiva($frecuenciaPeriodo)` | `configuraciones['metas.frecuencia_periodo']` → frecuencia del periodo → `METAS_FRECUENCIA_PERIODO_DEFAULT` → `'Anual'` |
| `buildApplicationConfig()` | Marca visible: `name`, `tooltip`, `starturl` |
| `buildEmailSendConfig()` | Configuración SMTP |
| `getConnectionConfig($clave)` | Devuelve la conexión o lanza `'No existe la configuración de conexión'` |
| `getTemplateUrl($plantilla)` | URL base de una plantilla |

### Marca

| Propiedad | Valor por defecto |
|---|---|
| `$logo` | `logo_korum.png` |
| `$logo_login` | `logo_korum_login.png` |
| `$icon` | `icon.png` |
| `$appColor` | `blue` |

Los ficheros viven en `files/`. El favicon se sirve como `files/favicon.ico`.

---

## 6. `app/config/ConfigEnv.php`

Actualmente **vacío a propósito**. Sirve para lo que el `.env` no puede expresar:

```php
class ConfigEnv extends Config
{
    // Ejemplo: conexión adicional a Oracle
    // public static function addExtraConnections()
    // {
    //     self::$DB_CONNECTIONS['legacy'] = array(
    //         'name' => 'legacy', 'driver' => 'oracle',
    //         'dbname' => '…', 'host' => '…', 'user' => '…',
    //         'password' => '…', 'instance' => null,
    //     );
    // }
}
```

**Regla absoluta:** ⛔ **no redeclares ninguna propiedad estática que gestione el `.env`** (`$appId`, `$nombre_institucion`, `$debug`, `$LOGIN_*`, `$CSRF_STRICT`, `$LOG_*`, `$DB_CONNECTIONS`, `$ASISTENTE_IA`, `$OPENAI_API_KEY`, `$microsoft_login`…). Si lo haces, el `.env` deja de tener efecto sobre ella **sin ningún aviso**.

### `ConfigEnv.local.php`

Si existe **y** el `HTTP_HOST` contiene `localhost` o `127.0.0.1`, se carga **en lugar de** `ConfigEnv.php`. También lo consultan `bin/migrate` y `bin/seed`.

No existe en el checkout actual.

---

## 7. Otros ficheros de `app/config/`

| Fichero | Contenido | Cuándo tocarlo |
|---|---|---|
| `Menu.php` | Árbol del menú del backoffice (`$principal`) y del portal (`$public`), y su renderizado a HTML de Metronic | Al añadir o quitar un módulo del menú |
| `QuickActionsConfig.php` | Catálogo de 15 acciones para la búsqueda rápida, con permisos requeridos | Al exponer un módulo en el buscador |
| `Titles.php` | Array global `$titles`: prefijos de título por acción (`list`→`Lista `, `create`→`Creación `, `edit`→`Edición `, `info`/`view`→`Detalles `, `remove`→`Eliminación `) | Al añadir un tipo de acción nuevo |
| `GeneralDataArray.php` | Catálogos estáticos heredados (`$alertas` con iconos) | Rara vez |
| `Localidades.php`, `Sedes.php`, `TiposSede.php` | Catálogos estáticos heredados del framework base | 🔴 Uso residual; no ampliar |

---

## 8. La tabla `configuraciones`

Parámetros de negocio editables **sin desplegar**, desde `?c=configuraciones&a=view`.

```
configuraciones(Id VARCHAR(100) — la clave, Grupo VARCHAR(100), Valor TEXT)
```

Acceso desde el código:

```php
$valor = ConfiguracionesModel::getValor('metas.max_por_colaborador', 8);
```

### Contenido actual (12 filas)

| Clave (`Id`) | Grupo | Valor de ejemplo | Efecto |
|---|---|---|---|
| `metas.max_por_colaborador` | `metas` | `8` | Máximo de metas si el periodo no lo define |
| `EmpresaNombre` | `General` | `Empresa Demo S.A.S.` | Datos de la empresa en documentos |
| `EmpresaNIT` | `General` | `900123456-7` | |
| `EmpresaCorreoNomina` | `General` | `nomina@empresa.local` | |
| `EmpresaTelefono` | `General` | `+57 601 555 5555` | |
| `UmbralAusentismo` | `Umbrales` | `0.05` | KPI del dashboard |
| `UmbralRotacion` | `Umbrales` | `0.12` | KPI del dashboard |
| `CorreoAlertasRRHH` | `Notificaciones` | `rrhh.alertas@empresa.local` | Destinatario de alertas |
| `DetenerEnvioNotificaciones` | `Notificaciones` | `0` | Interruptor de emergencia del cron de correo |
| `EnvioMaximoNotificaciones` | `Notificaciones` | `200` | Correos por ejecución de cron (si no es numérico o ≤ 0 → 2) |
| `ReclutamientoContratoExigeFirma` | `Reclutamiento` | `1` | Exigir firma en el contrato |
| `RecruitmentContratoExigeFirma` | `Reclutamiento` | `1` | ⚠️ **Duplicado del anterior** en inglés |

### Claves consultadas por el código pero **ausentes** de la tabla

| Clave | Consultada desde | Comportamiento si falta |
|---|---|---|
| `metas.frecuencias_disponibles` | `Config::getFrecuenciasMetasDisponibles()` | Respaldo `['Anual','Semestral']` |
| `metas.frecuencia_periodo` | `Config::getFrecuenciaMetasPeriodoActiva()` | Cae al valor del periodo o al del `.env` |

### Otras tablas de configuración

| Tabla | Contenido |
|---|---|
| `nomina_parametros` | `AUX_TRANSPORTE`, `PORC_SALUD`, `PORC_PENSION`, `HORAS_EXTRA_FACTOR`, … |
| `talento_config` | Pesos y umbral del ecosistema de talento (`Clave`, `Valor`, `Descripcion`) |
| `talento_categorias` | 5 categorías del mapa de talento |
| `talento_celdas_9box` | 9 celdas de la matriz |
| `adelanto_politicas` | Política única activa de adelantos |
| `politicas_vacaciones` | Políticas de vacaciones |
| `servicio_sla_politicas` | SLA por prioridad de ticket |
| `reconocimiento_reglas` | `LimiteMensualPorEmisor` |

---

## 9. Configuración del servidor web

### `.htaccess` de la raíz

```apache
DirectoryIndex public/index.php?r=home/index
<FilesMatch "^\.env">           Require all denied  </FilesMatch>
<FilesMatch "^(\.env.*|\.gitignore|composer\.(json|lock)|sftp\.json)$">
                                Require all denied  </FilesMatch>
Options -Indexes
```

El propio fichero deja constancia del problema de fondo:

> *NOTA: esto es una mitigación, no la solución. La corrección real es que el DocumentRoot del vhost apunte a `public/` y no a la raíz del proyecto; mientras no sea así, `app/`, `core/`, `database/` y `.git/` siguen siendo alcanzables. Ver hallazgo **HR-014** de la auditoría.*

Incluye las dos sintaxis (Apache 2.2 y 2.4).

### `public/.htaccess`

```apache
<Files 403.shtml> order allow,deny  allow from all </Files>
php_value max_input_vars 5000
RewriteEngine Off
DirectoryIndex index.php
```

`max_input_vars 5000` es necesario para los formularios de carga masiva.

### ⚠️ La contradicción del DocumentRoot

| Si el DocumentRoot es… | Consecuencia |
|---|---|
| **La raíz del proyecto** | ✅ Funciona `files/` (el favicon se sirve como `URL::base_url().'/../files/…'`) y `core/ajax.js`. 🔴 `app/`, `core/`, `database/`, `.env`, `.git/` quedan accesibles por HTTP, mitigados solo por `.htaccess` |
| **`public/`** | ✅ Correcto desde el punto de vista de seguridad. 🔴 `files/` y `core/ajax.js` quedan fuera del árbol servido y hay que exponerlos con un `Alias` |

Configuración recomendada:

```apache
<VirtualHost *:443>
    ServerName kuorum.ejemplo.com
    DocumentRoot /ruta/al/proyecto/public

    <Directory /ruta/al/proyecto/public>
        AllowOverride All
        Require all granted
        Options -Indexes
    </Directory>

    # files/ debe seguir siendo accesible (logos, adjuntos, favicon)
    Alias /files /ruta/al/proyecto/files
    <Directory /ruta/al/proyecto/files>
        Require all granted
        Options -Indexes -ExecCGI
        php_flag engine off        # no ejecutar PHP subido
    </Directory>
</VirtualHost>
```

---

## 10. Cabeceras de seguridad

Emitidas por `core/AutoLoad.php` después de procesar la petición, 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
```

> ⚠️ La CSP incluye `'unsafe-inline'` porque `ListaAjax` genera `<script>` inline. Y **no declara** `fonts.googleapis.com`, `fonts.gstatic.com` ni `cdn.datatables.net`, que sí se cargan.

---

## 11. Configuración de sesión

Fijada en `core/AutoLoad.php` **antes** de `session_start()`:

```php
$isSecure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
         || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443);

ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', $isSecure ? '1' : '0');

session_set_cookie_params([
    'httponly' => true,
    'secure'   => $isSecure,
    'samesite' => 'Strict',
]);
```

> Detrás de un proxy inverso que termine TLS, `$_SERVER['HTTPS']` puede no estar definido y la cookie **no** se marcará `Secure`. Configura el proxy para enviar `HTTPS=on` o usa `SetEnvIf` en Apache.

---

## 12. Localización

```php
setlocale(LC_ALL, "es_CO.utf8");
setlocale(LC_MONETARY, "es_CO.utf8");
setlocale(LC_TIME, 'Spanish_Colombia');
date_default_timezone_set('America/Bogota');
```

> ⚠️ Si la locale `es_CO.utf8` no está generada en el sistema, `setlocale()` falla silenciosamente y los formatos de fecha y moneda salen en el idioma por defecto. Genérala con `locale-gen es_CO.UTF-8`.
> `'Spanish_Colombia'` es una locale de **Windows**; en Linux no existe.

---

## 13. Extensiones PHP

`core/AutoLoad.php` comprueba y **solo registra en `error_log` si falta alguna** (no aborta):

```php
$requiredExt = array('gd', 'mbstring', 'pdo', 'pdo_mysql', 'openssl');
```

Además se usan de hecho:
- **`curl`** — Microsoft OAuth, Ollama. Se comprueba con `function_exists('curl_init')` antes de usarla en el flujo OAuth.
- **`json`**, **`session`**, **`fileinfo`** (elFinder), **`iconv`**/**`mbstring`** (mPDF).

---

## 14. Permisos del sistema de ficheros

| Ruta | Permiso necesario | Uso |
|---|---|---|
| `files/` y todas sus subcarpetas | **Escritura** del usuario del servidor web | Adjuntos, fotos, logotipos, evidencias, CV |
| `storage/cache/` | **Escritura** | `classmap.php`, caché de consultas, `demo_data_ready.flag` |
| `logs/` | **Escritura** | Ficheros de `Logger` con rotación |
| Todo lo demás | Solo lectura | |

Si `storage/cache/` no es escribible, el classmap y la caché **degradan en silencio**: el autoload sigue funcionando por búsqueda de fichero, más lento.

`Controller::CrearCarpetas($ruta)` crea directorios con `mkdir($ruta, 0755, true)`.

---

## 15. Lista de comprobación de configuración

Al desplegar en un entorno nuevo:

- [ ] `.env` creado a partir de `.env.example`
- [ ] `DB_NAME`, `DB_USER`, `DB_PASSWORD` rellenos y probados
- [ ] `APP_DEBUG=false`
- [ ] `DEMO_MODE_ENABLED=false`
- [ ] `DIRECTORIO_ACTIVO=false`
- [ ] `APP_ID` definido (y consciente de que cambiarlo cierra todas las sesiones)
- [ ] SMTP configurado y probado, con `MAIL_ENCRYPTION` coherente con `MAIL_PORT`
- [ ] `CRON_TOKEN` generado si se usa el envío programado
- [ ] Variables de Microsoft rellenas (las cuatro) o todas vacías
- [ ] `IA_OLLAMA_ENDPOINT` con `https://` si `IA_ENABLED=true`
- [ ] `files/`, `storage/cache/` y `logs/` con permiso de escritura
- [ ] `DocumentRoot` apuntando a `public/`, con `Alias /files`
- [ ] Locale `es_CO.UTF-8` generada en el sistema
- [ ] Extensiones `gd`, `mbstring`, `pdo_mysql`, `openssl`, `curl` instaladas
- [ ] `php bin/migrate status` sin migraciones pendientes
- [ ] Ejecución del perfil de semillas correcto (`base` en producción)
- [ ] Contraseña del usuario administrador cambiada respecto a la sembrada

---

## Documentos relacionados
- [12_INTEGRATIONS.md](12_INTEGRATIONS.md)
- [15_DEPLOYMENT.md](15_DEPLOYMENT.md)
- [16_TROUBLESHOOTING.md](16_TROUBLESHOOTING.md)
- [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md)
