# Plan — Fase 6: Biometría real con lector ZK9500

## Contexto

Hardware ya comprado (2026-09-21): **ZKTeco ZK9500**, lector USB
500dpi — confirmado que es el modelo correcto, el mismo recomendado en
`../CLAUDE.md` §4 desde el diseño original de este proyecto. Es un
lector/enrolador USB de mesa (SDK propietario ZKFinger), **no** uno de
los relojes checadores standalone en red — los paquetes Laravel tipo
`laravel-zkteco`/UDP puerto 4370 que abundan en internet son para ese
otro tipo de dispositivo y no aplican acá.

Arquitectura ya acordada de antes, sin cambios (`../CLAUDE.md` §4-10,
`docs/MODELO-DATOS.md` §2 `fingerprints`): agente local en la PC del
kiosko que envuelve el SDK, expone `enrolar`/`identificar` en
`127.0.0.1`, hace el matching 1:N **localmente** (nunca en el
servidor), y el servidor solo guarda el `template`, nunca una imagen.

Confirmado con el usuario en esta ronda (2026-09-21):
- Un solo lector físico por ahora; una laptop Windows dedicada
  exclusivamente al kiosko (ahí va el lector, ahí se enrola).
- El admin gestiona el sistema desde cualquier PC normalmente, pero el
  enrolamiento en sí (acción física de poner el dedo) solo puede pasar
  en esa laptop, porque es donde está el único lector.
- Producción: hosting **cPanel** (remoto) + esa laptop Windows.
  Desarrollo/pruebas: Fedora + Docker, `localhost`.
- Escala futura conocida: un segundo lector/estación en ~6 meses — el
  diseño de sincronización (cada agente jala su propia copia desde el
  servidor) ya soporta eso sin rediseño.

**Riesgo técnico a validar en la práctica, no en el diseño**: el
kiosko en producción se sirve por HTTPS (cPanel); el agente local
corre en `http://127.0.0.1:PUERTO`. `../CLAUDE.md` §4 asume que los
navegadores confían en `127.0.0.1` aunque la página sea HTTPS — en la
práctica reciente de Chrome esto puede bloquearse como *mixed
content*. El agente se diseña desde el día 1 con HTTPS + certificado
autofirmado como plan B, para no depender de que ese supuesto se
cumpla.

**Pruebas end-to-end reales, antes de tocar Windows**: el ZKFinger SDK
tiene build para Linux x86_64 — el ZK9500 se puede probar conectado
directo a la PC Fedora de desarrollo, sin esperar a la laptop Windows
dedicada. Como en local el kiosko corre por HTTP simple
(`localhost:8020`, sin HTTPS), el riesgo de *mixed content* de arriba
ni siquiera aplica ahí — el agente puede correr en HTTP plano durante
esta etapa. Secuencia real: (1) Cursor construye y testea la parte
Laravel sin hardware; (2) en Fedora, lector conectado por USB al host,
agente en HTTP simple contra `localhost:8020`, se valida el flujo
completo (enrolar + identificar) de verdad; (3) recién con eso
funcionando se porta el mismo agente (mismo código Python, cambia solo
el SDK instalado) a la laptop Windows de producción, ahí sí con HTTPS
+ certificado propio por el tema de cPanel.

## Alcance

**Incluye:**
- Migración `fingerprints` (ya diseñada en `docs/MODELO-DATOS.md` §2:
  `user_id` único FK, `template`, `enrolled_by`, `enrolled_at`,
  `active`).
- Modelo `Fingerprint`, relación `User::fingerprint()`.
- Dos endpoints protegidos por **token compartido** (no sesión — el
  agente no es un navegador logueado): sincronizar plantillas activas,
  y guardar/reemplazar la huella de un docente.
- Botón "Enrolar huella" en `/admin/users/teachers`.
- Botón "Marcar con huella" en el kiosko, con caída automática a modo
  manual si el agente no responde (mismo criterio ya escrito en
  `../CLAUDE.md` §10).
- El agente local (Python) — **proyecto aparte**, no es tarea de
  Cursor sobre este repo (ya estaba anotado así desde el diseño
  original, `../CLAUDE.md` §8).

**No incluye (a propósito):**
- Coordinar varios lectores/agentes entre sí (llega con el segundo
  lector, en ~6 meses — el diseño actual ya lo permite sin rediseño).
- Liveness/antispoofing más allá del hardware del propio ZK9500.
- Histórico de re-enrolamientos (una sola huella activa por docente,
  ya definido en `MODELO-DATOS.md`).

## Decisiones de esta ronda

1. **Auth agente↔Laravel**: token fijo en `.env`
   (`FINGERPRINT_AGENT_TOKEN`), enviado como `Authorization: Bearer`,
   verificado por un middleware simple — sin Sanctum/OAuth, mismo
   criterio de "mínimas piezas móviles" que el resto del proyecto.
2. **Sincronización**: el agente, al arrancar y tras cada
   enrolamiento/desactivación, llama a
   `GET /admin/api/fingerprints/sync` (con el token) y recarga el
   lector con lo que reciba.
