# Guía de integración FaceID — kit v2

FaceID (`https://faceapi.epunemi.gob.ec`) es el servicio central de reconocimiento facial de
EPUNEMI: **enrolamiento**, **verificación 1:1** (¿es esta persona?) e **identificación 1:N**
(¿quién es?). Cada sistema que lo usa (SGA, Moodle, asistencia, exámenes…) es una **empresa**
con su propia **API key**, y sólo ve sus propios registros.

Este kit trae todo lo necesario para integrarlo desde otro sistema: captura de cámara para el
navegador (JavaScript y React) y clientes de backend en **Python, Node.js y PHP**, con ejemplos.

## Descargas

Base: `https://faceapi.epunemi.gob.ec/static/faceid-kit/v2/`

| Archivo | Para qué |
|---|---|
| [`faceid-kit-v2.zip`](faceid-kit-v2.zip) | Todo el kit (carpeta raíz `faceid-kit-v2/`) |
| **Navegador** | |
| [`js/faceid-capture.js`](js/faceid-capture.js) | Captura de cámara, sin dependencias (`<script>`, CommonJS) |
| [`js/faceid-capture.mjs`](js/faceid-capture.mjs) | El mismo código como módulo ES (`import`) |
| [`js/ejemplo.html`](js/ejemplo.html) | Pantalla de ejemplo: registrar y verificar rostro contra TU backend |
| [`js/demo-asistencia.html`](js/demo-asistencia.html) | Demo completa de **marcación de asistencia**; pruébala sin servidor con [`?simulado=1`](js/demo-asistencia.html?simulado=1) |
| [`react/`](react/README.md) | Hook `useFaceIDCapture` + componente `<FaceIDVerificar>` (React 18) |
| **Backend** | |
| [`python/faceid_client.py`](python/faceid_client.py) | Cliente Python (`requests`) |
| [`python/ejemplo_django.py`](python/ejemplo_django.py) · [`python/ejemplo_fastapi.py`](python/ejemplo_fastapi.py) | Vistas Django / endpoints FastAPI |
| [`node/faceid-client.mjs`](node/faceid-client.mjs) | Cliente Node 18+ sin dependencias |
| [`node/ejemplo-express.mjs`](node/ejemplo-express.mjs) · [`node/package.json`](node/package.json) | Ejemplo Express + multer |
| [`php/FaceIDClient.php`](php/FaceIDClient.php) | Cliente PHP 7.4+/8 con cURL, sin Composer |
| [`php/ejemplo.php`](php/ejemplo.php) · [`php/ejemplo_laravel.php`](php/ejemplo_laravel.php) | Endpoint PHP plano / controlador Laravel |

```bash
curl -O https://faceapi.epunemi.gob.ec/static/faceid-kit/v2/faceid-kit-v2.zip
unzip faceid-kit-v2.zip
```

