iosum ← Índice

Web & PWA · LIVE

Fichaje por geolocalización (PWA)

App de check-in/out por GPS validado en el servidor (Haversine), 100% Cloudflare + D1. LIVE en producción.

  • Cloudflare Workers + Hono
  • D1 / KV
  • React + Vite PWA
  • JWT (Web Crypto)
  • MapLibre/Leaflet

App de control de asistencia para trabajadores de campo (impulsadores de retail): marcan llegada y salida desde el teléfono, y el servidor valida que de verdad estén en la tienda asignada. Todo corre en Cloudflare —Worker, base de datos y las dos PWAs— sin un solo servidor que mantener. LIVE en producción.

Stack: Cloudflare Workers + Hono · D1 (SQLite) · Workers KV · Pages · React + Vite (vite-plugin-pwa) · JWT vía Web Crypto · Leaflet/MapLibre (tiles OSM) · Estado: LIVE · Rol: diseño + desarrollo end-to-end (solo)

El problema

Una marca con personal en punto de venta necesita saber que su gente llegó a la tienda correcta y a la hora correcta. El fraude clásico de estas apps es el buddy-punching y el GPS spoofing: marcar desde la casa, o desde una ubicación falseada. Una app que confía en la posición que reporta el teléfono no sirve para nada — la validación tiene que vivir en el servidor, con la hora del servidor.

El segundo problema es operativo: estas apps las usa gente en la calle, con señal mala y baterías al límite. Tiene que funcionar offline, ser instalable como app sin pasar por una tienda, y no perder un fichaje porque se cayó el 4G.

Qué construí

Dos PWAs y un backend, todo en Cloudflare. La PWA del trabajador (instalable, offline-first) hace check-in/out con un botón; el Worker valida la distancia a la tienda asignada con la fórmula de Haversine (distancia ≤ radio_m) usando coordenadas del dispositivo pero decidiendo en el servidor con su propio timestamp. La PWA de admin define tiendas sobre un mapa, asigna trabajadores por día y consulta los eventos con export a CSV.

La decisión de arquitectura de fondo: el cliente nunca escribe la base de datos. D1 está co-localizada con el Worker, y toda escritura pasa por reglas de negocio en el edge. No es Supabase con RLS desde el navegador; es un Worker que es el único que toca la DB. Eso cierra de raíz toda una clase de ataques.

Decisiones técnicas interesantes

La validación es de servidor, y solo hay un motivo de rechazo duro. Medí qué pasa en campo real: el accuracy del GPS en una tienda con techo metálico es malísimo, y si rechazas por baja precisión bloqueas a trabajadores legítimos. Así que la precisión baja no rechaza — solo deja un flag para auditoría. El único hard-reject es la distancia: si estás fuera del geofence, el endpoint devuelve 409 y no hay fichaje. La hora la pone el servidor, nunca el cliente.

La cola offline jamás guarda el token. El flujo offline-first usa IndexedDB para encolar fichajes cuando no hay red. El detalle de seguridad: la cola nunca persiste el JWT. Si guardas el token en disco para reintentar luego, lo dejas expuesto en un dispositivo compartido. En su lugar, al hacer flush la app re-autentica y recién entonces manda los eventos encolados. El token vive solo en memoria.

Auth endurecida para dispositivos de campo. PIN obligatorio + device-binding (el fichaje se ata al dispositivo registrado) + código de trabajador aleatorio + idempotencia por client_event_id (un doble-tap o un reintento de la cola no crea dos fichajes) + chequeos anti-IDOR en cada endpoint. Hashing con PBKDF2 sobre Web Crypto —sin dependencias nativas, corre en el Worker— y JWT HS256. Para el admin descarté el 2FA casero: esa superficie la resuelve mejor Cloudflare Access (Zero Trust en el edge) que un TOTP escrito a mano.

El bug que solo aparece de noche. El Worker calculaba “hoy” en UTC. Un trabajador fichando de noche en Bogotá (UTC-5) caía en el día siguiente en UTC, su asignación del día no existía todavía, y el fichaje se rechazaba. El fix fue un módulo de tiempo dedicado (bogotaToday / bogotaDayRangeUtc, UTC-5 fijo sin DST porque Colombia no lo usa) aplicado de forma consistente en la consulta de asignaciones, en el /check y en el filtro de eventos del admin. Es el tipo de bug que no aparece en los tests de mediodía.

Habeas Data desde el diseño, no como parche. Son datos de ubicación de personas: en Colombia eso cae bajo la Ley 1581. La app trae consentimiento explícito, una política de retención y un Cron de Cloudflare que purga los eventos vencidos. La privacidad es una capa del sistema, no una página de términos.

PWA hoy, nativo mañana, sin reescribir. La app del trabajador es React + Vite con vite-plugin-pwa — instalable y offline ya. Está estructurada para envolverse con Capacitor en una Fase B y ganar lo que el navegador no da: detección de mock location nativa (isFromMockProvider, la verdadera joya anti-spoofing) y auto-geofencing en background que pega al mismo endpoint /check. La frontera está trazada para que ese salto sea aditivo, no un rewrite.

Qué demuestra

  • Confianza del lado correcto. Validación en el servidor, hora del servidor, el cliente nunca escribe la DB. La seguridad está en la arquitectura, no en la buena fe del dispositivo.
  • Diseño para condiciones reales. Offline-first sin filtrar el token, precisión que avisa en vez de bloquear, idempotencia contra reintentos, zonas horarias resueltas de verdad.
  • Todo en el edge, costo casi cero. Worker + D1 + KV + Pages + Cron: un stack sin servidores que mantener, desplegado y corriendo en producción.
  • Cumplimiento como ingeniería. Consentimiento, retención y purga automatizada para datos de geolocalización bajo Habeas Data.

Estado y siguiente paso

LIVE en producción sobre subdominios propios (API + PWA del trabajador + admin), con tema claro/oscuro y paletas configurables en ambas apps. La Fase A —fichaje validado, admin con mapa, export— está cerrada y verificada end-to-end. La puerta abierta es la Fase B: el wrap nativo con Capacitor para mock-location y geofencing en background, y, si un cliente lo justifica, la verificación de ejecución en góndola con visión por computador (el verdadero presupuesto está en trade-marketing, no en el ahorro de nómina). El face-match quedó descartado a propósito: es dato biométrico sensible y la carga legal no compensa salvo fraude medido.