3. **Guardado del enrolamiento**: el agente le entrega el template
   capturado al **navegador** (vía su API local), y es el navegador —
   con la sesión normal del admin ya logueado — quien lo manda a
   Laravel. Así el guardado real queda protegido por la sesión de
   admin de siempre, no solo por el token del agente (el token del
   agente solo autoriza *leer* plantillas para sincronizar, no
   escribir).
4. **Certificado del agente**: autofirmado, generado una vez en la
   laptop; "confiar una vez" es un paso manual documentado, no
   automatizado (no vale la pena un ACME/Let's Encrypt para un
   loopback).

## Tareas — Cursor (este repo, Laravel)

- [x] 1. Migración `fingerprints` (schema exacto de `MODELO-DATOS.md` §2).
- [x] 2. Modelo `Fingerprint` + `User::fingerprint(): HasOne`.
- [x] 3. `.env`/`.env.example`: `FINGERPRINT_AGENT_TOKEN` (+ `FINGERPRINT_AGENT_PORT`).
  Middleware `VerifyFingerprintAgentToken` (Bearer vs
  `config('services.fingerprint_agent.token')`, 401 si no coincide).
- [x] 4. Ruta `GET /admin/api/fingerprints/sync` fuera del grupo auth/role,
  protegida por ese middleware → `[{document_number, template}]` de
  docentes activos con huella `active = true`.
- [x] 5. `Users.php`/`users.blade.php` (teachers): botón enrolar → JS al
  agente (`https://127.0.0.1:PUERTO/enroll`) → `saveFingerprint($template)`
  (crea o reemplaza; `enrolled_by` = admin logueado).
- [x] 6. `Kiosk.php`/`kiosk.blade.php`: probe `/status` (timeout corto);
  si responde, botón "Marcar con huella" → `/identify` →
  `$wire.set(documentNumber)` + `identificationMethod=fingerprint` +
  `$wire.call('identify')` sin reescribir `identify()`. Sin agente: idle
  idéntico (botón oculto con `x-show`/`x-cloak`).
- [x] 7. Tests `Fase6FingerprintsTest`: schema, factory/relación, sync
  token válido/inválido/ausente/vacío, saveFingerprint crea/reemplaza,
  flujo kiosko manual sin agente sigue verde.

### Verificación Cursor (2026-09-21)
- `migrate:fresh --seed` limpio (incluye `fingerprints`).
- `./vendor/bin/pint --test` limpio.
- Suite completa en verde (incl. 6 tests nuevos de Fase 6).

### No hecho (a propósito — fuera de alcance Cursor)
- Agente Python local, SDK ZKFinger, certificado HTTPS del agente,
  instrucciones Windows → sección "Tareas — mías, directas".
- Matching 1:N real / captura física (requiere hardware + agente).
- Actualizar `MODELO-DATOS.md` / `CAMBIOS.md` / `CLAUDE.md` (docs de
  cierre típicos de Claude tras verificar el plan).

## Tareas — mías, directas (agente local, proyecto aparte, fuera de este repo Laravel)

No es tarea de Cursor — es la única pieza no-Laravel del proyecto,
tratada aparte desde el diseño original (`../CLAUDE.md` §8):

1. Script Python (`pyzkfp` + FastAPI/Flask) con `GET /status`,
   `POST /enroll`, `POST /identify`.
2. Sincronización al arrancar contra
   `GET .../admin/api/fingerprints/sync`, recarga del lector vía
   `DBAdd` (la memoria del lector se limpia al desconectar/reiniciar,
   así que esto corre en cada arranque del agente, no solo la primera
   vez).
3. Certificado autofirmado + servidor HTTPS local.
4. Instrucciones de instalación para la laptop Windows del kiosko
   (SDK ZKFinger, Python, cómo dejarlo corriendo al iniciar sesión).

## Criterios de aceptación

- [x] `migrate:fresh --seed` limpio, suite completa en verde, Pint limpio.
- [x] Con el agente apagado, el kiosko se comporta **exactamente** igual
  que hoy — cero fricción, cero error visible (botón huella oculto vía
  Alpine; test de flujo manual sin agente en verde).
- [ ] Con el agente prendido y un docente enrolado: marcar con huella
  identifica bien y guarda `identification_method = fingerprint`.
  *(Laravel listo: JS setea el método antes de `identify()`; falta
  validar E2E con agente + hardware — tarea Claude/hardware.)*
- [x] Enrolar sin huella previa funciona; re-enrolar reemplaza (no
  duplica) la fila `fingerprints` (cubierto por test Livewire).
- [x] Token ausente o incorrecto → 401 en el endpoint de sync, nunca 500
  ni fuga de datos.

## Notas

- La parte de Cursor se puede escribir y testear completa **sin** el
  hardware conectado (todo lo simulable con `Livewire::test()` y
  mocks del endpoint). Lo que **no** se puede probar sin la laptop y
  el lector físico en mano: la captura real, el matching 1:N real, y
  el riesgo de mixed-content HTTPS mencionado en Contexto — eso se
  valida recién cuando la laptop esté lista, no antes.