> El kit v1 (`/static/faceid-kit/v1/`) sigue disponible y funcionando; v2 es compatible con el
> mismo API (`/api/faceid/v1.0.0/`). Ver [Cambios respecto a v1](#12-cambios-respecto-a-v1).

---


## Códigos de error y respuesta de `save/`

Además de `isSuccess: false` y `message`, los errores de negocio traen un código estable en `errors.code`.
Úsalo en tu lógica en lugar del texto, que puede cambiar.

| `errors.code` | Significado |
|---|---|
| `NO_COINCIDE` | El rostro no corresponde al registro (verificación 1:1) |
| `SIN_ROSTRO` | No se detectó un rostro en la foto |
| `VARIOS_ROSTROS` | Hay más de una persona en la foto |
| `SIN_REGISTRO` | La persona no tiene registro biométrico en tu empresa |
| `YA_REGISTRADO` | Ya existe un registro activo (envía `permite_actualizar` para reemplazarlo) |
| `CALIDAD_IMAGEN` / `IMAGEN_OSCURA` / `IMAGEN_PEQUENA` | La foto no cumple la calidad mínima (borrosa, oscura, girada, rostro pequeño) |
| `SUPLANTACION` | La prueba de vida pasiva detectó foto impresa o pantalla |
| `CLAVE_SECRETA` | Falta o es inválido `X-Secret-Key` |
| `VALIDACION` | Otro error de validación |

Los clientes del kit exponen `code` (`e.code` / `$e->code`) y `noCoincide` / `no_coincide`.

`POST save/` responde `aData: {"id": 123, "accion": "creado" | "actualizado"}`.

## 1. Arquitectura (regla de oro)

```
Navegador ──foto──▶ TU backend ──foto + API key──▶ FaceID
 (faceid-capture.js       (faceid_client.py,             (/api/faceid/v1.0.0/biometric/…)
  o <FaceIDVerificar>)     faceid-client.mjs,
                           FaceIDClient.php)
```

1. **El navegador nunca llama a FaceID.** Toma la foto y la envía a tu backend, con la sesión
   de tu sistema.
2. **La API key es un secreto de servidor.** Va sólo en tu backend, en el header
   `Authorization: Bearer <token>`. Nunca en HTML, JavaScript, apps móviles, logs ni en git.
3. **La identidad la decide tu backend**: el `cod_number` a verificar sale de la sesión del
   usuario en tu sistema, no de un campo que envíe el navegador.
4. **La decisión la toma tu backend**: registra la asistencia o abre el examen sólo cuando FaceID
   responde `is_same_person = true` *a tu servidor*. Lo que diga el navegador no cuenta.

### CORS

El API sólo acepta peticiones de navegador (CORS) desde **orígenes autorizados**. Si de verdad
necesitas llamar desde un navegador, solicita el alta de tu dominio al administrador de FaceID;
pero **no se recomienda**: obligaría a exponer la API key. Las llamadas servidor → servidor
(lo que hace este kit) no dependen de CORS.

## 2. Obtener la API key

Un administrador de FaceID crea tu empresa en **Seguridad → Empresas** y te entrega su token
(64 caracteres aleatorios). Guárdalo como variable de entorno o en tu gestor de secretos:

```bash
# .env / variables del servicio (no lo subas a git)
FACEID_URL=https://faceapi.epunemi.gob.ec
FACEID_API_KEY=<token de tu empresa>
FACEID_SECRET_KEY=<sólo si usas biometric_data/delete/reset>
```

- Cada empresa sólo ve y modifica sus propios registros.
- Si la empresa está **desactivada**, todas sus peticiones reciben **HTTP 403**
  (`"La empresa está desactivada; contacte al administrador de FaceID"`).
- Si la key se filtra, pide al administrador que la cambie de inmediato: la anterior deja de funcionar.

## 3. Autenticación

| Header | Cuándo | Valor |
|---|---|---|
| `Authorization` | **Todas** las peticiones | `Bearer <FACEID_API_KEY>` |
| `X-Secret-Key` | Sólo `biometric_data/`, `delete/` y `reset/` | Clave administrativa entregada por el administrador |

La clave administrativa va en header (no en la URL, porque quedaría en los logs de acceso). Por
compatibilidad se acepta `secretKey` en el cuerpo o la query, pero no la uses en integraciones nuevas.

```bash
curl -X POST https://faceapi.epunemi.gob.ec/api/faceid/v1.0.0/biometric/verify_face/ \
  -H "Authorization: Bearer $FACEID_API_KEY" \
  -F cod_number=12345 \
  -F face_photo_file=@captura.jpg
```

---

## 4. Referencia del API

Base: `https://faceapi.epunemi.gob.ec/api/faceid/v1.0.0/biometric/` — Swagger: `/api/swagger/`

### Formato de respuesta

```json
{"isSuccess": true, "message": "Acción realizada correctamente.", "aData": { }}
```

- Los **errores de negocio** (rostro distinto, sin rostro, sin registro, foto oscura…) llegan con
  **HTTP 200** y `isSuccess: false`. **Revisa siempre `isSuccess`**, no sólo el código HTTP.
- Key inválida, ausente o empresa desactivada → **HTTP 403** con `isSuccess: false`.
- Los clientes del kit convierten ambos casos en una excepción (`FaceIDError` / `FaceIDException`).
- Los registros (`biometric`) incluyen `face_embedding` (vector biométrico de 512 números). Los
  clientes del kit **lo eliminan por defecto**: no lo guardes ni lo envíes al navegador.

### Resumen de endpoints

| Método | Ruta | Parámetros | Respuesta (`aData`) |
|---|---|---|---|
| POST | `verify_face/` (multipart) | `face_photo_file`* + `cod_number` **o** `username` **o** `email` | `is_same_person`, `similarity_score`, `distance`, `threshold`, `model`, `biometric`, `liveness`? |
| POST | `identify/` (multipart) | `face_photo_file`* | `found`, `ambiguous`, `criterio`, `distance`, `similarity_score`, `threshold`, `margin`, `empresa`, `candidates_checked`, `candidates_without_embedding`, `biometric` |
| POST | `save/` (multipart) | `cod_number`*, `face_photo`* (archivo), `username`, `nombre`, `apellido`, `email`, `dni_number`, `permite_actualizar` | — (sólo `message`) |
| GET | `load_faces/` | `username` o `email` | `aData`: `data_count`, `aPeriod`; registros en `aaData` |
| GET | `load/` | `id` | el registro |
| GET | `biometric_data/` | `username` o `email` + header `X-Secret-Key` | el registro + `face_photo` |
| POST | `reset/` (JSON) | `{"usernames": [...]}` + header `X-Secret-Key` | lista de registros desactivados |
| DELETE | `delete/` | `id` (query) + header `X-Secret-Key` | — |
| POST | `verify_face_public/` | `face_photo_url`, `url_comparar` | comparación de dos URLs (sólo hosts permitidos; preferir `verify_face/`) |

`*` obligatorio. Todos los datos de ejemplo de esta guía son ficticios.

### 4.1 Verificación 1:1 — `POST verify_face/`

¿La persona de la foto es el usuario indicado? El registro activo se busca por `cod_number`
(o `username`, o `email`, en ese orden de prioridad).

```
POST /api/faceid/v1.0.0/biometric/verify_face/
Authorization: Bearer <FACEID_API_KEY>
Content-Type: multipart/form-data

cod_number=12345
face_photo_file=<captura.jpg>
```

Respuesta cuando **coincide**:

```json
{
  "isSuccess": true,
  "message": "Acción realizada correctamente.",
  "aData": {
    "is_same_person": true,
    "similarity_score": 0.7134,
    "distance": 0.2866,
    "threshold": 0.5,
    "model": "ArcFace",
    "biometric": {
      "id": 101, "empresa": 7, "username": "jperez", "nombre": "JUAN", "apellido": "PEREZ",
      "email": "jperez@ejemplo.com", "cod_number": "12345", "dni_number": "0900000000",
      "face_photo": "biometrico/faceid/jperez/jperez_a1b2c3.jpg",
      "is_verify": true, "date_verify": null, "fecha_creacion": "2026-03-02 08:15:00"
    }
  }
}
```

Respuesta cuando **no coincide** (HTTP 200):

```json
{
  "isSuccess": false,
  "message": "Sus datos biométricos no coinciden con los registrados. Asegúrese de que su rostro esté completamente visible. - Distancia: 0.8377, Puntaje de similitud: 0.1623"
}
```

Los clientes del kit marcan este caso (`e.no_coincide` en Python, `e.noCoincide` en Node/PHP) y
extraen `distance` / `similarity_score` del mensaje. A tu usuario muéstrale un texto simple
("El rostro no coincide…"), no la distancia.

### 4.2 Identificación 1:N — `POST identify/`

¿A quién de **mi empresa** pertenece este rostro? Compara contra todos los registros activos.

```
POST /api/faceid/v1.0.0/biometric/identify/
Authorization: Bearer <FACEID_API_KEY>

face_photo_file=<captura.jpg>
```

```json
{
  "isSuccess": true,
  "message": "Acción realizada correctamente.",
  "aData": {
    "found": true,
    "ambiguous": false,
    "criterio": "umbral",
    "distance": 0.2412,
    "similarity_score": 0.7588,
    "threshold": 0.4,
    "margin": 0.4105,
    "empresa": "MI_SISTEMA",
    "candidates_checked": 1250,
    "candidates_without_embedding": 0,
    "biometric": {"id": 101, "username": "jperez", "cod_number": "12345", "nombre": "JUAN", "apellido": "PEREZ"}
  }
}
```

| Campo | Significado |
|---|---|
| `found` | `true` si se identificó a alguien. `false` **no es un error**: persona no registrada o resultado ambiguo (`isSuccess` sigue siendo `true`) |
| `ambiguous` | `true` si el 2º candidato también pasa el umbral y está a menos de 0.05 de distancia del 1º: no se identifica a nadie para no confundir personas |
| `criterio` | Por qué se aceptó: `'umbral'` (distancia ≤ 0.40, el criterio normal) o `'margen'` (distancia entre 0.40 y 0.50, aceptada porque el 1º aventaja al 2º por ≥ 0.15 o es el único candidato). `null` si `found=false` |
| `distance` / `similarity_score` | Del **mejor** candidato, aunque `found` sea `false` (`null` si no hay candidatos) |
| `threshold` | Umbral 1:N de distancia (0.40; más estricto que el 1:1 porque se compara con muchas personas) |
| `margin` | Distancia del 2º candidato − distancia del 1º. Cuanto mayor, más clara la identificación. `null` con un solo candidato |
| `empresa` | Nombre de la empresa del registro encontrado (`null` si `found=false`) |
| `candidates_checked` | Registros activos comparados |
| `candidates_without_embedding` | Registros activos que no se pudieron comparar (sin plantilla vigente). Si es > 0, avisa al administrador |
| `biometric` | El registro identificado, o `null` |

Recomendación: para decisiones sensibles (asistencia, exámenes) usa **1:1** con la identidad de la
sesión; usa 1:N para buscar o sugerir ("¿eres Juan Pérez?") y confirma después.

### 4.3 Semántica de la similitud

- `distance` es la **distancia coseno** entre las plantillas faciales ArcFace (0 = idénticas).
- `similarity_score = 1 − distance`. Multiplica por 100 para mostrarlo en porcentaje.
- **1:1**: se acepta si `distance ≤ threshold` (0.50 por defecto) ⇒ **similitud ≥ 50 %**.
- **1:N**: umbral 0.40 (similitud ≥ 60 %) o el criterio de margen descrito arriba.
- La misma persona frente a una webcam normalmente da **55–85 %** contra su foto de registro.
  Personas distintas suelen quedar muy por debajo del 50 % (con frecuencia bajo 30 %).
- **100 % sólo ocurre con la imagen idéntica**; incluso la misma foto recomprimida (PNG → JPEG) da
  ~97–99 %. Una similitud cercana a 100 % en una captura "en vivo" es sospechosa (foto de la foto,
  reenvío de una imagen guardada): puedes registrarla como alerta.
- No compares similitudes entre modelos o versiones distintas; los umbrales los calibra FaceID.

### 4.4 Enrolamiento — `POST save/`

```
POST /api/faceid/v1.0.0/biometric/save/      (multipart)
cod_number=12345            ← obligatorio, identificador estable de TU sistema
username=jperez
nombre=JUAN
apellido=PEREZ
email=jperez@ejemplo.com
dni_number=0900000000
permite_actualizar=true     ← sólo si quieres reemplazar la foto de un registro activo
face_photo=<foto.jpg>       ← obligatorio
```

```json
{"isSuccess": true, "message": "Registro creado correctamente"}
```

- Si el usuario ya tiene registro activo y **no** envías `permite_actualizar`, falla con
  `"Ya se encuentra registrado su información biométrica"`. **Cualquier valor presente cuenta como
  "sí"** (incluso `false`): para no actualizar, omite el campo (los clientes del kit lo hacen).
- La foto se valida: mínimo 200×200 px, buena luz, **exactamente un rostro** (nadie al fondo), rostro
  grande (≥ 12 % del ancho), nítido y de frente.
- Si FaceID tiene activada la deduplicación, rechaza un rostro ya registrado con otro `cod_number`.

### 4.5 Consultas y administración

```bash
# Registros activos de un usuario (+ periodo de enrolamiento)
curl "$FACEID_URL/api/faceid/v1.0.0/biometric/load_faces/?username=jperez" -H "Authorization: Bearer $FACEID_API_KEY"
# → {"isSuccess":true,"aData":{"data_count":1,"aPeriod":{"title":"","start_date":null,"end_date":null,"is_active":false}},"aaData":[{...registro...}]}

# Un registro por id
curl "$FACEID_URL/api/faceid/v1.0.0/biometric/load/?id=101" -H "Authorization: Bearer $FACEID_API_KEY"

# Desactivar (resetear) registros — requiere X-Secret-Key
curl -X POST "$FACEID_URL/api/faceid/v1.0.0/biometric/reset/" \
  -H "Authorization: Bearer $FACEID_API_KEY" -H "X-Secret-Key: $FACEID_SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"usernames": ["jperez"]}'
# → {"isSuccess":true,"message":"Se han reseteado los registros biométricos correctamente.","aData":[{...}]}

# Eliminar un registro — requiere X-Secret-Key
curl -X DELETE "$FACEID_URL/api/faceid/v1.0.0/biometric/delete/?id=101" \
  -H "Authorization: Bearer $FACEID_API_KEY" -H "X-Secret-Key: $FACEID_SECRET_KEY"
```

### 4.6 Rendimiento y límites

- Envía **JPEG de hasta ~1280 px** de lado (calidad 0.9). Más resolución no mejora el resultado.
- Una verificación tarda ~1–3 s. Tras reiniciar FaceID, la primera puede tardar más (carga del
  modelo): usa un **timeout de 60 s** (los clientes del kit ya lo hacen).
- Limita en tu backend el tamaño de la foto (≈ 5 MB) y la frecuencia de intentos por usuario.

## 5. Errores frecuentes

| HTTP | `message` (resumen) | Causa | Qué hacer |
|---|---|---|---|
| 403 | `Acción no permitida: Token de autorización faltante` | Falta el header `Authorization` | Envía `Authorization: Bearer <key>` desde tu backend |
| 403 | `… Debe enviarse como: Bearer <token>` | Formato incorrecto del header | Exactamente `Bearer` + espacio + key |
| 403 | `… Token de empresa no válido o no registrado` | Key incorrecta, regenerada o con espacios/saltos de línea | Revisa la variable de entorno (`trim`) |
| 403 | `… La empresa está desactivada…` | El administrador desactivó tu empresa | Contacta al administrador de FaceID |
| 200 | `Sus datos biométricos no coinciden…` | Rostro distinto (o captura muy mala) | Pide reintentar con mejor luz; limita intentos |
| 200 | `No se detectó un rostro en la captura…` | Sin rostro, muy oscuro, de perfil, tapado | Rostro centrado, de frente, buena luz |
| 200 | `No se encontró el registro biométrico` | El usuario no está enrolado en **tu** empresa | Enrólalo primero (`save/`) |
| 200 | `Ya se encuentra registrado su información biométrica` | `save/` sin `permite_actualizar` y ya existe | Envía `permite_actualizar=true` si quieres reemplazar |
| 200 | `La imagen es demasiado pequeña…` / `…demasiado oscura…` / `…borrosa…` / `…una sola persona…` | Validación de calidad al enrolar | Repite la foto siguiendo el mensaje |
| 200 | `El campo face_photo_file es requerido` | Nombre de campo incorrecto | `face_photo_file` en verify/identify; `face_photo` en save |
| 200 | `Debe enviar cod_number, username o email` | Falta la identidad en `verify_face/` | Envía uno (tomado de la sesión) |
| 200 | `La clave secreta no coincide: secretKey` | Falta o es incorrecto `X-Secret-Key` | Sólo para `biometric_data/`, `delete/`, `reset/` |
| 200 | `Error al realizar la acción.` | Error interno no controlado | Reintenta; si persiste, reporta hora y endpoint |
| 413 / 502 / 504 | (HTML de nginx) | Foto enorme / servicio reiniciando / timeout | Reduce la foto; reintenta con espera |

En el navegador, `faceid-capture.js` traduce los errores de cámara (permiso denegado, cámara ocupada,
imagen negra, navegador interno de WhatsApp/Facebook…) a mensajes en español; ver sección 6.

---

## 6. Navegador — JavaScript (sin frameworks)

```html
<video id="video"></video>
<button id="btnCamara">Activar cámara</button>
<button id="btnVerificar">Verificar</button>
<script src="/static/js/faceid-capture.js"></script>   <!-- copia el archivo a TU sitio -->
<script>
  const cam = new FaceIDCapture(document.getElementById('video'));

  document.getElementById('btnCamara').onclick = async () => {
    try { await cam.start(); console.log(cam.info()); }      // modelo, resolución, fps
    catch (e) { alert(e.message); }                         // mensaje claro en español
  };

  document.getElementById('btnVerificar').onclick = async () => {
    const datos = new FormData();
    datos.append('foto', await cam.capture(), 'captura.jpg');
    const r = await (await fetch('/faceid/verificar/', {     // TU backend, no FaceID
      method: 'POST', body: datos, credentials: 'same-origin', headers: {'X-CSRFToken': csrftoken}
    })).json();
  };
</script>
```

Como módulo ES (Vite, webpack, `<script type="module">`):

```js
import { FaceIDCapture } from './faceid-capture.mjs';
```

API pública (igual que v1, más `destruir()`):

| Método | Descripción |
|---|---|
| `FaceIDCapture.soporte()` | `{ok, error?}` sin pedir permisos (HTTPS, navegador, app interna) |
| `new FaceIDCapture(video, opciones?)` | `ancho`, `alto`, `calidad`, `maxLado`, `espejo`, `recordar`, `brilloMinimo`, `onDesconectada` |
| `start(deviceId?)` | Abre la cámara (llamar dentro de un click por iOS) |
| `capture()` | `Promise<Blob>` JPEG sin espejo; rechaza frames negros (`DarkFrame`) |
| `stop()` | Apaga la cámara |
| `info()` | `{modelo, usb, ancho, alto, fps, deviceId}` |
| `camaras()` | Lista de cámaras (para un selector) |
| `camaraAsignada()` | Cámara recordada en este navegador |
| `destruir()` | Apaga, cancela un `start()` en curso y quita listeners (SPA) |

| Situación | Qué hace |
|---|---|
| Página sin HTTPS, navegador antiguo | `soporte()` lo detecta antes de pedir permisos |
| Navegador interno de WhatsApp/Facebook/Instagram/TikTok | Avisa que se abra en Chrome o Safari |
| Permiso denegado, sin cámara, cámara ocupada | Mensaje claro; reintenta hasta 3 veces si está ocupada |
| Imagen negra (cámara IR, virtual, tapa de privacidad) | Prueba automáticamente las otras cámaras |
| La cámara recordada ya no abre | Usa la predeterminada |
| Cámara que no acepta la resolución | Reintenta con la configuración mínima |
| Cámara desconectada en uso | Llama `onDesconectada(error)` |
| Safari iOS / Android en segundo plano | `playsinline` + `muted`; reanuda el video al volver a la pestaña |
| Salir de la página | Libera la cámara (`pagehide`) |

**Asignación de cámara:** la cámara elegida con `start(deviceId)` (o la que funcionó tras descartar
una negra) se recuerda en `localStorage['faceid_camara']`. Cambia la clave con
`{recordar: 'mi_clave'}` o desactívalo con `{recordar: false}`. Diagnóstico de cámaras:
`https://faceapi.epunemi.gob.ec/camtest/`.

**Una página web no puede quitarle la cámara a otra aplicación** (Zoom, Teams…): sólo puede
detectarlo, avisar y reintentar.

### Demo de marcación de asistencia

[`js/demo-asistencia.html`](js/demo-asistencia.html) es una pantalla autónoma (HTML + CSS + JS, sin
jQuery, Bootstrap ni MediaPipe) inspirada en la marcación de FaceID: reloj, jornada (Entrada,
Salida, Salida al almuerzo, Regreso del almuerzo), pasos *Jornada → Cámara → Verificación*,
óvalo guía, chips de intento (3 por jornada) y tiempo, captura automática tras una cuenta
regresiva, botones Registrar / Cancelar / Intentar nuevamente y «Registro del día».

| URL | Comportamiento |
|---|---|
| `demo-asistencia.html?simulado=1` | Sin servidor: la verificación siempre resulta exitosa (similitud aleatoria 62–82 %) |
| `demo-asistencia.html?simulado=fallo` | Sin servidor: "el rostro no coincide", para ver los intentos y la incidencia |
| `demo-asistencia.html` | Envía `POST` multipart a `URL_MARCAR` (constante al inicio del script) |

Contrato con tu backend: campos `foto` (JPEG) y `tipo` (`1`=Entrada, `2`=Salida, `3`=Salida al
almuerzo, `4`=Regreso del almuerzo), cookie de sesión y header CSRF; responde
`{"isSuccess": true, "message": "…", "aData": {"is_same_person": true, "similarity_score": 0.71, "hora": "08:02:11"}}`
o `{"isSuccess": false, "message": "…"}`. Tu backend toma la identidad de la sesión, llama a
`verify_face/` y guarda la marcada con la **hora del servidor** (el reloj de la demo es el del
equipo, sólo informativo). En la demo, el «Registro del día» se guarda en `localStorage`; en tu
sistema debe venir del servidor, igual que el control de intentos.

## 7. Navegador — React 18

Ver [`react/README.md`](react/README.md). Resumen:

```jsx
import FaceIDVerificar from './faceid/FaceIDVerificar.jsx';

<FaceIDVerificar urlVerificar="/faceid/verificar/" csrfToken={csrf}
                 onVerificado={(aData) => irAlExamen()} />
```

Instalación: copiar `react/faceid-capture.mjs`, `react/useFaceIDCapture.js` y
`react/FaceIDVerificar.jsx` a tu proyecto. Sin dependencias además de `react`.

## 8. Backend — Python (Django, FastAPI, Flask)

```bash
pip install requests
```

```python
import os
from faceid_client import FaceIDClient, FaceIDError

faceid = FaceIDClient(api_key=os.environ['FACEID_API_KEY'])

# Enrolar
faceid.enrolar(open('foto.jpg', 'rb'), cod_number='12345', username='jperez',
               nombre='JUAN', apellido='PEREZ', email='jperez@ejemplo.com', permite_actualizar=True)

# Verificar 1:1
try:
    r = faceid.verificar(open('captura.jpg', 'rb'), cod_number='12345')
    print(r['is_same_person'], r['similarity_score'])
except FaceIDError as e:
    if e.no_coincide:
        print('Rostro distinto', e.similarity_score)
    elif e.status == 403:
        print('Revisa la API key / empresa desactivada')
    else:
        print('No verificado:', e.message)

# Identificar 1:N
r = faceid.identificar(open('captura.jpg', 'rb'))
if r['found']:
    print(r['biometric']['cod_number'], r['criterio'], r['similarity_score'])
```

Métodos: `enrolar`, `verificar`, `identificar`, `registros`, `obtener`, `datos_biometricos`*,
`resetear`*, `eliminar`*, `comparar_urls` (`*` requieren `FaceIDClient(..., secret_key=...)`).

- **Django**: copia `faceid_client.py` y `ejemplo_django.py` a tu app; agrega las URLs
  `faceid/enrolar/` y `faceid/verificar/` (con `login_required` + CSRF).
- **FastAPI**: `ejemplo_fastapi.py` (usa `run_in_threadpool` para no bloquear el event loop).
- **Flask**: igual que FastAPI con `request.files['foto']` y tu `login_required`.

## 9. Backend — Node.js 18+ (Express)

```bash
# El cliente no tiene dependencias; el ejemplo usa Express y multer:
npm i express multer
```

```js
import { FaceIDClient, FaceIDError } from './faceid-client.mjs';

const faceid = new FaceIDClient({ apiKey: process.env.FACEID_API_KEY });

try {
  const r = await faceid.verificar(req.file.buffer, { codNumber: String(req.session.usuario.id) });
  // r.is_same_person, r.similarity_score
} catch (e) {
  if (e instanceof FaceIDError && e.noCoincide) { /* rostro distinto */ }
  else if (e instanceof FaceIDError && e.status === 403) { /* key inválida / empresa desactivada */ }
}

const quien = await faceid.identificar(buffer);   // {found, ambiguous, criterio, biometric, …}
```

Métodos: `enrolar(foto, {codNumber, username, nombre, apellido, email, dniNumber, permiteActualizar})`,
`verificar(foto, {codNumber | username | email})`, `identificar(foto)`, `registros({username|email})`,
`obtener(id)`, `datosBiometricos`*, `resetear(usernames)`*, `eliminar(id)`* (`*` con `secretKey`).
`foto` puede ser `Buffer`, `Uint8Array`, `ArrayBuffer` o `Blob`. `FaceIDError` trae `message`,
`status` y `respuesta`.

`ejemplo-express.mjs` expone `POST /faceid/enrolar` y `POST /faceid/verificar` (campo `foto`), con
la identidad tomada de la sesión (`req.session.usuario`), nunca del navegador.

## 10. Backend — PHP 7.4+ / 8 (sin Composer)

Requiere las extensiones `curl` y `json` (habituales en cualquier hosting).

```php
require_once __DIR__ . '/FaceIDClient.php';

$faceid = new FaceIDClient(getenv('FACEID_API_KEY'));
try {
    // $_FILES['foto']['tmp_name'], un CURLFile o los bytes del JPEG
    $r = $faceid->verificar($_FILES['foto']['tmp_name'], ['cod_number' => (string)$_SESSION['usuario']['id']]);
    // $r['is_same_person'], $r['similarity_score']
} catch (FaceIDException $e) {
    if ($e->noCoincide) { /* rostro distinto */ }
    elseif ($e->status === 403) { /* key inválida / empresa desactivada */ }
    else { echo $e->getMessage(); }
}
```

Métodos: `enrolar($foto, [...])`, `verificar($foto, [...])`, `identificar($foto)`,
`registros($username, $email)`, `obtener($id)`, `datosBiometricos`*, `resetear($usernames)`*,
`eliminar($id)`* (`*` con `new FaceIDClient($key, null, $secretKey)`).

- **PHP plano**: `ejemplo.php` (sesión PHP + token CSRF en sesión + validación de la foto).
- **Laravel**: `ejemplo_laravel.php` (controlador con `Auth::user()`, validación y CSRF de Laravel).

### Otros lenguajes

Son peticiones HTTP normales: multipart para `save/`, `verify_face/` e `identify/`, header
`Authorization: Bearer`, y revisar `isSuccess` en la respuesta JSON (ver sección 4).

---

## 11. Checklist de seguridad y protección de datos (LOPDP)

**Seguridad**
- [ ] La API key vive sólo en el backend (variable de entorno / gestor de secretos), nunca en git,
      HTML, JavaScript, apps móviles ni logs.
- [ ] El navegador habla con tu backend; tu backend habla con FaceID (servidor → servidor, HTTPS).
- [ ] El `cod_number` a verificar sale de la sesión de tu sistema, no del navegador.
- [ ] La acción (asistencia, examen) se registra en tu servidor sólo si FaceID respondió
      `isSuccess=true` e `is_same_person=true`.
- [ ] Tus endpoints exigen sesión + CSRF, limitan el tamaño (≈ 5 MB) y el tipo de la foto.
- [ ] Limitas los intentos por usuario (p. ej. 3 fallos ⇒ incidencia/revisión manual).
- [ ] Revisas `isSuccess` además del código HTTP; tratas 403 como error de configuración.
- [ ] La clave administrativa (`X-Secret-Key`) sólo existe en procesos administrativos, no en el
      flujo normal de verificación.
- [ ] No desactivas la verificación TLS (`verify=False`, `CURLOPT_SSL_VERIFYPEER=false`).

**Datos personales (Ley Orgánica de Protección de Datos Personales, Ecuador)**
- [ ] Los datos biométricos son **datos sensibles**: informa la finalidad y obtén el
      **consentimiento** explícito del titular antes de enrolarlo.
- [ ] Usas la biometría sólo para la finalidad informada (proporcionalidad / minimización).
- [ ] No guardas las fotos ni `face_embedding` en tu sistema ni en logs; no reenvías al navegador
      más datos de los necesarios (los ejemplos devuelven sólo `is_same_person` y la similitud).
- [ ] Ofreces una alternativa a quien no pueda o no quiera usar reconocimiento facial.
- [ ] Atiendes derechos de acceso, rectificación y eliminación: solicita al administrador de
      FaceID el reseteo/eliminación de registros cuando corresponda.
- [ ] Registras quién y cuándo se verificó (auditoría) sin almacenar la imagen.

---

## 12. Cambios respecto a v1

- **Nuevos clientes**: Node.js (`node/`), PHP (`php/`) y React (`react/`).
- `faceid-capture.js`: también como módulo ES (`faceid-capture.mjs`) y CommonJS; `destruir()`;
  `capture()` rechaza frames negros; aviso de cámara desconectada; si la cámara recordada falla se
  usa la predeterminada; `start()` simultáneos ya no dejan cámaras abiertas; seguro en SSR.
- Cliente Python: `datos_biometricos()` y `comparar_urls()`; `FaceIDError` con `status`,
  `no_coincide`, `distance`, `similarity_score`; `X-Secret-Key` sólo en las operaciones que lo
  requieren; elimina `face_embedding` de las respuestas.
- `ejemplo.html` ya no pide el código de usuario: la identidad la pone el backend desde la sesión.
- Documentados los campos nuevos de `identify/` (`ambiguous`, `criterio`, `margin`, `empresa`).
