Compare commits
197 Commits
recovery/d
...
3721fa7d12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3721fa7d12 | ||
|
|
d486c07f87 | ||
|
|
e6e091895e | ||
|
|
9ee1e11608 | ||
|
|
4125ec627b | ||
|
|
0d6c006297 | ||
|
|
6c234f60fd | ||
|
|
3f850db0ae | ||
|
|
14b0a03b14 | ||
|
|
f18bfe65c3 | ||
|
|
9fdf049ea8 | ||
|
|
13562d3298 | ||
|
|
c71c58c5aa | ||
|
|
9f13d4f274 | ||
|
|
9e65a0b447 | ||
|
|
edcd02e469 | ||
|
|
88fb707a66 | ||
|
|
ea035b4b60 | ||
|
|
be3fcf1f28 | ||
|
|
a97f968be2 | ||
|
|
0ada878809 | ||
|
|
fd4220c98d | ||
|
|
0329296ea5 | ||
|
|
bfda385620 | ||
|
|
690f7ff48d | ||
|
|
e7c142f4cb | ||
|
|
5734abe176 | ||
|
|
6e755e2b56 | ||
|
|
4c7e092ffa | ||
|
|
095ec2ff25 | ||
|
|
66229ab050 | ||
|
|
83e067b334 | ||
|
|
c12deda9f9 | ||
|
|
c45862f49d | ||
|
|
eec83ea63d | ||
|
|
5ce407d5b1 | ||
|
|
e1f8d968a8 | ||
|
|
940f58ecdd | ||
|
|
4126ea9aa0 | ||
|
|
b7c348b655 | ||
|
|
509c91321f | ||
|
|
2928da900f | ||
|
|
0698ca2471 | ||
|
|
686704b658 | ||
|
|
a777a99367 | ||
|
|
13aea702f3 | ||
|
|
b43c41d2ae | ||
|
|
0e43805154 | ||
|
|
2ecca522cf | ||
|
|
37780092e3 | ||
|
|
7e6993fc72 | ||
|
|
8035f5005e | ||
|
|
7fb03039bd | ||
|
|
b90b91f7db | ||
|
|
fb0764bf9a | ||
|
|
86c85c4110 | ||
|
|
8b572457e0 | ||
|
|
bd473719bb | ||
|
|
b4dcae280a | ||
|
|
0c510e120b | ||
|
|
92675d9616 | ||
|
|
932932e9e6 | ||
|
|
c9d7f82b2d | ||
|
|
5e385667e8 | ||
|
|
1652669aad | ||
|
|
10fce1119f | ||
|
|
1d1b13b478 | ||
|
|
d7dc59b7f3 | ||
|
|
9cfddf0be6 | ||
|
|
4f91382b5e | ||
|
|
e29210b16c | ||
|
|
b8ab916c8d | ||
|
|
d2a3143c85 | ||
|
|
473631d329 | ||
|
|
70a2282716 | ||
|
|
fe0176da4a | ||
|
|
2bc9eb3120 | ||
|
|
0bb57a0749 | ||
|
|
3ca1d31ede | ||
|
|
3be117cb29 | ||
|
|
2da824f1a6 | ||
|
|
89590121a6 | ||
|
|
83f4c54d2c | ||
|
|
e45b05d288 | ||
|
|
e6a9a40ff8 | ||
|
|
14aa7c252a | ||
|
|
2e42230f54 | ||
|
|
4217e8ec39 | ||
|
|
9cdafcd262 | ||
|
|
1c08e44f45 | ||
|
|
08e097d640 | ||
|
|
330fb7cb30 | ||
|
|
5301f72e28 | ||
|
|
06c81822d0 | ||
|
|
02f24f27ce | ||
|
|
28d1a7d930 | ||
|
|
1c57abd97a | ||
|
|
fb0c172bb5 | ||
|
|
6f486a038b | ||
|
|
78e8ecb55a | ||
|
|
9f3d7212ee | ||
|
|
c81ed907f7 | ||
|
|
f90fb552fc | ||
|
|
83988cfcde | ||
|
|
22b39aadd3 | ||
|
|
9c878a310b | ||
|
|
2d9ef90b09 | ||
|
|
e6d884ae95 | ||
|
|
2dd1975b8d | ||
|
|
fa1be83665 | ||
|
|
f19a13def1 | ||
|
|
2c3dd8e8bd | ||
|
|
47d6a4e48d | ||
|
|
7ccadb0272 | ||
|
|
154f183341 | ||
| d1647ddbcf | |||
|
|
d130ee578f | ||
|
|
6afacdc7ce | ||
|
|
4566a57e94 | ||
|
|
79dd41ba6f | ||
|
|
77bcde08ef | ||
|
|
56bb76fdb2 | ||
|
|
7a774a70fc | ||
|
|
2a53ae7e9e | ||
|
|
c97ab61512 | ||
|
|
d6bbc66676 | ||
|
|
997b6b0da8 | ||
|
|
555aef226e | ||
|
|
f9af6a552e | ||
|
|
60ad7ca44f | ||
|
|
de213a22f0 | ||
|
|
93307c3b8f | ||
|
|
edc773b367 | ||
|
|
f43a3f202d | ||
|
|
a379c8dd59 | ||
|
|
02151e56dd | ||
|
|
db259b5dbe | ||
|
|
8359ae9a06 | ||
|
|
2430419257 | ||
|
|
ba0b0c633f | ||
|
|
7d2013cedf | ||
|
|
660092a8ff | ||
|
|
0092d20e35 | ||
|
|
1c9059a0ea | ||
|
|
a0cb973078 | ||
|
|
c92e0b5c06 | ||
|
|
0949f19c9d | ||
|
|
3bde9096ab | ||
| 63ef77d1ce | |||
| dae5e8bf00 | |||
|
|
a524e52a19 | ||
|
|
6f780cf22e | ||
|
|
0ad8fc05c1 | ||
|
|
977e500bd6 | ||
|
|
7efef41179 | ||
|
|
accefcd375 | ||
|
|
f83d530c18 | ||
|
|
58de3dc1f9 | ||
|
|
80403eeb20 | ||
|
|
dcf72cef79 | ||
|
|
58ebb1adf0 | ||
|
|
e56dac1269 | ||
|
|
98a09289a6 | ||
|
|
b34213c9c8 | ||
|
|
6e9fdc0f17 | ||
| a36b06bbf8 | |||
| 84f5c42e5b | |||
| 4f1f530ce1 | |||
|
|
dd4511cdfc | ||
|
|
55a3c9895d | ||
|
|
ee0ed8800a | ||
|
|
801c78fd57 | ||
|
|
9e5b69f3a2 | ||
|
|
11f4757fe0 | ||
|
|
980fbcc3b3 | ||
|
|
f3fd71e286 | ||
|
|
f0ce0a67cc | ||
| 7aec3b67fc | |||
| 0551d41475 | |||
| dade0608a8 | |||
| ebb165bbff | |||
|
|
6d0c432043 | ||
|
|
c72868f65b | ||
|
|
39eea9d6a4 | ||
|
|
d00db12ec0 | ||
| e83d88c599 | |||
|
|
e26af8ef37 | ||
|
|
0b73ef5c49 | ||
|
|
f5ba0df791 | ||
|
|
12c3564e01 | ||
|
|
a4f1fa137d | ||
|
|
952514784e | ||
|
|
ab000b29a9 | ||
|
|
487b075493 | ||
|
|
671a5339a9 | ||
| f910768719 | |||
| 30eff47bec |
10
.cargo/config.toml
Normal file
10
.cargo/config.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Cargo Configuration — Workspace-level settings
|
||||||
|
# ==============================================================================
|
||||||
|
# Profile settings (dev/release) are defined in Cargo.toml [profile.*] sections.
|
||||||
|
# This file is for non-profile settings only.
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
# Split debug info into separate files so the linker doesn't embed them.
|
||||||
|
# On Windows this is already the default (.pdb), but being explicit helps.
|
||||||
|
split-debuginfo = "packed"
|
||||||
@@ -24,6 +24,14 @@ infrastructure/postgres/data/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Python virtualenv
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Installers & Temp Data
|
||||||
|
installer_incoming/
|
||||||
|
installer_output/
|
||||||
|
scratch/
|
||||||
|
|
||||||
# Docs & Meta
|
# Docs & Meta
|
||||||
docs/
|
docs/
|
||||||
LICENSE
|
LICENSE
|
||||||
|
|||||||
46
.env.example
46
.env.example
@@ -1,46 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# OMNIOIL SCADA - LISTA CONSOLIDADA
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# --- CONTROL DE ARCHIVOS COMPOSE ---
|
|
||||||
# Local (desarrollo): COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
|
|
||||||
# Producción (Dokploy): COMPOSE_FILE=docker-compose.yml
|
|
||||||
COMPOSE_PATH_SEPARATOR=:
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
|
|
||||||
|
|
||||||
# --- INFRAESTRUCTURA CORE ---
|
|
||||||
DB_USER=adminomnioil
|
|
||||||
DB_PASSWORD=admin123
|
|
||||||
DB_NAME=omnioil
|
|
||||||
|
|
||||||
# --- REDIS & STORAGE ---
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
MINIO_USER=admin_s3
|
|
||||||
MINIO_PASSWORD=otra_clave_segura_minio
|
|
||||||
|
|
||||||
# --- SEGURIDAD (JWT & Tunnels) ---
|
|
||||||
JWT_SECRET=llave_maestra_para_tokens_api
|
|
||||||
TUNNEL_SECRET=llave_para_tuneles_seguros_edge
|
|
||||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://mobile.omnioil.app,https://app.omnioil.app
|
|
||||||
|
|
||||||
# --- EDGE & ORQUESTACIÓN ---
|
|
||||||
DOCKER_REGISTRY_URL=docker-registry:5000
|
|
||||||
MQTT_HOST=mosquitto
|
|
||||||
MQTT_PORT=1883
|
|
||||||
API_URL=
|
|
||||||
API_KEY=
|
|
||||||
MQTT_USER=omnioil_admin
|
|
||||||
MQTT_PASSWORD=admin123
|
|
||||||
# Hash para 'admin123' (generado para Bcrypt)
|
|
||||||
MQTT_ADMIN_HASH='$2a$10$WlHbLOyB/A6WKwIXwh24Y.Njgi0w26eAq9nwpsRQIoqlEU7.n3H5O'
|
|
||||||
REDIS_PASSWORD=redisoilsecret456
|
|
||||||
|
|
||||||
# --- VOLUMENES (Desarrollo: Bind Mounts | Producción: Named Volumes) ---
|
|
||||||
TIMESCALEDB_DATA=./.docker/timescaledb_data
|
|
||||||
REDIS_DATA=./.docker/redis_data
|
|
||||||
MINIO_DATA=./.docker/minio_data
|
|
||||||
MOSQUITTO_DATA=./.docker/mosquitto_data
|
|
||||||
MOSQUITTO_LOG=./.docker/mosquitto_log
|
|
||||||
REGISTRY_DATA=./.docker/registry_data
|
|
||||||
INSTALLER_INCOMING=./.docker/installer_incoming
|
|
||||||
INSTALLER_OUTPUT=./.docker/installer_output
|
|
||||||
121
.env.example.docker
Normal file
121
.env.example.docker
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE DOCKER (NGINX AWARE + DOCUMENTADA)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN DE COMPOSE ---
|
||||||
|
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
|
||||||
|
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto
|
||||||
|
|
||||||
|
# --- BASE DE DATOS (Postgres/Timescale) ---
|
||||||
|
DB_USER=adminomnioil # Usuario administrador de la DB
|
||||||
|
DB_PASSWORD=admin123 # Contraseña de la DB
|
||||||
|
DB_NAME=omnioil # Nombre de la base de datos principal
|
||||||
|
DB_HOST=timescaledb # Host (Nombre del servicio en Docker)
|
||||||
|
DB_PORT=5432 # Puerto estándar de Postgres
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# --- CACHE (Redis) ---
|
||||||
|
REDIS_PASSWORD=redisoilsecret456 # Contraseña de seguridad de Redis
|
||||||
|
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 # Conexión interna de Docker a Redis
|
||||||
|
|
||||||
|
# --- ALMACENAMIENTO (MinIO / S3) ---
|
||||||
|
MINIO_USER=admin_s3
|
||||||
|
MINIO_PASSWORD=otra_clave_segura_minio
|
||||||
|
# URL INTERNA de MinIO (solo accesible desde la red Docker — nunca expuesta al exterior)
|
||||||
|
MINIO_INTERNAL_URL=http://anh-minio:9000
|
||||||
|
MINIO_ENDPOINT_URL=http://anh-minio:9000
|
||||||
|
MINIO_ENDPOINT=http://anh-minio:9000
|
||||||
|
MINIO_BUCKET=anh-reports # Bucket para reportes generados
|
||||||
|
MINIO_ACCESS_KEY=admin_s3 # = MINIO_USER
|
||||||
|
MINIO_SECRET_KEY=otra_clave_segura_minio # = MINIO_PASSWORD
|
||||||
|
S3_INSTALLERS_BUCKET=omnioil-releases # Bucket exclusivo para instaladores MSI/EXE del agente
|
||||||
|
# MINIO_PUBLIC_URL ya NO se usa — MinIO está detrás del backend API (no expuesto)
|
||||||
|
AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
|
||||||
|
# --- AGENTE EDGE (OTA updates) ---
|
||||||
|
# Versión del agente que se distribuye. Actualizar cuando se publique nueva versión.
|
||||||
|
# Ejemplo: "0.2.1" — el backend usará este valor en GET /api/edge/update-info
|
||||||
|
AGENT_LATEST_VERSION=0.3.0
|
||||||
|
# URL base del backend (usada internamente para construir download_url en update-info)
|
||||||
|
API_BASE_URL=https://api.omnioil.com
|
||||||
|
|
||||||
|
# --- FRONTEND (Rutas vía Nginx Gateway) ---
|
||||||
|
VITE_API_URL=http://api.omnioil.app # URL del API unificada por Nginx
|
||||||
|
VITE_API_BASE=/api # Prefijo de ruta para la API
|
||||||
|
VITE_ADMIN_APP_ORIGIN=http://app.omnioil.app # Subdominio de Administración en Nginx
|
||||||
|
VITE_MOBILE_APP_ORIGIN=http://mobile.omnioil.app # Subdominio de la PWA en Nginx
|
||||||
|
INVITATION_BASE_URL=https://app.omnioil.app # Base pública para links /activate de solicitudes aprobadas
|
||||||
|
|
||||||
|
# --- EMAIL SMTP (alertas y aprobación/activación de solicitudes) ---
|
||||||
|
SMTP_HOST=smtp.tu-proveedor.com # Host SMTP de producción
|
||||||
|
SMTP_PORT=587 # Puerto SMTP del proveedor (587 STARTTLS o 465 TLS)
|
||||||
|
SMTP_USER=notificaciones@omnioil.app # Usuario SMTP de envío
|
||||||
|
SMTP_PASS= # Secreto SMTP; cargar desde Dokploy/secrets, no versionar valor real
|
||||||
|
SMTP_FROM=notificaciones@omnioil.app # Remitente para alertas y correos de activación
|
||||||
|
|
||||||
|
# --- MENSAJERÍA (Mosquitto / MQTT) ---
|
||||||
|
MQTT_HOST=mosquitto # Host del broker interno (servicio Docker)
|
||||||
|
MQTT_PORT=1883 # Puerto estándar (TCP) interno
|
||||||
|
MQTT_USER=omnioil_admin # Usuario para el broker MQTT
|
||||||
|
MQTT_PASSWORD=admin123 # Contraseña para el broker MQTT
|
||||||
|
MOSQUITTO_GO_AUTH=true # Habilita autenticación vía base de datos en Mosquitto
|
||||||
|
|
||||||
|
# --- SEGURIDAD Y TÚNELES ---
|
||||||
|
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
||||||
|
TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP
|
||||||
|
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
||||||
|
ALLOWED_ORIGINS=http://app.omnioil.app,http://mobile.omnioil.app,https://console.omnioil.app # Orígenes permitidos (Subdominios Nginx)
|
||||||
|
VITE_CONSOLE_APP_ORIGIN=https://console.omnioil.app
|
||||||
|
VITE_LANDING_APP_ORIGIN=https://omnioil.app/
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
|
||||||
|
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA # Clave de sitio Turnstile (pública)
|
||||||
|
TURNSTILE_SECRET_KEY=0x4AAAAAAABBBBBBBBBBBBBBBBBBBBBBBB # Clave secreta Turnstile (backend)
|
||||||
|
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN Y REGISTRY ---
|
||||||
|
DOCKER_REGISTRY_URL=docker-registry:5000 # Registro accesible entre contenedores
|
||||||
|
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro que verá el agente desde fuera
|
||||||
|
BACKEND_API_URL=http://backend-api:8000 # Comunicación interna directa entre contenedores
|
||||||
|
|
||||||
|
# --- CONFIGURACIÓN INYECTADA A AGENTES EDGE ---
|
||||||
|
MQTT_PUBLIC_HOST=omnioil.app # IP o Dominio del servidor que verá el agente remoto
|
||||||
|
MQTT_PUBLIC_PORT=9883 # Puerto MQTT (WebSockets) que verá el agente
|
||||||
|
MQTT_USE_WS=true # Indica al agente que debe usar WebSockets
|
||||||
|
|
||||||
|
# --- CONTROL DE PROCESOS ---
|
||||||
|
RUN_ON_STARTUP=false # Si es true, el generador de reportes corre al iniciar
|
||||||
|
|
||||||
|
# --- VOLUMENES Y PERSISTENCIA ---
|
||||||
|
TIMESCALEDB_DATA=timescaledb_data # Volumen para datos de Postgres
|
||||||
|
REDIS_DATA=redis_data # Volumen para persistencia de Redis
|
||||||
|
MINIO_DATA=minio_data # Volumen para archivos en MinIO
|
||||||
|
MOSQUITTO_DATA=mosquitto_data # Volumen para persistencia de MQTT
|
||||||
|
MOSQUITTO_LOG=mosquitto_log # Volumen para logs de MQTT
|
||||||
|
REGISTRY_DATA=registry_data # Volumen para las imágenes del Docker Registry
|
||||||
|
INSTALLER_INCOMING=./.docker/installer_incoming # Carpeta host donde se inyectan los .exe
|
||||||
|
INSTALLER_OUTPUT=./.docker/installer_output # Carpeta host donde se generan los .msi
|
||||||
|
|
||||||
|
# --- AGENTE EDGE (OTA updates) ---
|
||||||
|
AGENT_LATEST_VERSION=0.2.0 # Versión del agente distribuida vía OTA
|
||||||
|
API_BASE_URL=http://localhost:8000 # URL base del backend (para download_url en update-info)
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
GRAFANA_USER=admin
|
||||||
|
GRAFANA_PASSWORD=omnioil_grafana_2024
|
||||||
|
|
||||||
|
# --- SEGURIDAD 2FA / TOTP ---
|
||||||
|
TOTP_SECRET_ENCRYPTION_KEY=vw4XKE= # Clave base64 de 32 bytes para cifrar secretos Authenticator
|
||||||
|
|
||||||
|
# --- SMTP / EMAILS TRANSACCIONALES ---
|
||||||
|
SMTP_HOST=mail.omnioil.app # Servidor SMTP propio docker-mailserver
|
||||||
|
SMTP_PORT=587 # Puerto SMTP con STARTTLS para envío
|
||||||
|
SMTP_TLS_MODE=starttls # Modo seguro para producción; no usar "none"
|
||||||
|
SMTP_USER=soporte@omnioil.app # Cuenta que enviará los correos
|
||||||
|
SMTP_PASS=123Soport # Mismo valor de PASSWORD_SOPORTE del servicio Correos
|
||||||
|
SMTP_FROM="OmniOil <soporte@omnioil.app>" # Remitente visible en los correos
|
||||||
|
|
||||||
|
# --- LINKS DE ACTIVACIÓN ---
|
||||||
|
INVITATION_BASE_URL=https://app.omnioil.app # URL del Admin cliente; aquí abre /activate?token=...
|
||||||
|
PLATFORM_CONSOLE_URL=https://console.omnioil.app/login # URL de Console para usuarios platform_admin/platform_operator
|
||||||
115
.env.example.local
Normal file
115
.env.example.local
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN DE COMPOSE ---
|
||||||
|
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
|
||||||
|
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto
|
||||||
|
|
||||||
|
# --- BASE DE DATOS (Postgres/Timescale) ---
|
||||||
|
DB_USER=adminomnioil # Usuario administrador de la DB
|
||||||
|
DB_PASSWORD=admin123 # Contraseña de la DB
|
||||||
|
DB_NAME=omnioil # Nombre de la base de datos principal
|
||||||
|
DB_HOST=localhost # Host (localhost para desarrollo nativo)
|
||||||
|
DB_PORT=5432 # Puerto estándar de Postgres
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# --- CACHE (Redis) ---
|
||||||
|
REDIS_PASSWORD=redisoilsecret456 # Contraseña de seguridad de Redis
|
||||||
|
REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:6379 # Conexión local a Redis
|
||||||
|
|
||||||
|
# --- ALMACENAMIENTO (MinIO / S3) ---
|
||||||
|
MINIO_USER=admin_s3 # Access Key de MinIO
|
||||||
|
MINIO_PASSWORD=otra_clave_segura_minio # Secret Key de MinIO
|
||||||
|
MINIO_ENDPOINT_URL=http://localhost:9000 # Endpoint para el SDK de AWS (desarrollo nativo)
|
||||||
|
MINIO_INTERNAL_URL=http://anh_minio:9000 # URL interna Docker (solo accesible entre contenedores)
|
||||||
|
MINIO_ENDPOINT=http://localhost:9000 # Endpoint interno para servicios
|
||||||
|
MINIO_BUCKET=anh-reports # Bucket para reportes generados
|
||||||
|
MINIO_ACCESS_KEY=admin_s3 # = MINIO_USER (alias para update_info handler)
|
||||||
|
MINIO_SECRET_KEY=otra_clave_segura_minio # = MINIO_PASSWORD (alias para update_info handler)
|
||||||
|
S3_INSTALLERS_BUCKET=omnioil-releases # Bucket para instaladores MSI y agentes OTA
|
||||||
|
# MINIO_PUBLIC_URL eliminado — MinIO nunca se expone al exterior.
|
||||||
|
# Las descargas pasan por: GET /api/edge/projects/:id/download-installer
|
||||||
|
AWS_ACCESS_KEY_ID=${MINIO_USER} # Duplicado para compatibilidad con AWS SDK
|
||||||
|
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} # Duplicado para compatibilidad con AWS SDK
|
||||||
|
AWS_REGION=us-east-1 # Región ficticia requerida por AWS SDK
|
||||||
|
|
||||||
|
# --- FRONTEND ---
|
||||||
|
VITE_API_URL=http://localhost:8000 # URL base del Backend para el Frontend
|
||||||
|
VITE_API_BASE=/api # Prefijo de ruta para la API
|
||||||
|
VITE_ADMIN_APP_ORIGIN=http://localhost:3000 # URL de la aplicación de Administración
|
||||||
|
VITE_MOBILE_APP_ORIGIN=http://localhost:3001 # URL de la aplicación PWA (Mobile)
|
||||||
|
INVITATION_BASE_URL=http://localhost:3000 # Base pública para links /activate de solicitudes aprobadas
|
||||||
|
|
||||||
|
# --- EMAIL SMTP (alertas y aprobación/activación de solicitudes) ---
|
||||||
|
SMTP_HOST=localhost # Host SMTP local/stub; en prod usar proveedor real
|
||||||
|
SMTP_PORT=1025 # Puerto SMTP local/stub; en prod usar 587/465 según proveedor
|
||||||
|
SMTP_USER= # Usuario SMTP; dejar vacío si el stub local no autentica
|
||||||
|
SMTP_PASS= # Secreto SMTP; nunca escribir credenciales reales en el ejemplo
|
||||||
|
SMTP_FROM=notificaciones-local@omnioil.app # Remitente para alertas y correos de activación
|
||||||
|
|
||||||
|
# --- MENSAJERÍA (Mosquitto / MQTT) ---
|
||||||
|
MQTT_HOST=localhost # Host del broker para servicios internos
|
||||||
|
MQTT_PORT=1883 # Puerto estándar (TCP) para servicios internos
|
||||||
|
MQTT_USER=omnioil_admin # Usuario para el broker MQTT
|
||||||
|
MQTT_PASSWORD=admin123 # Contraseña para el broker MQTT
|
||||||
|
MOSQUITTO_GO_AUTH=true # Habilita autenticación vía base de datos en Mosquitto
|
||||||
|
|
||||||
|
# --- SEGURIDAD Y TÚNELES ---
|
||||||
|
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
|
||||||
|
TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP
|
||||||
|
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3004,https://console.omnioil.app # Orígenes permitidos para CORS
|
||||||
|
VITE_CONSOLE_APP_ORIGIN=http://localhost:3002
|
||||||
|
VITE_LANDING_APP_ORIGIN=http://localhost:3004/
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||||
|
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
|
||||||
|
|
||||||
|
# --- ORQUESTACIÓN Y REGISTRY ---
|
||||||
|
DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host
|
||||||
|
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC
|
||||||
|
BACKEND_API_URL=http://localhost:8000 # URL del API reportada a los agentes
|
||||||
|
|
||||||
|
# --- CONFIGURACIÓN INYECTADA A AGENTES EDGE ---
|
||||||
|
MQTT_PUBLIC_HOST=localhost # Host MQTT que verá el agente (localhost o IP)
|
||||||
|
MQTT_PUBLIC_PORT=9883 # Puerto MQTT (WebSockets) que verá el agente
|
||||||
|
MQTT_USE_WS=true # Indica al agente que debe usar WebSockets
|
||||||
|
|
||||||
|
# --- CONTROL DE PROCESOS ---
|
||||||
|
RUN_ON_STARTUP=false # Si es true, el generador de reportes corre al iniciar
|
||||||
|
|
||||||
|
# --- VOLUMENES Y PERSISTENCIA ---
|
||||||
|
TIMESCALEDB_DATA=timescaledb_data # Volumen para datos de Postgres
|
||||||
|
REDIS_DATA=redis_data # Volumen para persistencia de Redis
|
||||||
|
MINIO_DATA=minio_data # Volumen para archivos en MinIO
|
||||||
|
MOSQUITTO_DATA=mosquitto_data # Volumen para persistencia de MQTT
|
||||||
|
MOSQUITTO_LOG=mosquitto_log # Volumen para logs de MQTT
|
||||||
|
REGISTRY_DATA=registry_data # Volumen para las imágenes del Docker Registry
|
||||||
|
INSTALLER_INCOMING=./.docker/installer_incoming # Carpeta host donde se inyectan los .exe
|
||||||
|
INSTALLER_OUTPUT=./.docker/installer_output # Carpeta host donde se generan los .msi
|
||||||
|
|
||||||
|
# --- AGENTE EDGE (OTA updates) ---
|
||||||
|
AGENT_LATEST_VERSION=0.3.0 # Versión del agente que se distribuye vía OTA.
|
||||||
|
# Actualizar cuando se publique nueva versión en MinIO.
|
||||||
|
# El endpoint GET /api/edge/update-info usa este valor.
|
||||||
|
API_BASE_URL=http://localhost:8000 # URL base del backend (para construir download_url en update-info)
|
||||||
|
# En producción: https://api.omnioil.com
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
GRAFANA_USER=admin
|
||||||
|
GRAFANA_PASSWORD=omnioil_grafana_2024
|
||||||
|
|
||||||
|
# --- SEGURIDAD 2FA / TOTP ---
|
||||||
|
TOTP_SECRET_ENCRYPTION_KEY=vw4n59VyanMx6aiBXEG5kzt/GGR9WWpJqhOfWaINXKE= # Clave base64 de 32 bytes para cifrar secretos Authenticator
|
||||||
|
|
||||||
|
# --- SMTP / EMAILS TRANSACCIONALES ---
|
||||||
|
SMTP_HOST=mail.omnioil.app # Servidor SMTP propio docker-mailserver
|
||||||
|
SMTP_PORT=587 # Puerto SMTP con STARTTLS para envío
|
||||||
|
SMTP_TLS_MODE=starttls # Modo seguro para producción; no usar "none"
|
||||||
|
SMTP_USER=soporte@omnioil.app # Cuenta que enviará los correos
|
||||||
|
SMTP_PASS=123Soport # Mismo valor de PASSWORD_SOPORTE del servicio Correos
|
||||||
|
SMTP_FROM="OmniOil <soporte@omnioil.app>" # Remitente visible en los correos
|
||||||
|
|
||||||
|
# --- LINKS DE ACTIVACIÓN ---
|
||||||
|
INVITATION_BASE_URL=https://app.omnioil.app # URL del Admin cliente; aquí abre /activate?token=...
|
||||||
|
PLATFORM_CONSOLE_URL=https://console.omnioil.app/login # URL de Console para usuarios platform_admin/platform_operator
|
||||||
19
.gitattributes
vendored
Normal file
19
.gitattributes
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Normalize line endings to LF on commit (prevents CRLF/LF checksum mismatch
|
||||||
|
# between Windows dev machines and Linux Docker containers).
|
||||||
|
# sqlx calculates migration checksums from file bytes — a CRLF vs LF difference
|
||||||
|
# causes VersionMismatch errors at startup.
|
||||||
|
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary files — never touch line endings
|
||||||
|
*.msi binary
|
||||||
|
*.exe binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.ico binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
*.gz binary
|
||||||
|
*.zip binary
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -18,10 +18,10 @@ yarn-error.log*
|
|||||||
*.tar
|
*.tar
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
# Environment Variables & Secrets
|
# Environment Variables & Secrets (LOW-1)
|
||||||
.env
|
.env*
|
||||||
.env.*
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.example.*
|
||||||
|
|
||||||
# OS Generated Files
|
# OS Generated Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -45,3 +45,5 @@ CLAUDE.md
|
|||||||
.atl/
|
.atl/
|
||||||
.npm
|
.npm
|
||||||
__pycache__
|
__pycache__
|
||||||
|
build_test/
|
||||||
|
venv
|
||||||
28
.sqlx/query-57eb8303ea6429049713835870d26eb0fcf5df2d2f56584339b922ec54e9f034.json
generated
Normal file
28
.sqlx/query-57eb8303ea6429049713835870d26eb0fcf5df2d2f56584339b922ec54e9f034.json
generated
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, name FROM edge_projects WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "57eb8303ea6429049713835870d26eb0fcf5df2d2f56584339b922ec54e9f034"
|
||||||
|
}
|
||||||
20
.sqlx/query-68778d9d9414d7c1b4aa7b43b8a0f50b064d369fc7f462efef07b6eae8c93697.json
generated
Normal file
20
.sqlx/query-68778d9d9414d7c1b4aa7b43b8a0f50b064d369fc7f462efef07b6eae8c93697.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id FROM edge_projects WHERE is_active = true",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "68778d9d9414d7c1b4aa7b43b8a0f50b064d369fc7f462efef07b6eae8c93697"
|
||||||
|
}
|
||||||
2249
Cargo.lock
generated
2249
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
65
Cargo.toml
65
Cargo.toml
@@ -4,7 +4,6 @@ members = [
|
|||||||
"services/data-collector",
|
"services/data-collector",
|
||||||
"services/json-generator",
|
"services/json-generator",
|
||||||
"services/shared-lib",
|
"services/shared-lib",
|
||||||
"services/flow-generator",
|
|
||||||
"services/build-orchestrator",
|
"services/build-orchestrator",
|
||||||
"services/edge-agent",
|
"services/edge-agent",
|
||||||
"services/events-relay",
|
"services/events-relay",
|
||||||
@@ -12,3 +11,67 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
# ── Shared dependencies ───────────────────────────────────────────────────────
|
||||||
|
# Centralized versions prevent conflicts and reduce Cargo.lock churn.
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
|
chrono-tz = "0.10.4"
|
||||||
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
||||||
|
rumqttc = { version = "0.25.1", features = ["use-rustls"] }
|
||||||
|
zstd = "0.13"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
|
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
|
||||||
|
bigdecimal = { version = "0.4.7", features = ["serde"] }
|
||||||
|
aes-gcm = "0.10.3"
|
||||||
|
pbkdf2 = "0.12.2"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
rand = "0.8.5"
|
||||||
|
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "stream"] }
|
||||||
|
tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"] }
|
||||||
|
s7 = "0.1.2"
|
||||||
|
semver = "1.0"
|
||||||
|
shared-lib = { path = "services/shared-lib" }
|
||||||
|
|
||||||
|
# ── Disk-space optimized profiles ──────────────────────────────────────────────
|
||||||
|
# These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use.
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
# "limited" keeps backtraces but skips detailed type info → PDBs ~6x smaller.
|
||||||
|
debug = "limited"
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
# Compile third-party crates with basic optimizations even in debug.
|
||||||
|
# Makes dependency .rlib files 20-40% smaller and tests run faster.
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
# Strip all debug symbols from release binaries.
|
||||||
|
strip = true
|
||||||
|
# Thin LTO produces smaller binaries with manageable link time.
|
||||||
|
lto = "thin"
|
||||||
|
# Single codegen unit for maximum size reduction.
|
||||||
|
codegen-units = 1
|
||||||
|
# Abort on panic — removes unwinding tables (~5-10% smaller binary).
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# ── CI/Docker profile: fast builds, still production-grade ─────────────────────
|
||||||
|
# Use with `cargo build --profile ci`. Output goes to target/ci/.
|
||||||
|
# Skips LTO (~3-5 min saved) and uses parallel codegen units.
|
||||||
|
# Binaries are ~5-10% larger but functionally identical.
|
||||||
|
[profile.ci]
|
||||||
|
inherits = "release"
|
||||||
|
lto = false
|
||||||
|
strip = true
|
||||||
|
codegen-units = 8
|
||||||
|
|
||||||
|
|||||||
@@ -19,24 +19,77 @@ COPY Cargo.toml Cargo.lock ./
|
|||||||
COPY services/ services/
|
COPY services/ services/
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez (con cache mounts)
|
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
|
||||||
FROM base AS cacher
|
FROM base AS cacher
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,target=/app/target \
|
||||||
|
cargo chef cook --release --recipe-path recipe.json
|
||||||
|
|
||||||
|
# 4. BUILDER-LINUX: Base para servicios Linux (rápida)
|
||||||
|
FROM base AS builder-linux
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
# Crear estructura de carpetas y archivos dummy
|
||||||
|
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
||||||
|
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
||||||
|
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
||||||
|
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
||||||
|
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
||||||
|
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
||||||
|
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
||||||
|
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
||||||
|
|
||||||
|
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
||||||
|
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
||||||
|
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
||||||
|
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
||||||
|
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
|
||||||
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
|
||||||
|
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
|
||||||
|
|
||||||
|
# Pre-descargar todas las dependencias de forma secuencial para evitar colisiones
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
cargo fetch
|
||||||
|
|
||||||
|
# Compilar shared-lib primero
|
||||||
|
COPY services/shared-lib services/shared-lib
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
cargo chef cook --release --recipe-path recipe.json
|
cargo build --release -p shared-lib
|
||||||
|
|
||||||
# 4. BUILDER-BASE: Código + dependencias pre-compiladas + cache mounts
|
# 5. BUILDER-WINDOWS: Especializada en el agente
|
||||||
FROM base AS builder
|
FROM base AS builder-windows
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY services/ services/
|
|
||||||
|
|
||||||
# COMPILACIÓN CRUZADA PARA WINDOWS (Template del agente de borde)
|
|
||||||
RUN apt-get update && apt-get install -y gcc-mingw-w64-x86-64 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gcc-mingw-w64-x86-64 && rm -rf /var/lib/apt/lists/*
|
||||||
RUN rustup target add x86_64-pc-windows-gnu
|
RUN rustup target add x86_64-pc-windows-gnu
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
||||||
|
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
||||||
|
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
||||||
|
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
||||||
|
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
|
||||||
|
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
|
||||||
|
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
|
||||||
|
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
|
||||||
|
|
||||||
|
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
||||||
|
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
||||||
|
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
||||||
|
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
||||||
|
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
|
||||||
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
|
||||||
|
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
|
||||||
|
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
|
cargo fetch
|
||||||
|
|
||||||
|
COPY services/shared-lib services/shared-lib
|
||||||
|
COPY services/edge-agent services/edge-agent
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -44,11 +97,16 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
|||||||
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/
|
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# INDIVIDUAL SERVICE TARGETS (con cache mounts para incremental)
|
# INDIVIDUAL SERVICE TARGETS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# --- BACKEND-API ---
|
# --- BACKEND-API ---
|
||||||
FROM builder AS builder-backend-api
|
FROM builder-linux AS builder-backend-api
|
||||||
|
COPY services/backend-api services/backend-api
|
||||||
|
COPY services/shared-lib services/shared-lib
|
||||||
|
COPY services/json-generator services/json-generator
|
||||||
|
COPY .sqlx .sqlx
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -57,14 +115,15 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
|||||||
|
|
||||||
FROM debian:bookworm-slim AS backend-api
|
FROM debian:bookworm-slim AS backend-api
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y libssl3 ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=builder-backend-api /app/backend-api .
|
COPY --from=builder-backend-api /app/backend-api .
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["./backend-api"]
|
CMD ["./backend-api"]
|
||||||
|
|
||||||
# --- DATA-COLLECTOR ---
|
# --- DATA-COLLECTOR ---
|
||||||
FROM builder AS builder-data-collector
|
FROM builder-linux AS builder-data-collector
|
||||||
|
COPY services/data-collector services/data-collector
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -79,7 +138,8 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./data-collector"]
|
CMD ["./data-collector"]
|
||||||
|
|
||||||
# --- JSON-GENERATOR ---
|
# --- JSON-GENERATOR ---
|
||||||
FROM builder AS builder-json-generator
|
FROM builder-linux AS builder-json-generator
|
||||||
|
COPY services/json-generator services/json-generator
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -94,7 +154,8 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./json-generator"]
|
CMD ["./json-generator"]
|
||||||
|
|
||||||
# --- TELEMETRY-INGESTOR ---
|
# --- TELEMETRY-INGESTOR ---
|
||||||
FROM builder AS builder-telemetry-ingestor
|
FROM builder-linux AS builder-telemetry-ingestor
|
||||||
|
COPY services/telemetry-ingestor services/telemetry-ingestor
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -109,7 +170,8 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./telemetry-ingestor"]
|
CMD ["./telemetry-ingestor"]
|
||||||
|
|
||||||
# --- EVENTS-RELAY ---
|
# --- EVENTS-RELAY ---
|
||||||
FROM builder AS builder-events-relay
|
FROM builder-linux AS builder-events-relay
|
||||||
|
COPY services/events-relay services/events-relay
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -124,7 +186,8 @@ ENV RUST_LOG=info
|
|||||||
CMD ["./events-relay"]
|
CMD ["./events-relay"]
|
||||||
|
|
||||||
# --- BUILD-ORCHESTRATOR ---
|
# --- BUILD-ORCHESTRATOR ---
|
||||||
FROM builder AS builder-build-orchestrator
|
FROM builder-linux AS builder-build-orchestrator
|
||||||
|
COPY services/build-orchestrator services/build-orchestrator
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
|
||||||
--mount=type=cache,sharing=locked,target=/app/target \
|
--mount=type=cache,sharing=locked,target=/app/target \
|
||||||
@@ -138,11 +201,11 @@ RUN apt-get update && apt-get install -y libssl3 ca-certificates mosquitto-clien
|
|||||||
# Binario del orquestador
|
# Binario del orquestador
|
||||||
COPY --from=builder-build-orchestrator /app/build-orchestrator .
|
COPY --from=builder-build-orchestrator /app/build-orchestrator .
|
||||||
|
|
||||||
# Template del agente (NECESARIO PARA EL OVERLAY)
|
# Template del agente (Desde la etapa builder-windows)
|
||||||
RUN mkdir -p target/x86_64-pc-windows-gnu/release
|
RUN mkdir -p target/x86_64-pc-windows-gnu/release
|
||||||
COPY --from=builder /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
|
COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
|
||||||
|
|
||||||
# Cargo.toml del agente (PARA EXTRACTAR VERSIÓN EN RUNTIME)
|
# Cargo.toml del agente
|
||||||
RUN mkdir -p services/edge-agent
|
RUN mkdir -p services/edge-agent
|
||||||
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
|
||||||
|
|
||||||
|
|||||||
128
README.md
128
README.md
@@ -1,62 +1,106 @@
|
|||||||
# 🌐 OmniOil SCADA: Hydrocarbon Monitoring System (ANH)
|
# 🌐 OmniOil SCADA — Hydrocarbon Telemetry Platform
|
||||||
|
|
||||||
**OmniOil** is a robust, microservices-based, and event-driven SCADA system designed for real-time monitoring and official reporting of hydrocarbon telemetry in accordance with the **ANH (Agencia Nacional de Hidrocarburos)** Resolution 0651/2025.
|
> **Plataforma B2B SaaS IoT/SCADA** para telemetría de hidrocarburos en Colombia
|
||||||
|
> Construida por **Kyrbot Innovations** · Cumplimiento **ANH Resolución 0651/2025**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture Stack
|
## 🏗️ Stack de Arquitectura
|
||||||
- **Backend API**: Rust Axum (Asynchronous, High-Performance)
|
|
||||||
- **Data## 🛠️ Infraestructura de Telemetría (Security Hardened)
|
|
||||||
|
|
||||||
OmniOil utiliza Eclipse Mosquitto con un sistema de autenticación dinámico mediante Postgres (`Go-Auth`).
|
| Capa | Tecnología |
|
||||||
|
|------|-----------|
|
||||||
### 🛡️ Blindaje de Seguridad Implementado
|
| **Backend API** | Rust / Axum (asíncrono, high-performance) |
|
||||||
- **Cero Secretos**: Las claves nunca viajan en el código ni en archivos estáticos `.conf`.
|
| **Edge Agent** | Rust / Windows Service + MSI installer |
|
||||||
- **Hasheo Nativo (Postgres)**: Usamos la extensión `pgcrypto` para generar Hashes Bcrypt dinámicamente en la base de datos al registrar agentes.
|
| **Frontends** | React / TypeScript / Vite (Admin + PWA) |
|
||||||
- **Aislamiento Multi-Tenant**: Cada Agente de Borde tiene un **DNI MQTT único (UUID)** y una clave aleatoria de 40 caracteres generada por el orquestador.
|
| **Base de Datos** | TimescaleDB (PostgreSQL + series de tiempo) |
|
||||||
- **Micro-Segmentación por ACL**: Los usuarios de clientes están restringidos mediante ACLs dinámicas a su propio canal: `telemetry/{project_id}/#`.
|
| **Mensajería** | Eclipse Mosquitto + MQTT sobre WSS :443 |
|
||||||
|
| **Storage** | MinIO (S3 compatible, artefactos y reportes ANH) |
|
||||||
### 🚀 Despliegue Dinámico
|
| **Monitoreo** | Prometheus + Grafana |
|
||||||
El sistema utiliza un puente de inyección basado en un `entrypoint.sh` personalizado que pobla las variables de entorno en el archivo de configuración de Mosquitto en tiempo de ejecución, eliminando la necesidad de archivos `.passwd` o configuraciones manuales.
|
| **Seguridad** | Bcrypt · Argon2id · JWT · 2FA TOTP · AES-256 |
|
||||||
- **Persistence**: TimescaleDB (Time-series optimization) + MinIO (S3 object storage)
|
|
||||||
- **Edge Layer**: Local Agent for field device extraction (Modbus/OPC-UA)
|
|
||||||
- **Build System**: Unified Docker multi-stage pipeline with **Cargo Chef** & sharing-locked caching.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Getting Started (Production)
|
## 🚀 Quick Start
|
||||||
|
|
||||||
To deploy OmniOil in your production environment using **Dokploy**, follow these steps:
|
```bash
|
||||||
|
# Clonar y levantar stack completo
|
||||||
|
git clone <repo>
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
1. **Clone the Repository** and push to your Dokploy GitHub/GitLab integration.
|
# Frontends en desarrollo
|
||||||
2. **Environment Setup**: Follow the [Deployment Guide (docs/DEPLOYMENT.md)](docs/DEPLOYMENT.md) for Dokploy-specific steps.
|
cd services/frontend-admin && pnpm install && pnpm run dev # http://localhost:5173
|
||||||
3. **Architecture Details**: For technical details, database schemas, and data flow, check the [Reference Guide (docs/REFERENCE.md)](docs/REFERENCE.md).
|
cd services/frontend-pwa && pnpm install && pnpm run dev # http://localhost:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
**Primera vez:** Navegar a `http://localhost:5173/setup` para crear el superadmin.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 🌐 URLs de Producción
|
||||||
```text
|
|
||||||
/OmniOilPersonal
|
| URL | Servicio |
|
||||||
├── docker-compose.yml # Production-ready orchestration
|
|-----|---------|
|
||||||
├── services/ # Core microservices (Rust/React)
|
| https://app.omnioil.app | Panel de Administración |
|
||||||
│ ├── backend-api/ # RESTful API (Axum)
|
| https://mobile.omnioil.app | PWA de Campo |
|
||||||
│ ├── data-collector/ # High-frequency ingestion
|
| https://api.omnioil.app | API REST Backend |
|
||||||
│ ├── events-relay/ # Outbox dispatcher
|
| https://api.omnioil.app/docs | Swagger UI |
|
||||||
│ ├── json-generator/ # Daily ANH report generator
|
| https://api.omnioil.app/health | Health Check |
|
||||||
│ └── shared-lib/ # Domain Logic & Models
|
| https://storage.omnioil.app | MinIO S3 Console |
|
||||||
├── infrastructure/ # Configurations (PostgreSQL, Mosquitto, Nginx)
|
| mqtt://mqtt.omnioil.app:443 | MQTT sobre WSS (agentes) |
|
||||||
└── tests/ # End-to-end Modbus simulation scripts
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
OmniOilPersonal/
|
||||||
|
├── services/
|
||||||
|
│ ├── backend-api/ # API REST (Rust/Axum)
|
||||||
|
│ ├── data-collector/ # Ingesta MQTT → TimescaleDB
|
||||||
|
│ ├── events-relay/ # Transactional Outbox (Redis Streams)
|
||||||
|
│ ├── json-generator/ # Reportes diarios ANH
|
||||||
|
│ ├── build-orchestrator/ # Generador de instaladores MSI
|
||||||
|
│ ├── edge-agent/ # Agente de campo (Windows Service)
|
||||||
|
│ ├── shared-lib/ # Modelos y lógica de dominio
|
||||||
|
│ ├── frontend-admin/ # Dashboard (React/TypeScript)
|
||||||
|
│ └── frontend-pwa/ # PWA operadores (React/TypeScript)
|
||||||
|
├── infrastructure/ # PostgreSQL, Mosquitto, Nginx, MinIO
|
||||||
|
├── tests/ # Simuladores E2E y pruebas MQTT
|
||||||
|
└── docs/ # Documentación técnica y normativa
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔐 Compliance & Integrity
|
## 📚 Documentación
|
||||||
- **Transactional Outbox**: Guaranteed data sync between database and event stream.
|
|
||||||
- **Data Hashing**: SHA256 checksums for all official ANH JSON reports.
|
| Documento | Descripción |
|
||||||
- **UTC Alignment**: All telemetry and report timestamps strictly follow the **ISO 8601 (UTC)** format.
|
|-----------|------------|
|
||||||
- **Redundancy**: Local persistence in the Edge Agent ensures measurements continue even if cloud connectivity is lost.
|
| [docs/MANUAL_USUARIO.md](docs/MANUAL_USUARIO.md) | Manual completo del usuario (Admin + PWA + ANH) |
|
||||||
|
| [docs/MANUAL_TECNICO.md](docs/MANUAL_TECNICO.md) | Arquitectura, API, DB, MQTT, microservicios |
|
||||||
|
| [docs/DESPLIEGUE.md](docs/DESPLIEGUE.md) | Despliegue local, producción (Dokploy), HA multi-región |
|
||||||
|
| [docs/SEGURIDAD.md](docs/SEGURIDAD.md) | Auditoría de seguridad, hardening, 2FA |
|
||||||
|
| [docs/ISO27001.md](docs/ISO27001.md) | Cumplimiento ISO/IEC 27001:2022, SoA, matriz de riesgos |
|
||||||
|
| [docs/ANH_CUMPLIMIENTO.md](docs/ANH_CUMPLIMIENTO.md) | Resolución 0651, matriz de cumplimiento normativo |
|
||||||
|
| [docs/EDGE_AGENT.md](docs/EDGE_AGENT.md) | Guía completa del agente de campo |
|
||||||
|
| [docs/MODELO_NEGOCIO.md](docs/MODELO_NEGOCIO.md) | Precios, planes, estrategia comercial |
|
||||||
|
| [tests/README.md](tests/README.md) | Suite de pruebas E2E, simuladores, MQTT auth |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 License
|
## 🔐 Cumplimiento y Seguridad
|
||||||
This project is licensed under the MIT License.
|
|
||||||
|
- **ANH Resolución 0651**: Reportes JSON + SHA-256, UTC timestamps, audit trail completo
|
||||||
|
- **ISO 27001 Alineado**: Multi-tenancy, RBAC, 2FA, cifrado en tránsito y reposo
|
||||||
|
- **Store & Forward**: Los datos de campo no se pierden ante caídas de conectividad
|
||||||
|
- **HA Multi-Región**: Arquitectura Active-Passive (Brasil primario / USA standby)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Licencia
|
||||||
|
|
||||||
|
MIT License — Kyrbot Innovations
|
||||||
|
|||||||
BIN
SECURITY_AUDIT_REPORT.pdf
Normal file
BIN
SECURITY_AUDIT_REPORT.pdf
Normal file
Binary file not shown.
@@ -1,52 +0,0 @@
|
|||||||
# Reporte de Fortalecimiento y Remediación de Seguridad - OmniOil Backend
|
|
||||||
|
|
||||||
Este documento resume las acciones tomadas para asegurar la infraestructura de **OmniOil** y resolver los hallazgos críticos de la auditoría.
|
|
||||||
|
|
||||||
## 1. Resumen de Remediaciones
|
|
||||||
|
|
||||||
| # | Hallazgo Original | Estado | Acción Tomada |
|
|
||||||
|---|-------------------|--------|---------------|
|
|
||||||
| 1 | API Abierta (Sin JWT) | **RESUELTO** | Se integró el extractor `Claims` en todos los handlers de la API. Ninguna ruta (excepto Auth login/register) es accesible sin un JWT válido. |
|
|
||||||
| 2 | Movimientos sin Backend | **RESUELTO** | Se creó el módulo `operations_movements.rs`, se definieron los modelos en `shared-lib` y se implementó la migración SQL `20260407000000_operation_movements.sql`. |
|
|
||||||
| 3 | Field Name Mismatch | **RESUELTO** | Se sincronizó el PWA (`EdgeAsset`) con el backend. Los campos `asset_code`/`asset_name` ahora son `code`/`name` en todos los componentes de la interfaz. |
|
|
||||||
| 4 | Multi-tenant sin Enforcement | **RESUELTO** | Cada consulta SQL (CRUD, Telemetría, Logs, Dashboard) ahora incluye un JOIN con `user_project_access` para garantizar que el usuario solo vea sus datos. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Cambios Técnicos Realizados
|
|
||||||
|
|
||||||
### Backend (Rust / Axum)
|
|
||||||
- **Handlers Asegurados**:
|
|
||||||
- `edge_projects.rs`, `edge_assets.rs`, `edge_devices.rs`, `edge_variables.rs`, `edge_deployments.rs`: Todos ahora validan el rol del usuario y el acceso al proyecto antes de procesar peticiones.
|
|
||||||
- `telemetry.rs`: Protegido para evitar la fuga de datos de sensores entre diferentes clientes.
|
|
||||||
- `dashboard.rs`: El resumen estadístico ahora filtra dinámicamente según el portafolio del usuario.
|
|
||||||
- `edge_agents.rs`: Los logs y el estado de salud están restringidos a los dueños del proyecto.
|
|
||||||
- **Nuevas Funcionalidades**:
|
|
||||||
- `operations_movements.rs`: Handlers `POST /api/movements` y `GET /api/movements` para el cumplimiento con la ANH.
|
|
||||||
|
|
||||||
### Base de Datos (PostgreSQL / TimescaleDB)
|
|
||||||
- **Migración O6**: Creación de la tabla `operation_movements` con tipos específicos para hidrocarburos (Venta, Recibo, Transferencia, etc.) y precisión decimal.
|
|
||||||
- **Sincronización**: La ejecución de migraciones es automática al inicio del contenedor backend.
|
|
||||||
|
|
||||||
### Frontend (PWA / Vite)
|
|
||||||
- **Alineación de Modelos**: Se actualizó `EdgeAsset` en `assets-api.ts`.
|
|
||||||
- **UI Corregida**: Se modificaron `MeasurementPage`, `MovementPage` y sus `AssetSelector` correspondientes para usar los nuevos campos mapeados desde el backend, eliminando los errores de "undefined".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Guía de Despliegue y Validación
|
|
||||||
|
|
||||||
### Paso 1: Reconstrucción en Dokploy
|
|
||||||
Para que estos cambios surtan efecto en producción, debe forzar una reconstrucción ("Redeploy") desde el panel de Dokploy.
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> El backend ejecutará automáticamente la nueva migración SQL al arrancar. No es necesario realizar cambios manuales en la base de datos.
|
|
||||||
|
|
||||||
### Paso 2: Pruebas de Validación
|
|
||||||
1. **Acceso Prohibido**: Intente llamar a `/api/edge/projects` sin el header `Authorization`. El backend debe responder con `401 Unauthorized`.
|
|
||||||
2. **Aislamiento Multi-tenant**: Use una cuenta de tipo "Supervisor" o "Operador" y verifique que solo aparecen los proyectos asignados en su tabla de acceso.
|
|
||||||
3. **Registro de Movimientos**: Desde la PWA, cree un movimiento de "Venta" y verifique que se guarda correctamente en la nueva tabla del backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> **Recomendación para Desarrolladores**: Mantener el modelo de "Backend como fuente de verdad". Ante cualquier desajuste futuro entre PWA y Backend, siempre debe prevalecer el esquema de Rust para mantener la integridad de la base de datos.
|
|
||||||
53
TRACKING.md
53
TRACKING.md
@@ -1,53 +0,0 @@
|
|||||||
# Tracking — Estabilización OmniOil (local)
|
|
||||||
|
|
||||||
Fecha: 2026-04-07
|
|
||||||
Estado: plan operativo inmediato (no publicado)
|
|
||||||
|
|
||||||
Fuentes clave
|
|
||||||
- PRD completo: `docs/PRD_ESTABILIZACION_PRODUCCION.md` (publicado en main)
|
|
||||||
- Resumen ejecutivo: `docs/PRD_RESUMEN_EJECUTIVO.md` (local, sin commit)
|
|
||||||
|
|
||||||
Objetivo 72h
|
|
||||||
- Dejar la plataforma segura y consistente para continuar con cierres funcionales.
|
|
||||||
|
|
||||||
Checklist inmediato (72h)
|
|
||||||
- [ ] Seguridad: aplicar middleware/JWT en todos los handlers del backend.
|
|
||||||
- [ ] Seguridad: rotar `JWT_SECRET` y variables sensibles; mover a `.env`/secrets.
|
|
||||||
- [ ] Seguridad: CORS por allowlist (dominios específicos por ambiente).
|
|
||||||
- [ ] Seguridad: cerrar puertos externos (MQTT/DB/Redis/MinIO) y usar red interna.
|
|
||||||
- [ ] Consistencia: corregir mismatch de campos de assets entre backend y PWA.
|
|
||||||
- [ ] Datos: reconciliar migraciones `sqlx` con `init.sql` (fuente única: migraciones).
|
|
||||||
- [ ] Funcional: implementar `GET/POST /api/movements` y modelo compartido.
|
|
||||||
- [ ] Offline: integrar `use-sync-worker` para movements (cola + reintentos).
|
|
||||||
|
|
||||||
Notas técnicas rápidas
|
|
||||||
- Enforce JWT: agregar validador/middleware global y remover `#[allow(dead_code)]` de `verify_jwt()`; exigir auth en rutas `/api/**`.
|
|
||||||
- Multi‑tenant: filtrar por usuario/proyecto en queries (usar `user_project_access`).
|
|
||||||
- Migraciones: crear migración que cree/ajuste tablas faltantes (`operations_movements`, `operations_downtime`, `lab_results`, etc.) y alinear tipos/constraints.
|
|
||||||
|
|
||||||
Comandos útiles
|
|
||||||
- Publicar resumen ejecutivo (cuando decidas):
|
|
||||||
- `git add docs/PRD_RESUMEN_EJECUTIVO.md`
|
|
||||||
- `git commit -m "docs: resumen ejecutivo PRD"`
|
|
||||||
- `git push origin main`
|
|
||||||
|
|
||||||
- Auditar handlers sin auth (búsqueda rápida):
|
|
||||||
- `rg -n "(axum::routing|Router::new|route\(|get\(|post\()" services/backend-api/src | rg -v "auth|jwt|layer"`
|
|
||||||
- `rg -n "TODO|FIXME|allow\(dead_code\)|unwrap\(\)" services/backend-api/src`
|
|
||||||
|
|
||||||
- Diferencias de esquema:
|
|
||||||
- `rg -n "CREATE TABLE|ALTER TABLE" infrastructure/postgres/init.sql`
|
|
||||||
- `ls services/backend-api/migrations && rg -n "CREATE TABLE|ALTER TABLE" services/backend-api/migrations`
|
|
||||||
|
|
||||||
- Verificación de puertos expuestos (infra):
|
|
||||||
- `rg -n "ports:\n|\- \"?\d{2,5}:\d{2,5}\"?" docker-compose.yml infrastructure -n`
|
|
||||||
|
|
||||||
Decisiones pendientes
|
|
||||||
- Política CORS por entorno (dominios/clientes permitidos).
|
|
||||||
- Estrategia de secretos (dotenv vs. secrets manager en despliegue).
|
|
||||||
- Modelo de roles/ACL por proyecto y scopes de API.
|
|
||||||
|
|
||||||
Siguientes entregables
|
|
||||||
- PR: Fase 1 completa (seguridad) + migración de reconciliación.
|
|
||||||
- PR: Movements backend + integración de sync en PWA.
|
|
||||||
|
|
||||||
@@ -19,22 +19,54 @@ services:
|
|||||||
|
|
||||||
# Exponer backend para pruebas con Postman/Insomnia
|
# Exponer backend para pruebas con Postman/Insomnia
|
||||||
backend-api:
|
backend-api:
|
||||||
|
environment:
|
||||||
|
- APP_ENV=development
|
||||||
|
- SMTP_HOST=mailpit
|
||||||
|
- SMTP_PORT=1025
|
||||||
|
- SMTP_TLS_MODE=none
|
||||||
|
- SMTP_USER=
|
||||||
|
- SMTP_PASS=
|
||||||
|
- SMTP_FROM=OmniOil <soporte@omnioil.app>
|
||||||
|
- INVITATION_BASE_URL=http://localhost:3000
|
||||||
|
- PLATFORM_CONSOLE_URL=http://localhost:3002/login
|
||||||
|
depends_on:
|
||||||
|
mailpit:
|
||||||
|
condition: service_started
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
# Exponer el Registry para inspección opcional
|
# Captura correos SMTP en desarrollo local para pruebas end-to-end
|
||||||
docker-registry:
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.27.8
|
||||||
|
container_name: anh_mailpit
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "1025:1025" # SMTP
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
networks:
|
||||||
|
- anh_network
|
||||||
|
|
||||||
# Exponer frontend para ver la UI localmente
|
# Exponer frontend para ver la UI localmente
|
||||||
|
landing:
|
||||||
|
ports:
|
||||||
|
- "3004:80"
|
||||||
|
|
||||||
frontend-admin:
|
frontend-admin:
|
||||||
build:
|
build:
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
|
- VITE_LANDING_APP_ORIGIN=http://localhost:3004/
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
|
|
||||||
|
# Exponer consola interna OmniOil/Kyrbot
|
||||||
|
frontend-console:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
- VITE_API_BASE=/api
|
||||||
|
ports:
|
||||||
|
- "3002:80"
|
||||||
|
|
||||||
# Exponer PWA para campo
|
# Exponer PWA para campo
|
||||||
frontend-pwa:
|
frontend-pwa:
|
||||||
build:
|
build:
|
||||||
@@ -47,3 +79,23 @@ services:
|
|||||||
mosquitto:
|
mosquitto:
|
||||||
ports:
|
ports:
|
||||||
- "1883:1883"
|
- "1883:1883"
|
||||||
|
- "9883:9883"
|
||||||
|
|
||||||
|
# Desarrollo: Sincronizar versión y scripts del instalador
|
||||||
|
build-orchestrator:
|
||||||
|
volumes:
|
||||||
|
- ./services/edge-agent/Cargo.toml:/app/services/edge-agent/Cargo.toml
|
||||||
|
|
||||||
|
edge-agent-msi-builder:
|
||||||
|
volumes:
|
||||||
|
- ./services/edge-agent/Cargo.toml:/app/services/edge-agent/Cargo.toml
|
||||||
|
- ./services/edge-agent/wix:/app/services/edge-agent/wix
|
||||||
|
- ./services/edge-agent/msi_worker.sh:/app/services/edge-agent/msi_worker.sh
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
ports:
|
||||||
|
- "3003:3000"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
image: redis:8.6.2-alpine
|
image: redis:8.6.2-alpine
|
||||||
container_name: anh_redis
|
container_name: anh_redis
|
||||||
restart: always
|
restart: always
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
|
command: [ "redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes" ]
|
||||||
volumes:
|
volumes:
|
||||||
- ${REDIS_DATA:-redis_data}:/data
|
- ${REDIS_DATA:-redis_data}:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -60,22 +60,43 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- TOTP_SECRET_ENCRYPTION_KEY=${TOTP_SECRET_ENCRYPTION_KEY}
|
||||||
- MQTT_HOST=mosquitto
|
- MQTT_HOST=mosquitto
|
||||||
- MQTT_PORT=1883
|
- MQTT_PORT=1883
|
||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
ports:
|
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL}
|
||||||
- "8000:8000"
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
||||||
|
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
|
||||||
|
- APP_ENV=production
|
||||||
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
- SMTP_TLS_MODE=${SMTP_TLS_MODE:-starttls}
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- SMTP_FROM=${SMTP_FROM}
|
||||||
|
- INVITATION_BASE_URL=${INVITATION_BASE_URL}
|
||||||
|
- PLATFORM_CONSOLE_URL=${PLATFORM_CONSOLE_URL}
|
||||||
|
- MQTT_PUBLIC_HOST=${MQTT_PUBLIC_HOST}
|
||||||
|
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883}
|
||||||
|
- MQTT_USE_WS=${MQTT_USE_WS:-false}
|
||||||
|
- APP_PUBLIC_URL=${APP_PUBLIC_URL}
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
timescaledb:
|
timescaledb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
mosquitto:
|
||||||
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8000' || exit 1" ]
|
test: [ "CMD-SHELL", "curl -fsS http://localhost:8000/health >/dev/null || exit 1" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -129,7 +150,9 @@ services:
|
|||||||
target: json-generator
|
target: json-generator
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- MINIO_ENDPOINT=http://minio:9000
|
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||||
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -143,6 +166,12 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: services/landing/Dockerfile
|
dockerfile: services/landing/Dockerfile
|
||||||
|
args:
|
||||||
|
- NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY}}
|
||||||
|
environment:
|
||||||
|
- LANDING_API_PROXY_TARGET=${LANDING_API_PROXY_TARGET:-http://backend-api:8000}
|
||||||
|
depends_on:
|
||||||
|
- backend-api
|
||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
|
|
||||||
@@ -154,6 +183,9 @@ services:
|
|||||||
dockerfile: services/frontend-pwa/Dockerfile
|
dockerfile: services/frontend-pwa/Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
|
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
||||||
|
- VITE_MOBILE_APP_ORIGIN=${VITE_MOBILE_APP_ORIGIN}
|
||||||
|
- VITE_API_URL=${VITE_API_URL}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-api
|
- backend-api
|
||||||
networks:
|
networks:
|
||||||
@@ -167,6 +199,28 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_BASE=/api
|
- VITE_API_BASE=/api
|
||||||
|
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
|
||||||
|
- VITE_MOBILE_APP_ORIGIN=${VITE_MOBILE_APP_ORIGIN}
|
||||||
|
- VITE_API_URL=${VITE_API_URL}
|
||||||
|
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
|
||||||
|
- VITE_LANDING_APP_ORIGIN=${VITE_LANDING_APP_ORIGIN:-https://omnioil.app/}
|
||||||
|
depends_on:
|
||||||
|
- backend-api
|
||||||
|
networks:
|
||||||
|
- anh_network
|
||||||
|
|
||||||
|
frontend-console:
|
||||||
|
container_name: anh_frontend_console
|
||||||
|
restart: always
|
||||||
|
build:
|
||||||
|
context: ./services/frontend-console
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- VITE_API_BASE=/api
|
||||||
|
- VITE_CONSOLE_APP_ORIGIN=${VITE_CONSOLE_APP_ORIGIN:-https://console.omnioil.app}
|
||||||
|
- VITE_ACCESS_REQUEST_REVIEWER_EMAILS=${ACCESS_REQUEST_REVIEWER_EMAILS:-w.farfan@omnioil.app,h.ortegon@omnioil.app}
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-api
|
- backend-api
|
||||||
networks:
|
networks:
|
||||||
@@ -179,7 +233,7 @@ services:
|
|||||||
context: ./infrastructure/mosquitto
|
context: ./infrastructure/mosquitto
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- MOSQUITTO_GO_AUTH=true
|
- MOSQUITTO_GO_AUTH=${MOSQUITTO_GO_AUTH:-true}
|
||||||
- POSTGRES_HOST=timescaledb
|
- POSTGRES_HOST=timescaledb
|
||||||
- POSTGRES_USER=${DB_USER}
|
- POSTGRES_USER=${DB_USER}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
@@ -190,23 +244,9 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
timescaledb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
docker-registry:
|
|
||||||
image: registry:3.0.0
|
|
||||||
container_name: anh_docker_registry
|
|
||||||
restart: always
|
|
||||||
expose:
|
|
||||||
- "5000"
|
|
||||||
environment:
|
|
||||||
REGISTRY_STORAGE_DELETE_ENABLED: "true"
|
|
||||||
OTEL_TRACES_EXPORTER: "none"
|
|
||||||
networks:
|
|
||||||
- anh_network
|
|
||||||
volumes:
|
|
||||||
- ${REGISTRY_DATA:-registry_data}:/var/lib/registry
|
|
||||||
|
|
||||||
build-orchestrator:
|
build-orchestrator:
|
||||||
container_name: anh_build_orchestrator
|
container_name: anh_build_orchestrator
|
||||||
build:
|
build:
|
||||||
@@ -219,16 +259,18 @@ services:
|
|||||||
- MQTT_PORT=1883
|
- MQTT_PORT=1883
|
||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
- DOCKER_REGISTRY_URL=docker-registry:5000
|
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL}
|
||||||
- MINIO_ENDPOINT_URL=http://minio:9000
|
|
||||||
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
- AWS_REGION=us-east-1
|
- AWS_REGION=${AWS_REGION}
|
||||||
- AWS_EC2_METADATA_DISABLED=true
|
- AWS_EC2_METADATA_DISABLED=${AWS_EC2_METADATA_DISABLED:-true}
|
||||||
- BACKEND_API_URL=${BACKEND_API_URL}
|
- BACKEND_API_URL=${BACKEND_API_URL}
|
||||||
- MQTT_PUBLIC_HOST=${MQTT_PUBLIC_HOST}
|
- MQTT_PUBLIC_HOST=${MQTT_PUBLIC_HOST}
|
||||||
|
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-9883}
|
||||||
|
- MQTT_USE_WS=${MQTT_USE_WS:-true}
|
||||||
|
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
|
||||||
|
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto
|
||||||
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
|
||||||
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output
|
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output
|
||||||
@@ -251,6 +293,61 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- anh_network
|
- anh_network
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.4.0
|
||||||
|
container_name: anh_prometheus
|
||||||
|
restart: always
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--storage.tsdb.retention.time=30d'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
volumes:
|
||||||
|
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
networks:
|
||||||
|
- anh_network
|
||||||
|
depends_on:
|
||||||
|
- backend-api
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:12.0.1
|
||||||
|
container_name: anh_grafana
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-omnioil_grafana}
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s/grafana
|
||||||
|
- GF_SERVER_SERVE_FROM_SUB_PATH=true
|
||||||
|
- GF_AUTH_ANONYMOUS_ENABLED=false
|
||||||
|
- GF_ANALYTICS_REPORTING_ENABLED=false
|
||||||
|
- GF_ANALYTICS_CHECK_FOR_UPDATES=false
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
- ./infrastructure/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./infrastructure/monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
networks:
|
||||||
|
- anh_network
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.9.1
|
||||||
|
container_name: anh_node_exporter
|
||||||
|
restart: always
|
||||||
|
command:
|
||||||
|
- '--path.procfs=/host/proc'
|
||||||
|
- '--path.rootfs=/rootfs'
|
||||||
|
- '--path.sysfs=/host/sys'
|
||||||
|
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
networks:
|
||||||
|
- anh_network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
anh_network:
|
anh_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -262,6 +359,7 @@ volumes:
|
|||||||
minio_data:
|
minio_data:
|
||||||
mosquitto_data:
|
mosquitto_data:
|
||||||
mosquitto_log:
|
mosquitto_log:
|
||||||
registry_data:
|
|
||||||
installer_incoming:
|
installer_incoming:
|
||||||
installer_output:
|
installer_output:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
|
|||||||
254
docs/ANH_CUMPLIMIENTO.md
Normal file
254
docs/ANH_CUMPLIMIENTO.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# ⚖️ Guía de Cumplimiento ANH — Resolución 0651 de 2025
|
||||||
|
**OmniOil SCADA** · Kyrbot Innovations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Marco Legal
|
||||||
|
|
||||||
|
La **Resolución 0651 de 2025** de la Agencia Nacional de Hidrocarburos (ANH) establece los requisitos para el sistema de **Fiscalización Online** de operaciones de producción de hidrocarburos en Colombia.
|
||||||
|
|
||||||
|
### 1.1 Obligaciones Principales
|
||||||
|
|
||||||
|
- Instalación de sistemas de telemetría certificados en todos los campos de producción activos
|
||||||
|
- Reporte diario automático en formato JSON con identificador único de recurso
|
||||||
|
- Almacenamiento de datos con frecuencia mínima de 5 minutos
|
||||||
|
- Mecanismos de integridad, inalterabilidad y trazabilidad del dato reportado
|
||||||
|
- Trazabilidad completa de ingresos y movimientos de crudo
|
||||||
|
- Registro de paradas planificadas y no planificadas con justificación
|
||||||
|
|
||||||
|
### 1.2 Incumplimiento
|
||||||
|
|
||||||
|
El incumplimiento de la Resolución puede resultar en:
|
||||||
|
- Multas de hasta **2.000 salarios mínimos mensuales**
|
||||||
|
- Suspensión de operaciones
|
||||||
|
- Revocación de contratos de exploración y producción
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Matriz de Cumplimiento
|
||||||
|
|
||||||
|
| Artículo | Requisito Normativo | Implementación OmniOil | Estado |
|
||||||
|
|---------|---------------------|----------------------|--------|
|
||||||
|
| **Art. 7** | Sistema de medición continua (mínimo cada 5 min) | Telemetría cada 10 segundos vía MQTT, almacenada en TimescaleDB | ✅ |
|
||||||
|
| **Art. 17** | Almacenamiento periódico mínimo 5 min | Hypertable con registros cada ≤5 minutos. Resolución real: 10 segundos | ✅ |
|
||||||
|
| **Art. 22** | Redundancia ante fallos de comunicación | Store & Forward en Edge Agent (SQLite AES-256 local) | ✅ |
|
||||||
|
| **Art. 26** | Integridad e inalterabilidad de datos | TimescaleDB append-only + artefactos JSON sellados en MinIO; hash SHA-384 como evidencia interna de auditoría sobre los bytes sellados | ✅ |
|
||||||
|
| **Art. 27** | Timestamps UTC obligatorios | `timestamptz` en todas las tablas, NTP sincronizado | ✅ |
|
||||||
|
| **Art. 34** | Nomenclatura del archivo de reporte | `OPERADOR_CONTRATO_DD-MMM-YYYY.json` implementado | ✅ |
|
||||||
|
| **Art. 35** | ID de recurso coincidente con Forma 4CR | Campo `id_recurso` mapeado al código ANH del pozo | ✅ |
|
||||||
|
| **Art. 40** | Georreferenciación de activos | Coordenadas lat/lon, municipio y departamento en cada reporte | ✅ |
|
||||||
|
| **Art. 45** | Registro de movimientos de crudo | Tabla `operation_movements` con tipos: Venta, Recibo, Transferencia, Quema, Consumo | ✅ |
|
||||||
|
| **Art. 52** | Registro de paradas y excepciones | Sección `exceptions` en el reporte JSON con alarmas detectadas | ✅ |
|
||||||
|
| **Art. 55** | Auditoría de datos ingresados manualmente | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | ✅ |
|
||||||
|
| **Art. 60** | Metrología y calibración | Campos de calibración en activos: última fecha, próxima fecha, certificado | ✅ |
|
||||||
|
| **Art. 65** | Acceso del ente regulador | Rol `anh_reader` con acceso de solo lectura a reportes y datos históricos | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Integridad de Datos
|
||||||
|
|
||||||
|
### 3.1 Artefacto JSON sellado e integridad interna
|
||||||
|
|
||||||
|
El JSON entregado a la ANH mantiene únicamente la estructura normativa del Anexo 4. OmniOil no agrega campos propios como `Hash Sello`, `INTEGRITY_HASH` o un `hash_sha256` en el cuerpo del JSON entregable.
|
||||||
|
|
||||||
|
Para auditoría interna, cada reporte se sella como bytes finales en MinIO y el sistema calcula `hash_sha384` sobre esos bytes exactos. Ese hash se conserva como metadato de evidencia, separado del contenido normativo del archivo.
|
||||||
|
|
||||||
|
Cualquier alteración del objeto sellado cambia el SHA-384 esperado y permite detectar manipulaciones sin modificar la forma del JSON que recibe la ANH.
|
||||||
|
|
||||||
|
### 3.2 Timestamps UTC
|
||||||
|
|
||||||
|
Todos los timestamps del sistema siguen el estándar **ISO 8601 (UTC)**:
|
||||||
|
- La base de datos usa `timestamptz` (timezone-aware) en todas las tablas
|
||||||
|
- El Edge Agent envía timestamps en UTC con el formato `YYYY-MM-DDTHH:MM:SSZ`
|
||||||
|
- El Store & Forward preserva el timestamp original del dato cuando hay caídas de conectividad
|
||||||
|
|
||||||
|
### 3.3 Inmutabilidad de Telemetría
|
||||||
|
|
||||||
|
- La tabla `telemetry_raw` es una **Hypertable de TimescaleDB** optimizada para escrituras append-only
|
||||||
|
- Los registros históricos no pueden ser modificados retroactivamente
|
||||||
|
- Los datos de la hypertable tienen retención configurable:
|
||||||
|
- **Hot** (TimescaleDB): 6 meses de datos en línea
|
||||||
|
- **Warm** (MinIO): 2 años comprimidos
|
||||||
|
- **Cold** (Google Drive): indefinido para cumplimiento ANH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gestión de Excepciones y Alarmas
|
||||||
|
|
||||||
|
### 4.1 Detección Automática
|
||||||
|
|
||||||
|
El sistema monitorea variables críticas en tiempo real y detecta:
|
||||||
|
- Valores fuera de rango (por debajo o encima del umbral configurado)
|
||||||
|
- Pérdida de señal del sensor (variable sin actualizar > umbral configurado)
|
||||||
|
- Desconexión del agente de campo (sin heartbeat > 60 segundos)
|
||||||
|
|
||||||
|
### 4.2 Registro de Alarmas
|
||||||
|
|
||||||
|
Cada anomalía detectada se registra en la tabla `telemetry_alarms`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE telemetry_alarms (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
project_id UUID REFERENCES edge_projects(id),
|
||||||
|
asset_id UUID REFERENCES edge_assets(id),
|
||||||
|
variable_id UUID REFERENCES edge_variables(id),
|
||||||
|
alarm_type VARCHAR(50), -- 'HIGH', 'LOW', 'LOSS_OF_SIGNAL', 'OFFLINE'
|
||||||
|
severity VARCHAR(20), -- 'INFO', 'WARNING', 'CRITICAL'
|
||||||
|
triggered_at TIMESTAMPTZ,
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolved_by UUID REFERENCES users(id),
|
||||||
|
value_at_trigger DECIMAL(15,4),
|
||||||
|
threshold DECIMAL(15,4),
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Inclusión en Reporte ANH
|
||||||
|
|
||||||
|
Las alarmas del período son incluidas automáticamente en la sección `exceptions` del reporte diario:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"excepciones": [
|
||||||
|
{
|
||||||
|
"tipo": "FUERA_DE_RANGO",
|
||||||
|
"variable": "Presión de Cabeza WHP",
|
||||||
|
"inicio": "2026-05-03T14:22:00Z",
|
||||||
|
"fin": "2026-05-03T14:45:00Z",
|
||||||
|
"valor": 450.2,
|
||||||
|
"umbral": 400.0,
|
||||||
|
"operador_resolvio": "Juan Operador",
|
||||||
|
"observacion": "Cierre de válvula temporal por mantenimiento"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Metrología y Trazabilidad
|
||||||
|
|
||||||
|
### 5.1 Gestión de Calibraciones
|
||||||
|
|
||||||
|
Cada activo registrado en OmniOil incluye:
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|-------|------------|
|
||||||
|
| `last_calibration_date` | Fecha de la última calibración del instrumento |
|
||||||
|
| `next_calibration_date` | Próxima fecha programada de calibración |
|
||||||
|
| `calibration_certificate_url` | Enlace al certificado digital de calibración |
|
||||||
|
| `metrological_entity` | Entidad certificadora (ej. INM, laboratorio acreditado) |
|
||||||
|
|
||||||
|
### 5.2 Alertas de Vencimiento
|
||||||
|
|
||||||
|
El sistema genera alertas automáticas:
|
||||||
|
- **30 días antes** del vencimiento de calibración: alerta `WARNING`
|
||||||
|
- **7 días antes**: alerta `CRITICAL` con notificación por Email y Telegram
|
||||||
|
- **En el reporte ANH**: se indica el estado de calibración vigente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Notificaciones Proactivas (Watchdog)
|
||||||
|
|
||||||
|
### 6.1 Pérdida de Telemetría
|
||||||
|
|
||||||
|
Si un proyecto activo deja de enviar datos:
|
||||||
|
- **>5 minutos de silencio**: alerta interna `WARNING`
|
||||||
|
- **>15 minutos** (configurable): notificación vía Email + Telegram al supervisor
|
||||||
|
- El umbral se configura en la variable de entorno `WATCHDOG_SILENCE_THRESHOLD_MIN`
|
||||||
|
|
||||||
|
### 6.2 Heartbeat del Agente
|
||||||
|
|
||||||
|
El Edge Agent envía un heartbeat cada 60 segundos al topic:
|
||||||
|
```
|
||||||
|
omnioil/health/{project_id}
|
||||||
|
```
|
||||||
|
Si el heartbeat se interrumpe, el sistema cambia el estado a `OFFLINE` y genera alerta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Trazabilidad de Datos Manuales
|
||||||
|
|
||||||
|
### 7.1 Audit Trail
|
||||||
|
|
||||||
|
Todo dato ingresado manualmente (movimientos, mediciones, ajustes) queda registrado en `audit_log`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
action VARCHAR(50), -- 'CREATE', 'UPDATE', 'DELETE'
|
||||||
|
entity_type VARCHAR(50), -- 'operation_movement', 'telemetry_manual', etc.
|
||||||
|
entity_id UUID,
|
||||||
|
old_value JSONB,
|
||||||
|
new_value JSONB,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
performed_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Visibilidad del Audit Trail
|
||||||
|
|
||||||
|
- **Administradores**: acceso completo al audit trail desde el panel
|
||||||
|
- **Rol ANH Reader**: puede ver el audit trail para verificación de integridad
|
||||||
|
- **Exportación**: disponible en CSV para auditorías externas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Formato y Generación del Reporte
|
||||||
|
|
||||||
|
### 8.1 Proceso de Generación
|
||||||
|
|
||||||
|
El microservicio `json-generator` genera reportes ANH con una programación administrable desde el panel Admin. La configuración por defecto es **06:00** en zona horaria **America/Bogota** y puede activarse/desactivarse por unidad de generación ANH.
|
||||||
|
|
||||||
|
1. Consulta todos los proyectos activos con contrato ANH configurado
|
||||||
|
2. Para cada proyecto, agrega:
|
||||||
|
- Telemetría de las últimas 24h (con promedio, mínimo y máximo por variable)
|
||||||
|
- Movimientos de producción del período
|
||||||
|
- Alarmas y excepciones del período
|
||||||
|
3. Serializa el JSON normativo final, sin campos de hash agregados por OmniOil
|
||||||
|
4. Guarda los bytes finales en MinIO (bucket `anh-reports`) y registra `hash_sha384` como metadato interno de auditoría
|
||||||
|
5. Deja el reporte en estado generado/disponible para revisión; no lo aprueba automáticamente
|
||||||
|
|
||||||
|
### 8.2 Estados del ciclo de vida
|
||||||
|
|
||||||
|
- `GENERATED`: reporte sellado y disponible para revisión manual.
|
||||||
|
- `APPROVED`: reporte aprobado manualmente por un administrador.
|
||||||
|
- `SENT` / `DISPOSED`: transición externa posterior, separada de la aprobación manual.
|
||||||
|
- `FAILED`: generación fallida o error operativo.
|
||||||
|
|
||||||
|
### 8.3 Generación Manual
|
||||||
|
|
||||||
|
Para generar el reporte de un día específico:
|
||||||
|
```
|
||||||
|
POST /api/reports/anh/generate
|
||||||
|
{
|
||||||
|
"project_id": "uuid-del-proyecto",
|
||||||
|
"date": "2026-05-03"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist de Verificación ANH
|
||||||
|
|
||||||
|
Antes de la certificación ante la ANH, verificar:
|
||||||
|
|
||||||
|
- [ ] Todos los pozos activos tienen contrato ANH configurado
|
||||||
|
- [ ] Coordenadas geográficas correctas en todos los activos
|
||||||
|
- [ ] Dispositivos Modbus/OPC-UA conectados y variables configuradas
|
||||||
|
- [ ] Edge Agent instalado y conectado (estado ONLINE)
|
||||||
|
- [ ] Telemetría llegando con frecuencia ≤5 minutos
|
||||||
|
- [ ] Reglas de alerta configuradas para variables críticas
|
||||||
|
- [ ] Fechas de calibración actualizadas en todos los activos
|
||||||
|
- [ ] Primer reporte ANH generado y verificado manualmente
|
||||||
|
- [ ] Hash interno SHA-384 verificado contra los bytes sellados descargados desde MinIO
|
||||||
|
- [ ] Nomenclatura del archivo cumple con el Art. 34
|
||||||
|
- [ ] Rol `anh_reader` creado para el auditor de la ANH
|
||||||
|
- [ ] MinIO accesible para descarga de reportes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*OmniOil SCADA — Guía de Cumplimiento ANH actualizada Mayo 2026*
|
||||||
|
*Ref: Resolución ANH 0651 de 2025 — Kyrbot Innovations*
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# ⚙️ OmniOil Deployment Guide (Dokploy & Production)
|
|
||||||
|
|
||||||
This guide describes how to deploy and configure the OmniOil SCADA system in a production environment using **Dokploy**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ 1. Infrastructure Overview (Dokploy)
|
|
||||||
|
|
||||||
### 🏗️ Configuración Dinámica de Mosquitto
|
|
||||||
El broker MQTT (`mosquitto`) ya no depende de archivos de configuración estáticos con contraseñas.
|
|
||||||
|
|
||||||
#### El Flujo de Inyección en Caliente:
|
|
||||||
1. **Plantilla**: Se usa el archivo `infrastructure/mosquitto/mosquitto.conf` como una plantilla (template).
|
|
||||||
2. **Inyección**: El archivo `infrastructure/mosquitto/entrypoint.sh` se ejecuta al arrancar el contenedor y realiza el reemplazo de los campos `PLACEHOLDER` por las variables reales (`DB_USER`, `DB_PASSWORD`, etc.).
|
|
||||||
3. **Encadenamiento**:Mosquitto inicia con la configuración inyectada en memoria (dentro del contenedor).
|
|
||||||
|
|
||||||
### 🛡️ Seguridad Multi-Cliente (Postgres-Backed)
|
|
||||||
La autenticación de cada nuevo agente se gestiona dinámicamente mediante la tabla `mqtt_users`:
|
|
||||||
- **Auto-Hashing**: No es necesario generar hashes manuales. El orquestador inserta la clave en texto plano y Postgres genera el hash Bcrypt al vuelo usando `pgcrypto`.
|
|
||||||
- **UUID Identity**: Los agentes utilizan el ID del proyecto como nombre de usuario para asegurar un mapeo unívoco.
|
|
||||||
- **Micro-Segmentación (ACL)**: Cada agente tiene un permiso restringido para escribir únicamente en su canal industrial: `omnioil/telemetry/{PROYECTO_ID}/#`.
|
|
||||||
- **Ingesta Centralizada**: El microservicio **telemetry-ingestor** procesa todos los mensajes bajo el prefijo `omnioil/` y los persiste en TimescaleDB.
|
|
||||||
|
|
||||||
When deploying to **Dokploy**, each main service is exposed as a subdomain:
|
|
||||||
|
|
||||||
| Service | Port (Internal) | Subdomain (Recommended) | HTTPS | Description |
|
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
|
||||||
| **Landing Page** | 80 | `omnioil.app` / `www` | ✅ | Public Marketing & Sales |
|
|
||||||
| **SaaS Dashboard**| 80 | `app.omnioil.app` | ✅ | `frontend-admin` canonical admin/dashboard surface |
|
|
||||||
| **Field PWA** | 80 | `mobile.omnioil.app` | ✅ | `frontend-pwa` canonical field/operator surface |
|
|
||||||
| **Backend API** | 8000 | `api.omnioil.app` | ✅ | Central Orchestrator & Logic |
|
|
||||||
| **Telemetry Ingestor**| - | *(Internal)* | - | MQTT-to-DB Bridge (Rust) |
|
|
||||||
| **MinIO Console** | 9001 | `storage.omnioil.app` | ✅ | S3 Object UI |
|
|
||||||
| **Mosquitto (WSS)*** | 443 | `mqtt.omnioil.app` | ✅ | Encrypted Edge Communication |
|
|
||||||
|
|
||||||
*\*Nota: El puerto público es el 443 (HTTPS/WSS) mapeado internamente por el proxy.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📡 2. Edge Agent Communication (MQTT over WSS)
|
|
||||||
|
|
||||||
Communication with field agents MUST be done via **WSS (Port 443)** for maximum compatibility:
|
|
||||||
1. **Redirection in Dokploy**: Direct the public subdomain `mqtt.omnioil.app` (Port 443) to the internal container `mosquitto` (Port 9001 - WebSocket).
|
|
||||||
2. **Security**: Dokploy/Traefik handles TLS termination. The agent connects via `wss://mqtt.omnioil.app:443`.
|
|
||||||
3. **Single Channel**: Telemetry, Health, and Logs all flow through this single encrypted connection.
|
|
||||||
4. **Topics Estandarizados**:
|
|
||||||
- `omnioil/telemetry/{id}`: Datos SCADA.
|
|
||||||
- `omnioil/health/{id}`: Estado del hardware y contenedores.
|
|
||||||
- `omnioil/logs/{id}`: Trazabilidad del agente.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔑 3. Environment Variable Checklist
|
|
||||||
|
|
||||||
Copy and paste these variables into the **Environment Variables** tab of your project in Dokploy.
|
|
||||||
|
|
||||||
```env
|
|
||||||
# =============================================================================
|
|
||||||
# OMNIOIL SCADA - LISTA CONSOLIDADA
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# --- CONTROL DE ARCHIVOS COMPOSE ---
|
|
||||||
# Producción (Dokploy): COMPOSE_FILE=docker-compose.yml
|
|
||||||
COMPOSE_PATH_SEPARATOR=:
|
|
||||||
COMPOSE_FILE=docker-compose.yml
|
|
||||||
|
|
||||||
# --- INFRAESTRUCTURA CORE ---
|
|
||||||
DB_USER=admin_omnioil
|
|
||||||
DB_PASSWORD=una_clave_muy_segura_generada
|
|
||||||
DB_NAME=omnioil
|
|
||||||
|
|
||||||
# --- REDIS & STORAGE ---
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
MINIO_USER=admin_s3
|
|
||||||
MINIO_PASSWORD=otra_clave_segura_minio
|
|
||||||
|
|
||||||
# --- SEGURIDAD (JWT & Tunnels) ---
|
|
||||||
JWT_SECRET=llave_maestra_para_tokens_api
|
|
||||||
TUNNEL_SECRET=llave_para_tuneles_seguros_edge
|
|
||||||
|
|
||||||
# --- EDGE & ORQUESTACIÓN ---
|
|
||||||
DOCKER_REGISTRY_URL=docker-registry:5000
|
|
||||||
MQTT_HOST=mosquitto
|
|
||||||
MQTT_PORT=1883 # Puerto interno (inter-service)
|
|
||||||
BACKEND_API_URL=https://api.omnioil.app
|
|
||||||
MQTT_PUBLIC_HOST=mqtt.omnioil.app
|
|
||||||
|
|
||||||
# --- FRONTEND ---
|
|
||||||
VITE_API_BASE=https://api.omnioil.app
|
|
||||||
|
|
||||||
# --- VOLUMENES (Producción: Named Volumes) ---
|
|
||||||
# Dejar estas variables vacías o no definidas para usar volúmenes nombrados de alto rendimiento
|
|
||||||
TIMESCALEDB_DATA=
|
|
||||||
REDIS_DATA=
|
|
||||||
MINIO_DATA=
|
|
||||||
MOSQUITTO_DATA=
|
|
||||||
MOSQUITTO_LOG=
|
|
||||||
REGISTRY_DATA=
|
|
||||||
INSTALLER_INCOMING=
|
|
||||||
INSTALLER_OUTPUT=
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚡ 4. Build Optimization (Dockerfile.unified)
|
|
||||||
|
|
||||||
OmniOil uses a unified multi-stage build pipeline with **Cargo Chef** to drastically reduce deployment times:
|
|
||||||
|
|
||||||
1. **Shared Cache**: The build process uses `sharing=locked` cache mounts for `/usr/local/cargo/registry` and `/usr/local/cargo/git`.
|
|
||||||
2. **Reproducibility**: `Cargo.lock` is included to ensure predictable builds.
|
|
||||||
3. **No Build-Time DB**: `sqlx` queries are checked at runtime, eliminating the need for a live database during the Docker build phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗄️ 5. Data Persistence (Docker Volumes)
|
|
||||||
|
|
||||||
1. **Development**: Uses bind mounts (`./.docker/...`) for visibility and local debugging.
|
|
||||||
2. **Production**: Ensure the variables for `_DATA` (e.g., `TIMESCALEDB_DATA`) are **EMPTY** in Dokploy. This triggers the use of **Named Volumes** declared in `docker-compose.yml`, which offer high performance on Linux VPS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 6. Deployment Checklist
|
|
||||||
- [ ] Active **Let's Encrypt** SSL in Dokploy for all public domains.
|
|
||||||
- [ ] Ensure `timescaledb` and `redis` ports are NOT mapped publicly in the UI.
|
|
||||||
- [ ] Verify `VITE_API_BASE` matches the actual API subdomain.
|
|
||||||
- [ ] Switched to `native-tls` and `aws_lc_rs` for maximum stability.
|
|
||||||
|
|
||||||
## 7. Canonical Frontend Domain Map
|
|
||||||
|
|
||||||
Use a single canonical hostname per frontend surface:
|
|
||||||
|
|
||||||
| Frontend | Canonical host | Ownership |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `frontend-admin` | `app.omnioil.app` | Admin and superadmin dashboard/login |
|
|
||||||
| `frontend-pwa` | `mobile.omnioil.app` | Field/operator login and PWA flows |
|
|
||||||
|
|
||||||
Legacy hostnames stay only as migration redirects at the gateway layer:
|
|
||||||
|
|
||||||
| Legacy host | Redirect target | Policy |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `admin.omnioil.app` | `https://app.omnioil.app$request_uri` | Redirect only, no direct app ownership |
|
|
||||||
| `campo.omnioil.app` | `https://mobile.omnioil.app$request_uri` | Redirect only, no direct app ownership |
|
|
||||||
413
docs/DESPLIEGUE.md
Normal file
413
docs/DESPLIEGUE.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# 🚀 Guía de Despliegue — OmniOil SCADA
|
||||||
|
**Kyrbot Innovations** · Local · Producción · Alta Disponibilidad
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Quick Start — Desarrollo Local
|
||||||
|
|
||||||
|
### 1.1 Requisitos
|
||||||
|
|
||||||
|
- Docker Engine 24+ y Docker Compose v2+
|
||||||
|
- Node.js 20+ y pnpm 8+ (para frontends)
|
||||||
|
- Rust 1.77+ y Cargo (para servicios Rust)
|
||||||
|
|
||||||
|
### 1.2 Levantar el Stack Completo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clonar repositorio
|
||||||
|
git clone <repo-url>
|
||||||
|
cd OmniOilPersonal
|
||||||
|
|
||||||
|
# Copiar variables de entorno
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env con tus valores locales
|
||||||
|
|
||||||
|
# Levantar todos los servicios
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Verificar que todos los servicios están corriendo
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Levantar Frontends en Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Admin Dashboard
|
||||||
|
cd services/frontend-admin
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev # Corre en http://localhost:5173
|
||||||
|
|
||||||
|
# Consola interna OmniOil/Kyrbot
|
||||||
|
cd services/frontend-console
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev # Corre en http://localhost:3002
|
||||||
|
|
||||||
|
# PWA de Campo
|
||||||
|
cd services/frontend-pwa
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev # Corre en http://localhost:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Primera Configuración
|
||||||
|
|
||||||
|
1. Abrir `http://localhost:5173/setup`
|
||||||
|
2. Crear el superadmin
|
||||||
|
3. Navegar a `http://localhost:5173` para acceder al panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Despliegue en Producción (Dokploy)
|
||||||
|
|
||||||
|
### 2.1 Configuración de Dokploy
|
||||||
|
|
||||||
|
Dokploy es la plataforma recomendada para despliegue en VPS. Cada servicio se expone como un subdominio:
|
||||||
|
|
||||||
|
| Servicio | Puerto Interno | Subdominio | HTTPS |
|
||||||
|
|---------|---------------|-----------|-------|
|
||||||
|
| **frontend-admin** | 80 | `app.omnioil.app` | ✅ |
|
||||||
|
| **frontend-console** | 80 | `console.omnioil.app` | ✅ |
|
||||||
|
| **frontend-pwa** | 80 | `mobile.omnioil.app` | ✅ |
|
||||||
|
| **backend-api** | 8000 | `api.omnioil.app` | ✅ |
|
||||||
|
| **MinIO Console** | 9001 | `storage.omnioil.app` | ✅ |
|
||||||
|
| **Mosquitto WSS** | 9001 | `mqtt.omnioil.app` | ✅ |
|
||||||
|
| **Landing** | 80 | `omnioil.app` | ✅ |
|
||||||
|
| **Grafana** | 3003 | Interno | Solo desarrollo |
|
||||||
|
| **Prometheus** | 9090 | Interno | Solo desarrollo |
|
||||||
|
|
||||||
|
> Legacy hostnames (`admin.omnioil.app`, `campo.omnioil.app`) redirigen permanentemente a los canónicos.
|
||||||
|
|
||||||
|
En **Domains** de Dokploy, `console.omnioil.app` debe apuntar al servicio `frontend-console` en el puerto interno `80`. No debe apuntar a `frontend-admin`, `landing` ni `backend-api`.
|
||||||
|
|
||||||
|
### 2.2 Variables de Entorno en Dokploy
|
||||||
|
|
||||||
|
En la pestaña **Environment Variables** del proyecto en Dokploy, pegar las variables de `.env.example` con los valores de producción:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# CRÍTICO: Solo usar volúmenes nombrados en producción
|
||||||
|
# Dejar estas variables VACÍAS para activar Named Volumes de Docker:
|
||||||
|
TIMESCALEDB_DATA=
|
||||||
|
REDIS_DATA=
|
||||||
|
MINIO_DATA=
|
||||||
|
MOSQUITTO_DATA=
|
||||||
|
MOSQUITTO_LOG=
|
||||||
|
REGISTRY_DATA=
|
||||||
|
INSTALLER_INCOMING=
|
||||||
|
INSTALLER_OUTPUT=
|
||||||
|
|
||||||
|
# Usar solo docker-compose.yml (no el .dev.yml) en producción:
|
||||||
|
COMPOSE_FILE=docker-compose.yml
|
||||||
|
COMPOSE_PATH_SEPARATOR=:
|
||||||
|
|
||||||
|
# Invitaciones de solicitudes de acceso aprobadas:
|
||||||
|
INVITATION_BASE_URL=https://app.omnioil.app
|
||||||
|
SMTP_HOST=smtp.tu-proveedor.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=soporte@omnioil.app
|
||||||
|
SMTP_PASS=<gestionar-en-secrets-manager>
|
||||||
|
SMTP_FROM=OmniOil <soporte@omnioil.app>
|
||||||
|
```
|
||||||
|
|
||||||
|
`INVITATION_BASE_URL` debe apuntar al Admin App público porque los clientes activan su cuenta en `https://app.omnioil.app/activate?token=...`, no en la Console interna. Las variables `SMTP_*` son requeridas para acuses de recibo, rechazos, aprobación/activación y reutilizan la configuración SMTP del backend; `SMTP_PASS` debe cargarse desde el gestor de secretos de Dokploy o equivalente, nunca desde el repositorio. Los únicos correos autorizados para aprobar, rechazar, marcar en revisión o reenviar invitaciones son `w.farfan@omnioil.app` y `h.ortegon@omnioil.app`; esta allowlist se aplica en backend.
|
||||||
|
|
||||||
|
Cuando se aprueba una solicitud, el backend crea el proyecto, el usuario administrador cliente, el acceso al proyecto y una suscripción inicial `trial` de 30 días antes de intentar enviar el correo. Si SMTP falla, la aprobación NO se revierte: el operador debe revisar la solicitud en Console y usar `Reenviar invitación` después de corregir la configuración o incidente SMTP.
|
||||||
|
|
||||||
|
### 2.3 Configuración MQTT sobre WSS
|
||||||
|
|
||||||
|
En Dokploy, la entrada del proxy para `mqtt.omnioil.app`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Subdominio público: mqtt.omnioil.app (HTTPS/WSS :443)
|
||||||
|
→ Proxy interno: mosquitto:9001 (WebSocket no cifrado)
|
||||||
|
→ TLS: Traefik / Let's Encrypt automático
|
||||||
|
```
|
||||||
|
|
||||||
|
El Edge Agent se conecta con:
|
||||||
|
```
|
||||||
|
wss://mqtt.omnioil.app:443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Checklist de Despliegue
|
||||||
|
|
||||||
|
- [ ] SSL/TLS Let's Encrypt activado en Dokploy para todos los subdominios públicos
|
||||||
|
- [ ] Variables `TIMESCALEDB_DATA`, `REDIS_DATA`, etc. vacías (Named Volumes)
|
||||||
|
- [ ] `JWT_SECRET` generado con al menos 256 bits de entropía
|
||||||
|
- [ ] `TOTP_SECRET_ENCRYPTION_KEY` generado con `openssl rand -base64 32`, guardado en el gestor de secretos/env y respaldado de forma segura
|
||||||
|
- [ ] `DB_PASSWORD` y `MINIO_PASSWORD` fuertes y únicos
|
||||||
|
- [ ] `TURNSTILE_SECRET_KEY` configurado en backend para validar solicitudes públicas de acceso
|
||||||
|
- [ ] Puertos 5432 (TimescaleDB) y 6379 (Redis) NO expuestos públicamente
|
||||||
|
- [ ] `VITE_API_BASE` apunta al subdominio correcto de la API
|
||||||
|
- [ ] `VITE_CONSOLE_APP_ORIGIN` apunta a `https://console.omnioil.app`
|
||||||
|
- [ ] `ALLOWED_ORIGINS` incluye `https://app.omnioil.app`, `https://mobile.omnioil.app` y `https://console.omnioil.app`
|
||||||
|
- [ ] `VITE_TURNSTILE_SITE_KEY` configurado en el frontend admin para el formulario público de registro
|
||||||
|
- [ ] `INVITATION_BASE_URL=https://app.omnioil.app` configurado para links de activación de solicitudes aprobadas
|
||||||
|
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER=soporte@omnioil.app`, `SMTP_PASS` y `SMTP_FROM=OmniOil <soporte@omnioil.app>` configurados para alertas ANH y correos de acceso
|
||||||
|
- [ ] Console recibe `VITE_ACCESS_REQUEST_REVIEWER_EMAILS=w.farfan@omnioil.app,h.ortegon@omnioil.app` como ayuda visual; la autorización real se valida en backend
|
||||||
|
- [ ] Operadores informados: un fallo SMTP no revierte la aprobación; corregir SMTP y usar `Reenviar invitación` desde Console
|
||||||
|
- [ ] `TELEGRAM_*` configurado para alertas ANH cuando aplique
|
||||||
|
- [ ] Backup inicial de la base de datos configurado
|
||||||
|
|
||||||
|
#### Nota previa a migrar 2FA
|
||||||
|
|
||||||
|
La migración de TOTP elimina la columna legacy `users.two_factor_secret` en texto plano y crea almacenamiento cifrado (`two_factor_secret_encrypted`, `two_factor_secret_nonce`) más códigos de recuperación. Antes de desplegar, verificar que `TOTP_SECRET_ENCRYPTION_KEY` exista y decodifique a exactamente 32 bytes; si se pierde esa clave, los usuarios con 2FA deberán ser reseteados por un `platform_admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Build del Código Rust
|
||||||
|
|
||||||
|
OmniOil usa un pipeline de build unificado con **Cargo Chef** para caché eficiente:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile.unified (fragmento simplificado)
|
||||||
|
FROM rust:1.77 AS chef
|
||||||
|
RUN cargo install cargo-chef
|
||||||
|
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner recipe.json recipe.json
|
||||||
|
# Caché de dependencias (solo re-ejecuta si Cargo.toml cambia)
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ventajas**:
|
||||||
|
- Primera compilación: ~10-15 minutos
|
||||||
|
- Re-compilaciones (solo código cambia): 2-5 minutos
|
||||||
|
- `Cargo.lock` incluido para builds reproducibles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Persistencia de Datos (Docker Volumes)
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.dev.yml (bind mounts para visibilidad local)
|
||||||
|
volumes:
|
||||||
|
- ./.docker/timescaledb:/var/lib/postgresql/data
|
||||||
|
- ./.docker/minio:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producción
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml (Named Volumes de alto rendimiento)
|
||||||
|
volumes:
|
||||||
|
timescaledb_data: # PostgreSQL data directory
|
||||||
|
redis_data: # Redis persistence
|
||||||
|
minio_data: # S3 object storage
|
||||||
|
mosquitto_data: # MQTT persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
> ✅ En producción, dejar vacías las variables `TIMESCALEDB_DATA`, `REDIS_DATA`, etc. en el `.env` para activar los Named Volumes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Monitoreo con Prometheus y Grafana
|
||||||
|
|
||||||
|
### Configuración
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.52.0
|
||||||
|
ports:
|
||||||
|
- "9090:9090" # Solo en desarrollo
|
||||||
|
volumes:
|
||||||
|
- ./infrastructure/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.4.3
|
||||||
|
ports:
|
||||||
|
- "3003:3000" # Solo en desarrollo
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Acceso
|
||||||
|
|
||||||
|
- **Prometheus**: `http://localhost:9090` (solo desarrollo)
|
||||||
|
- **Grafana**: `http://localhost:3003` — user: `admin`, pass: valor de `GRAFANA_PASSWORD`
|
||||||
|
|
||||||
|
### Métricas Clave
|
||||||
|
|
||||||
|
El backend expone métricas en `/api/metrics`. Panel Grafana preconfigurado incluye:
|
||||||
|
- Requests por segundo y latencia de la API
|
||||||
|
- Proyectos activos / offline
|
||||||
|
- Mensajes MQTT procesados por minuto
|
||||||
|
- Alarmas activas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Alta Disponibilidad Multi-Región (Active-Passive)
|
||||||
|
|
||||||
|
Para entornos de producción críticos, OmniOil soporta una arquitectura **Active-Passive** con dos regiones:
|
||||||
|
|
||||||
|
### 6.1 Topología
|
||||||
|
|
||||||
|
```
|
||||||
|
CLOUDFLARE (DNS + Health Checks)
|
||||||
|
│
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
│ │
|
||||||
|
🇧🇷 BRASIL (PRIMARIO) 🇺🇸 USA (STANDBY)
|
||||||
|
São Paulo (activo) Miami/NYC (pasivo)
|
||||||
|
│ ◄────WAL────► │
|
||||||
|
TimescaleDB TimescaleDB
|
||||||
|
(Primary) (Replica)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Flujo de Failover
|
||||||
|
|
||||||
|
```
|
||||||
|
T+00s: Servidor Brasil se cae
|
||||||
|
T+30s: Cloudflare health check falla 2/3 veces → Brasil marcado DOWN
|
||||||
|
T+60s: Cloudflare cambia DNS: *.omnioil.app → IP de USA
|
||||||
|
T+90s: Edge Agents resuelven nuevo DNS → conectan a USA
|
||||||
|
→ Drenan cola SQLite local → DATOS RECUPERADOS ✅
|
||||||
|
→ TimescaleDB Replica promovida a Primary
|
||||||
|
T+???s: Brasil se recupera → se reconecta como REPLICA
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Configuración Cloudflare Load Balancer
|
||||||
|
|
||||||
|
```
|
||||||
|
Load Balancer: api.omnioil.app
|
||||||
|
Pool primario: brasil-primary → 200.XXX.XXX.XXX
|
||||||
|
Health check: GET /api/health → 200 OK (cada 30s)
|
||||||
|
Pool secundario: usa-standby → 45.XXX.XXX.XXX
|
||||||
|
Steering: Failover
|
||||||
|
TTL: 60 segundos
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💰 Cloudflare Load Balancing ~$5/mes por hostname. Para 5 subdominios: ~$25/mes.
|
||||||
|
|
||||||
|
### 6.4 Replicación TimescaleDB (WAL Streaming)
|
||||||
|
|
||||||
|
**En el servidor Brasil (Primary)**:
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml — Brasil
|
||||||
|
services:
|
||||||
|
timescaledb:
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c wal_level=replica
|
||||||
|
-c max_wal_senders=3
|
||||||
|
-c wal_keep_size=256MB
|
||||||
|
-c synchronous_commit=off
|
||||||
|
```
|
||||||
|
|
||||||
|
**En el servidor USA (Replica)**:
|
||||||
|
```bash
|
||||||
|
# Script de inicialización de la réplica
|
||||||
|
PGPASSWORD="${DB_REPLICATION_PASSWORD}" pg_basebackup \
|
||||||
|
-h "${PRIMARY_DB_HOST}" \
|
||||||
|
-U "${DB_REPLICATION_USER}" \
|
||||||
|
-D "$PGDATA" -Fp -Xs -P -R
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Promoción a Primary (USA en Failover)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ejecutar en USA cuando se detecte failover
|
||||||
|
docker exec anh_timescaledb psql -U $DB_USER -c "SELECT pg_promote();"
|
||||||
|
# Verificar: pg_is_in_recovery() debe retornar 'f'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Variables Adicionales (servidor USA)
|
||||||
|
|
||||||
|
```env
|
||||||
|
PRIMARY_DB_HOST=<IP_BRASIL_o_VPN_endpoint>
|
||||||
|
DB_REPLICATION_USER=omnioil_replicator
|
||||||
|
DB_REPLICATION_PASSWORD=<clave_segura>
|
||||||
|
JWT_SECRET=<MISMA_clave_que_Brasil> # CRÍTICO: idéntica para tokens válidos
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 Sincronización MinIO
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Replicación de artefactos e instaladores MSI entre regiones
|
||||||
|
services:
|
||||||
|
minio-replicator:
|
||||||
|
image: minio/mc:latest
|
||||||
|
command: >
|
||||||
|
sh -c "mc alias set source http://minio-brasil:9000 $MINIO_USER $MINIO_PASSWORD &&
|
||||||
|
mc alias set target http://minio-usa:9000 $MINIO_USER $MINIO_PASSWORD &&
|
||||||
|
mc mirror --watch source/installers target/installers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data Tiering — Archivado a Largo Plazo
|
||||||
|
|
||||||
|
Para cumplir con la retención indefinida exigida por la ANH:
|
||||||
|
|
||||||
|
| Nivel | Almacenamiento | Retención | Proceso |
|
||||||
|
|-------|---------------|-----------|---------|
|
||||||
|
| **Hot** | TimescaleDB | 6 meses | Consultas en tiempo real y reportes diarios |
|
||||||
|
| **Warm** | MinIO (S3 local) | 2 años | JSON comprimidos (`.json.gz`) |
|
||||||
|
| **Cold** | Google Drive | Indefinido | Backup final para cumplimiento ANH |
|
||||||
|
|
||||||
|
### Proceso de Archivado Automático (Semanal)
|
||||||
|
|
||||||
|
El backend ejecuta un job semanal que:
|
||||||
|
1. Busca datos en `telemetry_raw` con más de 180 días
|
||||||
|
2. Exporta a archivo `.json.gz`
|
||||||
|
3. Sube a MinIO bucket `history`
|
||||||
|
4. Elimina los registros de TimescaleDB (libera espacio)
|
||||||
|
|
||||||
|
### Sincronización MinIO → Google Drive (rclone)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.backup.yml
|
||||||
|
services:
|
||||||
|
rclone-backup:
|
||||||
|
image: rclone/rclone:latest
|
||||||
|
volumes:
|
||||||
|
- ./infra/rclone/config:/config/rclone
|
||||||
|
- ./infra/minio/data:/data:ro
|
||||||
|
command: sync /data gdrive:OmniOil_Backups --verbose --cron "0 2 * * *"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Checklist Final de Producción
|
||||||
|
|
||||||
|
```
|
||||||
|
INFRAESTRUCTURA:
|
||||||
|
[ ] SSL/TLS Let's Encrypt activado para todos los subdominios
|
||||||
|
[ ] Puertos 5432, 6379 NO expuestos a internet
|
||||||
|
[ ] Named Volumes en uso (variables _DATA vacías)
|
||||||
|
[ ] Firewall: solo 80 y 443 abiertos (+ 22 para SSH admin)
|
||||||
|
|
||||||
|
SEGURIDAD:
|
||||||
|
[ ] JWT_SECRET de alta entropía (>256 bits)
|
||||||
|
[ ] DB_PASSWORD único y fuerte
|
||||||
|
[ ] .env NO en repositorio git (.gitignore verificado)
|
||||||
|
[ ] Superadmin con 2FA activado
|
||||||
|
|
||||||
|
FUNCIONAL:
|
||||||
|
[ ] Setup inicial completado (/setup)
|
||||||
|
[ ] Primer proyecto creado y MSI generado
|
||||||
|
[ ] Edge Agent instalado y mostrando ONLINE
|
||||||
|
[ ] Telemetría llegando al dashboard
|
||||||
|
[ ] Primer reporte ANH generado y verificado
|
||||||
|
|
||||||
|
MONITOREO:
|
||||||
|
[ ] SMTP configurado (alertas de email)
|
||||||
|
[ ] Telegram configurado (notificaciones críticas)
|
||||||
|
[ ] Watchdog activo (WATCHDOG_SILENCE_THRESHOLD_MIN configurado)
|
||||||
|
|
||||||
|
BACKUPS:
|
||||||
|
[ ] TimescaleDB backup automático programado
|
||||||
|
[ ] MinIO backup programado
|
||||||
|
[ ] Proceso de restauración probado
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*OmniOil SCADA — Guía de Despliegue — Kyrbot Innovations — Mayo 2026*
|
||||||
340
docs/EDGE_AGENT.md
Normal file
340
docs/EDGE_AGENT.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# 🤖 Guía del Edge Agent — OmniOil SCADA
|
||||||
|
**Kyrbot Innovations** · Agente de Campo para Telemetría Industrial
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ¿Qué es el Edge Agent?
|
||||||
|
|
||||||
|
El **Edge Agent** es el componente de campo de OmniOil. Es un servicio de Windows que se instala en el equipo de cómputo ubicado en el pozo o campo petrolero y cumple estas funciones:
|
||||||
|
|
||||||
|
1. **Recolección**: Lee datos de los dispositivos industriales (PLCs, medidores de flujo, sensores) mediante protocolos estándar
|
||||||
|
2. **Persistencia local**: Almacena los datos en una base de datos SQLite local cifrada con AES-256
|
||||||
|
3. **Transmisión**: Envía los datos al servidor en la nube via MQTT sobre WSS (WebSockets Secure, puerto 443)
|
||||||
|
4. **Watchdog**: Reporta su estado de salud (CPU, RAM, versión) cada 60 segundos
|
||||||
|
5. **Resiliencia**: Cuando no hay internet, guarda datos localmente y los sincroniza cuando se recupera la conexión
|
||||||
|
|
||||||
|
El agente se distribuye como un instalador **MSI** de Windows, personalizado para cada proyecto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Arquitectura del Edge Agent
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ EDGE AGENT (Windows Service) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌───────────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ data_collector│───►│ persistence_manager│───►│mqtt_publisher│ │
|
||||||
|
│ │ │ │ (SQLite AES-256) │ │ (WSS :443) │ │
|
||||||
|
│ │ • Modbus TCP │ └───────────────────┘ └─────────────┘ │
|
||||||
|
│ │ • Siemens S7 │ │
|
||||||
|
│ │ • EtherNet/IP │ ┌───────────────────┐ │
|
||||||
|
│ │ • OPC-UA* │ │ watchdog │ │
|
||||||
|
│ └──────────────┘ │ (heartbeat 60s) │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ MQTT/WSS :443
|
||||||
|
▼
|
||||||
|
mqtt.omnioil.app
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
│ omnioil/│
|
||||||
|
│ health │ → telemetry-ingestor → TimescaleDB
|
||||||
|
│ telemetry│
|
||||||
|
│ logs │
|
||||||
|
└─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Módulos Principales
|
||||||
|
|
||||||
|
| Módulo | Descripción |
|
||||||
|
|--------|-------------|
|
||||||
|
| `data_collector` | Lee variables desde dispositivos industriales en el intervalo configurado |
|
||||||
|
| `persistence_manager` | Almacena lecturas en SQLite local (AES-256 en reposo) |
|
||||||
|
| `mqtt_publisher` | Publica datos al broker MQTT usando WebSockets Secure (WSS) |
|
||||||
|
| `watchdog` | Envía heartbeat periódico con CPU, RAM, uptime y versión del agente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Protocolos Industriales Soportados
|
||||||
|
|
||||||
|
### 3.1 Modbus TCP (Implementado — Fase 1)
|
||||||
|
- **Puerto default**: 502
|
||||||
|
- **Funciones**: `01` Coil Status, `02` Discrete Input, `03` Holding Register, `04` Input Register
|
||||||
|
- **Configuración**: IP del PLC, puerto, Slave ID, dirección del registro
|
||||||
|
|
||||||
|
### 3.2 Siemens S7 (Implementado — Fase 1)
|
||||||
|
- **Puerto default**: 102 (ISO on TCP)
|
||||||
|
- **Bloques**: DB, M, I, Q
|
||||||
|
- **Configuración**: IP, rack, slot, tipo de bloque, offset, tipo de dato
|
||||||
|
|
||||||
|
### 3.3 EtherNet/IP — Allen Bradley (Implementado — Fase 1)
|
||||||
|
- **Puerto default**: 44818
|
||||||
|
- **Tipos**: CIP (Common Industrial Protocol)
|
||||||
|
- **Configuración**: IP del controlador, tag name
|
||||||
|
|
||||||
|
### 3.4 OPC-UA (Fase 7 — Planificado)
|
||||||
|
- **Protocolo**: IEC 62541
|
||||||
|
- **Configuración**: endpoint URL (`opc.tcp://host:4840`), node ID, security policy
|
||||||
|
- **Estado**: en hoja de ruta, no disponible en la versión actual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Proceso de Build del MSI
|
||||||
|
|
||||||
|
### 4.1 Compilación Base
|
||||||
|
|
||||||
|
El binario base `edge-agent.exe` se compila para Windows x86_64:
|
||||||
|
```bash
|
||||||
|
# Target cross-compilation desde Linux
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu -p edge-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Overlay de Credenciales
|
||||||
|
|
||||||
|
El binario genérico es personalizado con las credenciales del proyecto mediante el proceso de **Overlay**:
|
||||||
|
|
||||||
|
```
|
||||||
|
edge-agent (genérico)
|
||||||
|
+
|
||||||
|
embedded_config.toml (credenciales del proyecto)
|
||||||
|
↓
|
||||||
|
edge-agent-{PROJECT_ID}.exe (personalizado)
|
||||||
|
```
|
||||||
|
|
||||||
|
El archivo `embedded_config.toml` contiene:
|
||||||
|
```toml
|
||||||
|
[mqtt]
|
||||||
|
host = "mqtt.omnioil.app"
|
||||||
|
port = 443
|
||||||
|
use_wss = true
|
||||||
|
client_id = "PROYECTO-UUID"
|
||||||
|
username = "PROYECTO-UUID"
|
||||||
|
password = "clave-aleatoria-40-chars"
|
||||||
|
|
||||||
|
[api]
|
||||||
|
backend_url = "https://api.omnioil.app"
|
||||||
|
project_id = "PROYECTO-UUID"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Empaquetado MSI
|
||||||
|
|
||||||
|
El Build Orchestrator usa **WiX Toolset** para crear el instalador:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- wix/main.wxs (fragmento) -->
|
||||||
|
<Product Name="OmniOil Edge Agent" ...>
|
||||||
|
<Component Id="EdgeAgent">
|
||||||
|
<File Source="edge-agent.exe" />
|
||||||
|
<File Source="edge-tray.exe" />
|
||||||
|
<ServiceInstall Name="OmniOilEdgeAgent"
|
||||||
|
Start="auto"
|
||||||
|
Type="ownProcess"
|
||||||
|
Account="LocalSystem" />
|
||||||
|
</Component>
|
||||||
|
</Product>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Disponibilidad para Descarga
|
||||||
|
|
||||||
|
El archivo `OmniOilEdgeAgent.msi` resultante:
|
||||||
|
1. Se sube al bucket `installers` en MinIO
|
||||||
|
2. El backend actualiza `installer_status = 'READY'` en la BD
|
||||||
|
3. El frontend notifica al usuario que el instalador está listo
|
||||||
|
|
||||||
|
> ⚠️ Cada instalador MSI es único y contiene credenciales exclusivas del proyecto. No reutilizar entre proyectos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tópicos MQTT del Edge Agent
|
||||||
|
|
||||||
|
| Tópico | Dirección | Descripción |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `omnioil/telemetry/{project_id}` | Agente → Servidor | Datos SCADA en tiempo real (variables, valores, timestamps) |
|
||||||
|
| `omnioil/health/{project_id}` | Agente → Servidor | Heartbeat: CPU, RAM, uptime, versión del agente |
|
||||||
|
| `omnioil/logs/{project_id}` | Agente → Servidor | Eventos de sistema, errores de lectura, reconexiones |
|
||||||
|
| `omnioil/deploy/{project_id}` | Servidor → Agente | Comando para aplicar nueva configuración de variables |
|
||||||
|
| `omnioil/commands/{project_id}` | Servidor → Agente | Comandos directos: reinicio, diagnóstico |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Store & Forward (Resiliencia Offline)
|
||||||
|
|
||||||
|
### 6.1 Funcionamiento
|
||||||
|
|
||||||
|
Cuando el agente no puede conectar al broker MQTT:
|
||||||
|
|
||||||
|
```
|
||||||
|
Edge Agent sin internet
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Guardar en SQLite local
|
||||||
|
• Cifrado AES-256 en reposo
|
||||||
|
• Preserva timestamp original
|
||||||
|
• Capacidad: ~10 GB en disco local
|
||||||
|
│
|
||||||
|
[Reconexión]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Drenar cola cada 30 segundos
|
||||||
|
• Envío en orden cronológico
|
||||||
|
• QoS AtLeastOnce (garantía de entrega)
|
||||||
|
• El servidor deduplica por timestamp+variable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Estructura de la Cola Local
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- SQLite local en el equipo de campo
|
||||||
|
CREATE TABLE pending_telemetry (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
asset_id TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL, -- JSON cifrado
|
||||||
|
timestamp TEXT NOT NULL, -- UTC original
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Watchdog y Monitoreo de Salud
|
||||||
|
|
||||||
|
### 7.1 Heartbeat
|
||||||
|
|
||||||
|
Cada 60 segundos, el agente publica en `omnioil/health/{project_id}`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": "uuid-del-proyecto",
|
||||||
|
"hostname": "EQUIPO-CAMPO-01",
|
||||||
|
"agent_version": "2.1.0",
|
||||||
|
"uptime_seconds": 86400,
|
||||||
|
"cpu_usage_pct": 12.5,
|
||||||
|
"ram_usage_mb": 256,
|
||||||
|
"ram_total_mb": 4096,
|
||||||
|
"pending_queue_size": 0,
|
||||||
|
"mqtt_connected": true,
|
||||||
|
"last_telemetry_at": "2026-05-04T10:30:00Z",
|
||||||
|
"timestamp": "2026-05-04T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Estado en el Dashboard
|
||||||
|
|
||||||
|
El backend actualiza `edge_projects.last_health_report` con cada heartbeat. El frontend marca el agente como **Offline** si el último heartbeat fue hace más de 60 segundos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. OTA Updates — Actualizaciones Remotas (Fase 7)
|
||||||
|
|
||||||
|
El sistema de actualización automática OTA (Over-The-Air) estará disponible en la Fase 7:
|
||||||
|
|
||||||
|
1. El servidor detecta que hay una nueva versión del agente disponible
|
||||||
|
2. Notifica al agente via `omnioil/commands/{project_id}`: `{"action": "update", "version": "2.2.0"}`
|
||||||
|
3. El agente descarga silenciosamente el nuevo `.msi` desde MinIO
|
||||||
|
4. Ejecuta: `msiexec /i OmniOilEdgeAgent-2.2.0.msi /qn`
|
||||||
|
5. WiX detiene el servicio actual, reemplaza el binario y reinicia el servicio
|
||||||
|
|
||||||
|
> 💡 Este método preserva la integridad del registro de Windows ("Agregar o quitar programas"), a diferencia del método anterior de `self_replace` que podía corromper las entradas de desinstalación.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Deadband Filtering (Fase 7)
|
||||||
|
|
||||||
|
Para reducir el volumen de datos y el consumo de ancho de banda:
|
||||||
|
- **Deadband por valor**: solo publicar si el valor cambió más de X% respecto al último publicado
|
||||||
|
- **Deadband por tiempo**: publicar aunque el valor no cambie, mínimo cada N segundos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Instalación en Campo
|
||||||
|
|
||||||
|
### 10.1 Requisitos del Equipo
|
||||||
|
|
||||||
|
| Requisito | Mínimo | Recomendado |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| Sistema Operativo | Windows 10 (x64) | Windows 10/11 (x64) Pro |
|
||||||
|
| CPU | Dual-core 1.6 GHz | Quad-core 2.0 GHz |
|
||||||
|
| RAM | 2 GB | 4 GB |
|
||||||
|
| Disco | 20 GB libres | 100 GB |
|
||||||
|
| Red | Acceso a internet (puerto 443 saliente) | Ethernet estable |
|
||||||
|
|
||||||
|
### 10.2 Pasos de Instalación
|
||||||
|
|
||||||
|
1. **Descargar** el archivo `OmniOilEdgeAgent.msi` desde el panel de administración
|
||||||
|
2. **Copiar** el archivo al equipo de campo (USB, red local o descarga directa)
|
||||||
|
3. **Ejecutar** el instalador con doble clic (requiere permisos de administrador)
|
||||||
|
4. El instalador:
|
||||||
|
- Instala el servicio `OmniOilEdgeAgent` en Windows
|
||||||
|
- Configura el inicio automático
|
||||||
|
- Instala el ícono en la bandeja del sistema (`edge-tray.exe`)
|
||||||
|
5. **Verificar**: en el panel de administración, el agente debe aparecer en estado **ONLINE** en los próximos 60 segundos
|
||||||
|
|
||||||
|
### 10.3 Verificar Conectividad
|
||||||
|
|
||||||
|
Antes de instalar, verificar desde el equipo de campo:
|
||||||
|
```powershell
|
||||||
|
# Verificar conectividad al broker MQTT (puerto 443)
|
||||||
|
Test-NetConnection -ComputerName mqtt.omnioil.app -Port 443
|
||||||
|
|
||||||
|
# Verificar conectividad a la API
|
||||||
|
Invoke-WebRequest -Uri https://api.omnioil.app/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Troubleshooting
|
||||||
|
|
||||||
|
### El agente no aparece como ONLINE
|
||||||
|
|
||||||
|
1. Verificar que el servicio `OmniOilEdgeAgent` está corriendo:
|
||||||
|
```powershell
|
||||||
|
Get-Service OmniOilEdgeAgent
|
||||||
|
Start-Service OmniOilEdgeAgent # Si está detenido
|
||||||
|
```
|
||||||
|
2. Verificar conectividad al puerto 443 con el comando de la sección 10.3
|
||||||
|
3. Revisar los logs del agente:
|
||||||
|
```powershell
|
||||||
|
# Logs del servicio Windows
|
||||||
|
Get-EventLog -LogName Application -Source OmniOilEdgeAgent -Newest 20
|
||||||
|
```
|
||||||
|
|
||||||
|
### No llega telemetría pero el heartbeat funciona
|
||||||
|
|
||||||
|
1. Verificar que los dispositivos Modbus/S7 están accesibles desde el equipo de campo
|
||||||
|
2. Confirmar que las variables tienen el registro y función correcta configurados
|
||||||
|
3. Verificar en la BD que las variables del proyecto están activas
|
||||||
|
|
||||||
|
### El instalador falla al instalar
|
||||||
|
|
||||||
|
1. Verificar que se tiene permisos de Administrador local
|
||||||
|
2. Verificar que no hay una versión anterior del agente sin desinstalar
|
||||||
|
3. Revisar el log de instalación:
|
||||||
|
```powershell
|
||||||
|
msiexec /i OmniOilEdgeAgent.msi /log install.log
|
||||||
|
notepad install.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### La cola de Store & Forward no se drena
|
||||||
|
|
||||||
|
1. Verificar conectividad a `mqtt.omnioil.app:443`
|
||||||
|
2. Verificar que el servicio tiene acceso a escritura en el directorio de datos
|
||||||
|
3. Si la cola supera 10,000 registros, verificar espacio en disco
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Transición MQTT (Historia Técnica)
|
||||||
|
|
||||||
|
**Versión anterior**: El agente usaba un modelo híbrido HTTP/MQTT donde los logs y health reports se enviaban por API REST y la telemetría por MQTT en texto plano (puerto 1883).
|
||||||
|
|
||||||
|
**Versión actual (2.0)**: Migración a modelo **Outbound-Only Single-Tunnel**:
|
||||||
|
- Todo el tráfico (telemetría, health, logs) fluye por un único canal WSS en el puerto 443
|
||||||
|
- ACLs dinámicas en Mosquitto restringen a cada agente a su propio `project_id`
|
||||||
|
- Eliminación completa del canal HTTP del agente (reducción de superficie de ataque)
|
||||||
|
- El puerto 1883 queda deshabilitado para comunicación pública
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*OmniOil Edge Agent — Kyrbot Innovations — Versión 2.0 — Mayo 2026*
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# OmniOil SCADA - Guía de Servicios
|
|
||||||
|
|
||||||
## Edge Agent - Proceso de Overlay y Empaquetado
|
|
||||||
|
|
||||||
El Agente de Borde (`edge-agent`) es un binario que se compila de forma genérica para Windows (`x86_64-pc-windows-gnu`) pero que requiere una personalización específica para cada cliente/instalación antes de ser distribuido.
|
|
||||||
|
|
||||||
### 1. Compilación Base (Binario Genérico)
|
|
||||||
El binario base contiene toda la lógica de orquestación, contenedores y túneles. Durante el build de Docker (`Dockerfile.builder`), se genera este ejecutable estable.
|
|
||||||
|
|
||||||
### 2. Proceso de Overlay (Configuración Embebida)
|
|
||||||
El binario está diseñado para que se le "inyecten" datos específicos mediante el proceso de **Overlay**:
|
|
||||||
- **Token de Acceso**: Identificador único del agente para el servidor.
|
|
||||||
- **Servidor MQTT**: URL y puerto (9883 interno / 443 público vía **WSS**) del servidor de orquestación.
|
|
||||||
- **Configuración de Red**: Parámetros específicos de la red industrial.
|
|
||||||
|
|
||||||
Este paso ocurre después de la compilación pero antes de generar el instalador MSI. El sistema toma el binario, aplica el overlay sobre el archivo `embedded_config.toml` (o mediante inyección de recursos) y luego entrega el binario final al constructor del instalador.
|
|
||||||
|
|
||||||
### 3. Generación del MSI
|
|
||||||
Una vez que el binario tiene el overlay aplicado, se utiliza la herramienta `wixl` en el constructor del MSI para empaquetar:
|
|
||||||
- El ejecutable `edge-agent.exe` (con overlay).
|
|
||||||
- El ejecutable del tray icon `edge-tray.exe`.
|
|
||||||
- Los servicios de Windows asociados.
|
|
||||||
|
|
||||||
### 4. Disponibilidad para el Usuario
|
|
||||||
Finalmente, el archivo `OmniOilEdgeAgent.msi` resultante se pone a disposición en el servidor para que el usuario pueda descargarlo. Cada instalador es único y viene "pre-configurado" para el servidor al que debe conectarse.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> El error `Error al compilar recursos de Windows` durante el build inicial suele deberse a la falta del compilador de recursos `windres` en el path de Linux (cross-compilation). Ha sido corregido forzando la ruta hacia `x86_64-w64-mingw32-windres` en `build.rs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Deuda Técnica y Recomendaciones Futuras (Fase 2)
|
|
||||||
|
|
||||||
A medida que el despliegue del Agente de Borde escala en producción, se recomienda estructurar las siguientes 3 mejoras arquitectónicas clave para sistemas IoT remotos:
|
|
||||||
|
|
||||||
### 1. El Auto-Updater vs El Windows Installer (.msi)
|
|
||||||
Actualmente el módulo interno `updater.rs` descarga actualizaciones desde Gitea y utiliza el método `self_replace` para sobrescribir el propio archivo `.exe` del agente.
|
|
||||||
* **Conflicto:** Como ahora la distribución se realiza oficial y exclusivamente a través de Windows Installer (`.msi`), realizar un reemplazo al `.exe` por debajo de la mesa eludirá el caché de Windows, corrompiendo los registros de desinstalación de "Agregar o quitar programas", dificultando reparaciones limpias.
|
|
||||||
* **Recomendación:** Refactorizar el Auto-Updater para que descargue silenciosamente el nuevo `.msi` completo en lugar del binario puro. Una vez descargado, ejecutar desde background mediante CMD: `msiexec /i instalador_nuevo.msi /qn`. De este modo, la actualización entra nativamente al registro de Windows, WiX detiene el servicio actual, hace el swapping seguro de DLLs y reinicia la aplicación exitosamente.
|
|
||||||
|
|
||||||
### 2. Resiliencia de Red y Latencia (Store & Forward)
|
|
||||||
Los pozos petroleros en locaciones geográficas inaccesibles lidian con desconexiones de red constantes (Micro-cortes en radio, celular o satélite).
|
|
||||||
* **Conflicto:** Si la conexión a la nube subyacente de Docker experimenta timeout y se emiten eventos o logs críticos por la librería `rumqttc`, el `Data Gateway` perderá dichos datos en el aire si la red cae durante un envío.
|
|
||||||
* **Recomendación:** Modificar la capa IPC/Gateways del agente y embeber una base de datos ligera como **SQLite**. Implementar un patrón de arquitectura **Store & Forward**: Cuando no hay internet, la telemetría reposa temporalmente en disco. Cuando se re-establece la conectividad MQTT (`QoS::AtLeastOnce`), se desaloja ("flush") la cola de métricas local asíncronamente sincronizando el Pozo con los servidores de Azure en orden cronológico exacto.
|
|
||||||
|
|
||||||
### 3. Orfandad de Contenedores en Desinstalación
|
|
||||||
El agente monta localmente volúmenes de Docker e imágenes pesadas (~500MB) para levantar Node-RED y TimescaleDB local y encapsular los drivers SCADA (Modbus, OPC UA).
|
|
||||||
* **Conflicto:** Al hacer click en *Desinstalar* en Windows, el instalador (`wixl`) elimina el ejecutable base, pero los contenedores administrados de Docker perduran ejecutándose como sistemas en la sombra (Orfandad), secuestrando permanentemente CPU y almacenamiento del equipo industrial del cliente.
|
|
||||||
* **Recomendación:** Extender el script principal `wix/main.wxs` insertando un **CustomAction**. Este evento pre-desinstalación le daría una orden explícita a la API de Docker Desktop: `docker stop omnioil-edge && docker rm omnioil-edge && docker rmi X` destruyendo de raíz toda la virtualización anclada **antes** de permitirse la auto-eliminación física de los binarios y directorios del agente OmniOil.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# OmniOil: Infraestructura de Datos y API Reference
|
|
||||||
|
|
||||||
Este documento detalla la estructura lógica de la base de datos y los puntos de acceso (REST/MQTT) para el desarrollo del ecosistema OmniOil.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Modelo de Datos (ER Diagram)
|
|
||||||
|
|
||||||
El sistema utiliza **TimescaleDB** (PostgreSQL) para gestionar tanto datos relacionales como series de tiempo (telemetría).
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
CUSTOMERS ||--o{ EDGE_PROJECTS : "pertenece a"
|
|
||||||
EDGE_PROJECTS ||--o{ EDGE_ASSETS : "contiene"
|
|
||||||
EDGE_ASSETS ||--o{ EDGE_VARIABLES : "define"
|
|
||||||
EDGE_PROJECTS ||--o{ MQTT_USERS : "identidad"
|
|
||||||
MQTT_USERS ||--o{ MQTT_ACLS : "permisos"
|
|
||||||
EDGE_PROJECTS ||--o{ AGENT_LOGS : "genera"
|
|
||||||
EDGE_PROJECTS ||--o{ TELEMETRY_RAW : "registra historial"
|
|
||||||
|
|
||||||
EDGE_PROJECTS {
|
|
||||||
uuid id PK
|
|
||||||
string name
|
|
||||||
string installer_status "READY, BUILDING, ERROR"
|
|
||||||
jsonb last_health_report "Snapshot en tiempo real"
|
|
||||||
}
|
|
||||||
|
|
||||||
TELEMETRY_RAW {
|
|
||||||
timestamptz time PK
|
|
||||||
uuid project_id FK
|
|
||||||
uuid asset_id FK
|
|
||||||
jsonb data "Valores de variables"
|
|
||||||
}
|
|
||||||
|
|
||||||
MQTT_USERS {
|
|
||||||
string username PK
|
|
||||||
string password_hash "Bcrypt"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. API REST (Backend)
|
|
||||||
Base URL: `http://localhost:3000/api`
|
|
||||||
|
|
||||||
### Auth & Usuarios
|
|
||||||
- `POST /auth/login`: Autenticación JWT.
|
|
||||||
- `GET /auth/me`: Verificación de sesión.
|
|
||||||
|
|
||||||
### Gestión de Agentes (Edge)
|
|
||||||
- `GET /edge/projects`: Lista todos los proyectos/agentes.
|
|
||||||
- `POST /edge/projects`: Crea un nuevo agente (dispara generación de MSI).
|
|
||||||
- `GET /edge/projects/:id`: Detalle completo y último estado de salud.
|
|
||||||
- `GET /edge/projects/:id/installer`: Obtiene el link de descarga del MSI.
|
|
||||||
|
|
||||||
### Assets & Variables
|
|
||||||
- `GET /edge/assets`: Lista de gemelos digitales.
|
|
||||||
- `POST /edge/assets`: Crear nuevo activo.
|
|
||||||
- `GET /edge/variables?asset_id=...`: Lista variables (Sensores) de un activo.
|
|
||||||
|
|
||||||
### Telemetría & Logs
|
|
||||||
- `GET /telemetry/history/:project_id`: Consulta de datos históricos (Cold Path).
|
|
||||||
- `GET /edge/projects/:id/logs`: Consulta de logs de salud y eventos MQTT.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Arquitectura MQTT (Mosquitto)
|
|
||||||
El frontend y los agentes se comunican vía MQTT para el **Hot Path** (Tiempo Real).
|
|
||||||
|
|
||||||
### Tópicos Clave
|
|
||||||
| Tópico | Dirección | Descripción |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `omnioil/health/:project_id` | Agente -> Todos | Reporte de vida y estado de Docker. |
|
|
||||||
| `omnioil/telemetry/:project_id` | Agente -> Todos | Datos de sensores en tiempo real (Live View). |
|
|
||||||
| `omnioil/logs/:project_id` | Agente -> Todos | Eventos de sistema y errores. |
|
|
||||||
| `omnioil/deploy/:project_id` | Web -> Agente | Comando para desplegar nueva configuración. |
|
|
||||||
| `omnioil/commands/:project_id` | Web -> Agente | Comandos directos (Reinicio, etc). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Guía de Conexión para Frontend
|
|
||||||
|
|
||||||
### Paso A: Visualización en Tiempo Real (WSS)
|
|
||||||
El frontend debe usar una librería como `mqtt.js` para conectar:
|
|
||||||
- **Broker**: `wss://tu-dominio.com/mqtt` (Puerto 443).
|
|
||||||
- **Auth**: El backend proporciona credenciales MQTT temporales o el `project_id`.
|
|
||||||
- **Suscripción**: `client.subscribe("omnioil/telemetry/+")` para ver todo en vivo.
|
|
||||||
|
|
||||||
### Paso B: Almacenamiento y Norma (REST)
|
|
||||||
Para reportes legales o gráficas de largo plazo (días/meses):
|
|
||||||
- Consultar `GET /telemetry/history/:project_id`.
|
|
||||||
- Estos datos están filtrados a la resolución de 10 minutos requerida por la ANH.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Recomendaciones de Implementación
|
|
||||||
1. **Cache de Salud**: El frontend debe considerar a un agente como "Offline" si no recibe un mensaje en `omnioil/health` durante más de 60 segundos.
|
|
||||||
2. **Seguridad**: Nunca exponer el token de la base de datos directo; siempre pasar por el proxy de la API REST.
|
|
||||||
3. **Gráficas**: Usar librerías que soporten "Stream Data" (como Chart.js con streaming plugin o Recharts) para procesar los mensajes MQTT sin refrescar la página.
|
|
||||||
396
docs/ISO27001.md
Normal file
396
docs/ISO27001.md
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
# 🛡️ ISO/IEC 27001:2022 — Documento Consolidado de Cumplimiento
|
||||||
|
## OmniOil SCADA Platform — Kyrbot Innovations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Organización:** Kyrbot Innovations
|
||||||
|
**Producto:** OmniOil SCADA Platform
|
||||||
|
**Versión del sistema:** 2.0 (Build 2026)
|
||||||
|
**Norma:** ISO/IEC 27001:2022
|
||||||
|
**Fecha:** Mayo 2026
|
||||||
|
**Código de documento:** KI-SEC-ISO27K-001
|
||||||
|
**Clasificación:** Confidencial — Uso interno y soporte ante clientes y organismos certificadores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Tabla de Contenido
|
||||||
|
|
||||||
|
1. [Declaración de Cumplimiento para Clientes](#1-declaración-de-cumplimiento-para-clientes)
|
||||||
|
2. [Resumen Ejecutivo](#2-resumen-ejecutivo)
|
||||||
|
3. [Alcance del SGSI](#3-alcance-del-sgsi)
|
||||||
|
4. [Evaluación de Cláusulas §4–§10](#4-evaluación-de-cláusulas-principales)
|
||||||
|
5. [Evaluación de Controles Anexo A](#5-controles-del-anexo-a)
|
||||||
|
6. [Matriz de Riesgos y Tratamiento](#6-matriz-de-riesgos-y-tratamiento)
|
||||||
|
7. [Plan de Acción y Hoja de Ruta](#7-plan-de-acción-y-hoja-de-ruta)
|
||||||
|
8. [Declaración de Aplicabilidad (SoA)](#8-declaración-de-aplicabilidad-resumida)
|
||||||
|
9. [Conclusiones y Nivel de Madurez](#9-conclusiones-y-nivel-de-madurez)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Declaración de Cumplimiento para Clientes
|
||||||
|
|
||||||
|
### ¿Por qué ISO 27001?
|
||||||
|
|
||||||
|
La norma ISO/IEC 27001 es el estándar internacional de referencia para la **gestión de la seguridad de la información**. La alineación de OmniOil con esta norma garantiza que sus datos están protegidos de manera sistemática, se previenen vulnerabilidades y se garantiza la continuidad operativa.
|
||||||
|
|
||||||
|
Para una operadora de hidrocarburos, esto significa:
|
||||||
|
|
||||||
|
> ✅ Sus datos de telemetría y producción están protegidos contra accesos no autorizados.
|
||||||
|
> ✅ Los datos de sus pozos son confidenciales y no pueden ser vistos por otros clientes.
|
||||||
|
> ✅ La información no puede ser alterada sin dejar trazabilidad.
|
||||||
|
> ✅ El sistema está diseñado para seguir operando incluso ante fallos o ataques.
|
||||||
|
|
||||||
|
### Pilares de Seguridad de OmniOil
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SEGURIDAD EN PROFUNDIDAD │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ CAPA 1 │ │ CAPA 2 │ │ CAPA 3 │ │
|
||||||
|
│ │ Red / TLS │ │ Auth JWT │ │ Multi-Tenant│ │
|
||||||
|
│ │ WSS :443 │ │ Bcrypt+2FA │ │ Aislamiento │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ CAPA 4 │ │ CAPA 5 │ │ CAPA 6 │ │
|
||||||
|
│ │ Auditoría │ │ Cifrado │ │ Continuidad │ │
|
||||||
|
│ │ Completa │ │ AES/SHA256 │ │ Store&Fwd │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Capa | Tecnología | Beneficio para el Cliente |
|
||||||
|
|------|-----------|--------------------------|
|
||||||
|
| 1. Red / TLS | HTTPS, WSS (TLS 1.2+), rustls/aws_lc_rs | Datos cifrados en tránsito — imposible interceptar |
|
||||||
|
| 2. Autenticación | JWT + Bcrypt (cost 10) + 2FA TOTP | Solo personal autorizado accede al sistema |
|
||||||
|
| 3. Multi-Tenancy | `user_project_access`, ACLs MQTT por `project_id` | Sus datos son invisibles para otros clientes |
|
||||||
|
| 4. Auditoría | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | Trazabilidad completa ISO 27001 + ANH |
|
||||||
|
| 5. Cifrado de datos | SHA-256 en reportes, AES-256 en Edge Agent | Integridad verificable, datos en reposo protegidos |
|
||||||
|
| 6. Continuidad | Store & Forward SQLite en Edge Agent, restart:always | Sin pérdida de datos aunque caiga internet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Resumen Ejecutivo
|
||||||
|
|
||||||
|
OmniOil es una plataforma SaaS-IoT de telemetría industrial orientada al sector de hidrocarburos en Colombia, diseñada para cumplir con la Resolución ANH 0651 y con los más altos estándares de seguridad de la información.
|
||||||
|
|
||||||
|
### Resultados Generales
|
||||||
|
|
||||||
|
| Área | Estado | Nivel de Madurez |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| Arquitectura de seguridad técnica | ✅ Implementado | Alto (4/5) |
|
||||||
|
| Autenticación y control de acceso | ✅ Implementado | Alto (4/5) |
|
||||||
|
| Cifrado y protección de datos | ✅ Implementado | Alto (4/5) |
|
||||||
|
| Segmentación de red y aislamiento | ✅ Implementado | Alto (4/5) |
|
||||||
|
| Gestión de identidades multi-tenant | ✅ Implementado | Alto (4/5) |
|
||||||
|
| Trazabilidad y auditoría | ✅ Implementado | Alto (4/5) |
|
||||||
|
| Gestión formal de riesgos documentada | ⚠️ Parcial | Medio (3/5) |
|
||||||
|
| Política SGSI y documentación formal | ⚠️ En proceso | Medio (3/5) |
|
||||||
|
| Controles de personas y RRHH | ⚠️ Parcial | Bajo-Medio (2/5) |
|
||||||
|
| Seguridad física (instalaciones) | ⚠️ Aplica a cliente | Medio (3/5) |
|
||||||
|
| Continuidad del negocio documentada | ⚠️ Parcial | Medio (3/5) |
|
||||||
|
| Auditorías internas y revisión dirección | 🔄 Planificado | Bajo-Medio (2/5) |
|
||||||
|
|
||||||
|
**Nivel global de madurez estimado: 3.2 / 5.0 (Nivel "Definido")**
|
||||||
|
|
||||||
|
> OmniOil demuestra una sólida implementación técnica que supera significativamente el promedio del sector IoT industrial. La brecha principal está en la formalización documental del SGSI, la cual es completamente abordable en los próximos ciclos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Alcance del SGSI
|
||||||
|
|
||||||
|
### 3.1 Sistema Evaluado
|
||||||
|
|
||||||
|
| Componente | Descripción | En Alcance |
|
||||||
|
|-----------|-------------|-----------|
|
||||||
|
| Backend API (Rust/Axum) | Orquestador con JWT, RBAC, multi-tenant | ✅ |
|
||||||
|
| Edge Agent (Rust/Windows) | Agente de campo, drivers SCADA, Store&Forward | ✅ |
|
||||||
|
| Frontend Admin (React) | Panel administrativo | ✅ |
|
||||||
|
| Frontend PWA (React) | Aplicación operadores de campo | ✅ |
|
||||||
|
| Infraestructura (Docker/TimescaleDB/Redis/MinIO/Mosquitto) | Stack de servicios | ✅ |
|
||||||
|
| Build Orchestrator | Generación de instaladores MSI | ✅ |
|
||||||
|
| Red física del cliente | Sensores, PLCs, pozos | ❌ Excluido — responsabilidad del cliente |
|
||||||
|
| Hosting VPS (Dokploy) | Proveedor externo | ❌ Excluido — gestión de tercero |
|
||||||
|
|
||||||
|
### 3.2 Partes Interesadas
|
||||||
|
|
||||||
|
| Parte | Relación | Expectativas |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| Operadoras de O&G | Usuarios del sistema | Confidencialidad, disponibilidad 24/7, integridad |
|
||||||
|
| ANH (Regulador) | Ente de control | Trazabilidad, inalterabilidad, Res. 0651 |
|
||||||
|
| Operadores de campo | Usuarios finales PWA | Acceso seguro, disponibilidad offline |
|
||||||
|
| Kyrbot Innovations | Desarrollador/Proveedor | Reputación, cumplimiento contractual |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Evaluación de Cláusulas Principales
|
||||||
|
|
||||||
|
### § 4 — Contexto de la Organización · Madurez: 3/5
|
||||||
|
|
||||||
|
| Req. | Estado | Evidencia |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 4.1 Contexto interno/externo | ⚠️ Parcial | Documentación técnica extensa. Falta análisis formal FODA/PESTEL. |
|
||||||
|
| 4.2 Partes interesadas | ⚠️ Parcial | Identificadas en arquitectura y guía ANH. Requiere registro formal. |
|
||||||
|
| 4.3 Alcance del SGSI | ✅ Cumple | Definido en este documento y DESPLIEGUE.md. |
|
||||||
|
| 4.4 Sistema de Gestión SI | 🔄 Planificado | Arquitectura robusta; formalización de procesos en curso. |
|
||||||
|
|
||||||
|
### § 5 — Liderazgo · Madurez: 2.5/5
|
||||||
|
|
||||||
|
| Req. | Estado | Evidencia |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 5.1 Liderazgo y compromiso | ⚠️ Parcial | Compromiso técnico alto demostrado. Requiere política formal firmada por dirección. |
|
||||||
|
| 5.2 Política de seguridad | 📋 Documental | Principios en SEGURIDAD.md. Requiere política formal independiente. |
|
||||||
|
| 5.3 Roles y responsabilidades | ⚠️ Parcial | Roles RBAC definidos. Falta matriz RACI para el SGSI. |
|
||||||
|
|
||||||
|
### § 6 — Planificación · Madurez: 3/5
|
||||||
|
|
||||||
|
| Req. | Estado | Evidencia |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 6.1.2 Evaluación de riesgos | ⚠️ Parcial | Riesgos técnicos identificados y mitigados (ver §6 de este doc). Requiere metodología formal. |
|
||||||
|
| 6.1.3 Tratamiento de riesgos | ⚠️ Parcial | Acciones implementadas. Requiere Plan de Tratamiento de Riesgos (PTR) formal. |
|
||||||
|
| 6.2 Objetivos de seguridad | 📋 Documental | KPIs en arquitectura (p95 < 300ms, 0 vuln. críticas). Requiere objetivos SGSI formales. |
|
||||||
|
|
||||||
|
### § 7 — Soporte · Madurez: 3/5
|
||||||
|
|
||||||
|
| Req. | Estado | Evidencia |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 7.1 Recursos | ✅ Cumple | Stack tecnológico definido. VPS provisionado. |
|
||||||
|
| 7.2 Competencia | ⚠️ Parcial | Calidad del código evidencia competencia técnica. Falta registro formal. |
|
||||||
|
| 7.3 Concienciación | 🔄 Planificado | Pendiente programa formal. |
|
||||||
|
| 7.4 Comunicación | ⚠️ Parcial | Alertas Email/Telegram implementadas. Falta plan de comunicación de incidentes. |
|
||||||
|
| 7.5 Información documentada | ⚠️ Parcial | Documentación técnica extensa. Falta estructura formal SGSI. |
|
||||||
|
|
||||||
|
### § 8 — Operación · Madurez: 3.5/5
|
||||||
|
|
||||||
|
| Req. | Estado | Evidencia |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 8.1 Planificación y control | ✅ Cumple | Docker Compose versionado. Build reproducible con Cargo Chef. |
|
||||||
|
| 8.2 Evaluación de riesgos | ⚠️ Parcial | Evaluaciones ad-hoc. Requiere calendario periódico formal. |
|
||||||
|
| 8.3 Tratamiento de riesgos | ✅ Cumple | Mitigaciones implementadas y documentadas en SEGURIDAD.md. |
|
||||||
|
|
||||||
|
### § 9 — Evaluación del Desempeño · Madurez: 2.5/5
|
||||||
|
|
||||||
|
| Req. | Estado | Evidencia |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 9.1 Seguimiento y medición | ⚠️ Parcial | Prometheus + Grafana implementados (Phase 5). Falta dashboard de métricas SGSI. |
|
||||||
|
| 9.2 Auditoría interna | 🔄 Planificado | Auditoría técnica realizada. Falta programa formal periódico. |
|
||||||
|
| 9.3 Revisión por la dirección | 🔄 Planificado | Pendiente establecer ciclo formal. |
|
||||||
|
|
||||||
|
### § 10 — Mejora · Madurez: 3.5/5
|
||||||
|
|
||||||
|
| Req. | Estado | Evidencia |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 10.1 Mejora continua | ✅ Cumple | Desarrollo iterativo en fases documentadas. Hardening continuo evidente. |
|
||||||
|
| 10.2 No conformidades | ⚠️ Parcial | Hallazgos resueltos documentados. Falta registro formal de no conformidades. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Controles del Anexo A
|
||||||
|
|
||||||
|
### A.5 — Controles Organizacionales (37 controles)
|
||||||
|
|
||||||
|
| Control | Título | Estado | Implementación en OmniOil |
|
||||||
|
|---------|--------|--------|---------------------------|
|
||||||
|
| A.5.1 | Políticas de seguridad | 📋 Documental | Principios documentados en SEGURIDAD.md. Política formal en elaboración. |
|
||||||
|
| A.5.2 | Roles y responsabilidades | ⚠️ Parcial | Roles RBAC en BD. Falta matriz RACI del SGSI. |
|
||||||
|
| A.5.3 | Segregación de funciones | ✅ Cumple | Multi-tenancy enforced: `user_project_access`, ACLs MQTT por proyecto. |
|
||||||
|
| A.5.8 | Seguridad en gestión de proyectos | ✅ Cumple | Security-by-design. Fases P0 de seguridad prioritarias. |
|
||||||
|
| A.5.14 | Transferencia de información | ✅ Cumple | Todo tráfico via HTTPS/WSS (TLS 1.2+). JWT para API. |
|
||||||
|
| A.5.15 | Control de acceso | ✅ Cumple | JWT en 100% de endpoints. RBAC por roles. Multi-tenant en todas las queries SQL. |
|
||||||
|
| A.5.16 | Gestión de identidades | ✅ Cumple | UUIDs únicos por usuario/proyecto/agente. Credenciales dinámicas por instalador. |
|
||||||
|
| A.5.17 | Información de autenticación | ✅ Cumple | Bcrypt (cost 10). Credenciales de 36-40 chars para agentes MQTT. JWT con secret rotable. |
|
||||||
|
| A.5.18 | Derechos de acceso | ✅ Cumple | ACLs MQTT por proyecto (`omnioil/telemetry/{project_id}/#`). |
|
||||||
|
| A.5.23 | Servicios en nube | ✅ Cumple | MinIO privado. Sin exposición directa. Datos soberanos en VPS del cliente. |
|
||||||
|
| A.5.28 | Recopilación de evidencia | ✅ Cumple | `audit_log`: usuario, fecha, IP, valor anterior/nuevo. Logs de agentes persistidos. |
|
||||||
|
| A.5.31 | Requisitos legales/regulatorios | ✅ Cumple | Cumplimiento ANH Res. 0651. Reportes, SHA-256, audit trail implementados. |
|
||||||
|
| A.5.33 | Protección de registros | ✅ Cumple | TimescaleDB como almacén inmutable. Hash SHA-256 en reportes ANH. |
|
||||||
|
| A.5.24 | Gestión de incidentes (planif.) | ⚠️ Parcial | Alertas Email/Telegram. Watchdog activo. Falta plan formal de respuesta. |
|
||||||
|
|
||||||
|
### A.6 — Controles de Personas (8 controles) · Madurez: 2/5
|
||||||
|
|
||||||
|
| Control | Título | Estado | Observación |
|
||||||
|
|---------|--------|--------|-------------|
|
||||||
|
| A.6.1 | Investigación de antecedentes | 🔄 Planificado | Pendiente política de verificación para personal con acceso. |
|
||||||
|
| A.6.2 | Términos de empleo | 🔄 Planificado | Pendiente cláusulas de confidencialidad en contratos. |
|
||||||
|
| A.6.3 | Formación en SI | 🔄 Planificado | Pendiente programa de capacitación. |
|
||||||
|
| A.6.5 | Cese/cambio de empleo | ⚠️ Parcial | Credenciales únicas facilitan revocación. Falta procedimiento formal offboarding. |
|
||||||
|
| A.6.6 | NDAs | 🔄 Planificado | Pendiente NDAs formales con clientes y personal. |
|
||||||
|
| A.6.7 | Trabajo remoto | ⚠️ Parcial | Acceso via HTTPS/JWT + 2FA disponible. Falta política formal. |
|
||||||
|
| A.6.8 | Reporte de eventos SI | ⚠️ Parcial | Alertas automáticas implementadas. Falta canal formal para usuarios. |
|
||||||
|
|
||||||
|
> **Nota:** A.6 es el área de mayor brecha — principalmente documental y de procesos RRHH.
|
||||||
|
|
||||||
|
### A.7 — Controles Físicos (14 controles)
|
||||||
|
|
||||||
|
> La mayoría de los controles físicos (A.7.1–A.7.9) **no aplican** al alcance del sistema SaaS — corresponden a la infraestructura física del cliente o del proveedor de hosting. Los controles A.7.10–A.7.14 son parcialmente aplicables y están en estado PARCIAL o PLANIFICADO.
|
||||||
|
|
||||||
|
### A.8 — Controles Tecnológicos (34 controles) · Los más fuertes
|
||||||
|
|
||||||
|
| Control | Título | Estado | Implementación en OmniOil |
|
||||||
|
|---------|--------|--------|---------------------------|
|
||||||
|
| A.8.2 | Acceso privilegiado | ✅ Cumple | Superadmin separado de admin operativo. Roles mínimo privilegio. |
|
||||||
|
| A.8.3 | Restricción de acceso a info. | ✅ Cumple | Filtros por proyecto en todas las queries. API multi-tenant enforced. |
|
||||||
|
| A.8.5 | Autenticación segura | ✅ Cumple | JWT + Bcrypt + 2FA-TOTP. Bcrypt ralentiza ataques de fuerza bruta. |
|
||||||
|
| A.8.6 | Gestión de capacidad | ✅ Cumple | TimescaleDB con políticas de retención. Compresión automática. |
|
||||||
|
| A.8.7 | Protección contra malware | ✅ Cumple | Rust (memory-safe). Sin exposición de puertos internos. |
|
||||||
|
| A.8.12 | Prevención pérdida datos | ✅ Cumple | Store-and-forward Edge Agent. Backups MinIO. Retención TimescaleDB. |
|
||||||
|
| A.8.13 | Respaldo de información | ⚠️ Parcial | MinIO backups manuales posibles. Falta automatización con verificación. |
|
||||||
|
| A.8.15 | Registros de actividad | ✅ Cumple | `audit_log`, `agent_logs`, `telemetry_alarms`. Logs estructurados con tracing. |
|
||||||
|
| A.8.16 | Actividades de monitoreo | ✅ Cumple | Prometheus + Grafana. Watchdog de telemetría. Alertas automáticas. |
|
||||||
|
| A.8.20 | Seguridad en redes | ✅ Cumple | Red Docker interna `anh_network`. Solo API, frontends y MQTT públicos. DB sin exposición. |
|
||||||
|
| A.8.21 | Servicios de red | ✅ Cumple | HTTPS/TLS para todos los servicios públicos. WSS (443) para MQTT. |
|
||||||
|
| A.8.22 | Separación de redes | ✅ Cumple | Red Docker dedicada. Edge Agents comunicación solo via WSS:443. |
|
||||||
|
| A.8.24 | Uso de criptografía | ✅ Cumple | Bcrypt, JWT HMAC-SHA256, TLS 1.2+ (rustls/aws_lc_rs), WSS, SHA-256 ANH, AES-256 Edge. |
|
||||||
|
| A.8.25 | Ciclo de vida desarrollo seguro | ✅ Cumple | Security-by-design. Hardening activo. Fases P0 prioritarias. |
|
||||||
|
| A.8.26 | Requisitos de seguridad app | ✅ Cumple | JWT, CORS, multi-tenant, rate limiting en requisitos. Rust memory-safe. |
|
||||||
|
| A.8.27 | Arquitectura segura | ✅ Cumple | Zero-Trust. Mínimo privilegio. Defense-in-depth (red + auth + autorización + cifrado). |
|
||||||
|
| A.8.28 | Codificación segura | ✅ Cumple | Rust garantiza memory-safety en compilación. `aws_lc_rs` para operaciones criptográficas. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Matriz de Riesgos y Tratamiento
|
||||||
|
|
||||||
|
**Escala:** Nivel de riesgo = Probabilidad × Impacto
|
||||||
|
🟢 Bajo (1–4) | 🟡 Medio (5–9) | 🔴 Alto (10–14) | ⛔ Crítico (15–25)
|
||||||
|
|
||||||
|
| ID | Riesgo | P | I | Nivel | Estado |
|
||||||
|
|----|--------|---|---|-------|--------|
|
||||||
|
| R-01 | Interceptación de tráfico MQTT en campo | 2 | 5 | 🔴 Alto (10) | ✅ **Mitigado**: WSS (TLS 1.2+) en puerto 443. |
|
||||||
|
| R-02 | Acceso no autorizado a telemetría | 2 | 5 | 🔴 Alto (10) | ✅ **Mitigado**: JWT, multi-tenant, ACLs MQTT. |
|
||||||
|
| R-03 | Exfiltración de credenciales agente MQTT | 2 | 4 | 🟡 Medio (8) | ⚠️ **Parcial**: Credenciales inyectadas en binario. Falta: AES-256 del overlay. |
|
||||||
|
| R-04 | Ataques de fuerza bruta a auth | 3 | 4 | 🔴 Alto (12) | ⚠️ **Parcial**: Bcrypt ralentiza. Falta: Rate limiting vía Redis/Traefik. |
|
||||||
|
| R-05 | Movimiento lateral entre clientes (MQTT) | 2 | 5 | 🔴 Alto (10) | ✅ **Mitigado**: ACLs dinámicas por `project_id`. |
|
||||||
|
| R-06 | Pérdida de datos por caída de conectividad | 2 | 4 | 🟡 Medio (8) | ✅ **Mitigado**: Store-and-forward AES-256 en Edge Agent. |
|
||||||
|
| R-07 | Compromiso credenciales admin BD | 1 | 5 | 🟡 Medio (5) | ✅ **Mitigado**: BD sin exposición pública. Variables de entorno. |
|
||||||
|
| R-08 | Ingeniería inversa binario Edge Agent | 2 | 3 | 🟡 Medio (6) | ⚠️ **Parcial**: Credenciales inyectadas en binario compilado. |
|
||||||
|
| R-09 | Indisponibilidad del servicio (DoS) | 2 | 4 | 🟡 Medio (8) | ⚠️ **Parcial**: Healthchecks, restart:always. Falta: CDN, auto-scaling. |
|
||||||
|
| R-10 | Fuga de datos de prod en entorno dev | 2 | 4 | 🟡 Medio (8) | ⚠️ **Parcial**: docker-compose.dev.yml separado. Falta: datos de prueba anonimizados. |
|
||||||
|
| R-11 | Vulnerabilidades en dependencias | 2 | 4 | 🟡 Medio (8) | ⚠️ **Parcial**: Cargo.lock con versiones pinadas. Falta: cargo-audit en CI. |
|
||||||
|
| R-12 | Alteración de reportes regulatorios ANH | 1 | 5 | 🟡 Medio (5) | ✅ **Mitigado**: Hash SHA-256 + TimescaleDB append-only. |
|
||||||
|
| R-13 | Acceso físico no autorizado al Edge Agent | 3 | 3 | 🟡 Medio (9) | ⚠️ **Parcial**: Responsabilidad del cliente. Guía de hardening incluida. |
|
||||||
|
| R-14 | Pérdida de datos por fallo almacenamiento | 2 | 4 | 🟡 Medio (8) | ⚠️ **Parcial**: Volúmenes Docker persistentes. Falta: backups automáticos verificados. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Plan de Acción y Hoja de Ruta
|
||||||
|
|
||||||
|
### 🔴 Prioridad Alta — 30 días
|
||||||
|
|
||||||
|
| Acción | Control | Responsable |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| Redactar Política de Seguridad de la Información formal | A.5.1 | CISO / Dirección |
|
||||||
|
| Implementar Rate Limiting en endpoints críticos (auth, API) | A.8.5, R-04 | Equipo Backend |
|
||||||
|
| Cifrado AES-256 para credenciales en overlay del Edge Agent | A.8.24, R-03 | Equipo Backend |
|
||||||
|
| Configurar `cargo-audit` en pipeline CI/CD | A.5.21, R-11 | DevOps |
|
||||||
|
| Crear runbook básico de respuesta a incidentes | A.5.26 | Equipo de Seguridad |
|
||||||
|
|
||||||
|
### 🟡 Prioridad Media — 60–90 días
|
||||||
|
|
||||||
|
| Acción | Control | Responsable |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| Evaluación formal de riesgos (MAGERIT o ISO 27005) | §6.1.2 | CISO |
|
||||||
|
| Backups automáticos con pruebas de restauración | A.8.13, R-14 | DevOps |
|
||||||
|
| Entorno de staging entre desarrollo y producción | A.8.31 | DevOps |
|
||||||
|
| NDAs para personal y clientes | A.6.6 | Legal/Dirección |
|
||||||
|
| Escaneo SCA de contenedores (Trivy/Snyk) | A.8.8, R-11 | DevOps |
|
||||||
|
| Inventario formal de activos de información | A.5.9 | Equipo de Seguridad |
|
||||||
|
|
||||||
|
### 🟢 Prioridad Baja — 90–180 días
|
||||||
|
|
||||||
|
| Acción | Control | Responsable |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| Programa de concienciación en seguridad | A.6.3 | RRHH / CISO |
|
||||||
|
| Auditoría externa independiente de seguridad | A.5.35 | Dirección |
|
||||||
|
| Definir RTO/RPO y plan de continuidad del negocio (BCP) | A.5.30 | Dirección |
|
||||||
|
| Cumplimiento Ley 1581 Colombia (datos personales) | A.5.34 | Legal |
|
||||||
|
| Programa formal de auditorías internas SGSI | §9.2 | CISO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Declaración de Aplicabilidad Resumida (SoA)
|
||||||
|
|
||||||
|
La siguiente tabla resume los 93 controles del Anexo A de ISO 27001:2022:
|
||||||
|
|
||||||
|
| Categoría | Total | Aplica | Cumple Total | Cumple Parcial | Planificado | No Aplica |
|
||||||
|
|-----------|-------|--------|-------------|----------------|-------------|-----------|
|
||||||
|
| **A.5** Organizacionales | 37 | 31 | 14 (45%) | 11 (35%) | 6 (19%) | 6 |
|
||||||
|
| **A.6** Personas | 8 | 8 | 0 (0%) | 3 (37%) | 5 (63%) | 0 |
|
||||||
|
| **A.7** Físicos | 14 | 6 | 0 (0%) | 4 (67%) | 1 (17%) | 8 |
|
||||||
|
| **A.8** Tecnológicos | 34 | 33 | 18 (55%) | 12 (36%) | 2 (6%) | 1 |
|
||||||
|
| **TOTAL** | **93** | **78** | **32 (41%)** | **30 (38%)** | **14 (18%)** | **15 (19%)** |
|
||||||
|
|
||||||
|
> **Controles con algún nivel de cumplimiento: 79%**
|
||||||
|
> **Controles con cumplimiento total: 41%**
|
||||||
|
> **Controles en proceso (parcial + planificado): 56%**
|
||||||
|
|
||||||
|
### Leyenda de Estado
|
||||||
|
|
||||||
|
| Código | Estado |
|
||||||
|
|--------|--------|
|
||||||
|
| ✅ **CUMPLE** | Control completamente implementado y verificable |
|
||||||
|
| ⚠️ **PARCIAL** | Control parcialmente implementado; requiere acciones complementarias |
|
||||||
|
| 🔄 **PLANIFICADO** | Control identificado pero pendiente de implementación formal |
|
||||||
|
| ❌ **NO APLICA** | Control no aplicable al alcance o gestionado por tercero |
|
||||||
|
| 📋 **DOCUMENTAL** | Control técnicamente implementado pero sin documentación formal |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Conclusiones y Nivel de Madurez
|
||||||
|
|
||||||
|
### 9.1 Fortalezas Destacadas
|
||||||
|
|
||||||
|
1. **Arquitectura técnica sobresaliente**: Rust (memory-safe), Zero-Trust networking, cifrado end-to-end y micro-segmentación MQTT posicionan a OmniOil significativamente por encima del promedio del sector IoT industrial.
|
||||||
|
|
||||||
|
2. **Modelo de identidad robusto**: JWT + Bcrypt + 2FA-TOTP + UUIDs únicos + credenciales dinámicas por instalador = implementación de gestión de identidades de nivel enterprise.
|
||||||
|
|
||||||
|
3. **Multi-tenancy con enforcement real**: Aislamiento a nivel de query SQL + ACL MQTT + modelo de datos. Ningún cliente puede acceder a datos de otro.
|
||||||
|
|
||||||
|
4. **Cumplimiento regulatorio ANH como pilar**: Hash SHA-256, audit trail, store-and-forward. La arquitectura fue diseñada con cumplimiento desde el inicio.
|
||||||
|
|
||||||
|
5. **Código fuente auditable**: Stack Rust + TypeScript con convenciones claras y documentación extensa.
|
||||||
|
|
||||||
|
### 9.2 Áreas de Mejora Principales
|
||||||
|
|
||||||
|
1. **Formalización SGSI**: Política de Seguridad formal, procedimientos documentados, evaluación de riesgos metodológica.
|
||||||
|
2. **Controles de personas**: NDAs, capacitación, procedimientos RRHH son el área con menor madurez.
|
||||||
|
3. **CI/CD de seguridad**: cargo-audit, Trivy, SAST automático en el pipeline.
|
||||||
|
4. **Continuidad del negocio**: Backups automáticos, RTO/RPO definidos, BCP formal.
|
||||||
|
|
||||||
|
### 9.3 Nivel de Madurez Global (CMMI adaptado)
|
||||||
|
|
||||||
|
| Nivel | Nombre | Estado OmniOil |
|
||||||
|
|-------|--------|----------------|
|
||||||
|
| 1 | Inicial — Ad-hoc | ✅ Superado |
|
||||||
|
| 2 | Gestionado — Procesos básicos | ✅ Superado |
|
||||||
|
| **3** | **Definido — Procesos estandarizados** | **✅ Nivel actual (3.2/5)** |
|
||||||
|
| 4 | Cuantitativamente gestionado — Métricas | 🔄 En progreso |
|
||||||
|
| 5 | En optimización — Mejora continua proactiva | 🔄 Planificado |
|
||||||
|
|
||||||
|
### 9.4 Declaración de Preparación para Certificación
|
||||||
|
|
||||||
|
**OmniOil se encuentra en condiciones de iniciar el proceso de certificación ISO 27001:2022** con las siguientes consideraciones:
|
||||||
|
|
||||||
|
- ✅ Los controles técnicos del Anexo A (especialmente A.8) están implementados a nivel de producción.
|
||||||
|
- ✅ La arquitectura sigue los principios de ISO 27001 (confidencialidad, integridad, disponibilidad).
|
||||||
|
- ⚠️ Se requiere completar la formalización documental del SGSI antes de una auditoría formal.
|
||||||
|
- 📋 **Tiempo estimado para preparación completa: 3 a 6 meses**, concentrado en formalización documental y controles de personas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anexo — Referencias
|
||||||
|
|
||||||
|
- ISO/IEC 27001:2022 — Information security, cybersecurity and privacy protection
|
||||||
|
- ISO/IEC 27002:2022 — Information security controls
|
||||||
|
- ISO/IEC 27005:2022 — Information security risk management
|
||||||
|
- Resolución ANH 0651 de 2025 — Colombia
|
||||||
|
- `docs/SEGURIDAD.md` — Guía de seguridad técnica OmniOil
|
||||||
|
- `docs/DESPLIEGUE.md` — Guía de despliegue OmniOil
|
||||||
|
- `docs/ANH_CUMPLIMIENTO.md` — Cumplimiento Resolución ANH 0651
|
||||||
|
- `docs/EDGE_AGENT.md` — Guía del agente de borde
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento consolidado — Kyrbot Innovations*
|
||||||
|
*Versión: 1.0 — Mayo 2026*
|
||||||
|
*Combina: Informe de Cumplimiento + Declaración de Cumplimiento + Declaración de Aplicabilidad (SoA)*
|
||||||
608
docs/MANUAL_TECNICO.md
Normal file
608
docs/MANUAL_TECNICO.md
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
# 🔧 Manual Técnico — OmniOil SCADA v2.0
|
||||||
|
**Kyrbot Innovations** · Arquitectura, API y Referencia Técnica
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de Contenido
|
||||||
|
1. [Arquitectura del Sistema](#1-arquitectura-del-sistema)
|
||||||
|
2. [Microservicios](#2-microservicios)
|
||||||
|
3. [Esquema de Base de Datos](#3-esquema-de-base-de-datos)
|
||||||
|
4. [Tópicos MQTT](#4-tópicos-mqtt)
|
||||||
|
5. [Flujo de Datos](#5-flujo-de-datos)
|
||||||
|
6. [API REST — Autenticación y Endpoints](#6-api-rest)
|
||||||
|
7. [Variables de Entorno](#7-variables-de-entorno)
|
||||||
|
8. [Métricas Prometheus](#8-métricas-prometheus)
|
||||||
|
9. [Rutas del Frontend](#9-rutas-del-frontend)
|
||||||
|
10. [Roadmap Estratégico (Clase Mundial)](./ROADMAP_ESTRATEGICO.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Arquitectura del Sistema
|
||||||
|
|
||||||
|
OmniOil es una plataforma de microservicios basada en eventos, diseñada para operar en un entorno híbrido (VPS + Cloud).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ INTERNET / CLIENTES │
|
||||||
|
└──────────┬────────────────────────┬──────────────────────────┬─────┘
|
||||||
|
│ HTTPS :443 │ HTTPS :443 │ WSS :443
|
||||||
|
┌──────▼────────┐ ┌────────▼───────────┐ ┌─────────▼────────┐
|
||||||
|
│ frontend-admin│ │ frontend-console │ │ frontend-pwa │
|
||||||
|
│ Admin clientes│ │ Control plane │ │ React PWA (campo) │
|
||||||
|
└──────┬────────┘ └────────┬───────────┘ └─────────┬────────┘
|
||||||
|
│ │ REST API │ REST API
|
||||||
|
└───────────────────────▼───────────────────────────▼
|
||||||
|
┌───────────────────┐
|
||||||
|
│ backend-api │
|
||||||
|
│ (Rust / Axum) │
|
||||||
|
└───────┬───────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────┼────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌─────────▼──────┐ ┌──────────▼──────┐ ┌────────▼──────────┐
|
||||||
|
│ TimescaleDB │ │ Redis │ │ MinIO │
|
||||||
|
│ (series tiempo)│ │ (cache/sessions) │ │ (S3, artefactos) │
|
||||||
|
└────────────────┘ └─────────────────┘ └───────────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼───────────────────┐
|
||||||
|
│ data-collector │ ← suscribe a omnioil/#
|
||||||
|
│ (Rust MQTT → TimescaleDB)│
|
||||||
|
└───────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼───────────────┐ ┌───────────────────┐
|
||||||
|
│ events-relay │ │ json-generator │
|
||||||
|
│ (Transactional Outbox)│ │ (Reportes ANH │
|
||||||
|
│ Redis Streams │ │ 06:30 AM UTC) │
|
||||||
|
└───────────────────────┘ └───────────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼────────────────┐
|
||||||
|
│ build-orchestrator │ ← genera instaladores MSI
|
||||||
|
│ (Docker socket API) │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
CAMPO:
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ edge-agent │ Windows Service
|
||||||
|
│ Modbus/S7/EIP→MQTT │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Microservicios
|
||||||
|
|
||||||
|
### 2.1 backend-api
|
||||||
|
- **Tecnología**: Rust / Axum
|
||||||
|
- **Puerto interno**: 8000
|
||||||
|
- **Responsabilidades**: Autenticación JWT, RBAC, CRUD completo de la jerarquía Project→Asset→Device→Variable, proxy de artefactos MinIO, watchdog
|
||||||
|
- **Base de datos**: TimescaleDB via SQLx
|
||||||
|
|
||||||
|
### 2.2 frontend-console
|
||||||
|
- **Tecnología**: React / TypeScript / Vite
|
||||||
|
- **Dominio**: `console.omnioil.app`
|
||||||
|
- **Responsabilidades**: Control plane interno OmniOil/Kyrbot para workflows provider-only como revisión de solicitudes de acceso y, posteriormente, billing/revenue y supervisión SaaS.
|
||||||
|
- **Autorización**: requiere roles `platform_admin` o `platform_operator`; no usa roles tenant `admin`/`superadmin`.
|
||||||
|
|
||||||
|
### 2.3 data-collector (telemetry-ingestor)
|
||||||
|
- **Tecnología**: Rust
|
||||||
|
- **Función**: Suscripción MQTT a `omnioil/#`, persistencia en TimescaleDB
|
||||||
|
- **Tópicos escuchados**: `omnioil/telemetry/+`, `omnioil/health/+`, `omnioil/logs/+`
|
||||||
|
- **Acción**: Actualiza `telemetry_raw`, `last_health_report` en `edge_projects`
|
||||||
|
|
||||||
|
### 2.4 events-relay
|
||||||
|
- **Tecnología**: Rust / Redis Streams
|
||||||
|
- **Función**: Implementa el patrón **Transactional Outbox** para garantizar entrega de eventos entre microservicios
|
||||||
|
- **Garantía**: At-least-once delivery
|
||||||
|
|
||||||
|
### 2.5 json-generator
|
||||||
|
- **Tecnología**: Rust / Cron
|
||||||
|
- **Función**: Genera reportes JSON diarios para la ANH a las 06:30 UTC
|
||||||
|
- **Output**: Archivo `OPERADOR_CONTRATO_DD-MMM-YYYY.json` en MinIO bucket `anh-reports`
|
||||||
|
- **Incluye**: Agregación de telemetría, movimientos, alarmas, hash SHA-256
|
||||||
|
|
||||||
|
### 2.6 build-orchestrator
|
||||||
|
- **Tecnología**: Rust
|
||||||
|
- **Función**: Orquesta la generación de instaladores MSI del Edge Agent por proyecto
|
||||||
|
- **Proceso**: Genera credenciales aleatorias → overlay en binario → empaqueta con WiX
|
||||||
|
- **Dependencia**: Docker socket API (riesgo de seguridad → ver docs/SEGURIDAD.md)
|
||||||
|
|
||||||
|
### 2.7 edge-agent
|
||||||
|
- **Tecnología**: Rust / Windows Service
|
||||||
|
- **Función**: Lee dispositivos industriales en campo y transmite via MQTT/WSS
|
||||||
|
- **Ver**: `docs/EDGE_AGENT.md` para documentación completa
|
||||||
|
|
||||||
|
### 2.8 shared-lib
|
||||||
|
- **Función**: Librería compartida entre microservicios con modelos de dominio, tipos, validaciones y utilidades comunes
|
||||||
|
- **Evita duplicación**: todos los servicios Rust la importan como dependencia del workspace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Esquema de Base de Datos
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
ROLES {
|
||||||
|
int id PK
|
||||||
|
varchar name
|
||||||
|
jsonb permissions
|
||||||
|
}
|
||||||
|
USERS {
|
||||||
|
uuid id PK
|
||||||
|
varchar email
|
||||||
|
varchar password_hash
|
||||||
|
int role_id FK
|
||||||
|
boolean is_active
|
||||||
|
boolean totp_enabled
|
||||||
|
varchar totp_secret
|
||||||
|
}
|
||||||
|
EDGE_PROJECTS {
|
||||||
|
uuid id PK
|
||||||
|
varchar name
|
||||||
|
varchar client
|
||||||
|
varchar anh_contract
|
||||||
|
varchar municipality
|
||||||
|
varchar department
|
||||||
|
decimal lat
|
||||||
|
decimal lon
|
||||||
|
varchar installer_status
|
||||||
|
text installer_url
|
||||||
|
jsonb last_health_report
|
||||||
|
timestamptz created_at
|
||||||
|
}
|
||||||
|
USER_PROJECT_ACCESS {
|
||||||
|
uuid user_id FK
|
||||||
|
uuid project_id FK
|
||||||
|
}
|
||||||
|
EDGE_ASSETS {
|
||||||
|
uuid id PK
|
||||||
|
uuid project_id FK
|
||||||
|
varchar code
|
||||||
|
varchar asset_type
|
||||||
|
date last_calibration_date
|
||||||
|
date next_calibration_date
|
||||||
|
text calibration_cert_url
|
||||||
|
}
|
||||||
|
EDGE_DEVICES {
|
||||||
|
uuid id PK
|
||||||
|
uuid asset_id FK
|
||||||
|
varchar protocol
|
||||||
|
varchar host
|
||||||
|
int port
|
||||||
|
jsonb protocol_config
|
||||||
|
}
|
||||||
|
EDGE_VARIABLES {
|
||||||
|
uuid id PK
|
||||||
|
uuid device_id FK
|
||||||
|
varchar alias
|
||||||
|
varchar data_type
|
||||||
|
int register_address
|
||||||
|
int polling_ms
|
||||||
|
decimal scale_factor
|
||||||
|
}
|
||||||
|
TELEMETRY_RAW {
|
||||||
|
timestamptz time PK
|
||||||
|
uuid asset_id PK
|
||||||
|
jsonb data
|
||||||
|
}
|
||||||
|
OPERATION_MOVEMENTS {
|
||||||
|
uuid id PK
|
||||||
|
uuid asset_id FK
|
||||||
|
varchar movement_type
|
||||||
|
decimal volumen_neto
|
||||||
|
timestamptz start_time
|
||||||
|
timestamptz end_time
|
||||||
|
uuid registered_by FK
|
||||||
|
}
|
||||||
|
TELEMETRY_ALARMS {
|
||||||
|
uuid id PK
|
||||||
|
uuid project_id FK
|
||||||
|
uuid variable_id FK
|
||||||
|
varchar alarm_type
|
||||||
|
varchar severity
|
||||||
|
timestamptz triggered_at
|
||||||
|
timestamptz resolved_at
|
||||||
|
uuid resolved_by FK
|
||||||
|
text notes
|
||||||
|
}
|
||||||
|
AUDIT_LOG {
|
||||||
|
uuid id PK
|
||||||
|
uuid user_id FK
|
||||||
|
varchar action
|
||||||
|
varchar entity_type
|
||||||
|
uuid entity_id
|
||||||
|
jsonb old_value
|
||||||
|
jsonb new_value
|
||||||
|
inet ip_address
|
||||||
|
timestamptz performed_at
|
||||||
|
}
|
||||||
|
MQTT_USERS {
|
||||||
|
varchar username PK
|
||||||
|
varchar password_hash
|
||||||
|
boolean is_admin
|
||||||
|
}
|
||||||
|
MQTT_ACLS {
|
||||||
|
int id PK
|
||||||
|
varchar username FK
|
||||||
|
varchar topic
|
||||||
|
int rw
|
||||||
|
}
|
||||||
|
SUBSCRIPTION_PLANS {
|
||||||
|
uuid id PK
|
||||||
|
varchar name
|
||||||
|
decimal base_price_usd
|
||||||
|
int included_projects
|
||||||
|
decimal extra_project_price_usd
|
||||||
|
}
|
||||||
|
CUSTOMER_SUBSCRIPTIONS {
|
||||||
|
uuid id PK
|
||||||
|
uuid customer_id FK
|
||||||
|
uuid plan_id FK
|
||||||
|
varchar stripe_subscription_id
|
||||||
|
varchar status
|
||||||
|
timestamptz current_period_end
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLES ||--o{ USERS : "define"
|
||||||
|
USERS ||--o{ USER_PROJECT_ACCESS : "accede"
|
||||||
|
EDGE_PROJECTS ||--o{ USER_PROJECT_ACCESS : "es accedido"
|
||||||
|
EDGE_PROJECTS ||--o{ EDGE_ASSETS : "contiene"
|
||||||
|
EDGE_ASSETS ||--o{ EDGE_DEVICES : "conecta"
|
||||||
|
EDGE_DEVICES ||--o{ EDGE_VARIABLES : "mapea"
|
||||||
|
EDGE_ASSETS ||--o{ TELEMETRY_RAW : "registra"
|
||||||
|
EDGE_ASSETS ||--o{ OPERATION_MOVEMENTS : "audita"
|
||||||
|
EDGE_PROJECTS ||--o{ TELEMETRY_ALARMS : "genera"
|
||||||
|
MQTT_USERS ||--o{ MQTT_ACLS : "autoriza"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hypertable TimescaleDB
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- telemetry_raw es una Hypertable (optimizada para series de tiempo)
|
||||||
|
SELECT create_hypertable('telemetry_raw', 'time');
|
||||||
|
|
||||||
|
-- Política de compresión automática (después de 7 días)
|
||||||
|
SELECT add_compression_policy('telemetry_raw', INTERVAL '7 days');
|
||||||
|
|
||||||
|
-- Política de retención (eliminar datos >6 meses)
|
||||||
|
SELECT add_retention_policy('telemetry_raw', INTERVAL '6 months');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tópicos MQTT
|
||||||
|
|
||||||
|
| Tópico | Dirección | QoS | Descripción |
|
||||||
|
|--------|-----------|-----|-------------|
|
||||||
|
| `omnioil/telemetry/{project_id}` | Agente → Servidor | 1 | Datos SCADA en tiempo real |
|
||||||
|
| `omnioil/health/{project_id}` | Agente → Servidor | 1 | Heartbeat (CPU, RAM, versión) cada 60s |
|
||||||
|
| `omnioil/logs/{project_id}` | Agente → Servidor | 0 | Eventos y errores del agente |
|
||||||
|
| `omnioil/deploy/{project_id}` | Servidor → Agente | 1 | Comando para nueva configuración |
|
||||||
|
| `omnioil/commands/{project_id}` | Servidor → Agente | 1 | Comandos directos (reinicio, update) |
|
||||||
|
|
||||||
|
### Payload de Telemetría
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"asset_id": "a1b2c3d4-...",
|
||||||
|
"timestamp": "2026-05-04T10:30:00Z",
|
||||||
|
"variables": {
|
||||||
|
"presion_whp": 287.5,
|
||||||
|
"temperatura_linea": 45.2,
|
||||||
|
"caudal_gas": 12500.0,
|
||||||
|
"nivel_tanque": 85.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Flujo de Datos
|
||||||
|
|
||||||
|
### 5.1 Hot Path — Telemetría en Tiempo Real
|
||||||
|
|
||||||
|
```
|
||||||
|
Dispositivo Industrial (Modbus/S7/EIP)
|
||||||
|
↓ [data_collector — polling cada N ms]
|
||||||
|
Edge Agent SQLite (buffer local)
|
||||||
|
↓ [mqtt_publisher — MQTT/WSS :443]
|
||||||
|
Mosquitto Broker (go-auth ACLs)
|
||||||
|
↓ [suscripción omnioil/#]
|
||||||
|
data-collector (Rust service)
|
||||||
|
↓ [INSERT]
|
||||||
|
TimescaleDB: telemetry_raw
|
||||||
|
↓ [UPDATE]
|
||||||
|
edge_projects.last_health_report (JSONB)
|
||||||
|
↓ [WebSocket MQTT]
|
||||||
|
Frontend Admin (live view)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Latencia típica**: 1-3 segundos extremo a extremo en condiciones normales.
|
||||||
|
|
||||||
|
### 5.2 Cold Path — Cumplimiento ANH
|
||||||
|
|
||||||
|
```
|
||||||
|
TimescaleDB: telemetry_raw (6 meses)
|
||||||
|
↓ [06:30 UTC daily — json-generator]
|
||||||
|
Agregar: promedio, min, max por variable
|
||||||
|
↓
|
||||||
|
Incluir: operation_movements + telemetry_alarms
|
||||||
|
↓
|
||||||
|
Calcular hash SHA-256
|
||||||
|
↓
|
||||||
|
PUT en MinIO bucket: anh-reports/
|
||||||
|
OPERADOR_CONTRATO_DD-MMM-YYYY.json
|
||||||
|
↓
|
||||||
|
Notificación Email/Telegram: "Reporte generado"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Flujo de Movimientos (ANH)
|
||||||
|
|
||||||
|
```
|
||||||
|
Operador en PWA → POST /api/movements
|
||||||
|
↓ [INSERT operation_movements]
|
||||||
|
TimescaleDB
|
||||||
|
↓ [Outbox event]
|
||||||
|
events-relay → Redis Streams
|
||||||
|
↓ [06:30 UTC]
|
||||||
|
json-generator incluye movimientos en reporte
|
||||||
|
↓
|
||||||
|
Audit log: quién, cuándo, qué valor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API REST
|
||||||
|
|
||||||
|
### 6.1 Autenticación
|
||||||
|
|
||||||
|
**Base URL**: `https://api.omnioil.app/api`
|
||||||
|
|
||||||
|
Todos los endpoints (excepto `/auth/login` y `/auth/setup`) requieren token JWT:
|
||||||
|
```
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Obtener Token
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "tu_contraseña",
|
||||||
|
"two_factor_code": "123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"user": {
|
||||||
|
"id": "uuid",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Endpoints Completos
|
||||||
|
|
||||||
|
#### Auth y Usuarios
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `POST` | `/api/auth/login` | Autenticación, retorna JWT |
|
||||||
|
| `GET` | `/api/auth/me` | Info del usuario actual |
|
||||||
|
| `POST` | `/api/auth/setup` | Crear superadmin (solo primer uso) |
|
||||||
|
| `PUT` | `/api/users/{id}/password` | Cambiar contraseña |
|
||||||
|
| `POST` | `/api/auth/2fa/enable` | Activar 2FA (genera QR) |
|
||||||
|
| `POST` | `/api/auth/2fa/verify` | Verificar y confirmar 2FA |
|
||||||
|
| `DELETE` | `/api/auth/2fa/disable` | Desactivar 2FA |
|
||||||
|
|
||||||
|
#### Gestión de Proyectos (Edge)
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/edge/projects` | Listar proyectos del usuario |
|
||||||
|
| `POST` | `/api/edge/projects` | Crear proyecto |
|
||||||
|
| `GET` | `/api/edge/projects/{id}` | Detalle con estado de salud |
|
||||||
|
| `PUT` | `/api/edge/projects/{id}` | Actualizar proyecto |
|
||||||
|
| `DELETE` | `/api/edge/projects/{id}` | Eliminar proyecto |
|
||||||
|
| `POST` | `/api/edge/projects/{id}/build` | Disparar generación MSI |
|
||||||
|
| `GET` | `/api/edge/projects/{id}/installer` | URL de descarga del MSI |
|
||||||
|
| `GET` | `/api/edge/projects/{id}/logs` | Logs del agente |
|
||||||
|
|
||||||
|
#### Assets y Dispositivos
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/edge/assets?project_id=` | Listar activos |
|
||||||
|
| `POST` | `/api/edge/assets` | Crear activo |
|
||||||
|
| `GET` | `/api/edge/assets/{id}` | Detalle del activo |
|
||||||
|
| `PUT` | `/api/edge/assets/{id}` | Actualizar activo |
|
||||||
|
| `GET` | `/api/edge/devices?asset_id=` | Listar dispositivos |
|
||||||
|
| `POST` | `/api/edge/devices` | Agregar dispositivo |
|
||||||
|
| `PUT` | `/api/edge/devices/{id}` | Actualizar dispositivo |
|
||||||
|
|
||||||
|
#### Variables
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/edge/variables?device_id=` | Listar variables |
|
||||||
|
| `POST` | `/api/edge/variables` | Crear variable |
|
||||||
|
| `PUT` | `/api/edge/variables/{id}` | Actualizar variable |
|
||||||
|
| `DELETE` | `/api/edge/variables/{id}` | Eliminar variable |
|
||||||
|
|
||||||
|
#### Telemetría
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/telemetry/history/{project_id}` | Histórico (resolución ANH 10 min) |
|
||||||
|
| `GET` | `/api/telemetry/live/{project_id}` | Último valor de cada variable |
|
||||||
|
| `GET` | `/api/telemetry/export?project_id=&from=&to=` | Exportar a CSV |
|
||||||
|
|
||||||
|
#### Movimientos y Operaciones
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/movements?asset_id=` | Listar movimientos |
|
||||||
|
| `POST` | `/api/movements` | Registrar movimiento |
|
||||||
|
| `PUT` | `/api/movements/{id}` | Corregir movimiento (requiere justificación) |
|
||||||
|
|
||||||
|
#### Alertas
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/alarms?project_id=` | Alarmas activas y resueltas |
|
||||||
|
| `POST` | `/api/alarms/rules` | Crear regla de alerta |
|
||||||
|
| `PUT` | `/api/alarms/{id}/resolve` | Resolver alarma con nota |
|
||||||
|
|
||||||
|
#### Reportes ANH
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/reports/anh?project_id=&date=` | Obtener reporte por fecha |
|
||||||
|
| `POST` | `/api/reports/anh/generate` | Generar reporte manualmente |
|
||||||
|
| `GET` | `/api/reports/anh/list?project_id=` | Listar reportes disponibles |
|
||||||
|
|
||||||
|
#### Auditoría
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/audit/logs` | Registro de cambios (filtros: user, date, entity) |
|
||||||
|
|
||||||
|
#### Sistema
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `GET` | `/api/health` | Estado del sistema (DB, MQTT, Redis) |
|
||||||
|
| `GET` | `/api/metrics` | Métricas Prometheus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Variables de Entorno
|
||||||
|
|
||||||
|
```env
|
||||||
|
# =============================================================================
|
||||||
|
# OMNIOIL SCADA — REFERENCIA COMPLETA DE VARIABLES DE ENTORNO
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- BASE DE DATOS ---
|
||||||
|
DB_USER=admin_omnioil
|
||||||
|
DB_PASSWORD=clave_segura_generada
|
||||||
|
DB_NAME=omnioil
|
||||||
|
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
|
|
||||||
|
# --- REDIS ---
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# --- MINIO (S3) ---
|
||||||
|
MINIO_USER=admin_s3
|
||||||
|
MINIO_PASSWORD=clave_segura_minio
|
||||||
|
MINIO_URL=http://minio:9000
|
||||||
|
|
||||||
|
# --- SEGURIDAD ---
|
||||||
|
JWT_SECRET=llave_maestra_jwt_minimo_256_bits
|
||||||
|
TUNNEL_SECRET=llave_para_tuneles_edge
|
||||||
|
TURNSTILE_SECRET_KEY=clave_privada_cloudflare_turnstile
|
||||||
|
|
||||||
|
# --- MQTT ---
|
||||||
|
MQTT_HOST=mosquitto
|
||||||
|
MQTT_PORT=1883 # Puerto interno (inter-service)
|
||||||
|
MQTT_PUBLIC_HOST=mqtt.omnioil.app
|
||||||
|
MQTT_PUBLIC_PORT=443
|
||||||
|
|
||||||
|
# --- BACKEND ---
|
||||||
|
BACKEND_API_URL=https://api.omnioil.app
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
# --- FRONTENDS ---
|
||||||
|
VITE_API_BASE=https://api.omnioil.app
|
||||||
|
VITE_MQTT_HOST=mqtt.omnioil.app
|
||||||
|
VITE_MQTT_PORT=443
|
||||||
|
VITE_TURNSTILE_SITE_KEY=clave_publica_cloudflare_turnstile
|
||||||
|
|
||||||
|
# --- BUILD ORCHESTRATOR ---
|
||||||
|
DOCKER_REGISTRY_URL=docker-registry:5000
|
||||||
|
|
||||||
|
# --- NOTIFICACIONES ---
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=soporte@omnioil.app
|
||||||
|
SMTP_PASS=clave_de_aplicacion_smtp
|
||||||
|
SMTP_FROM=OmniOil <soporte@omnioil.app>
|
||||||
|
TELEGRAM_BOT_TOKEN=bot_token_telegram
|
||||||
|
TELEGRAM_CHAT_ID=-100123456789
|
||||||
|
|
||||||
|
# --- WATCHDOG ---
|
||||||
|
WATCHDOG_CHECK_INTERVAL_SEC=300
|
||||||
|
WATCHDOG_SILENCE_THRESHOLD_MIN=15
|
||||||
|
|
||||||
|
# --- BILLING (Opcional) ---
|
||||||
|
STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Métricas Prometheus
|
||||||
|
|
||||||
|
El backend expone métricas en `GET /api/metrics` en formato Prometheus:
|
||||||
|
|
||||||
|
| Métrica | Tipo | Descripción |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `omnioil_projects_active_total` | Gauge | Proyectos con agente ONLINE |
|
||||||
|
| `omnioil_telemetry_messages_total` | Counter | Total de mensajes MQTT procesados |
|
||||||
|
| `omnioil_api_requests_total` | Counter | Requests HTTP por endpoint y status |
|
||||||
|
| `omnioil_api_latency_ms` | Histogram | Latencia de la API (p50, p95, p99) |
|
||||||
|
| `omnioil_alarms_active_total` | Gauge | Alarmas activas en este momento |
|
||||||
|
| `omnioil_reports_generated_total` | Counter | Reportes ANH generados exitosamente |
|
||||||
|
| `omnioil_mqtt_connections_total` | Gauge | Conexiones MQTT activas |
|
||||||
|
|
||||||
|
**Grafana**: Panel preconfigurado disponible en `http://localhost:3003` (solo desarrollo).
|
||||||
|
**Prometheus**: `http://localhost:9090` (solo desarrollo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Rutas del Frontend
|
||||||
|
|
||||||
|
### Panel Admin (app.omnioil.app)
|
||||||
|
|
||||||
|
| Ruta | Descripción | Rol mínimo |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| `/login` | Inicio de sesión | Público |
|
||||||
|
| `/setup` | Configuración inicial (solo primer uso) | Público |
|
||||||
|
| `/` o `/dashboard` | Dashboard principal con KPIs | operador |
|
||||||
|
| `/projects` | Lista de proyectos | operador |
|
||||||
|
| `/projects/new` | Crear nuevo proyecto | admin |
|
||||||
|
| `/projects/:id` | Detalle del proyecto | operador |
|
||||||
|
| `/projects/:id/assets` | Activos del proyecto | operador |
|
||||||
|
| `/projects/:id/assets/new` | Crear activo | admin |
|
||||||
|
| `/projects/:id/devices` | Dispositivos | admin |
|
||||||
|
| `/projects/:id/variables` | Variables/Tags | admin |
|
||||||
|
| `/projects/:id/installer` | Descargar MSI | admin |
|
||||||
|
| `/alarms` | Centro de alarmas | supervisor |
|
||||||
|
| `/reports/anh` | Reportes ANH | anh_reader |
|
||||||
|
| `/audit` | Log de auditoría | admin |
|
||||||
|
| `/billing` | Resumen de facturación | admin |
|
||||||
|
| `/profile` | Configuración de cuenta | operador |
|
||||||
|
| `/api-explorer` | Explorador de API REST | admin |
|
||||||
|
|
||||||
|
### PWA Campo (mobile.omnioil.app)
|
||||||
|
|
||||||
|
| Ruta | Descripción |
|
||||||
|
|------|-------------|
|
||||||
|
| `/login` | Login de operador |
|
||||||
|
| `/projects` | Lista de proyectos asignados |
|
||||||
|
| `/projects/:id/assets` | Activos del proyecto |
|
||||||
|
| `/assets/:id/readings` | Registrar medición manual |
|
||||||
|
| `/assets/:id/movements` | Registrar movimiento de producción |
|
||||||
|
| `/offline` | Estado offline / sincronización pendiente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Roadmap Estratégico (Clase Mundial)
|
||||||
|
|
||||||
|
Para más información sobre la visión a futuro del sistema, la redundancia geográfica en EE.UU. (Contabo), alta disponibilidad y observabilidad avanzada, consulte el documento:
|
||||||
|
👉 **[ROADMAP_ESTRATEGICO.md](./ROADMAP_ESTRATEGICO.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*OmniOil SCADA v2.0 — Manual Técnico — Kyrbot Innovations — Mayo 2026*
|
||||||
356
docs/MANUAL_USUARIO.md
Normal file
356
docs/MANUAL_USUARIO.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# 📖 Manual de Usuario — OmniOil SCADA v2.0
|
||||||
|
**Kyrbot Innovations** · Plataforma de Telemetría de Hidrocarburos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de Contenido
|
||||||
|
1. [Panel de Administración](#1-panel-de-administración-appomniioilapp)
|
||||||
|
2. [Aplicación de Campo (PWA)](#2-aplicación-de-campo-pwa-mobileomniioilapp)
|
||||||
|
3. [Reportes ANH](#3-reportes-anh)
|
||||||
|
4. [URLs de Referencia](#4-urls-de-referencia)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Panel de Administración (app.omnioil.app)
|
||||||
|
|
||||||
|
### 1.1 Configuración Inicial (Primera Vez)
|
||||||
|
|
||||||
|
Al desplegar OmniOil por primera vez, el sistema no tiene usuarios. Para inicializar:
|
||||||
|
|
||||||
|
1. Navegar a `https://app.omnioil.app/setup`
|
||||||
|
2. Completar el formulario de superadmin:
|
||||||
|
- **Email**: correo corporativo del administrador principal
|
||||||
|
- **Contraseña**: mínimo 12 caracteres, con mayúsculas, números y símbolos
|
||||||
|
- **Nombre completo**: para el registro de auditoría
|
||||||
|
3. Hacer clic en **"Crear Superadmin"**
|
||||||
|
4. El sistema redirigirá al login. Esta ruta `/setup` queda desactivada automáticamente.
|
||||||
|
|
||||||
|
> ⚠️ La ruta `/setup` solo funciona cuando no existe ningún usuario en el sistema. Si ya existe un superadmin, esta página mostrará un error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Inicio de Sesión
|
||||||
|
|
||||||
|
- **URL**: `https://app.omnioil.app/login`
|
||||||
|
- **Método de autenticación**: JWT (JSON Web Token)
|
||||||
|
- Ingresar email y contraseña registrados
|
||||||
|
- Si el usuario tiene **2FA activado**, el sistema solicitará el código de 6 dígitos de Google Authenticator
|
||||||
|
|
||||||
|
#### Roles del Sistema
|
||||||
|
|
||||||
|
| Rol | Descripción | Permisos |
|
||||||
|
|-----|-------------|----------|
|
||||||
|
| **superadmin** | Administrador de la plataforma | Acceso total, gestión de clientes y usuarios |
|
||||||
|
| **admin** | Administrador de organización | Gestión de proyectos, activos, dispositivos y usuarios de su org |
|
||||||
|
| **supervisor** | Supervisor de operaciones | Ver todos los datos, crear alertas, aprobar movimientos |
|
||||||
|
| **operador** | Operador de campo | Registrar movimientos y mediciones manuales, ver telemetría |
|
||||||
|
| **anh_reader** | Auditor ANH | Solo lectura de reportes y datos históricos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Dashboard Principal
|
||||||
|
|
||||||
|
Al ingresar, el dashboard muestra:
|
||||||
|
|
||||||
|
- **KPIs en tiempo real**: proyectos activos, activos conectados, alarmas activas
|
||||||
|
- **Estado de proyectos**: mapa o lista con indicadores Online/Offline por agente
|
||||||
|
- **Últimas alarmas**: listado de alarmas recientes con severidad y estado
|
||||||
|
- **Gráficas de telemetría**: variables críticas en tiempo real via MQTT/WSS
|
||||||
|
|
||||||
|
> 💡 El indicador de estado de un agente se marca como **Offline** si no recibe un mensaje de heartbeat en los últimos 60 segundos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Gestión de Proyectos
|
||||||
|
|
||||||
|
#### Crear Proyecto
|
||||||
|
1. Ir a **Proyectos → Nuevo Proyecto**
|
||||||
|
2. Completar los campos requeridos:
|
||||||
|
- **Nombre del proyecto**: ej. "Campo Castilla Norte"
|
||||||
|
- **Contrato ANH**: número de contrato oficial (ej. "CONTRATO-CO-2025-001")
|
||||||
|
- **Municipio y Departamento**: ubicación geográfica para reportes ANH
|
||||||
|
- **Coordenadas**: latitud y longitud del campo (para georreferenciación)
|
||||||
|
- **Operador**: nombre de la empresa operadora
|
||||||
|
3. Hacer clic en **"Crear Proyecto"**
|
||||||
|
4. El sistema asignará automáticamente un UUID único y credenciales MQTT para el agente de borde
|
||||||
|
|
||||||
|
#### Ver Detalle de Proyecto
|
||||||
|
- En la lista de proyectos, hacer clic en el nombre del proyecto
|
||||||
|
- Se muestra:
|
||||||
|
- Estado de conexión del agente (Online/Offline)
|
||||||
|
- Último heartbeat recibido
|
||||||
|
- CPU, RAM y versión del agente
|
||||||
|
- Lista de activos y variables activas
|
||||||
|
- Botón **"Descargar Instalador"** (cuando el MSI está listo)
|
||||||
|
|
||||||
|
#### Estado del Instalador
|
||||||
|
| Estado | Descripción |
|
||||||
|
|--------|-------------|
|
||||||
|
| `READY` | MSI listo para descarga |
|
||||||
|
| `BUILDING` | El sistema está compilando el instalador |
|
||||||
|
| `ERROR` | Falló el proceso de build (revisar logs) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 Gestión de Activos (Pozos/Tanques/Medidores)
|
||||||
|
|
||||||
|
#### Crear Activo
|
||||||
|
1. Dentro de un proyecto, ir a **Activos → Nuevo Activo**
|
||||||
|
2. Completar:
|
||||||
|
- **Código del activo**: identificador único (ej. "POZO-01", "TK-001")
|
||||||
|
- **Nombre descriptivo**: ej. "Pozo Inyector Norte"
|
||||||
|
- **Tipo de activo**:
|
||||||
|
- `POZO`: pozo productor o inyector
|
||||||
|
- `TANQUE`: tanque de almacenamiento
|
||||||
|
- `MEDIDOR`: medidor de flujo u otro instrumento
|
||||||
|
- **Fecha de calibración**: última calibración del instrumento
|
||||||
|
- **Próxima calibración**: fecha programada (aparecerá en reportes ANH)
|
||||||
|
- **Certificado de calibración**: URL o referencia al documento
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 Gestión de Dispositivos
|
||||||
|
|
||||||
|
Los dispositivos representan el hardware industrial conectado al activo.
|
||||||
|
|
||||||
|
#### Agregar Dispositivo
|
||||||
|
1. Dentro de un activo, ir a **Dispositivos → Nuevo Dispositivo**
|
||||||
|
2. Seleccionar el protocolo industrial:
|
||||||
|
- **Modbus TCP**: ingrese IP y puerto (default 502), Slave ID
|
||||||
|
- **Siemens S7**: ingrese IP y puerto (default 102), Rack y Slot
|
||||||
|
- **EtherNet/IP (Allen Bradley)**: ingrese IP y puerto (default 44818)
|
||||||
|
- **OPC-UA**: ingrese endpoint URL (ej. `opc.tcp://192.168.1.10:4840`)
|
||||||
|
3. Verificar conectividad desde el panel antes de guardar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.7 Gestión de Variables
|
||||||
|
|
||||||
|
Las variables son los puntos de datos individuales (tags) mapeados desde los dispositivos.
|
||||||
|
|
||||||
|
#### Configurar Variable
|
||||||
|
1. Dentro de un dispositivo, ir a **Variables → Nueva Variable**
|
||||||
|
2. Completar:
|
||||||
|
- **Alias**: nombre descriptivo (ej. "Presión de Cabeza", "Temperatura Línea")
|
||||||
|
- **Tipo de dato**: `FLOAT`, `INT`, `BOOL`, `STRING`
|
||||||
|
- **Registro/Dirección**: dirección del registro en el protocolo (ej. `40001` para Modbus)
|
||||||
|
- **Función Modbus**: `01 Coil`, `02 Discrete Input`, `03 Holding Register`, `04 Input Register`
|
||||||
|
- **Polling ms**: frecuencia de muestreo en milisegundos (mínimo 1000 ms recomendado)
|
||||||
|
- **Factor de escala**: multiplicador para conversión de unidades
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.8 Instalador del Edge Agent
|
||||||
|
|
||||||
|
El Edge Agent es el software que se instala en el equipo de campo para leer los dispositivos industriales.
|
||||||
|
|
||||||
|
#### Generar Instalador
|
||||||
|
1. En el detalle del proyecto, hacer clic en **"Generar Instalador"**
|
||||||
|
2. El sistema disparará el proceso de build (estado `BUILDING`)
|
||||||
|
3. Cuando el estado cambie a `READY`, aparecerá el botón de descarga
|
||||||
|
4. Descargar el archivo `OmniOilEdgeAgent.msi` (único y personalizado para ese proyecto)
|
||||||
|
|
||||||
|
> 🔐 Cada instalador contiene credenciales únicas preconfiguradas. No compartir el MSI entre proyectos diferentes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.9 Gestión de Alertas
|
||||||
|
|
||||||
|
#### Crear Regla de Alerta
|
||||||
|
1. Ir a **Alertas → Nueva Regla**
|
||||||
|
2. Configurar:
|
||||||
|
- **Variable a monitorear**: seleccionar de la lista de variables activas
|
||||||
|
- **Operador**: `>`, `<`, `>=`, `<=`, `==`, `!=`
|
||||||
|
- **Umbral**: valor numérico que activa la alerta
|
||||||
|
- **Severidad**: `INFO`, `WARNING`, `CRITICAL`
|
||||||
|
- **Notificación**: Email y/o Telegram
|
||||||
|
3. Guardar la regla — el sistema monitorea en tiempo real
|
||||||
|
|
||||||
|
#### Ver y Resolver Alarmas
|
||||||
|
- Las alarmas activas aparecen en el dashboard con color según severidad
|
||||||
|
- Para resolver una alarma: hacer clic en **"Resolver"** y agregar una nota explicativa
|
||||||
|
- El registro queda en el audit trail (quién resolvió, cuándo, con qué nota)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.10 Facturación y Suscripción
|
||||||
|
|
||||||
|
El resumen de facturación muestra:
|
||||||
|
- **Proyectos activos**: número de pozos/campos actualmente monitoreados
|
||||||
|
- **Tags activos**: número de variables configuradas
|
||||||
|
- **Plan actual**: Starter / Professional / Enterprise / Corporate
|
||||||
|
- **Costo estimado**: calculado automáticamente según el plan contratado
|
||||||
|
|
||||||
|
> 💰 Tarifa de referencia: desde USD $299/mes (Plan Starter, hasta 3 pozos). Ver `docs/MODELO_NEGOCIO.md` para detalles completos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.11 Auditoría (ISO 27001)
|
||||||
|
|
||||||
|
El módulo de auditoría registra todos los cambios realizados en el sistema:
|
||||||
|
|
||||||
|
- **Ir a**: Ajustes → Auditoría o `/audit`
|
||||||
|
- **Filtrando por**: usuario, fecha, tipo de operación, proyecto
|
||||||
|
- **Cada registro incluye**:
|
||||||
|
- Usuario que realizó la acción
|
||||||
|
- Fecha y hora (UTC)
|
||||||
|
- Dirección IP de origen
|
||||||
|
- Valor anterior y valor nuevo
|
||||||
|
- Tipo de operación (CREATE, UPDATE, DELETE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.12 Configuración de Cuenta
|
||||||
|
|
||||||
|
#### Perfil y Contraseña
|
||||||
|
- Ir a **Perfil → Configuración**
|
||||||
|
- Actualizar nombre, email o contraseña
|
||||||
|
- La contraseña se almacena con Bcrypt (irreversible)
|
||||||
|
|
||||||
|
#### Activar 2FA (Autenticación de Doble Factor)
|
||||||
|
1. Ir a **Perfil → Seguridad → Activar 2FA**
|
||||||
|
2. Escanear el código QR con **Google Authenticator** (o compatible TOTP)
|
||||||
|
3. Ingresar el código de 6 dígitos para confirmar
|
||||||
|
4. Guardar los **códigos de recuperación** en un lugar seguro
|
||||||
|
5. A partir de ese momento, cada login requerirá el código TOTP
|
||||||
|
|
||||||
|
#### Configurar Notificaciones
|
||||||
|
- **Email**: para alertas de producción y reportes diarios
|
||||||
|
- **Telegram**: ingresar chat_id del grupo/canal para notificaciones en tiempo real
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.13 API Explorer y Documentación
|
||||||
|
|
||||||
|
- **API Explorer**: navegar a `/api-explorer` dentro del panel admin para explorar los endpoints disponibles
|
||||||
|
- **Swagger UI**: disponible en `https://api.omnioil.app/docs` — documentación interactiva completa de todos los endpoints REST
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Aplicación de Campo (PWA) (mobile.omnioil.app)
|
||||||
|
|
||||||
|
La PWA (Progressive Web App) es la interfaz para operadores en campo. Funciona en dispositivos móviles con soporte offline.
|
||||||
|
|
||||||
|
### 2.1 Acceso
|
||||||
|
|
||||||
|
- **URL**: `https://mobile.omnioil.app`
|
||||||
|
- Los usuarios de campo son **provisionados por el administrador** — no hay registro público
|
||||||
|
- El admin crea el usuario con rol `operador` desde el panel de administración
|
||||||
|
|
||||||
|
### 2.2 Seleccionar Proyecto y Activo
|
||||||
|
|
||||||
|
1. Al ingresar, el operador ve la lista de proyectos a los que tiene acceso
|
||||||
|
2. Seleccionar el proyecto → seleccionar el activo (pozo o tanque)
|
||||||
|
3. Se muestran las variables actuales y el estado de conexión
|
||||||
|
|
||||||
|
### 2.3 Registrar Medición Manual
|
||||||
|
|
||||||
|
Para registrar una lectura de campo cuando los sensores no están disponibles:
|
||||||
|
|
||||||
|
1. Ir a **Activo → Nueva Medición**
|
||||||
|
2. Seleccionar la variable a medir
|
||||||
|
3. Ingresar el valor y la unidad
|
||||||
|
4. Agregar notas opcionales (condiciones de campo, instrumento usado)
|
||||||
|
5. Confirmar — el sistema registra en `audit_log` quién ingresó el dato
|
||||||
|
|
||||||
|
### 2.4 Registrar Movimiento de Producción
|
||||||
|
|
||||||
|
Los movimientos registran volúmenes para el reporte ANH:
|
||||||
|
|
||||||
|
1. Ir a **Activo → Nuevo Movimiento**
|
||||||
|
2. Seleccionar tipo de movimiento:
|
||||||
|
- **Venta**: volumen vendido a terceros
|
||||||
|
- **Recibo**: volumen recibido (transferencia de otro campo)
|
||||||
|
- **Transferencia**: movimiento interno entre activos
|
||||||
|
- **Quema**: volumen quemado en antorcha
|
||||||
|
- **Consumo**: uso interno del campo (combustible, etc.)
|
||||||
|
3. Ingresar: volumen neto (barriles o m³), fecha/hora de inicio y fin
|
||||||
|
4. Confirmar — queda registrado para el reporte diario ANH
|
||||||
|
|
||||||
|
### 2.5 Indicador Online/Offline
|
||||||
|
|
||||||
|
La PWA muestra un indicador de conectividad:
|
||||||
|
- 🟢 **Online**: sincronización en tiempo real con el servidor
|
||||||
|
- 🟡 **Pendiente**: hay datos en cola esperando sincronización
|
||||||
|
- 🔴 **Offline**: sin conexión — los datos se guardan localmente
|
||||||
|
|
||||||
|
### 2.6 Sincronización Pendiente
|
||||||
|
|
||||||
|
Cuando la PWA recupera conectividad:
|
||||||
|
1. El ícono de sincronización parpadeará indicando datos pendientes
|
||||||
|
2. Tocar **"Sincronizar"** para enviar los registros pendientes al servidor
|
||||||
|
3. Los datos se envían preservando el timestamp original (cumplimiento ANH)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Reportes ANH
|
||||||
|
|
||||||
|
### 3.1 Generación Automática
|
||||||
|
|
||||||
|
El sistema genera automáticamente un reporte JSON diario:
|
||||||
|
- **Frecuencia**: diariamente a las **06:30 AM UTC**
|
||||||
|
- **Cobertura**: datos de las últimas 24 horas (00:00 - 23:59 UTC)
|
||||||
|
- **Proceso**: el microservicio `json-generator` agrega telemetría, movimientos y alarmas
|
||||||
|
|
||||||
|
### 3.2 Formato del Reporte
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"operador": "OPERADORA_EJEMPLO_SAS",
|
||||||
|
"contrato": "CONTRATO-CO-2025-001",
|
||||||
|
"fecha": "2026-05-03",
|
||||||
|
"hash_sha256": "a3f5c8d9...",
|
||||||
|
"generado_en": "2026-05-04T06:30:00Z"
|
||||||
|
},
|
||||||
|
"pozos": [
|
||||||
|
{
|
||||||
|
"id_recurso": "POZO-01",
|
||||||
|
"municipio": "Villavicencio",
|
||||||
|
"departamento": "Meta",
|
||||||
|
"coordenadas": {"lat": 4.1534, "lon": -73.6352},
|
||||||
|
"telemetria": [...],
|
||||||
|
"movimientos": [...],
|
||||||
|
"excepciones": [...]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Nomenclatura de Archivos
|
||||||
|
|
||||||
|
Siguiendo el Artículo 34 de la Resolución 0651:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPERADOR_CONTRATO_DD-MMM-YYYY.json
|
||||||
|
Ejemplo: OPERADORA_EJEMPLO_CONTRATO-CO-2025-001_03-May-2026.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Descargar Reportes
|
||||||
|
|
||||||
|
Los reportes se almacenan en **MinIO** (bucket `anh-reports`):
|
||||||
|
1. Acceder a `https://storage.omnioil.app`
|
||||||
|
2. Credenciales de MinIO proporcionadas por el administrador de la plataforma
|
||||||
|
3. Navegar al bucket `anh-reports` → carpeta del operador
|
||||||
|
4. Los reportes están disponibles en formato `.json` con su hash SHA-256
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. URLs de Referencia
|
||||||
|
|
||||||
|
| URL | Descripción |
|
||||||
|
|-----|-------------|
|
||||||
|
| `https://app.omnioil.app` | Panel de administración |
|
||||||
|
| `https://app.omnioil.app/setup` | Setup inicial (solo primer uso) |
|
||||||
|
| `https://mobile.omnioil.app` | PWA de campo (operadores) |
|
||||||
|
| `https://api.omnioil.app` | API REST Backend |
|
||||||
|
| `https://api.omnioil.app/docs` | Swagger UI — documentación API |
|
||||||
|
| `https://api.omnioil.app/health` | Estado del sistema |
|
||||||
|
| `https://storage.omnioil.app` | MinIO S3 Console (reportes ANH) |
|
||||||
|
| `http://localhost:3003` | Grafana (solo desarrollo local) |
|
||||||
|
| `http://localhost:9090` | Prometheus (solo desarrollo local) |
|
||||||
|
| `mqtt://mqtt.omnioil.app:443` | MQTT sobre WSS (agentes de campo) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*OmniOil SCADA v2.0 — Kyrbot Innovations — Documento actualizado Mayo 2026*
|
||||||
485
docs/MODELO_NEGOCIO.md
Normal file
485
docs/MODELO_NEGOCIO.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# 💰 Estrategia de Monetización y Programa de Pagos — OmniOil SCADA
|
||||||
|
## Diseñada para el Mercado de Hidrocarburos en Colombia
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Documento:** KI-BIZ-PRICING-001
|
||||||
|
**Versión:** 1.0
|
||||||
|
**Fecha:** Mayo 2026
|
||||||
|
**Autor:** Kyrbot Innovations
|
||||||
|
**Clasificación:** Interno — Estrategia Comercial
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexto del Mercado y Fundamentos de Precios
|
||||||
|
|
||||||
|
### 1.1 ¿Por Qué las Operadoras DEBEN Pagar?
|
||||||
|
|
||||||
|
OmniOil no es un software opcional. La **Resolución ANH 0651** obliga a todas las operadoras de hidrocarburos en Colombia a implementar sistemas de telemetría certificados para reporte de producción. Esto crea una dinámica de mercado única:
|
||||||
|
|
||||||
|
> 🔑 **El cliente no compra una herramienta — compra el cumplimiento de una obligación legal cuyo incumplimiento genera multas de hasta 2.000 salarios mínimos mensuales.**
|
||||||
|
|
||||||
|
Esto justifica precios premium frente a software genérico.
|
||||||
|
|
||||||
|
### 1.2 Público Objetivo y Segmentación
|
||||||
|
|
||||||
|
| Segmento | Descripción | # Pozos Típicos | Facturación Mensual Estimada |
|
||||||
|
|----------|-------------|-----------------|------------------------------|
|
||||||
|
| **Micro-Operadora** | Empresas con concesiones pequeñas, frecuentemente en asocio | 1 – 4 pozos | USD $10K – $80K/mes |
|
||||||
|
| **Pequeña Operadora** | Operadoras nacionales con campos en exploración o producción temprana | 5 – 15 pozos | USD $80K – $400K/mes |
|
||||||
|
| **Mediana Operadora** | Operadoras con campos maduros o en crecimiento | 16 – 50 pozos | USD $400K – $2M/mes |
|
||||||
|
| **Gran Operadora** | Multinacionales o nacionales con portafolio amplio | 50+ pozos | USD $2M+/mes |
|
||||||
|
|
||||||
|
### 1.3 El Argumento de Valor (Value Proposition)
|
||||||
|
|
||||||
|
Para justificar el precio ante un CFO de una operadora:
|
||||||
|
|
||||||
|
```
|
||||||
|
COSTO de NO tener OmniOil:
|
||||||
|
├── Multa ANH por incumplimiento: USD $180,000+ por evento
|
||||||
|
├── Personal dedicado a reportes manuales: USD $2,500 – $5,000/mes
|
||||||
|
├── Errores en reportes (reprocesos): USD $1,000 – $3,000/mes
|
||||||
|
├── Pérdidas por falta de telemetría: Variable (puede ser USD $50K+)
|
||||||
|
└── TOTAL RIESGO MENSUAL: USD $10,000 – $50,000/mes mínimo
|
||||||
|
|
||||||
|
COSTO de OmniOil (plan recomendado): USD $800 – $4,000/mes
|
||||||
|
ROI estimado: 10x – 50x
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Unidad de Cobro Principal: El Proyecto / Pozo
|
||||||
|
|
||||||
|
La unidad más natural y justa para cobrar en este mercado es el **proyecto activo** (equivalente a un pozo o campo de producción monitoreado).
|
||||||
|
|
||||||
|
**Razones:**
|
||||||
|
- Cada pozo genera ingresos independientes para la operadora.
|
||||||
|
- Cada pozo requiere un Edge Agent instalado (costo técnico real).
|
||||||
|
- La regulación ANH se aplica por pozo/campo activo.
|
||||||
|
- Es fácil de entender y auditar para el cliente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Modelo de Precios Recomendado: Híbrido por Niveles
|
||||||
|
|
||||||
|
### 3.1 Estructura Base
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TARIFA = Fee Plataforma (fijo) + Fee por Pozo (variable) │
|
||||||
|
│ │
|
||||||
|
│ Ejemplo: Plan Profesional = $500/mes + $150/mes × 8 pozos │
|
||||||
|
│ = $500 + $1,200 = $1,700/mes │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Planes Comerciales
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 🌱 PLAN STARTER
|
||||||
|
**Para micro-operadoras y empresas que inician cumplimiento ANH**
|
||||||
|
|
||||||
|
| Componente | Detalle |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Fee de plataforma** | USD **$299 / mes** |
|
||||||
|
| **Pozos incluidos** | Hasta **3 pozos activos** |
|
||||||
|
| **Pozo adicional** | USD **$120 / pozo / mes** |
|
||||||
|
| **Usuarios** | Hasta **5 usuarios** |
|
||||||
|
| **Retención de datos** | 12 meses |
|
||||||
|
| **Soporte** | Email (respuesta 48h) |
|
||||||
|
| **SLA disponibilidad** | 99.0% |
|
||||||
|
| **Onboarding** | Self-service + documentación |
|
||||||
|
|
||||||
|
**Módulos incluidos:**
|
||||||
|
- ✅ Telemetría en tiempo real (MQTT/WSS)
|
||||||
|
- ✅ Reportes ANH básicos (JSON + SHA-256)
|
||||||
|
- ✅ Dashboard de monitoreo
|
||||||
|
- ✅ Alertas Email/Telegram
|
||||||
|
- ✅ Mediciones de campo (PWA)
|
||||||
|
- ✅ Audit trail básico
|
||||||
|
- ❌ Pruebas de pozo
|
||||||
|
- ❌ Análisis de laboratorio
|
||||||
|
- ❌ Integración ERP
|
||||||
|
|
||||||
|
**Precio típico:** USD $299 – $659 / mes
|
||||||
|
**Target:** Micro-operadoras con 1-3 pozos en producción temprana
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 🏭 PLAN PROFESSIONAL
|
||||||
|
**Para operadoras en crecimiento que necesitan cumplimiento completo ANH**
|
||||||
|
|
||||||
|
| Componente | Detalle |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Fee de plataforma** | USD **$699 / mes** |
|
||||||
|
| **Pozos incluidos** | Hasta **10 pozos activos** |
|
||||||
|
| **Pozo adicional** | USD **$95 / pozo / mes** |
|
||||||
|
| **Usuarios** | Hasta **20 usuarios** |
|
||||||
|
| **Retención de datos** | 36 meses |
|
||||||
|
| **Soporte** | Email + Chat (respuesta 8h) |
|
||||||
|
| **SLA disponibilidad** | 99.5% |
|
||||||
|
| **Onboarding** | Asistido (2 sesiones virtuales) |
|
||||||
|
|
||||||
|
**Módulos incluidos:**
|
||||||
|
- ✅ Todo lo del plan Starter
|
||||||
|
- ✅ Módulo completo de Movimientos (ventas, transferencias, recepciones)
|
||||||
|
- ✅ Pruebas de pozo
|
||||||
|
- ✅ Gestión de paradas (downtime)
|
||||||
|
- ✅ Análisis de laboratorio
|
||||||
|
- ✅ Gestión avanzada de usuarios y roles
|
||||||
|
- ✅ Reportes ANH completos (Res. 0651)
|
||||||
|
- ✅ Exportación de datos (CSV/Excel)
|
||||||
|
- ✅ Multi-factor authentication (2FA)
|
||||||
|
- ❌ API externa (integración ERP/SAP)
|
||||||
|
- ❌ White-label
|
||||||
|
|
||||||
|
**Precio típico:** USD $699 – $2,644 / mes
|
||||||
|
**Target:** Operadoras con 5-20 pozos, cumplimiento regulatorio completo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 🚀 PLAN ENTERPRISE
|
||||||
|
**Para grandes operadoras y multinacionales**
|
||||||
|
|
||||||
|
| Componente | Detalle |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Fee de plataforma** | USD **$1,499 / mes** |
|
||||||
|
| **Pozos incluidos** | Hasta **30 pozos activos** |
|
||||||
|
| **Pozo adicional** | USD **$65 / pozo / mes** |
|
||||||
|
| **Usuarios** | **Ilimitados** |
|
||||||
|
| **Retención de datos** | **Ilimitada** |
|
||||||
|
| **Soporte** | Dedicado (WhatsApp/teléfono, respuesta 2h) |
|
||||||
|
| **SLA disponibilidad** | **99.9%** |
|
||||||
|
| **Onboarding** | In-situ + capacitación al equipo |
|
||||||
|
|
||||||
|
**Módulos incluidos:**
|
||||||
|
- ✅ Todo lo del plan Professional
|
||||||
|
- ✅ API REST completa para integración con ERP/SAP/Quorum
|
||||||
|
- ✅ Webhooks configurables
|
||||||
|
- ✅ White-label (logo e identidad del cliente)
|
||||||
|
- ✅ Entorno de staging dedicado
|
||||||
|
- ✅ Informes personalizados (custom reports)
|
||||||
|
- ✅ Acceso SSH a logs en tiempo real
|
||||||
|
- ✅ Revisión trimestral de seguridad incluida
|
||||||
|
- ✅ Gestor de cuenta dedicado
|
||||||
|
|
||||||
|
**Precio típico:** USD $1,499 – $5,449+ / mes
|
||||||
|
**Target:** Grandes operadoras con 20-50+ pozos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 🏗️ PLAN CORPORATE (Negociación Directa)
|
||||||
|
**Para consorcios, operadoras con 50+ pozos o requerimientos especiales**
|
||||||
|
|
||||||
|
- Precio **100% personalizado** por negociación.
|
||||||
|
- Puede incluir: despliegue en servidores propios (on-premise), SLA personalizado, equipo de soporte dedicado.
|
||||||
|
- Contratos anuales o plurianuales con descuentos escalonados.
|
||||||
|
- Posibilidad de **revenue share** o pago por producción.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Tabla Comparativa de Planes
|
||||||
|
|
||||||
|
| Característica | Starter | Professional | Enterprise | Corporate |
|
||||||
|
|----------------|---------|-------------|------------|-----------|
|
||||||
|
| **Precio base/mes** | $299 | $699 | $1,499 | A negociar |
|
||||||
|
| **Pozos incluidos** | 3 | 10 | 30 | Ilimitados |
|
||||||
|
| **Pozo adicional** | $120 | $95 | $65 | Negociable |
|
||||||
|
| **Usuarios** | 5 | 20 | Ilimitados | Ilimitados |
|
||||||
|
| **Retención datos** | 12 meses | 36 meses | Ilimitada | Ilimitada |
|
||||||
|
| **SLA** | 99.0% | 99.5% | 99.9% | Custom |
|
||||||
|
| **Soporte** | Email | Chat/Email | Dedicado | Dedicado+ |
|
||||||
|
| **Telemetría RT** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Reportes ANH** | Básico | Completo | Completo | Custom |
|
||||||
|
| **Movimientos** | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| **Pruebas de pozo** | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| **Lab/Laboratorio** | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| **API Externa** | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| **White-label** | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| **On-premise** | ❌ | ❌ | Opcional | ✅ |
|
||||||
|
| **2FA** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Add-Ons y Servicios Complementarios
|
||||||
|
|
||||||
|
Los siguientes servicios se cobran por separado y aplican a cualquier plan:
|
||||||
|
|
||||||
|
| Servicio | Precio | Descripción |
|
||||||
|
|---------|--------|-------------|
|
||||||
|
| **Instalación en campo** | USD $350 – $800 / sitio | Instalación física del Edge Agent + configuración de sensores Modbus por un técnico |
|
||||||
|
| **Capacitación in-situ** | USD $800 / día + viáticos | Entrenamiento presencial para operadores y supervisores |
|
||||||
|
| **Migración de datos históricos** | USD $500 – $2,000 | Importación de datos históricos desde hojas de cálculo o sistemas legados |
|
||||||
|
| **Integración ERP** | USD $2,000 – $8,000 (único) | Integración con SAP, Oracle, Dynamics, Quorum o sistemas a medida |
|
||||||
|
| **Certificación ANH asistida** | USD $1,500 (único) | Acompañamiento en el proceso de certificación ante la ANH |
|
||||||
|
| **Reporte de cumplimiento ISO 27001** | USD $500 / año | Informe actualizado de cumplimiento para auditorías del cliente |
|
||||||
|
| **Backups gestionados adicionales** | USD $50 – $200 / mes | Retención extendida con backups diarios en región adicional |
|
||||||
|
| **IP fija dedicada** | USD $50 / mes | IP dedicada para integración con SCADA on-premise del cliente |
|
||||||
|
| **Horas de consultoría** | USD $80 – $120 / hora | Soporte técnico fuera del alcance del plan contratado |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Estructura de Facturación y Condiciones
|
||||||
|
|
||||||
|
### 5.1 Ciclos de Pago
|
||||||
|
|
||||||
|
| Ciclo | Descuento | Recomendado para |
|
||||||
|
|-------|-----------|-----------------|
|
||||||
|
| **Mensual** | Sin descuento | Empresas en evaluación / primer contrato |
|
||||||
|
| **Semestral** (6 meses) | **8% de descuento** | Clientes con certeza de operación continua |
|
||||||
|
| **Anual** (12 meses) | **15% de descuento** | Clientes estables; protege contra alzas de precio |
|
||||||
|
| **Bianual** (24 meses) | **22% de descuento** | Grandes operadoras con operaciones de largo plazo |
|
||||||
|
|
||||||
|
### 5.2 Moneda y Modalidades de Pago
|
||||||
|
|
||||||
|
**Moneda recomendada:** USD (dólares americanos)
|
||||||
|
*Razón: La industria petrolera en Colombia factura y presupuesta en USD. Usar COP expone a OmniOil a devaluación y genera fricción en presupuestos de los clientes.*
|
||||||
|
|
||||||
|
| Modalidad | Disponibilidad | Observación |
|
||||||
|
|-----------|---------------|-------------|
|
||||||
|
| Transferencia bancaria USD | ✅ Todos los planes | Factura electrónica DIAN |
|
||||||
|
| Tarjeta de crédito | ✅ Starter, Professional | Via Stripe / Wompi |
|
||||||
|
| Transferencia COP (TRM oficial) | ✅ Solo clientes nacionales pequeños | Con ajuste trimestral por TRM |
|
||||||
|
| Orden de compra corporativa (OC) | ✅ Enterprise, Corporate | Requiere aprobación previa |
|
||||||
|
| Pago contra contrato (hitos) | ✅ Corporate | Para contratos multi-año con implementación |
|
||||||
|
|
||||||
|
### 5.3 Condiciones Contractuales Clave
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Periodo de prueba: 14 días gratuitos en plan Professional (sin tarjeta requerida)
|
||||||
|
✅ Sin permanencia obligatoria en planes mensuales
|
||||||
|
✅ Aviso de cancelación: 30 días de anticipación
|
||||||
|
✅ Portabilidad de datos: Exportación completa garantizada al cancelar
|
||||||
|
✅ Datos eliminados: 90 días después de cancelación (con confirmación escrita)
|
||||||
|
✅ Ajuste de precio anual: máximo CPI + 3% para contratos anuales
|
||||||
|
✅ Créditos por indisponibilidad: Aplican si SLA no se cumple (crédito proporcional)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Estrategia de Entrada al Mercado
|
||||||
|
|
||||||
|
### 6.1 Precio de Lanzamiento (Primeros 6 Meses)
|
||||||
|
|
||||||
|
Para capturar los primeros clientes referenciales y obtener casos de éxito:
|
||||||
|
|
||||||
|
| Plan | Precio Normal | Precio Lanzamiento | Descuento |
|
||||||
|
|------|--------------|-------------------|-----------|
|
||||||
|
| Starter | $299/mes | **$149/mes** | 50% |
|
||||||
|
| Professional | $699/mes | **$399/mes** | 43% |
|
||||||
|
| Enterprise | $1,499/mes | **$999/mes** | 33% |
|
||||||
|
|
||||||
|
**Condición:** Clientes de lanzamiento firman contrato de 12 meses y aceptan ser caso de estudio (sin revelar datos operativos).
|
||||||
|
|
||||||
|
### 6.2 Estrategia de Upsell (Expansión de Ingresos)
|
||||||
|
|
||||||
|
```
|
||||||
|
JOURNEY DEL CLIENTE:
|
||||||
|
|
||||||
|
Mes 1-3: Onboarding Starter
|
||||||
|
→ Telemetría básica funcionando
|
||||||
|
→ Primer reporte ANH exitoso
|
||||||
|
→ NPS positivo
|
||||||
|
|
||||||
|
Mes 4-6: Trigger de Upsell
|
||||||
|
→ "Tiene 2 pozos más que no están en el sistema"
|
||||||
|
→ Oferta: Upgrade a Professional con 30 días gratis
|
||||||
|
|
||||||
|
Mes 6-12: Expansión Natural
|
||||||
|
→ Módulos de movimientos y laboratorio
|
||||||
|
→ Más pozos activos
|
||||||
|
→ Solicitan integración con ERP → Add-on
|
||||||
|
|
||||||
|
Año 2+: Retención y Expansión
|
||||||
|
→ Contrato anual con descuento
|
||||||
|
→ Referidos a otras operadoras del sector
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Canal de Ventas
|
||||||
|
|
||||||
|
| Canal | Comisión | Perfil |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| **Venta directa (Kyrbot)** | N/A | Clientes grandes, Enterprise, Corporate |
|
||||||
|
| **Consultores ANH** | 15-20% primer año | Consultores que asesoran cumplimiento regulatorio |
|
||||||
|
| **Integradores de sistemas** | 20-25% primer año | Empresas que instalan SCADA en campo |
|
||||||
|
| **Distribuidores regionales** | 25-30% primer año | Para llegar a regiones petroleras (Llanos, Costa) |
|
||||||
|
| **Referidos (clientes)** | 1 mes gratis | Clientes que refieren otras operadoras |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Proyección de Ingresos (Escenario Conservador)
|
||||||
|
|
||||||
|
### Año 1 — Fase de Validación
|
||||||
|
|
||||||
|
| Mes | Clientes | ARR estimado | Ingresos Mes |
|
||||||
|
|-----|---------|-------------|--------------|
|
||||||
|
| 1-2 | 2 (piloto) | $9,576 | $798 |
|
||||||
|
| 3-4 | 5 | $29,940 | $2,495 |
|
||||||
|
| 6 | 10 | $59,880 | $4,990 |
|
||||||
|
| 9 | 18 | $107,784 | $8,982 |
|
||||||
|
| 12 | 25 | $149,700 | $12,475 |
|
||||||
|
|
||||||
|
*Supuesto: Mix 60% Professional, 30% Starter, 10% Enterprise. Promedio ~$500/mes/cliente.*
|
||||||
|
|
||||||
|
### Año 2 — Crecimiento
|
||||||
|
|
||||||
|
| Métrica | Valor |
|
||||||
|
|---------|-------|
|
||||||
|
| Clientes activos | 60 – 80 |
|
||||||
|
| Churn mensual estimado | < 2% (alta fidelización por cumplimiento regulatorio) |
|
||||||
|
| MRR objetivo | USD $35,000 – $50,000 |
|
||||||
|
| ARR objetivo | USD $420,000 – $600,000 |
|
||||||
|
| LTV promedio por cliente | USD $15,000 – $30,000 |
|
||||||
|
|
||||||
|
### Año 3 — Consolidación
|
||||||
|
|
||||||
|
| Métrica | Valor |
|
||||||
|
|---------|-------|
|
||||||
|
| Clientes activos | 150 – 200 |
|
||||||
|
| MRR objetivo | USD $90,000 – $130,000 |
|
||||||
|
| ARR objetivo | USD $1.1M – $1.6M |
|
||||||
|
| Meta Enterprise | 5 – 10 clientes (40% del revenue) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Benchmarking de Precios del Sector
|
||||||
|
|
||||||
|
Referencia frente a soluciones competidoras o alternativas en el mercado:
|
||||||
|
|
||||||
|
| Solución | Modelo | Precio Estimado | Observación |
|
||||||
|
|---------|--------|-----------------|-------------|
|
||||||
|
| **AVEVA SCADA** | Licencia perpetua + mantenimiento | USD $15,000 – $80,000 inicial | Sin módulo ANH nativo |
|
||||||
|
| **Ignition (Inductive Automation)** | Licencia perpetua | USD $3,000+ inicial | Requiere integración custom ANH |
|
||||||
|
| **OSIsoft PI** | Licencia + servidor | USD $25,000 – $150,000 | Overkill para operadoras pequeñas |
|
||||||
|
| **Desarrollo a medida** | Proyecto único | USD $50,000 – $200,000 | Sin SaaS, sin actualizaciones |
|
||||||
|
| **Reportes manuales** | Personal dedicado | USD $2,500 – $5,000/mes | Alto riesgo de error y multa |
|
||||||
|
| **OmniOil** | SaaS / Suscripción | **USD $299 – $5,000/mes** | ✅ ANH nativo, SaaS moderno |
|
||||||
|
|
||||||
|
**Ventaja competitiva de precio:** OmniOil es 60-80% más económico que soluciones legacy y es el único con cumplimiento ANH nativo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Política de Descuentos y Excepciones
|
||||||
|
|
||||||
|
```
|
||||||
|
DESCUENTOS AUTORIZADOS (sin aprobación adicional):
|
||||||
|
├── Pago anual: -15%
|
||||||
|
├── Pago bianual: -22%
|
||||||
|
├── 3+ pozos adicionales: -5% en fee de pozos adicionales
|
||||||
|
└── Cliente referido: 1 mes gratis para cada parte
|
||||||
|
|
||||||
|
DESCUENTOS ESPECIALES (requieren aprobación de dirección):
|
||||||
|
├── Entidad pública/estatal (Ecopetrol, etc.): Hasta -25%
|
||||||
|
├── Startup o asocio exploratorio: Hasta -30% (máx. 6 meses)
|
||||||
|
├── Cliente ancla (caso de éxito público): Hasta -40% (12 meses)
|
||||||
|
└── ONG o proyecto social energético: Evaluar caso por caso
|
||||||
|
|
||||||
|
NUNCA hacer:
|
||||||
|
├── ❌ Dar acceso gratuito indefinido
|
||||||
|
├── ❌ Descuentos > 50% sin contrato firmado y aprobación de CEO
|
||||||
|
└── ❌ Precios en COP sin cláusula de ajuste por TRM
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Resumen de Implementación del Módulo de Pagos
|
||||||
|
|
||||||
|
Para implementar la facturación en el sistema OmniOil, se recomienda:
|
||||||
|
|
||||||
|
### 10.1 Stack Tecnológico de Billing
|
||||||
|
|
||||||
|
| Componente | Herramienta Recomendada | Razón |
|
||||||
|
|-----------|------------------------|-------|
|
||||||
|
| Procesador de pagos | **Stripe** | API robusta, soporte Latinoamérica, multi-currency |
|
||||||
|
| Facturación recurrente | **Stripe Billing** | Suscripciones, upgrades/downgrades automáticos |
|
||||||
|
| Factura electrónica DIAN | **Alegra o Siigo** | Obligatorio para facturación en Colombia |
|
||||||
|
| Portal de cliente | **Stripe Customer Portal** | Self-service para upgrades, historial de pagos |
|
||||||
|
| Contabilidad | **Integración Stripe → Alegra** | Automatización contable |
|
||||||
|
|
||||||
|
### 10.2 Lógica de Billing en OmniOil
|
||||||
|
|
||||||
|
```
|
||||||
|
EVENTO ACCIÓN DE BILLING
|
||||||
|
──────────────────────────────────────────────────
|
||||||
|
Cliente crea proyecto (pozo) → Incrementar contador de pozos activos
|
||||||
|
Cliente elimina proyecto → Decrementar contador (efectivo siguiente mes)
|
||||||
|
Cliente cambia de plan → Prorate automático del día de cambio
|
||||||
|
Pozo inactivo > 30 días → Notificación automática de revisión de factura
|
||||||
|
Pago fallido → Recordatorio D+3, D+7; suspensión D+15
|
||||||
|
Contrato anual vence → Notificación 60 y 30 días antes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Nuevas Tablas de BD para Billing
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Plan de suscripción
|
||||||
|
CREATE TABLE subscription_plans (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(50), -- 'starter', 'professional', 'enterprise'
|
||||||
|
base_price_usd DECIMAL(10,2),
|
||||||
|
included_projects INT,
|
||||||
|
extra_project_price_usd DECIMAL(10,2),
|
||||||
|
max_users INT,
|
||||||
|
data_retention_months INT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Suscripción activa del cliente
|
||||||
|
CREATE TABLE customer_subscriptions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
customer_id UUID REFERENCES customers(id),
|
||||||
|
plan_id UUID REFERENCES subscription_plans(id),
|
||||||
|
stripe_subscription_id VARCHAR(100),
|
||||||
|
status VARCHAR(20), -- 'active', 'past_due', 'canceled'
|
||||||
|
billing_cycle VARCHAR(10), -- 'monthly', 'annual'
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
current_period_end TIMESTAMPTZ,
|
||||||
|
discount_pct DECIMAL(5,2) DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Historial de facturas
|
||||||
|
CREATE TABLE invoices (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
customer_id UUID,
|
||||||
|
stripe_invoice_id VARCHAR(100),
|
||||||
|
amount_usd DECIMAL(10,2),
|
||||||
|
status VARCHAR(20),
|
||||||
|
issued_at TIMESTAMPTZ,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
dian_invoice_number VARCHAR(50)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Recomendación Final
|
||||||
|
|
||||||
|
### La Estrategia Óptima para OmniOil es:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. ARRANCAR con precio de lanzamiento (50% descuento) │
|
||||||
|
│ → Capturar 5-10 clientes referenciales en 6 meses │
|
||||||
|
│ │
|
||||||
|
│ 2. ANCLAR el precio al número de pozos activos │
|
||||||
|
│ → Crecimiento natural con el cliente sin fricción │
|
||||||
|
│ │
|
||||||
|
│ 3. IMPLEMENTAR Stripe Billing para automatización total │
|
||||||
|
│ → Sin esfuerzo manual en cobros = equipo enfocado en │
|
||||||
|
│ producto │
|
||||||
|
│ │
|
||||||
|
│ 4. CONSTRUIR canal de consultores ANH │
|
||||||
|
│ → Ellos venden por nosotros; comisión del 20% │
|
||||||
|
│ │
|
||||||
|
│ 5. APUNTAR a 1 cliente Enterprise en los primeros 12 meses │
|
||||||
|
│ → 1 cliente Enterprise = ingresos de 5 Starter │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**El diferencial clave de OmniOil en precio:** Es la única solución SaaS moderna con cumplimiento ANH nativo, que no requiere servidores propios, ni consultores de implementación de 6 meses, ni licencias perpetuas de $80,000. En ese contexto, **$700/mes es extremadamente competitivo** y hay espacio para crecer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*KI-BIZ-PRICING-001 v1.0 — Kyrbot Innovations — Documento Confidencial de Estrategia Comercial*
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
# PRD: Estabilizacion y Preparacion para Produccion — OmniOil
|
|
||||||
|
|
||||||
**Version:** 1.0
|
|
||||||
**Fecha:** 2026-04-07
|
|
||||||
**Autor:** Kyrbot Innovations
|
|
||||||
**Estado:** Borrador
|
|
||||||
**Proyecto:** OmniOil — SaaS de captura de datos de produccion de hidrocarburos (Resolucion ANH 0651)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Resumen Ejecutivo
|
|
||||||
|
|
||||||
OmniOil es un SaaS multi-tenant para captura de datos de produccion de hidrocarburos, requerido por la Resolucion 0651 de la ANH (Colombia). El sistema esta compuesto por 12 servicios (backend Rust/Axum, frontends React+Vite, MQTT, TimescaleDB, MinIO, Redis) con arquitectura de edge computing para campos petroleros.
|
|
||||||
|
|
||||||
Una auditoria tecnica completa revelo que el sistema no esta listo para produccion. Se identificaron **6 hallazgos criticos**, **4 de alta prioridad** y multiples items de deuda tecnica. Los mas graves: la API esta completamente abierta (sin validacion JWT en ningun endpoint), el modulo de movimientos no tiene backend, hay inconsistencias de schema entre `init.sql` y las migraciones sqlx, y el multi-tenant no se aplica a nivel de handlers.
|
|
||||||
|
|
||||||
**Impacto de negocio:** Sin estas correcciones, OmniOil no puede desplegarse en ningun cliente. La falta de autenticacion real implica que cualquier persona con acceso al dominio puede leer y modificar datos de produccion de todos los tenants. Los modulos faltantes de la Resolucion 0651 (paradas, pruebas de pozo, laboratorio, medidores) impiden el cumplimiento regulatorio.
|
|
||||||
|
|
||||||
Este PRD define un plan de estabilizacion en 4 fases, desde seguridad critica hasta calidad y mantenibilidad, con requisitos especificos, criterios de aceptacion y archivos afectados.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Contexto y Problema
|
|
||||||
|
|
||||||
### 2.1 Estado Actual
|
|
||||||
|
|
||||||
El sistema tiene funcionalidad parcial en los modulos base (auth, proyectos, activos, tanques, telemetria, dashboard) pero presenta fallas estructurales que bloquean cualquier despliegue productivo.
|
|
||||||
|
|
||||||
### 2.2 Hallazgos Criticos de la Auditoria
|
|
||||||
|
|
||||||
| # | Hallazgo | Severidad | Detalle |
|
|
||||||
|---|----------|-----------|---------|
|
|
||||||
| 1 | API completamente abierta | CRITICO | `verify_jwt()` existe en `services/backend-api/src/auth.rs:72-80` con `#[allow(dead_code)]`. Ningun handler valida JWT. Todos los endpoints son accesibles sin autenticacion. |
|
|
||||||
| 2 | Movimientos sin backend | CRITICO | PWA tiene UI completa (`services/frontend-pwa/src/features/field/movements/`) y llama a `/api/movements` pero ese handler no existe en `services/backend-api/src/handlers/`. Datos quedan en Dexie localmente. |
|
|
||||||
| 3 | Field name mismatch | CRITICO | Backend `EdgeAsset` (`shared-lib/src/models.rs:35-36`) retorna `code`/`name`. PWA (`features/field/measurements/api/assets-api.ts:9-10`) espera `asset_code`/`asset_name`. UI muestra `undefined`. |
|
|
||||||
| 4 | Multi-tenant sin enforcement | CRITICO | `user_project_access` existe en el schema pero los handlers (e.g. `edge_assets.rs:26-34`) hacen `SELECT * FROM edge_assets WHERE project_id = $1` sin filtrar por usuario autenticado. |
|
|
||||||
| 5 | Schema dual inconsistente | CRITICO | `infrastructure/postgres/init.sql` define `operations_movements` (linea 166), `operations_downtime` (linea 179), `lab_results` (linea 191) — ninguna existe en `services/backend-api/migrations/20260406000000_initial_schema.sql`. |
|
|
||||||
| 6 | Seguridad deplorable | CRITICO | JWT_SECRET: `devsecretkeysomnioil` (docker-compose.yml:71), CORS permissive (main.rs:173), MQTT 1883 expuesto (docker-compose.yml:179), credenciales por defecto (DB: `admin123`, Redis: `redisoilsecret456`, MinIO: `minio123`). |
|
|
||||||
|
|
||||||
### 2.3 Hallazgos de Alta Prioridad
|
|
||||||
|
|
||||||
| # | Hallazgo | Severidad |
|
|
||||||
|---|----------|-----------|
|
|
||||||
| 7 | Sync worker no funcional — `use-sync-worker.ts` no integrado en layout/router; `addToQueue` ignora ID pasado | ALTO |
|
|
||||||
| 8 | ~70% frontend duplicado entre admin y PWA (auth-store, client, db, cn, UI components) | ALTO |
|
|
||||||
| 9 | Sin tests — solo 1 test en `overlay.rs`; sin tests de handlers, frontend, ni CI/CD | ALTO |
|
|
||||||
| 10 | Modulos ANH faltantes — paradas, pruebas, laboratorio, medidores, visualizacion reportes, gestion usuarios, validacion datos | ALTO |
|
|
||||||
|
|
||||||
### 2.4 Completitud de Modulos vs. ANH 0651
|
|
||||||
|
|
||||||
| Modulo | Backend | Admin | PWA | Estado |
|
|
||||||
|--------|---------|-------|-----|--------|
|
|
||||||
| Auth | Si | Si | Si | Funciona pero sin validacion JWT |
|
|
||||||
| Projects CRUD | Si | Si | Si | Completo |
|
|
||||||
| Assets CRUD | Si | Parcial | Parcial | Falta UI creacion en PWA |
|
|
||||||
| Tanques (inventario + telemetria) | Si | Si | Si | Funcional |
|
|
||||||
| Mediciones de campo | Si | No | Si | Solo PWA |
|
|
||||||
| Movimientos | No | No | Si (UI) | Frontend sin backend |
|
|
||||||
| Paradas de pozo | No | No | No | No implementado |
|
|
||||||
| Pruebas de pozo | No | No | No | No implementado |
|
|
||||||
| Analisis de laboratorio | No | No | No | No implementado |
|
|
||||||
| Medidores (gas/liquido) | No | No | No | No implementado |
|
|
||||||
| Reporte ANH (generacion) | Si | No | No | Backend only |
|
|
||||||
| Reporte ANH (visualizacion) | No | No | No | No implementado |
|
|
||||||
| Dashboard | Si | Si | Si | Funcional |
|
|
||||||
| Alertas/Alarmas | Modelos | No | No | Solo modelos |
|
|
||||||
| Validacion de datos | No | No | No | No implementado |
|
|
||||||
| Edge Agent deploy | Si | Si | No | Solo admin |
|
|
||||||
| Gestion de usuarios | No | No | No | No implementado |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Objetivos
|
|
||||||
|
|
||||||
| ID | Objetivo | Metrica |
|
|
||||||
|----|----------|---------|
|
|
||||||
| OBJ-1 | Cerrar todas las vulnerabilidades de seguridad criticas | 0 endpoints accesibles sin JWT valido (excepto login/register/health) |
|
|
||||||
| OBJ-2 | Lograr consistencia completa entre schema de BD y migraciones | 1 unica fuente de verdad: migraciones sqlx |
|
|
||||||
| OBJ-3 | Completar todos los modulos requeridos por la Resolucion ANH 0651 | 100% de modulos con backend + al menos 1 frontend funcional |
|
|
||||||
| OBJ-4 | Implementar multi-tenancy real a nivel de handler | 100% de queries filtradas por user_project_access |
|
|
||||||
| OBJ-5 | Reducir duplicacion frontend a <15% | Paquete compartido con auth, client, db, UI base |
|
|
||||||
| OBJ-6 | Establecer infraestructura de testing y CI/CD | >80% cobertura en handlers, pipeline funcional |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Alcance
|
|
||||||
|
|
||||||
### 4.1 En Alcance
|
|
||||||
|
|
||||||
- Correccion de todas las vulnerabilidades de seguridad identificadas
|
|
||||||
- Unificacion de schema (migraciones como fuente unica)
|
|
||||||
- Implementacion de backend para modulos faltantes (movimientos, paradas, pruebas, laboratorio, medidores)
|
|
||||||
- Multi-tenant enforcement en todos los handlers
|
|
||||||
- Correccion del sync worker y bug de UUID
|
|
||||||
- Correccion del field name mismatch (code/name vs asset_code/asset_name)
|
|
||||||
- UI para reportes ANH y gestion de usuarios
|
|
||||||
- Validacion de datos operativos
|
|
||||||
- Frontend deduplication (shared package)
|
|
||||||
- Tests de handlers, tests de frontend, CI/CD basico
|
|
||||||
- Cleanup de deuda tecnica identificada
|
|
||||||
|
|
||||||
### 4.2 Fuera de Alcance
|
|
||||||
|
|
||||||
- Redesign de UI/UX (solo correcciones funcionales)
|
|
||||||
- Migracion a otro proveedor cloud (se mantiene Azure)
|
|
||||||
- Implementacion de IoT avanzado (SCADA, OPC UA en tiempo real)
|
|
||||||
- Aplicacion movil nativa
|
|
||||||
- Modulo de facturacion/cobro del SaaS
|
|
||||||
- Integraciones con sistemas ERP de clientes
|
|
||||||
- Internacionalizacion (el sistema opera en espanol)
|
|
||||||
- Alta disponibilidad / redundancia geografica
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Fases de Implementacion
|
|
||||||
|
|
||||||
### Fase 1: Seguridad Critica
|
|
||||||
|
|
||||||
**Objetivo:** Cerrar todos los vectores de ataque que bloquean el despliegue.
|
|
||||||
**Duracion estimada:** 2-3 semanas
|
|
||||||
**Criterio de entrada:** Acceso al repositorio y entorno de desarrollo
|
|
||||||
**Criterio de salida:** Ningun endpoint (excepto login, register, health) accesible sin JWT valido; secrets externalizados; CORS restrictivo; MQTT sobre TLS
|
|
||||||
|
|
||||||
### Fase 2: Integridad de Datos
|
|
||||||
|
|
||||||
**Objetivo:** Garantizar que los datos fluyen correctamente end-to-end y que el multi-tenant es real.
|
|
||||||
**Duracion estimada:** 3-4 semanas
|
|
||||||
**Criterio de entrada:** Fase 1 completada (auth middleware funcional)
|
|
||||||
**Criterio de salida:** Schema unificado, movimientos funcionales, sync worker operativo, field names corregidos, multi-tenant enforced
|
|
||||||
|
|
||||||
### Fase 3: Modulos ANH Faltantes
|
|
||||||
|
|
||||||
**Objetivo:** Implementar los modulos requeridos por la Resolucion 0651 que no existen.
|
|
||||||
**Duracion estimada:** 6-8 semanas
|
|
||||||
**Criterio de entrada:** Fase 2 completada (schema estable, multi-tenant funcional)
|
|
||||||
**Criterio de salida:** Todos los modulos ANH con backend + frontend funcional; validacion de datos; gestion de usuarios
|
|
||||||
|
|
||||||
### Fase 4: Calidad y Mantenibilidad
|
|
||||||
|
|
||||||
**Objetivo:** Reducir deuda tecnica, establecer testing y CI/CD.
|
|
||||||
**Duracion estimada:** 3-4 semanas
|
|
||||||
**Criterio de entrada:** Fases 1-3 completadas
|
|
||||||
**Criterio de salida:** Paquete frontend compartido, >80% cobertura en handlers, pipeline CI/CD funcional
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Requisitos por Fase
|
|
||||||
|
|
||||||
### 6.1 Fase 1: Seguridad Critica
|
|
||||||
|
|
||||||
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|
|
||||||
|----|-------------|------------------------|---------------------|-----------|-------------|
|
|
||||||
| REQ-F1-001 | Implementar middleware de autenticacion JWT como extractor de Axum que valide token en header `Authorization: Bearer <token>` y extraiga Claims (sub, role, exp) | Todo request a endpoints protegidos sin JWT valido retorna 401. Claims disponibles en handler via extractor `AuthUser`. | `services/backend-api/src/auth.rs` (remover `#[allow(dead_code)]`), nuevo `services/backend-api/src/middleware/auth.rs` | P0 | M |
|
|
||||||
| REQ-F1-002 | Aplicar middleware de auth a TODOS los endpoints excepto: `POST /api/auth/login`, `POST /api/auth/register`, `GET /` (health) | Test: request a `/api/edge/projects` sin token retorna 401; con token valido retorna 200 | `services/backend-api/src/main.rs` (lineas 184-210, reestructurar Router con layers) | P0 | M |
|
|
||||||
| REQ-F1-003 | Incluir `project_id` en Claims JWT y validarlo en handlers que operan sobre un proyecto | Login retorna token con `project_ids: Vec<Uuid>` basado en `user_project_access`. Handlers validan que el project_id del request esta en los claims. | `services/backend-api/src/auth.rs` (Claims struct), `services/backend-api/src/handlers/auth.rs` (login, lineas 87-102) | P0 | L |
|
|
||||||
| REQ-F1-004 | Externalizar todos los secrets: JWT_SECRET, DB credentials, MQTT credentials, Redis password, MinIO credentials. Eliminar defaults hardcodeados. | `docker-compose.yml` no contiene ningun valor por defecto para secrets. Se usa `.env.example` como referencia sin valores reales. Aplicacion falla al iniciar si falta JWT_SECRET. | `docker-compose.yml` (lineas 15-19, 70-76, 34, 50-51), nuevo `.env.example` | P0 | S |
|
|
||||||
| REQ-F1-005 | Restringir CORS a dominios especificos: `app.omnioil.app`, `mobile.omnioil.app`, `localhost:*` en dev | CORS rechaza requests desde origenes no listados. Header `Access-Control-Allow-Origin` no es `*`. | `services/backend-api/src/main.rs:173` (reemplazar `CorsLayer::permissive()`) | P0 | S |
|
|
||||||
| REQ-F1-006 | Configurar MQTT sobre WSS (puerto 443/8883). Deshabilitar listener 1883 en produccion. | Puerto 1883 no expuesto en docker-compose de produccion. Mosquitto acepta conexiones WSS. Edge agents conectan por WSS. | `docker-compose.yml:179-180`, `infrastructure/mosquitto/mosquitto.conf`, edge-agent config | P0 | L |
|
|
||||||
| REQ-F1-007 | Aplicar rate limiting solo a endpoints de auth (`/api/auth/*`), no globalmente | Rate limit (5 req/30s por IP) aplica a login y register. Endpoints de datos no tienen rate limit global. | `services/backend-api/src/main.rs:176-190` (mover GovernorLayer a sub-router de auth) | P1 | S |
|
|
||||||
| REQ-F1-008 | Deshabilitar endpoint de registro publico. Registro solo por invitacion de admin. | `POST /api/auth/register` retorna 403 sin token de admin. Admin puede crear usuarios via endpoint protegido. | `services/backend-api/src/handlers/auth.rs:120-182`, `services/backend-api/src/main.rs:187` | P1 | M |
|
|
||||||
| REQ-F1-009 | No exponer errores de base de datos al cliente. Mapear todos los errores DB a mensajes genericos. | Responses de error no contienen nombres de tablas, columnas, ni detalles SQL. Solo mensajes genericos con correlation ID. | `services/backend-api/src/handlers/edge_assets.rs` (todos los `.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))`) — patron repetido en todos los handlers | P1 | M |
|
|
||||||
| REQ-F1-010 | Implementar refresh tokens. Access token con TTL corto (15 min), refresh token con TTL largo (7 dias). | Tabla `refresh_tokens` (ya existe en init.sql) utilizada. Login retorna access + refresh. Endpoint `POST /api/auth/refresh`. | `services/backend-api/src/auth.rs`, nuevo handler `refresh`, migracion para tabla `refresh_tokens` | P1 | M |
|
|
||||||
|
|
||||||
### 6.2 Fase 2: Integridad de Datos
|
|
||||||
|
|
||||||
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|
|
||||||
|----|-------------|------------------------|---------------------|-----------|-------------|
|
|
||||||
| REQ-F2-001 | Unificar schema: migrar tablas de `init.sql` que no estan en migraciones sqlx (`operations_movements`, `operations_downtime`, `lab_results`) a nuevas migraciones | Todas las tablas definidas exclusivamente en migraciones sqlx. `init.sql` se usa solo para seed de datos iniciales (roles, usuario admin). `sqlx migrate run` crea todas las tablas necesarias. | Nueva migracion `services/backend-api/migrations/2026XXXX_add_operations_tables.sql`, `infrastructure/postgres/init.sql` (reducir a seed only) | P0 | M |
|
|
||||||
| REQ-F2-002 | Corregir field name mismatch: Backend retorna `code`/`name`, PWA espera `asset_code`/`asset_name` | Ambos frontends y backend usan los mismos nombres de campo. API contract documentado. UI no muestra `undefined`. | Opcion A (preferida): agregar `#[serde(rename)]` en `shared-lib/src/models.rs:35-36` para serializar como `asset_code`/`asset_name`. Opcion B: actualizar frontends. | P0 | S |
|
|
||||||
| REQ-F2-003 | Implementar handler backend para movimientos (`/api/movements`) con CRUD completo | `POST /api/movements` crea movimiento en `operations_movements`. `GET /api/movements?asset_id=X` lista movimientos por activo. PWA puede crear y listar movimientos. | Nuevo `services/backend-api/src/handlers/movements.rs`, actualizar `services/backend-api/src/handlers/mod.rs`, actualizar `services/backend-api/src/main.rs` (agregar rutas) | P0 | M |
|
|
||||||
| REQ-F2-004 | Enforcement multi-tenant: todos los handlers deben filtrar por proyectos del usuario autenticado | Queries incluyen `JOIN user_project_access upa ON ... WHERE upa.user_id = $auth_user_id`. Un usuario no puede ver/modificar datos de proyectos a los que no tiene acceso. | Todos los handlers en `services/backend-api/src/handlers/`: `edge_projects.rs`, `edge_assets.rs`, `telemetry.rs`, `dashboard.rs`, y futuros handlers | P0 | L |
|
|
||||||
| REQ-F2-005 | Integrar sync worker en el layout de PWA para que se ejecute automaticamente | `useSyncWorker()` se invoca en el layout principal. Items `pending_sync` se procesan cuando hay conexion. | `services/frontend-pwa/src/app/layouts/` (o equivalente), `services/frontend-pwa/src/lib/sync/use-sync-worker.ts` | P0 | S |
|
|
||||||
| REQ-F2-006 | Corregir bug de UUID en `addToQueue`: debe usar ID proporcionado si existe, no generar nuevo siempre | Si se pasa un `id` en el entry, se usa ese ID. Si no, se genera con `crypto.randomUUID()`. Test: crear item con ID conocido, verificar que se almacena con ese ID. | `services/frontend-pwa/src/lib/sync/sync-store.ts:34-35` (cambiar `Pick` para incluir `id` opcional, usar `entry.id ?? crypto.randomUUID()`) | P0 | S |
|
|
||||||
| REQ-F2-007 | Agregar columnas faltantes en migracion sqlx: `alarm_min`, `alarm_max`, `alarm_enabled` en `edge_variables`; `installer_status`, `installer_url` en `edge_projects` | Migraciones sqlx reflejan el schema completo de init.sql. `sqlx migrate run` en DB limpia produce schema identico al esperado. | `services/backend-api/migrations/20260406000000_initial_schema.sql` vs `infrastructure/postgres/init.sql` — nueva migracion para columnas faltantes | P1 | S |
|
|
||||||
| REQ-F2-008 | Agregar tabla `audit_logs` en migraciones para trazabilidad de operaciones | Tabla `audit_logs` con campos: id, user_id, action, entity_type, entity_id, old_value, new_value, created_at. Inserts en operaciones criticas (CRUD de datos operativos). | Nueva migracion, nuevo middleware o helper de audit | P1 | M |
|
|
||||||
| REQ-F2-009 | Implementar sync bidireccional: movimientos creados offline en PWA deben sincronizar al backend cuando haya conexion | Sync worker reconoce `operation_type: 'movement'` y llama a `POST /api/movements`. Items sincronizados cambian a `synced`. | `services/frontend-pwa/src/lib/sync/use-sync-worker.ts` (agregar handler para movements, actualmente solo maneja `measurement`) | P1 | M |
|
|
||||||
|
|
||||||
### 6.3 Fase 3: Modulos ANH Faltantes
|
|
||||||
|
|
||||||
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|
|
||||||
|----|-------------|------------------------|---------------------|-----------|-------------|
|
|
||||||
| REQ-F3-001 | Backend: CRUD de paradas de pozo (`operations_downtime`) | Endpoints `GET/POST/PUT/DELETE /api/downtime`. Filtrado por asset_id y rango de fecha. Campos: asset_id, start_time, end_time, is_planned, reason, comments. | Nuevo `services/backend-api/src/handlers/downtime.rs`, modelo en `shared-lib`, rutas en `main.rs` | P0 | M |
|
|
||||||
| REQ-F3-002 | Frontend: modulo de paradas de pozo en PWA | Formulario de captura con: seleccion de pozo, fecha inicio/fin, tipo (planificada/no planificada), razon, comentarios. Listado con filtros. Funciona offline con sync. | Nuevo `services/frontend-pwa/src/features/field/downtime/` | P0 | L |
|
|
||||||
| REQ-F3-003 | Backend: CRUD de pruebas de pozo | Endpoints `GET/POST/PUT/DELETE /api/well-tests`. Campos segun ANH 0651: asset_id, test_date, test_type, bopd, bwpd, gor, bsw, choke_size, wellhead_pressure, casing_pressure, comments. | Nuevo handler, modelo, migracion para tabla `well_tests` | P0 | M |
|
|
||||||
| REQ-F3-004 | Frontend: modulo de pruebas de pozo en PWA | Formulario con todos los campos ANH. Validacion Zod. Offline-capable. Listado historico por pozo. | Nuevo `services/frontend-pwa/src/features/field/well-tests/` | P0 | L |
|
|
||||||
| REQ-F3-005 | Backend: CRUD de analisis de laboratorio (`lab_results`) | Endpoints `GET/POST/PUT/DELETE /api/lab-results`. Campos: asset_id, sample_time, results (JSONB flexible), lab_technician. Soporte para diferentes tipos de analisis. | Nuevo handler, modelo. Tabla ya definida en init.sql pero requiere migracion (REQ-F2-001). | P0 | M |
|
|
||||||
| REQ-F3-006 | Frontend: modulo de analisis de laboratorio en PWA | Formulario dinamico segun tipo de analisis (crudo, agua, gas). Adjuntar resultados. Historial por activo. | Nuevo `services/frontend-pwa/src/features/field/lab/` | P0 | L |
|
|
||||||
| REQ-F3-007 | Backend: CRUD de medidores de gas y liquido | Endpoints `GET/POST/PUT/DELETE /api/meters/readings`. Campos: asset_id (tipo MEDIDOR_GAS o AGUA), reading_time, value, unit, meter_factor, corrected_value. Migracion para tabla `meter_readings`. | Nuevo handler, modelo, migracion | P0 | M |
|
|
||||||
| REQ-F3-008 | Frontend: modulo de medidores en PWA | Captura de lecturas de medidores. Diferenciacion gas/liquido. Calculo automatico de factor de correccion si aplica. Historico. | Nuevo `services/frontend-pwa/src/features/field/meters/` | P0 | L |
|
|
||||||
| REQ-F3-009 | Frontend Admin: visualizacion de reportes ANH | Pantalla en admin que muestra el reporte ANH generado por `json-generator`. Preview del JSON. Opcion de descarga. Filtro por proyecto y periodo. | Nuevo `services/frontend-admin/src/features/admin/reports/` | P1 | M |
|
|
||||||
| REQ-F3-010 | Backend + Admin: gestion de usuarios | CRUD de usuarios (solo admin). Asignacion de roles. Asignacion usuario-proyecto (`user_project_access`). Activar/desactivar cuentas. | Nuevo `services/backend-api/src/handlers/users.rs`, nuevo `services/frontend-admin/src/features/admin/users/` | P0 | L |
|
|
||||||
| REQ-F3-011 | Validacion de datos operativos | Reglas de validacion antes de persistir: volumenes positivos, fechas coherentes (end > start), campos obligatorios por tipo de operacion, rangos de BSW (0-100), GOR > 0, presiones > 0. | Logica de validacion en cada handler (movimientos, paradas, pruebas, lab, medidores). Errores claros al usuario. | P1 | M |
|
|
||||||
| REQ-F3-012 | Admin: modulo de mediciones de campo (paridad con PWA) | Admin tiene acceso a ver y gestionar mediciones que los operadores capturan en PWA. Tabla con filtros por proyecto, activo, fecha. | Nuevo `services/frontend-admin/src/features/admin/measurements/` | P2 | M |
|
|
||||||
| REQ-F3-013 | Alertas y alarmas funcionales | Backend evalua alarmas definidas en `edge_variables` (alarm_min, alarm_max). Genera eventos cuando telemetria excede umbrales. Frontend muestra notificaciones. | Logica en `events-relay` o `data-collector`, nuevo handler para consultar alertas activas, UI en admin | P2 | XL |
|
|
||||||
|
|
||||||
### 6.4 Fase 4: Calidad y Mantenibilidad
|
|
||||||
|
|
||||||
| ID | Descripcion | Criterio de Aceptacion | Archivos Afectados | Prioridad | Complejidad |
|
|
||||||
|----|-------------|------------------------|---------------------|-----------|-------------|
|
|
||||||
| REQ-F4-001 | Crear paquete compartido de frontend (`packages/shared` o workspace Vite) | Codigo comun extraido: `auth-store.ts`, `client.ts`, `db/index.ts`, `cn.ts`, todos los componentes UI base (`button`, `card`, `badge`, `separator`). Ambos frontends importan del paquete compartido. Duplicacion < 15%. | `services/frontend-admin/src/lib/`, `services/frontend-pwa/src/lib/`, `services/frontend-admin/src/components/ui/`, `services/frontend-pwa/src/components/ui/`, `services/frontend-admin/src/features/auth/` — todo migrado a paquete compartido | P1 | XL |
|
|
||||||
| REQ-F4-002 | Tests de handlers backend (unit + integration) | Cada handler tiene al menos 1 test de exito y 1 de error. Tests de auth middleware. Tests de multi-tenant isolation. Ejecutables con `cargo test`. | Nuevo directorio `services/backend-api/tests/`, archivos de test por modulo | P0 | L |
|
|
||||||
| REQ-F4-003 | Tests de frontend (componentes criticos) | Tests de auth flow, sync store, formularios de captura. Ejecutables con `vitest`. | Archivos `.test.ts`/`.test.tsx` junto a componentes en ambos frontends | P1 | L |
|
|
||||||
| REQ-F4-004 | Pipeline CI/CD basico | GitHub Actions: lint (clippy + eslint), test (cargo test + vitest), build (docker build). Se ejecuta en push a main y PRs. | Nuevo `.github/workflows/ci.yml` | P1 | M |
|
|
||||||
| REQ-F4-005 | Corregir `useTanks` para usar react-query en vez de useState/useEffect | `useTanks` usa `useQuery` de `@tanstack/react-query`, consistente con el patron del resto del frontend. Cache, refetch, loading states manejados por react-query. | `services/frontend-admin/src/features/admin/tanks/hooks/useTanks.ts` | P2 | S |
|
|
||||||
| REQ-F4-006 | Rehabilitar o eliminar servicios muertos: `flow-generator` (sin main.rs), `data-collector` (comentado) | Cada servicio listado en el workspace Cargo.toml compila y tiene proposito claro, o se elimina. | `services/flow-generator/`, `services/data-collector/`, `docker-compose.yml:86-100`, `Cargo.toml` | P2 | S |
|
|
||||||
| REQ-F4-007 | Documentar API con OpenAPI/Swagger | Spec OpenAPI generada automaticamente o mantenida manualmente. Disponible en `/api/docs`. Cubre todos los endpoints con request/response schemas. | Nuevo middleware o crate (e.g., `utoipa`), `services/backend-api/src/main.rs` | P2 | M |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Requisitos No Funcionales
|
|
||||||
|
|
||||||
### 7.1 Rendimiento
|
|
||||||
|
|
||||||
| Requisito | Metrica |
|
|
||||||
|-----------|---------|
|
|
||||||
| Latencia API (p95) | < 200ms para CRUD, < 500ms para queries con JOINs complejos |
|
|
||||||
| Telemetria (ingestion) | Soportar 100 data points/segundo por proyecto sin degradacion |
|
|
||||||
| PWA offline | Formularios funcionales sin conexion. Sync automatico al reconectar en < 30 segundos |
|
|
||||||
| Dashboard load | < 3 segundos para carga inicial con datos de 1 mes |
|
|
||||||
|
|
||||||
### 7.2 Disponibilidad
|
|
||||||
|
|
||||||
| Requisito | Metrica |
|
|
||||||
|-----------|---------|
|
|
||||||
| Uptime | 99.5% mensual (excluye mantenimiento programado) |
|
|
||||||
| Recovery | RTO < 1 hora, RPO < 15 minutos (backups de TimescaleDB) |
|
|
||||||
| Graceful degradation | PWA funciona offline. Perdida de MQTT no afecta captura manual. |
|
|
||||||
|
|
||||||
### 7.3 Seguridad
|
|
||||||
|
|
||||||
| Requisito | Detalle |
|
|
||||||
|-----------|---------|
|
|
||||||
| Autenticacion | JWT con firma HS256 minimo. Access token TTL <= 15 min. Refresh token TTL <= 7 dias. |
|
|
||||||
| Autorizacion | RBAC con 4 roles (admin, supervisor, operador, anh_reader). Multi-tenant por proyecto. |
|
|
||||||
| Transporte | HTTPS obligatorio en produccion. MQTT sobre WSS (puerto 443). |
|
|
||||||
| Secrets | Externalizados via variables de entorno. Sin defaults en docker-compose. |
|
|
||||||
| Passwords | Argon2id con salt aleatorio (ya implementado). Minimo 8 caracteres. |
|
|
||||||
| Auditoria | Todas las operaciones CRUD logueadas en `audit_logs` con user_id y timestamp. |
|
|
||||||
|
|
||||||
### 7.4 Cumplimiento
|
|
||||||
|
|
||||||
| Requisito | Detalle |
|
|
||||||
|-----------|---------|
|
|
||||||
| ANH Resolucion 0651 | Todos los campos de captura definidos por la resolucion implementados y validados |
|
|
||||||
| Retencion de datos | Telemetria comprimida despues de 7 dias (ya configurado en TimescaleDB). Datos operativos retenidos por periodo reglamentario (5 anos minimo). |
|
|
||||||
| Trazabilidad | Cada registro tiene `created_at`, `updated_at`, y entry en `audit_logs` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Riesgos y Mitigaciones
|
|
||||||
|
|
||||||
| # | Riesgo | Probabilidad | Impacto | Mitigacion |
|
|
||||||
|---|--------|-------------|---------|------------|
|
|
||||||
| R1 | Migraciones de schema rompen datos existentes en entornos de prueba | Media | Alto | Ejecutar migraciones primero en entorno staging. Backup antes de cada migracion. Usar `IF NOT EXISTS` y migrations idempotentes. |
|
|
||||||
| R2 | Introduccion de auth middleware rompe integraciones existentes (edge agents, MQTT) | Alta | Alto | Edge agents usan MQTT, no la API HTTP directamente. Verificar que el build-orchestrator tiene token de servicio. Crear cuenta de servicio para integraciones internas. |
|
|
||||||
| R3 | Rename de campos (code->asset_code) rompe clientes existentes | Media | Medio | Usar `#[serde(rename)]` en backend para no cambiar columnas DB. Deprecar nombres viejos con periodo de transicion. |
|
|
||||||
| R4 | Frontend deduplication introduce regresiones | Media | Medio | Ejecutar tests de humo manuales en ambos frontends despues de cada cambio. Crear tests automatizados primero (REQ-F4-003). |
|
|
||||||
| R5 | Especificacion ANH 0651 tiene campos no documentados o ambiguos | Media | Alto | Consultar documento oficial de la ANH. Validar estructura de reporte JSON con el `json-generator` existente. |
|
|
||||||
| R6 | Dependencia de Azure Container Apps puede tener limitaciones de configuracion WSS/MQTT | Baja | Alto | Documentar configuracion de Traefik/proxy para WSS. Tener fallback con Azure API Management para proxy MQTT. |
|
|
||||||
| R7 | Complejidad de multi-tenant enforcement puede degradar rendimiento de queries | Baja | Medio | Indices en `user_project_access(user_id, project_id)`. Evaluar caching de permisos en Redis. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Criterios de Exito
|
|
||||||
|
|
||||||
### Fase 1 Completada Cuando:
|
|
||||||
|
|
||||||
- [ ] `curl -X GET https://api.omnioil.app/api/edge/projects` retorna 401
|
|
||||||
- [ ] `curl -X GET -H "Authorization: Bearer <valid_token>" https://api.omnioil.app/api/edge/projects` retorna 200 con datos del proyecto asignado
|
|
||||||
- [ ] docker-compose.yml no contiene ningun secret hardcodeado
|
|
||||||
- [ ] CORS rechaza request desde `https://malicious-site.com`
|
|
||||||
- [ ] Puerto 1883 no es accesible desde fuera del docker network
|
|
||||||
- [ ] `POST /api/auth/register` requiere token de admin
|
|
||||||
- [ ] Responses de error no contienen informacion de schema de BD
|
|
||||||
|
|
||||||
### Fase 2 Completada Cuando:
|
|
||||||
|
|
||||||
- [ ] `sqlx migrate run` en BD vacia crea todas las tablas (incluyendo operations_movements, operations_downtime, lab_results, audit_logs)
|
|
||||||
- [ ] `GET /api/movements?asset_id=X` retorna movimientos del activo
|
|
||||||
- [ ] PWA muestra `asset_code` y `asset_name` correctamente (no `undefined`)
|
|
||||||
- [ ] Usuario A no puede ver proyectos de Usuario B
|
|
||||||
- [ ] Sync worker se ejecuta automaticamente al abrir PWA y procesa items pendientes
|
|
||||||
- [ ] Movimientos creados offline se sincronizan al reconectar
|
|
||||||
|
|
||||||
### Fase 3 Completada Cuando:
|
|
||||||
|
|
||||||
- [ ] Cada modulo ANH (paradas, pruebas, laboratorio, medidores) tiene CRUD backend funcional
|
|
||||||
- [ ] PWA permite capturar datos de todos los modulos ANH
|
|
||||||
- [ ] Admin permite visualizar reportes ANH generados
|
|
||||||
- [ ] Admin permite gestionar usuarios y asignaciones proyecto-usuario
|
|
||||||
- [ ] Datos ingresados pasan validacion (volumenes > 0, fechas coherentes, campos obligatorios presentes)
|
|
||||||
|
|
||||||
### Fase 4 Completada Cuando:
|
|
||||||
|
|
||||||
- [ ] `cargo test` ejecuta > 20 tests y pasa
|
|
||||||
- [ ] `vitest` ejecuta tests de componentes criticos y pasa
|
|
||||||
- [ ] CI pipeline ejecuta en cada PR: lint + test + build
|
|
||||||
- [ ] Duplicacion entre frontend-admin y frontend-pwa < 15% (medido por archivos identicos)
|
|
||||||
- [ ] `flow-generator` y `data-collector` estan activos con proposito o eliminados
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Dependencias
|
|
||||||
|
|
||||||
### Entre Fases
|
|
||||||
|
|
||||||
```
|
|
||||||
Fase 1 (Seguridad) ──> Fase 2 (Integridad)
|
|
||||||
│
|
|
||||||
├──> Fase 3 (Modulos ANH)
|
|
||||||
│
|
|
||||||
└──> Fase 4 (Calidad)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Fase 2 depende de Fase 1:** El auth middleware (F1) es prerequisito para multi-tenant enforcement (F2). Los handlers de movimientos (F2) necesitan validar JWT.
|
|
||||||
- **Fase 3 depende de Fase 2:** Los nuevos modulos deben construirse sobre schema unificado (F2) y con multi-tenant ya implementado.
|
|
||||||
- **Fase 4 puede iniciar parcialmente en paralelo con Fase 3:** Tests y CI/CD pueden configurarse mientras se desarrollan modulos. Frontend dedup puede hacerse post Fase 3.
|
|
||||||
|
|
||||||
### Dependencias Externas
|
|
||||||
|
|
||||||
| Dependencia | Tipo | Impacto |
|
|
||||||
|-------------|------|---------|
|
|
||||||
| Documento Resolucion ANH 0651 | Regulatoria | Define campos exactos de cada modulo. Necesario para REQ-F3-001 a REQ-F3-008. |
|
|
||||||
| Azure Container Apps | Infraestructura | Configuracion de custom domains, TLS certificates, routing rules para WSS. |
|
|
||||||
| Azure API Management | Infraestructura | Posible proxy para MQTT WSS si Traefik directo no es viable. |
|
|
||||||
| Crate `utoipa` o similar | Tecnica | Para documentacion OpenAPI automatica (REQ-F4-007). |
|
|
||||||
| `@tanstack/react-query` | Tecnica | Ya en uso parcial. Necesario para estandarizacion (REQ-F4-005). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Apendice A: Resumen de Archivos Clave
|
|
||||||
|
|
||||||
| Archivo | Rol | Estado |
|
|
||||||
|---------|-----|--------|
|
|
||||||
| `services/backend-api/src/auth.rs` | JWT creation + verification | `verify_jwt` marcado dead_code |
|
|
||||||
| `services/backend-api/src/main.rs` | Router, middleware, startup | CORS permissive, rate limit global |
|
|
||||||
| `services/backend-api/src/handlers/auth.rs` | Login/register | Register publico, no project_id en claims |
|
|
||||||
| `services/backend-api/src/handlers/edge_assets.rs` | CRUD assets | Sin filtro multi-tenant, expone errores DB |
|
|
||||||
| `services/shared-lib/src/models.rs` | Modelos compartidos | EdgeAsset usa `code`/`name` (no `asset_code`/`asset_name`) |
|
|
||||||
| `services/backend-api/migrations/` | Schema migraciones sqlx | Faltan tablas de operations, lab, audit |
|
|
||||||
| `infrastructure/postgres/init.sql` | Schema maestro (legacy) | Tiene tablas que migraciones no tienen |
|
|
||||||
| `services/frontend-pwa/src/lib/sync/sync-store.ts` | Cola de sync offline | Bug: ignora ID pasado |
|
|
||||||
| `services/frontend-pwa/src/lib/sync/use-sync-worker.ts` | Worker de sync | No integrado en layout, solo maneja measurements |
|
|
||||||
| `services/frontend-pwa/src/features/field/movements/` | UI movimientos | Llama a `/api/movements` que no existe |
|
|
||||||
| `services/frontend-pwa/src/features/field/measurements/api/assets-api.ts` | Tipo EdgeAsset en PWA | Usa `asset_code`/`asset_name` (mismatch con backend) |
|
|
||||||
| `docker-compose.yml` | Orquestacion | Secrets hardcodeados, MQTT 1883 expuesto |
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Resumen Ejecutivo — PRD Estabilización y Preparación para Producción — OmniOil
|
|
||||||
|
|
||||||
**Versión:** 1.0
|
|
||||||
**Fecha:** 2026-04-07
|
|
||||||
**Autor:** Kyrbot Innovations
|
|
||||||
**Fuente:** `docs/PRD_ESTABILIZACION_PRODUCCION.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Objetivo
|
|
||||||
|
|
||||||
Estabilizar OmniOil y dejarlo listo para un despliegue productivo seguro y conforme a la Resolución ANH 0651, cerrando brechas críticas de seguridad, completitud funcional y mantenibilidad en cuatro fases.
|
|
||||||
|
|
||||||
## 2) Riesgos críticos (actual)
|
|
||||||
|
|
||||||
- API sin verificación JWT: ningún endpoint aplica autenticación real.
|
|
||||||
- Módulo de movimientos: frontend sin backend; datos quedan locales.
|
|
||||||
- Desalineación PWA vs backend: campos `asset_code/name` vs `code/name`.
|
|
||||||
- Multi‑tenant sin enforcement: handlers no filtran por usuario/proyecto.
|
|
||||||
- Esquema dual inconsistente: `init.sql` difiere de migraciones `sqlx`.
|
|
||||||
- Seguridad débil: secretos por defecto, CORS permisivo, puertos expuestos.
|
|
||||||
|
|
||||||
## 3) Alcance por fases (high‑level)
|
|
||||||
|
|
||||||
- Fase 1 — Seguridad crítica (P0)
|
|
||||||
- Enforce JWT en todos los handlers; middleware global.
|
|
||||||
- Rotación de secretos; CORS restrictivo; hardening de red (MQTT/DB/Redis/MinIO).
|
|
||||||
- Corrección field mismatch assets; lint de rutas y cabeceras de seguridad.
|
|
||||||
|
|
||||||
- Fase 2 — Funcional base y consistencia (P0/P1)
|
|
||||||
- Backend de movimientos (`/api/movements`) + sync offline PWA.
|
|
||||||
- Alinear esquema: migraciones `sqlx` vs `init.sql` (única fuente).
|
|
||||||
- Enforcement multi‑tenant en queries y handlers.
|
|
||||||
- Integrar `use-sync-worker` y colas (measurements, movements).
|
|
||||||
|
|
||||||
- Fase 3 — Módulos ANH faltantes (P0)
|
|
||||||
- Paradas (`/api/downtime`), Pruebas de pozo (`/api/well-tests`).
|
|
||||||
- Laboratorio (`/api/lab-results`), Medidores (`/api/meters/readings`).
|
|
||||||
- Gestión de usuarios y reglas de validación de datos operativos.
|
|
||||||
|
|
||||||
- Fase 4 — Calidad y mantenibilidad (P1)
|
|
||||||
- Paquete compartido de frontend para eliminar duplicación.
|
|
||||||
- Tests backend/handlers, smoke E2E básicos, CI/CD mínima.
|
|
||||||
- Documentación de despliegue y procedimientos operativos (runbooks).
|
|
||||||
|
|
||||||
## 4) Listo para Producción (L4P) — criterios clave
|
|
||||||
|
|
||||||
- Autenticación: 100% endpoints protegidos con JWT y autorización por proyecto.
|
|
||||||
- Seguridad: secretos rotados; CORS por allowlist; servicios internos no expuestos.
|
|
||||||
- Datos: migraciones consistentes y reversibles; backups y restauración probados.
|
|
||||||
- Funcional: movimientos operativos y sync offline funcionando; gaps P0 cerrados.
|
|
||||||
- Calidad: smoke tests verdes; monitorización básica; sin P0/P1 abiertos.
|
|
||||||
|
|
||||||
## 5) Métricas de éxito (objetivos)
|
|
||||||
|
|
||||||
- p95 latencia endpoints core < 300 ms; error 5xx < 0.1%.
|
|
||||||
- Tiempo medio de sync (PWA→API) < 60 s en conectividad regular.
|
|
||||||
- Cobertura mínima handlers backend ≥ 50% (creciente); E2E smoke básico.
|
|
||||||
- 0 vulnerabilidades críticas en escaneo SCA/contenerización.
|
|
||||||
|
|
||||||
## 6) Plan inmediato (72 horas)
|
|
||||||
|
|
||||||
- Enforce JWT + middleware global; rotar `JWT_SECRET` y variables sensibles.
|
|
||||||
- Cerrar CORS; cerrar puertos externos (MQTT, DB, Redis, MinIO) y usar red interna.
|
|
||||||
- Arreglar mismatch de campos de assets en PWA o normalizar en backend.
|
|
||||||
- Definir y aplicar migración de reconciliación `sqlx` vs `init.sql` (fuente única).
|
|
||||||
- Implementar `POST/GET /api/movements` y conectar `use-sync-worker`.
|
|
||||||
|
|
||||||
## 7) Cronograma tentativo
|
|
||||||
|
|
||||||
- Semana 1: Fase 1 completa (seguridad) + arranque Fase 2.
|
|
||||||
- Semanas 2–3: Fase 2 completa (funcional base + consistencia).
|
|
||||||
- Semanas 3–6: Fase 3 (módulos ANH prioritarios).
|
|
||||||
- Semana 7: Fase 4 (calidad, paquete compartido, CI/CD básica).
|
|
||||||
|
|
||||||
## 8) Dependencias y decisiones
|
|
||||||
|
|
||||||
- Estrategia de migraciones: consolidar en `services/backend-api/migrations` como fuente única.
|
|
||||||
- Política CORS por ambientes y dominios cliente.
|
|
||||||
- Modelo de roles/ACL por proyecto y scopes de API.
|
|
||||||
- Rotación de secretos y almacenamiento seguro (env‑vars/secrets manager).
|
|
||||||
|
|
||||||
## 9) Seguimiento
|
|
||||||
|
|
||||||
- Tablero “Estabilización” con épicas por fase; etiquetas `P0/P1` y `Seguridad/Funcional/Calidad`.
|
|
||||||
- Stand‑up corto de riesgos diarios; demo quincenal de avances de Fases 2–3.
|
|
||||||
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 📘 OmniOil Reference Guide: Architecture & Specs
|
|
||||||
|
|
||||||
This document provides a technical overview of the OmniOil SCADA system, its data models, and its compliance with ANH regulations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 🏗️ High-Level Architecture
|
|
||||||
|
|
||||||
The OmniOil system is built as a microservices-based, event-driven architecture, designed to operate in a hybrid environment (VPS + Cloud).
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
- **Backend API (Rust/Axum)**: The central orchestrator and data gatekeeper.
|
|
||||||
- **Data Collector (Rust)**: High-frequency data ingestion engine (every 10 minutes).
|
|
||||||
- **Events Relay (Rust)**: Ensures "Transactional Outbox" delivery between microservices using Redis Streams.
|
|
||||||
- **JSON Generator (Rust)**: Automated daily compliance report generator.
|
|
||||||
- **Frontend (React/Vite)**: Unified administrative and operational portal.
|
|
||||||
- **Edge Agent (Rust)**: Local SCADA gateway for field devices (Modbus/MQTT).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 🗄️ Database Schema (PostgreSQL/TimescaleDB)
|
|
||||||
|
|
||||||
The system uses **TimescaleDB** for efficient storage of high-frequency telemetry data.
|
|
||||||
|
|
||||||
### Main Entities
|
|
||||||
- **Projects**: Top-level organizational unit.
|
|
||||||
- **Assets**: Physical locations or infrastructure (e.g., "Well Pad X", "Tank Farm Y").
|
|
||||||
- **Devices**: Specific data-producing hardware (e.g., PLCs, Flow Meters).
|
|
||||||
- **Variables**: Individual sensors or data points (e.g., "Inlet Pressure", "Volume").
|
|
||||||
- **Telemetry**: Hyper-table for storing time-series data from variables.
|
|
||||||
- **Domain Outbox**: Transactional log for event synchronization.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 📡 Data Flow: From Edge to ANH
|
|
||||||
|
|
||||||
1. **Extraction**: Field devices (Modbus/OPC-UA) are polled by Node-RED or local industrial protocols.
|
|
||||||
2. **Local Intake**: Data is sent to the **Edge Agent** via its local Data Gateway (API).
|
|
||||||
3. **Transmission**: The Agent encrypts and transmits telemetry to the Cloud via **MQTT/WSS**.
|
|
||||||
4. **Processing**: The **Data Collector** consumes MQTT messages and persists data in the **TimescaleDB** hyper-table.
|
|
||||||
5. **Compliance (ANH)**: Every day at 06:30 AM, the **JSON Generator** aggregates the last 24h of data into the official ANH-formatted JSON report and uploads it to **MinIO** storage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Compliance & Security (ANH Resolution 0651/2025)
|
|
||||||
|
|
||||||
1. **Inalterability**: All telemetry is stored with UTC timestamps and origin logging.
|
|
||||||
2. **Redundancy**: Local persistence at the edge ensures no data loss during internet outages.
|
|
||||||
3. **Integrity**: JSON reports are hashed (SHA256) and verified before submission to entes de control.
|
|
||||||
4. **Hierarchical Structure**: The database strictly follows the `Project -> Asset -> Device -> Variable` hierarchy required by the ANH v4.0 standard.
|
|
||||||
5. **Hardened Transport**:
|
|
||||||
- **Crypto Provider**: `aws_lc_rs` (FIPS-ready) for all TLS operations.
|
|
||||||
- **Backend/DB**: Migrated to `native-tls` for specialized database connections.
|
|
||||||
- **Identity**: Argon2id used for password hashing with strict complexity rules.
|
|
||||||
- **Encryption**: All edge-to-cloud communication is secured via **MQTT over WSS (WebSockets Secure)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 UI/UX Excellence
|
|
||||||
|
|
||||||
The OmniOil platform provides a premium experience:
|
|
||||||
- **Responsive Dashboard**: Fully adaptive glassmorphism design for executive and operational views.
|
|
||||||
- **Micro-animations**: Smooth transitions and interactive data visualizations.
|
|
||||||
- **PWA Ready**: Mobile-first Field PWA for onsite operators.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# OmniOil Security Hardening & MQTT Transition Summary
|
|
||||||
**Sesión: 08 de Abril, 2026**
|
|
||||||
|
|
||||||
## 1. Visión General del Cambio
|
|
||||||
Se ha migrado la arquitectura de comunicación campo-nube de un modelo híbrido (HTTP/MQTT) a un modelo de **Outbound-Only Single-Tunnel** basado exclusivamente en MQTT sobre WebSockets (WSS) a través del puerto **443**.
|
|
||||||
|
|
||||||
## 2. Cambios Técnicos en la Infraestructura
|
|
||||||
|
|
||||||
### 2.1 Estandarización de Temas (Topics)
|
|
||||||
Se ha implementado un nuevo namespace jerárquico bajo el prefijo `omnioil/` para garantizar aislamiento y escalabilidad:
|
|
||||||
- `omnioil/telemetry/{project_id}`: Datos SCADA en tiempo real.
|
|
||||||
- `omnioil/health/{project_id}`: Latidos (Heartbeats) y estado de salud (CPU, RAM, Versión).
|
|
||||||
- `omnioil/logs/{project_id}`: Logs centralizados de los agentes de borde.
|
|
||||||
|
|
||||||
### 2.2 Nuevo Microservicio: Telemetry Ingestor
|
|
||||||
- **Propósito**: Actúa como puente (bridge) entre el Broker MQTT (Mosquitto) y la base de datos (TimescaleDB).
|
|
||||||
- **Tecnología**: Escrito en Rust para máxima eficiencia y baja latencia.
|
|
||||||
- **Funcionalidad**: Suscripción global a `omnioil/#`, persistencia de datos en tablas particionadas y actualización de estado en vivo.
|
|
||||||
|
|
||||||
### 2.3 Hardening de Base de Datos
|
|
||||||
- Se añadieron las tablas `agent_logs` y se expandió `edge_projects` con la columna `last_health_report` (JSONB).
|
|
||||||
- Se unificó la tabla `telemetry_raw` para permitir ingesta simplificada a nivel de proyecto.
|
|
||||||
|
|
||||||
## 3. Mejoras en la Interfaz de Usuario (Frontend)
|
|
||||||
|
|
||||||
### 3.1 Monitoreo en Tiempo Real (Healthcheck)
|
|
||||||
Se integró un panel de estado en la página de detalles del proyecto:
|
|
||||||
- **Estado de Conexión**: Indicador visual dinámico (Online/Offline).
|
|
||||||
- **Diagnóstico Rápido**: Visualización de Hostname, Tiempo de Uptime y Versión del Agente sin salir del Dashboard.
|
|
||||||
- **Último Reporte**: Timestamp exacto del último mensaje recibido para garantizar la integridad de los datos visible al usuario.
|
|
||||||
|
|
||||||
## 4. Seguridad Industrial
|
|
||||||
- **ACLs Granulares**: Configuración de permisos a nivel de base de datos para restringir a cada agente únicamente a su propio `project_id`.
|
|
||||||
- **Admin Bypass**: Verificación de `superquery` en Mosquitto para acceso total de administradores del sistema.
|
|
||||||
- **Eliminación de Exposición HTTP**: El agente de borde ya no envía datos por API REST, eliminando puntos de ataque externos.
|
|
||||||
|
|
||||||
## 5. Pruebas y Validación
|
|
||||||
- Se desarrolló un script de verificación integral `tests/test_mqtt_ingestion.py`.
|
|
||||||
- Se resolvieron inconsistencias de checksum en las migraciones de `sqlx`.
|
|
||||||
- Validación exitosa del flujo completo: **Agente -> MQTT (WSS/443) -> Ingestor -> TimescaleDB -> Frontend Admin**.
|
|
||||||
|
|
||||||
---
|
|
||||||
*OmniOil - Secured Industrial Connectivity Framework*
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 📊 Informe de Verificación de Flujo y Cumplimiento Normativo — OmniOil SCADA v4.0
|
|
||||||
|
|
||||||
Este documento resume la auditoría de flujo realizada sobre el ecosistema OmniOil, contrastando la arquitectura actual con los requerimientos operativos de la ANH (**Resolución 0651 de 2025**).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 1. Verificación del Flujo de Usuario
|
|
||||||
|
|
||||||
Hemos validado paso a paso el ciclo de vida de un proyecto de borde (Edge).
|
|
||||||
|
|
||||||
### 🔐 Inicio de Sesión
|
|
||||||
- **Estado**: ✅ **FUNCIONAL**
|
|
||||||
- **Descripción**: Sistema basado en JWT servido por `backend-api`.
|
|
||||||
- **Zonas Verificadas**: `services/backend-api/src/handlers/auth.rs` y Frontend.
|
|
||||||
- **Roles**: Implementación completa de `admin`, `supervisor`, `operador`, `anh_reader`.
|
|
||||||
|
|
||||||
### 🏗️ Creación de Proyecto
|
|
||||||
- **Estado**: ✅ **FUNCIONAL**
|
|
||||||
- **Descripción**: La jerarquía `Proyecto -> Activo (Pozo) -> Dispositivo -> Variable` están modeladas correctamente en la DB.
|
|
||||||
- **Zonas Verificadas**: `CreateProjectPage.tsx` y `edge_projects.rs`.
|
|
||||||
|
|
||||||
### 🚀 Activación y Generación del Instalador
|
|
||||||
- **Estado**: ⚠️ **PARCIAL / PENDIENTE DE AUTOMATIZACIÓN**
|
|
||||||
- **Activación**: El botón "Instalador" en la lista de proyectos dispara la orden exitosamente (`triggerBuild`).
|
|
||||||
- **Data Overlay**: Existe el script `generate_custom_binary.py` para inyectar la configuración del cliente en el ejecutable usando XOR, pero **el backend aún no lo está convocando automáticamente** antes del build del MSI.
|
|
||||||
- **Creación del MSI**: El contenedor `edge-agent-msi-builder` con `msi_worker.sh` está listo para recibir el binario y empaquetarlo con WiXl.
|
|
||||||
|
|
||||||
### 🛢️ Pozos, Dispositivos y Variables
|
|
||||||
- **Estado**: ✅ **FUNCIONAL / COMPLETO**
|
|
||||||
- **Jerarquía**: Se ha comprobado la integridad de las llaves foráneas en PostgreSQL.
|
|
||||||
- **Dispositivos**: Soporte robusto para protocolos industriales (Modbus TCP/RTU, OPC UA, Ethernet/IP, Profinet).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚖️ 2. Contraste con Normativa ANH (Fiscalización Online)
|
|
||||||
|
|
||||||
Según la directiva de monitorización y medición de hidrocarburos:
|
|
||||||
|
|
||||||
| Requisito Normativo | Implementación en OmniOil | Estado |
|
|
||||||
| :--- | :--- | :---: |
|
|
||||||
| **Formato de Archivo** | Reportes consolidados en **formato JSON**. | ✅ |
|
|
||||||
| **Nomenclatura (Reg. 34)** | `OPERADOR_CONTRATO_FECHA(dd-mmm-yyyy).json` | ✅ |
|
|
||||||
| **ID Recurso (Reg. 35)** | Identificador único coincidente con Forma 4CR/ANH. | ✅ |
|
|
||||||
| **Frecuencia (Reg. 17)** | Almacenamiento periódico cada 10 minutos mínimo. | ✅ (Es 10 seg) |
|
|
||||||
| **Integridad Metrológica** | Inclusión de hash de integridad en el reporte. | ✅ |
|
|
||||||
| **Eventos de Parada** | Registro de paradas planificadas y por fallas. | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 3. Hallazgos Críticos y Roadmap
|
|
||||||
|
|
||||||
Para alcanzar el estado "Production Ready" al 100%, se recomiendan las siguientes acciones técnicas inmediatas:
|
|
||||||
|
|
||||||
1. **Integración Automática del Overlay**:
|
|
||||||
- Modificar `backend-api/src/handlers/edge_deployments.rs` para que antes de disparar el contenedor builder, recolecte los metadatos del proyecto y ejecute `generate_custom_binary.py`.
|
|
||||||
- Esto eliminará el manejo manual de plantillas y permitirá que el usuario descargue el MSI configurado al instante.
|
|
||||||
|
|
||||||
2. **Toggle de Configuración del Agente**:
|
|
||||||
- Añadir una sección de "Build Settings" en el proyecto para definir el broker MQTT oficial y puertos personalizados, reflejándolos en el `footer` del binario (Overlay).
|
|
||||||
|
|
||||||
3. **Monitoreo del Builder**:
|
|
||||||
- Actualmente el build ocurre en segundo plano (spawn). Se recomienda implementar un canal de WebSocket para notificar al frontend cuando el `.msi` esté disponible para descarga en `INSTALLER_OUTPUT`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Fecha del Reporte**: 2026-04-03
|
|
||||||
**Auditor**: Antigravity (Advanced Agentic Coding AI)
|
|
||||||
84
docs/ROADMAP_ESTRATEGICO.md
Normal file
84
docs/ROADMAP_ESTRATEGICO.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 🚀 Roadmap Estratégico: Hacia una Plataforma de Clase Mundial
|
||||||
|
**OmniOil SCADA** · Kyrbot Innovations
|
||||||
|
|
||||||
|
Este documento describe la hoja de ruta para escalar OmniOil de un sistema robusto a una plataforma de misión crítica con redundancia global, alta disponibilidad y estándares internacionales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Fases de Implementación
|
||||||
|
|
||||||
|
### Fase 1: Redundancia Geográfica y Backups (EE.UU. - Contabo)
|
||||||
|
**Objetivo:** Asegurar que no haya pérdida de datos ante un fallo total del servidor principal en Colombia.
|
||||||
|
|
||||||
|
#### 🛠️ ¿Cómo se implementa?
|
||||||
|
1. **Réplica de TimescaleDB (PostgreSQL):**
|
||||||
|
* Configurar el servidor principal (Master) para permitir réplicas.
|
||||||
|
* En el servidor de Contabo (Slave), iniciar PostgreSQL en modo `hot_standby`.
|
||||||
|
* Utilizar un túnel **WireGuard** para que la réplica viaje por una red privada segura.
|
||||||
|
2. **Réplica de MinIO (S3):**
|
||||||
|
* Configurar el comando `mc replicate` entre el servidor de origen y el destino.
|
||||||
|
* Esto garantiza que cada reporte ANH generado se copie al instante a EE.UU.
|
||||||
|
|
||||||
|
#### 💰 Costos Asociados
|
||||||
|
* **VPS Contabo (EE.UU.):** ~$12 - $25 USD/mes (Dependiendo de la capacidad de almacenamiento).
|
||||||
|
* **Ancho de Banda:** Incluido en el plan de Contabo (generalmente ilimitado o 32TB).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 2: Alta Disponibilidad (HA) y Failover Automático
|
||||||
|
**Objetivo:** Que el sistema nunca esté fuera de línea para el usuario final.
|
||||||
|
|
||||||
|
#### 🛠️ ¿Cómo se implementa?
|
||||||
|
1. **Cloudflare Load Balancer:**
|
||||||
|
* Configurar el dominio `api.omnioil.co` detrás de Cloudflare.
|
||||||
|
* Implementar un *Health Check* que monitoree ambos servidores.
|
||||||
|
* Si el servidor de Colombia no responde en 15 segundos, Cloudflare redirige el tráfico a EE.UU.
|
||||||
|
2. **Keepalived / HAProxy:** (Opcional) Para gestionar IPs virtuales si ambos servidores estuvieran en el mismo Data Center.
|
||||||
|
|
||||||
|
#### 💰 Costos Asociados
|
||||||
|
* **Cloudflare Pro/Business:** $20 - $200 USD/mes (El Load Balancer básico cuesta ~$5 USD/mes extra).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 3: Observabilidad de "Clase Mundial"
|
||||||
|
**Objetivo:** Detectar problemas antes de que el cliente los note.
|
||||||
|
|
||||||
|
#### 🛠️ ¿Cómo se implementa?
|
||||||
|
1. **Stack LGTM (Loki, Grafana, Tempo, Mimir):**
|
||||||
|
* **Grafana:** Dashboards de salud global.
|
||||||
|
* **Prometheus:** Métricas de rendimiento de los Edge Agents.
|
||||||
|
* **Alertmanager:** Notificaciones inteligentes a Telegram/WhatsApp basadas en severidad.
|
||||||
|
2. **Tracing End-to-End:** Implementar OpenTelemetry en los microservicios de Rust para ver el viaje de un dato.
|
||||||
|
|
||||||
|
#### 💰 Costos Asociados
|
||||||
|
* **Almacenamiento de Métricas:** Consumo de RAM extra en VPS (~2GB RAM adicionales).
|
||||||
|
* **Herramientas:** $0 (Todo es Open Source).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 4: Infraestructura como Código (IaC) y DevOps
|
||||||
|
**Objetivo:** Desplegar OmniOil en cualquier parte del mundo en 5 minutos.
|
||||||
|
|
||||||
|
#### 🛠️ ¿Cómo se implementa?
|
||||||
|
1. **Ansible:** Scripts para configurar servidores desde cero (instalar Docker, configurar firewall, optimizar SO).
|
||||||
|
2. **Terraform:** Para crear la infraestructura en la nube de forma programática.
|
||||||
|
3. **GitHub Actions:** Pipelines que prueban el código y lo despliegan automáticamente tras pasar auditorías de seguridad.
|
||||||
|
|
||||||
|
#### 💰 Costos Asociados
|
||||||
|
* **Tiempo de Ingeniería:** Inversión inicial en desarrollo de scripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Resumen de Inversión Estimada (Infraestructura)
|
||||||
|
|
||||||
|
| Componente | Nivel Actual | Nivel Clase Mundial | Costo Incremental |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Servidores** | 1 Servidor (CO) | 2 Servidores (CO + US) | +$15 USD/mes |
|
||||||
|
| **Networking** | DNS Básico | Cloudflare + Load Balancing | +$10 USD/mes |
|
||||||
|
| **Seguridad** | JWT/Bcrypt | Zero-Trust + SAST/DAST | $0 (Open Source) |
|
||||||
|
| **Monitoreo** | Logs Locales | Centralized Observability | +$5 USD (Storage) |
|
||||||
|
|
||||||
|
**TOTAL ESTIMADO:** +$30 USD/mes adicionales para una infraestructura con redundancia global.
|
||||||
|
|
||||||
|
---
|
||||||
|
*OmniOil Strategic Roadmap - Mayo 2026*
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# 🛠️ Auditoría de Seguridad — Mejoras Críticas (Abril 2026)
|
|
||||||
|
|
||||||
Se ha realizado una reconstrucción completa de la capa de autenticación para garantizar un despliegue de grado industrial.
|
|
||||||
|
|
||||||
## 🔐 1. Autenticación MQTT Dinámica (Go-Auth)
|
|
||||||
- **Problem**: Se usaban archivos estáticos y hashes manuales que podían filtrarse en repositorios.
|
|
||||||
- **Solution**: Migración total a Postgres.
|
|
||||||
- **Algorithm**: Se ha estandarizado el uso de **Bcrypt (cost 10)** mediante la extensión `pgcrypto` nativa en Postgres. 🛡️
|
|
||||||
- **Implementation**: El sistema ahora hashea las claves de los agentes al vuelo en la base de datos, eliminando la necesidad de almacenar hashes pre-calculados o secretos en texto plano en el servidor.
|
|
||||||
|
|
||||||
### Configuración de Conectividad (Inyectada en Agentes)
|
|
||||||
El sistema ahora utiliza un enfoque de "Zero-Code Changes" para cambiar endpoints. Asegúrese de configurar estas variables en el servidor:
|
|
||||||
|
|
||||||
| Variable | Propósito | Ejemplo |
|
|
||||||
|----------|-----------|---------|
|
|
||||||
| `BACKEND_API_URL` | Endpoint para reporte de logs/health extra | `https://api.omnioil.app` |
|
|
||||||
| `MQTT_PUBLIC_HOST` | Host público para conexión WSS | `mqtt.omnioil.app` |
|
|
||||||
|
|
||||||
### Notas de Red Industrial
|
|
||||||
- **Puerto 443**: El agente intentará conectar siempre por el puerto 443 usando el protocolo WSS. Asegúrese de que su proxy (Traefik/Nginx) mapee este puerto correctamente al contenedor de Mosquitto.
|
|
||||||
- **Certificados**: El agente utiliza `rustls` por defecto para validar la cadena de confianza del servidor.
|
|
||||||
|
|
||||||
### v2.0 - Dinamismo y Endurecimiento Industrial (Sesión Actual)
|
|
||||||
- **Autenticación Nativa**: Migración de hashes estáticos a Bcrypt dinámico vía `pgcrypto` en TimescaleDB.
|
|
||||||
- **Canal Único (WSS)**: Implementación de WebSockets sobre SSL (puerto 443) para toda la comunicación MQTT. Esto permite atravesar firewalls industriales sin configuraciones complejas.
|
|
||||||
- **Micro-segmentación Activa**: ACLs dinámicas que restringen a los agentes a sus propios tópicos de telemetría y comandos.
|
|
||||||
- **Eliminación de Out-of-Band**: Se clausuraron los canales HTTP para logs y health; ahora toda la metadata del agente fluye por el túnel seguro de MQTT.
|
|
||||||
|
|
||||||
## 🏢 2. Multi-Tenancy & Aislamiento (Client Separation)
|
|
||||||
- **UUID Identity**: Los agentes de borde ya no usan nombres genéricos. Se utiliza el `Project UUID` para identificarlos de forma unívoca.
|
|
||||||
- **Topic Scoping (ACL)**: La micro-segmentación ya no es opcional. El sistema de ACLs restringe a cada agente a su canal `telemetry/{project_id}/#`. 🔒
|
|
||||||
- **Credenciales Dinámicas**: El orquestador genera una clave aleatoria de 36-40 caracteres por cada instalador MSI que produce.
|
|
||||||
|
|
||||||
## 📦 3. Seguridad en el MSI (Agent Injection)
|
|
||||||
- Las claves generadas dinámicamente se inyectan en el binario del **Edge Agent** mediante el orquestador (`Project Isolation`).
|
|
||||||
- El agente nunca conoce el usuario administrador (`omnioil_admin`), solo su propia identidad restringida. 🕵️♂️
|
|
||||||
|
|
||||||
# Auditoría de Seguridad y Análisis de Riesgos - OmniOil
|
|
||||||
|
|
||||||
Este documento expone los hallazgos tras la evaluación arquitectónica de seguridad del ecosistema OmniOil, detallando los puntos fuertes estructurados en su diseño inicial y las vulnerabilidades mitigables de cara a un despliegue en grado de producción industrial masiva.
|
|
||||||
|
|
||||||
## 🟢 Fortalezas Arquitectónicas Implementadas (Aciertos)
|
|
||||||
|
|
||||||
1. **Blindaje de Almacenamiento (Proxy Inverso a MinIO):**
|
|
||||||
El clúster de almacenamiento en bloque (MinIO / S3) no expone acceso público ni emite credenciales pre-firmadas (`Pre-Signed URLs`) al exterior. Todas las descargas de artefactos atraviesan la API segura (Rust Axum), operando como proxy y ocultando credenciales, topología y endpoints de cara al público.
|
|
||||||
2. **Criptografía Robusta en Backend:**
|
|
||||||
El backend se asegura implementando tokens web (JWT) firmados sin persistencia en sesiones inseguras. La gestión de encriptado, incluyendo las operaciones de Base de Datos y contraseñas (con implementaciones subyacentes blindadas frente a desbordamientos y colisiones mediante librerías como `aws_lc_rs`) está sólidamente estructurada en el servidor.
|
|
||||||
3. **Distribución del Config (Overlay Injection):**
|
|
||||||
A diferencia de muchos proyectos IoT convencionales que depositan en claro un `.json` o `.env` vulnerable, el token per-proyecto está inyectado **estructuralmente dentro del ejecutable** (`edge-agent`), blindándolo contra modificaciones no intencionadas en el sistema de archivos del operador.
|
|
||||||
4. **Micro-aislamiento en Red Docker (Air-Gap):**
|
|
||||||
La configuración del `docker-compose.yml` sigue un férreo esquema de Zero-Trust a nivel perimetral. Bases de Datos como TimescaleDB, clústeres Redisson o el MinIO están expuestos en una `docker_network` hermética y sin balanceo de mapeos `ports: "X:X"`, permitiendo su consulta unilateral únicamente por el servidor y el orquestador principal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Vectores Críticos y Análisis de Deuda para Fase 2
|
|
||||||
|
|
||||||
### A. Cifrado IoT: Tráfico de Telemetría (MQTT en Plano crudo)
|
|
||||||
* **Severidad:** Crítico 🔥
|
|
||||||
* **Conflicto:** Por defecto, el servidor subyacente MQTT (Mosquitto) funciona sobre TCP el puerto HTTP plano estándar `1883`. Un Man-in-the-Middle o sniffer de red local podría monitorear u alterar las configuraciones SCADA u obligar peticiones hostiles de Docker.
|
|
||||||
* **Solución Próxima:** Deshabilitar el oyente `1883` público. Exigir **WSS** (WebSockets Secure) mapeando Traefik al puerto `443/8883` imponiendo los certificados TLS para evitar desestabilización IoT.
|
|
||||||
|
|
||||||
### B. Segmentación y Control de Acceso Lateral del Broker (ACLs)
|
|
||||||
* **Severidad:** Alto ⚠️
|
|
||||||
* **Conflicto:** Todos los Edge Agents entran y salen al Broker sin aislamiento formal estricto por clústeres. La infiltración o robo estático de la clave de un Pozo-A permitiría en teoría invadir el topic subscrito y leer/inyectar al Pozo-B alterando las reglas de Node-RED remotamente.
|
|
||||||
* **Solución Próxima:** Restringir el demonio Mosquitto inyectando de cara un **ACL** (Access Control List) obligando a que toda conexión individual reciba una lista blanca pre-concedida al Topic `omnioil/.../<UUID_exclusivo>` prohibiendo la promiscuidad generalizada a `#` (root context).
|
|
||||||
|
|
||||||
### C. Ingeniería Inversa sobre Overlay Binario
|
|
||||||
* **Severidad:** Moderado 🟡
|
|
||||||
* **Conflicto:** Utilizar un Overlay Json adherido directamente al extremo del compilador abre el vector donde un desarrollador armado con un editor Hexadecimal de la industria, aísle, seque y decodifique las claves del Pozo contenidas en el ejecutable plano.
|
|
||||||
* **Solución Próxima:** El constructor asincrónico (Build-Orchestrator) deberá transformar con `AES-256` la clave JSON en crudo usando una llave embonada al código generador en Rust previo a inyectar las capas de código en ensamblador de Linux y Windows, dejando el rastro decodificable únicamente expuesto en las celdas RAM al instante de bootear el proceso como Windows Service.
|
|
||||||
|
|
||||||
### D. Ataques de Fuerza Bruta y Limitación Web
|
|
||||||
* **Severidad:** Moderado 🛡️
|
|
||||||
* **Conflicto:** Ausencia de salvaguardas limitadoras de estrangulación perimetral (Rate Limiting). Endpoints neurálgicos como autenticación permiten colisiones y testeo masivo robótico para vulnerar administradores principales.
|
|
||||||
* **Solución Próxima:** Implantar barreras arquitectónicas de Capa 7, ya sea desde el Gateway API Traefik o insertando Middleware Axum Rate Limit anclado a **Redis** por IPs limitadas a ráfagas.
|
|
||||||
268
docs/SEGURIDAD.md
Normal file
268
docs/SEGURIDAD.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# 🛡️ Guía de Seguridad — OmniOil SCADA
|
||||||
|
**Kyrbot Innovations** · Arquitectura de Seguridad Industrial
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Arquitectura de Seguridad en Profundidad
|
||||||
|
|
||||||
|
OmniOil implementa el principio de **Seguridad en Profundidad** con 6 capas de protección:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SEGURIDAD EN PROFUNDIDAD │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ CAPA 1 │ │ CAPA 2 │ │ CAPA 3 │ │
|
||||||
|
│ │ Red / TLS │ │ Auth JWT │ │ Multi-Tenant│ │
|
||||||
|
│ │ WSS :443 │ │ Bcrypt+2FA │ │ Aislamiento │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ CAPA 4 │ │ CAPA 5 │ │ CAPA 6 │ │
|
||||||
|
│ │ Auditoría │ │ Cifrado │ │ Continuidad │ │
|
||||||
|
│ │ Completa │ │ AES/SHA256 │ │ Store&Fwd │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Capa 1: Seguridad de Red y Transporte
|
||||||
|
|
||||||
|
### 2.1 HTTPS / TLS 1.2+
|
||||||
|
|
||||||
|
Todos los servicios públicos de OmniOil usan HTTPS con TLS 1.2+:
|
||||||
|
- **API REST**: `https://api.omnioil.app` (Traefik + Let's Encrypt automático)
|
||||||
|
- **Frontends**: `https://app.omnioil.app` y `https://mobile.omnioil.app`
|
||||||
|
- **Storage**: `https://storage.omnioil.app`
|
||||||
|
|
||||||
|
### 2.2 MQTT sobre WSS (Hardening Implementado)
|
||||||
|
|
||||||
|
La telemetría industrial usa **WebSockets Secure** en el puerto 443:
|
||||||
|
|
||||||
|
| Configuración | Valor |
|
||||||
|
|--------------|-------|
|
||||||
|
| **Protocolo** | WSS (WebSockets Secure) |
|
||||||
|
| **Puerto público** | 443 (único canal outbound) |
|
||||||
|
| **Puerto interno** | 9001 (WebSocket Mosquitto, detrás del proxy) |
|
||||||
|
| **TLS** | Terminación en Traefik, Let's Encrypt |
|
||||||
|
| **Librería cliente** | `rustls` + `aws_lc_rs` (FIPS-ready) |
|
||||||
|
|
||||||
|
> ⚠️ El puerto 1883 (MQTT plano) está **deshabilitado** para acceso público. Solo el puerto 443 está expuesto.
|
||||||
|
|
||||||
|
### 2.3 Red Docker Aislada
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Configuración de red en docker-compose.yml
|
||||||
|
networks:
|
||||||
|
anh_network:
|
||||||
|
internal: false # Solo servicios con exposición explícita salen al exterior
|
||||||
|
```
|
||||||
|
|
||||||
|
| Servicio | Exposición pública | Observación |
|
||||||
|
|---------|-------------------|-------------|
|
||||||
|
| Backend API | ✅ Via Traefik | Solo puertos 80/443 |
|
||||||
|
| TimescaleDB | ❌ Solo interna | Sin mapeo de puertos |
|
||||||
|
| Redis | ❌ Solo interna | Sin mapeo de puertos |
|
||||||
|
| MinIO | ✅ Solo consola | Via Traefik, credenciales requeridas |
|
||||||
|
| Mosquitto | ✅ WSS :443 | Via Traefik, auth requerida |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Capa 2: Autenticación MQTT (Bcrypt + Go-Auth)
|
||||||
|
|
||||||
|
### 3.1 Plugin Go-Auth
|
||||||
|
|
||||||
|
OmniOil usa el plugin `mosquitto-go-auth` para autenticación dinámica basada en PostgreSQL:
|
||||||
|
|
||||||
|
- **Backend**: Base de datos TimescaleDB (PostgreSQL)
|
||||||
|
- **Algoritmo**: **Bcrypt (cost 10)** via extensión `pgcrypto`
|
||||||
|
- **Sin archivos estáticos**: Las credenciales nunca se almacenan en archivos `.passwd`
|
||||||
|
|
||||||
|
### 3.2 Flujo de Registro de Agente
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Admin crea proyecto en el panel
|
||||||
|
↓
|
||||||
|
2. Build Orchestrator genera clave aleatoria (40 chars)
|
||||||
|
↓
|
||||||
|
3. INSERT INTO mqtt_users (username, password)
|
||||||
|
→ El hash Bcrypt se genera ON THE FLY via pgcrypto
|
||||||
|
→ INSERT INTO mqtt_acls (username, topic='omnioil/+/PROJECT_ID', rw=2)
|
||||||
|
↓
|
||||||
|
4. Credenciales inyectadas en el binario del Edge Agent (overlay)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 ACLs Granulares
|
||||||
|
|
||||||
|
Cada agente tiene permisos estrictamente mínimos:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ACL de un agente: solo puede publicar en su propio canal
|
||||||
|
INSERT INTO mqtt_acls (username, topic, rw) VALUES
|
||||||
|
('PROJECT-UUID', 'omnioil/telemetry/PROJECT-UUID', 2), -- write
|
||||||
|
('PROJECT-UUID', 'omnioil/health/PROJECT-UUID', 2), -- write
|
||||||
|
('PROJECT-UUID', 'omnioil/logs/PROJECT-UUID', 2), -- write
|
||||||
|
('PROJECT-UUID', 'omnioil/commands/PROJECT-UUID', 1); -- read (recibir comandos)
|
||||||
|
```
|
||||||
|
|
||||||
|
`rw`: 1 = read, 2 = write, 3 = read+write
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Capa 3: JWT + Multi-Tenancy
|
||||||
|
|
||||||
|
### 4.1 Autenticación JWT
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
{
|
||||||
|
"email": "admin@operadora.com",
|
||||||
|
"password": "contraseña",
|
||||||
|
"two_factor_code": "123456" // Si 2FA está activo
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Algoritmo**: HMAC-SHA256
|
||||||
|
- **Expiración**: 1 hora (configurable)
|
||||||
|
- **Secret**: `JWT_SECRET` variable de entorno, rotable sin downtime
|
||||||
|
|
||||||
|
### 4.2 Multi-Tenancy Enforcement
|
||||||
|
|
||||||
|
**TODAS** las queries a la base de datos incluyen validación de propiedad:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Ejemplo en Rust - handlers de backend
|
||||||
|
// El usuario NO puede acceder a proyectos de otros clientes
|
||||||
|
async fn get_project(
|
||||||
|
claims: Claims, // Extraído del JWT
|
||||||
|
Path(project_id): Path<Uuid>,
|
||||||
|
State(db): State<DbPool>,
|
||||||
|
) -> Result<Project> {
|
||||||
|
// JOIN obligatorio con user_project_access
|
||||||
|
sqlx::query_as!(Project,
|
||||||
|
"SELECT p.* FROM edge_projects p
|
||||||
|
JOIN user_project_access upa ON upa.project_id = p.id
|
||||||
|
WHERE p.id = $1 AND upa.user_id = $2",
|
||||||
|
project_id, claims.user_id
|
||||||
|
)
|
||||||
|
.fetch_one(&db).await
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si un usuario intenta acceder a un proyecto al que no tiene acceso → `403 Forbidden`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Capa 4: Auditoría Completa
|
||||||
|
|
||||||
|
Toda acción crítica en el sistema se registra en `audit_log`:
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
|-------|------------|
|
||||||
|
| `user_id` | UUID del usuario que realizó la acción |
|
||||||
|
| `action` | `CREATE`, `UPDATE`, `DELETE`, `LOGIN`, `LOGOUT`, `FAILED_LOGIN` |
|
||||||
|
| `entity_type` | Tabla afectada: `edge_project`, `operation_movement`, etc. |
|
||||||
|
| `entity_id` | UUID del objeto modificado |
|
||||||
|
| `old_value` | Valor anterior (JSON) |
|
||||||
|
| `new_value` | Valor nuevo (JSON) |
|
||||||
|
| `ip_address` | IP de origen de la solicitud |
|
||||||
|
| `user_agent` | Browser/cliente usado |
|
||||||
|
| `performed_at` | Timestamp UTC exacto |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Capa 5: Cifrado de Datos
|
||||||
|
|
||||||
|
| Dato | Algoritmo | Implementación |
|
||||||
|
|------|-----------|---------------|
|
||||||
|
| Contraseñas de usuarios | **Bcrypt cost-10** | `bcrypt` crate en Rust |
|
||||||
|
| Contraseñas MQTT | **Bcrypt via pgcrypto** | `crypt()` en PostgreSQL |
|
||||||
|
| API keys / JWT secrets | **HMAC-SHA256** | `jsonwebtoken` crate |
|
||||||
|
| Certificados TLS | **TLS 1.2+ / RSA-2048** | rustls + aws_lc_rs |
|
||||||
|
| Reportes ANH (integridad) | **SHA-256** | Cálculo en json-generator |
|
||||||
|
| Telemetría en reposo (Edge) | **AES-256 (planificado)** | SQLite cifrado en Edge Agent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Hallazgos de Auditoría y Estado
|
||||||
|
|
||||||
|
### Hallazgos Resueltos ✅
|
||||||
|
|
||||||
|
| ID | Hallazgo | Severidad | Remediación |
|
||||||
|
|----|---------|-----------|-------------|
|
||||||
|
| A-01 | MQTT en texto plano (puerto 1883) | Crítico | ✅ Migrado a WSS :443 exclusivamente |
|
||||||
|
| A-02 | Credentials estáticas en Mosquitto | Alto | ✅ Migrado a Bcrypt dinámico via pgcrypto |
|
||||||
|
| A-03 | Movimiento lateral entre tenants vía MQTT | Alto | ✅ ACLs dinámicas por project_id |
|
||||||
|
| A-04 | Exposición de credenciales en código | Medio | ✅ Zero-secrets: todo via env vars |
|
||||||
|
| A-05 | HTTP sin cifrado en endpoints públicos | Medio | ✅ HTTPS obligatorio en todos los servicios |
|
||||||
|
| A-06 | DB y Redis expuestos en desarrollo | Medio | ✅ Solo en docker-compose.dev.yml, nunca en producción |
|
||||||
|
|
||||||
|
### Hallazgos Pendientes ⚠️
|
||||||
|
|
||||||
|
| ID | Hallazgo | Severidad | Plan de Remediación |
|
||||||
|
|----|---------|-----------|---------------------|
|
||||||
|
| P-01 | Docker socket expuesto en build-orchestrator | Alto | Implementar Docker-out-of-Docker con permisos restringidos o Kaniko |
|
||||||
|
| P-02 | Sin rate limiting en endpoints de autenticación | Medio | Implementar middleware Axum + Redis para rate limiting por IP |
|
||||||
|
| P-03 | Credenciales del Edge Agent no cifradas AES-256 | Medio | Cifrado AES-256 del overlay antes del build MSI (Fase 7) |
|
||||||
|
| P-04 | Sin cargo-audit en CI/CD | Bajo | Agregar cargo-audit + Trivy al pipeline de CI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 2FA — Autenticación de Doble Factor
|
||||||
|
|
||||||
|
OmniOil implementa 2FA compatible con **TOTP (Time-based One-Time Password)**:
|
||||||
|
|
||||||
|
### Activar 2FA
|
||||||
|
|
||||||
|
1. Ir a **Perfil → Seguridad → Activar 2FA**
|
||||||
|
2. El sistema genera un secreto TOTP y lo presenta como código QR
|
||||||
|
3. Escanear con **Google Authenticator**, **Authy** o cualquier app TOTP compatible
|
||||||
|
4. Ingresar el código de 6 dígitos para confirmar la activación
|
||||||
|
5. **Guardar los códigos de recuperación** (10 códigos de un solo uso)
|
||||||
|
|
||||||
|
### Login con 2FA
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login
|
||||||
|
{
|
||||||
|
"email": "admin@operadora.com",
|
||||||
|
"password": "contraseña",
|
||||||
|
"two_factor_code": "847392" // Código actual del autenticador
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consideraciones
|
||||||
|
|
||||||
|
- El 2FA es **obligatorio** para roles `superadmin` y `admin`
|
||||||
|
- Es **recomendado** para `supervisor` y `operador`
|
||||||
|
- Si se pierde el dispositivo, usar códigos de recuperación o contactar al superadmin para resetear
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Recomendaciones de Hardening en Producción
|
||||||
|
|
||||||
|
### Para el Operador/Cliente
|
||||||
|
|
||||||
|
1. **No exponer puertos de BD**: TimescaleDB y Redis nunca deben tener puertos públicos
|
||||||
|
2. **Firewall en el VPS**: Solo permitir tráfico en puertos 80 y 443 (más SSH si es necesario)
|
||||||
|
3. **Actualizar JWT_SECRET periódicamente**: Rotar la clave cada 90 días
|
||||||
|
4. **Backups regulares**: Configurar backup automático de TimescaleDB y MinIO
|
||||||
|
5. **Revisar audit_log mensualmente**: Detectar accesos anómalos
|
||||||
|
|
||||||
|
### Para Equipos de Campo
|
||||||
|
|
||||||
|
1. **Acceso físico controlado**: El equipo donde corre el Edge Agent debe estar en sala de servidores o gabinete cerrado con llave
|
||||||
|
2. **Contraseña de Windows**: El equipo debe tener contraseña de Windows y bloqueo automático
|
||||||
|
3. **No usar el MSI en otros equipos**: Cada instalador es único para un proyecto
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*OmniOil SCADA — Guía de Seguridad — Kyrbot Innovations — Mayo 2026*
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 🏗️ Arquitectura de Datos Enterprise - OmniOil
|
|
||||||
|
|
||||||
Este documento detalla el ecosistema de datos de **OmniOil**, diseñado para operaciones de alta disponibilidad en el sector Oil & Gas.
|
|
||||||
|
|
||||||
## 📈 Diagrama Entidad-Relación Detallado
|
|
||||||
|
|
||||||
El siguiente diagrama representa la complejidad técnica del sistema, incluyendo tipos de datos y restricciones clave.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
ROLES {
|
|
||||||
int id PK
|
|
||||||
varchar name
|
|
||||||
jsonb permissions
|
|
||||||
}
|
|
||||||
USERS {
|
|
||||||
uuid id PK
|
|
||||||
varchar email
|
|
||||||
varchar password_hash
|
|
||||||
int role_id FK
|
|
||||||
boolean is_active
|
|
||||||
}
|
|
||||||
EDGE_PROJECTS {
|
|
||||||
uuid id PK
|
|
||||||
varchar name
|
|
||||||
varchar client
|
|
||||||
varchar installer_status
|
|
||||||
text installer_url
|
|
||||||
jsonb metadata
|
|
||||||
}
|
|
||||||
EDGE_ASSETS {
|
|
||||||
uuid id PK
|
|
||||||
uuid project_id FK
|
|
||||||
varchar code
|
|
||||||
varchar asset_type
|
|
||||||
}
|
|
||||||
EDGE_DEVICES {
|
|
||||||
uuid id PK
|
|
||||||
uuid asset_id FK
|
|
||||||
varchar protocol
|
|
||||||
varchar host
|
|
||||||
int port
|
|
||||||
}
|
|
||||||
EDGE_VARIABLES {
|
|
||||||
uuid id PK
|
|
||||||
uuid device_id FK
|
|
||||||
varchar alias
|
|
||||||
varchar data_type
|
|
||||||
int polling_ms
|
|
||||||
}
|
|
||||||
TELEMETRY_RAW {
|
|
||||||
timestamptz time PK
|
|
||||||
uuid asset_id PK
|
|
||||||
jsonb data
|
|
||||||
}
|
|
||||||
OPERATION_MOVEMENTS {
|
|
||||||
uuid id PK
|
|
||||||
uuid asset_id FK
|
|
||||||
varchar movement_type
|
|
||||||
decimal volumen_neto
|
|
||||||
timestamptz start_time
|
|
||||||
timestamptz end_time
|
|
||||||
}
|
|
||||||
MQTT_USERS {
|
|
||||||
varchar username PK
|
|
||||||
varchar password_hash
|
|
||||||
boolean is_admin
|
|
||||||
}
|
|
||||||
MQTT_ACLS {
|
|
||||||
int id PK
|
|
||||||
varchar username FK
|
|
||||||
varchar topic
|
|
||||||
int rw
|
|
||||||
}
|
|
||||||
|
|
||||||
ROLES ||--o{ USERS : "define"
|
|
||||||
USERS ||--o{ EDGE_PROJECTS : "accede a"
|
|
||||||
EDGE_PROJECTS ||--o{ EDGE_ASSETS : "contiene"
|
|
||||||
EDGE_ASSETS ||--o{ EDGE_DEVICES : "conecta"
|
|
||||||
EDGE_DEVICES ||--o{ EDGE_VARIABLES : "mapea"
|
|
||||||
EDGE_ASSETS ||--o{ TELEMETRY_RAW : "registra historial"
|
|
||||||
EDGE_ASSETS ||--o{ OPERATION_MOVEMENTS : "audita volumen"
|
|
||||||
MQTT_USERS ||--o{ MQTT_ACLS : "autoriza"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🖼️ Visualización de Arquitectura de Alta Escala
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔬 Análisis de Capas de Datos
|
|
||||||
|
|
||||||
### 🏢 Capa de Estructura Organizativa
|
|
||||||
Gestiona la jerarquía desde el cliente comercial hasta la variable física capturada en campo. Permite la multi-tenencia mediante el filtrado por `project_id`.
|
|
||||||
|
|
||||||
### ⚡ Capa de Tiempo Real (TimescaleDB)
|
|
||||||
La tabla `telemetry_raw` es una **Hypertable** optimizada para millones de registros.
|
|
||||||
- **Compresión**: Los datos se almacenan en formato JSONB para flexibilidad total de protocolos.
|
|
||||||
- **Rendimiento**: Consultas instantáneas de promedios, máximos y mínimos mediante funciones nativas de Timescale.
|
|
||||||
|
|
||||||
### 🛡️ Capa de Ciberseguridad Industrial
|
|
||||||
Control total del flujo MQTT mediante el plugin **Go-Auth**.
|
|
||||||
- **RBAC**: Los administradores tienen acceso total (`#`), los agentes solo a sus tópicos específicos.
|
|
||||||
- **Persistencia**: Las credenciales se inyectan dinámicamente desde el entorno para evitar fugas de seguridad.
|
|
||||||
|
|
||||||
### 📊 Capa de Cumplimiento (ANH)
|
|
||||||
La tabla `operation_movements` está diseñada para el reporting normativo:
|
|
||||||
- Clasificación de tipos de movimiento (Venta, Quema, Transferencia).
|
|
||||||
- Cálculos de volumen neto con precisión decimal de 4 dígitos.
|
|
||||||
|
|
||||||
---
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Esta arquitectura soporta escalado horizontal. Los microservicios como `events-relay` actúan como puentes entre la telemetría viva (MQTT) y la persistencia (Postgres).
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Propuesta Técnica: Telemetría en Tiempo Real y Cumplimiento Normativo ANH
|
|
||||||
|
|
||||||
## 1. Objetivo
|
|
||||||
Lograr una visualización de datos de dispositivos en tiempo real (estilo SCADA/Grafana) con latencia mínima, garantizando al mismo tiempo que el almacenamiento histórico cumpla estrictamente con la norma **ANH 0651** (registro cada 10 minutos) sin saturar la base de datos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Arquitectura de Doble Camino (Dual-Path)
|
|
||||||
|
|
||||||
### A. Camino Caliente (Hot Path) - Visualización Live
|
|
||||||
Este camino está diseñado para que el usuario "vea" qué está pasando ahora mismo sin necesidad de consultar la base de datos.
|
|
||||||
|
|
||||||
* **Protocolo**: MQTT sobre WebSockets (WSS) en el puerto 443.
|
|
||||||
* **Flujo**:
|
|
||||||
1. El **Edge Agent** publica datos cada 1-5 segundos (o por cambio significativo).
|
|
||||||
2. El **Broker (Mosquitto)** recibe el dato.
|
|
||||||
3. El **Frontend (React)**, conectado vía WSS, recibe la actualización instantáneamente.
|
|
||||||
* **Ventaja**: Latencia < 200ms. Carga de base de datos: **Cero**.
|
|
||||||
|
|
||||||
### B. Camino Frío (Cold Path) - Cumplimiento ANH 0651
|
|
||||||
Este camino se encarga de la persistencia legal y auditoría.
|
|
||||||
|
|
||||||
* **Frecuencia**: Cada 10 minutos (según norma).
|
|
||||||
* **Flujo**:
|
|
||||||
1. El **Ingestor** recibe todos los mensajes MQTT del Camino Caliente.
|
|
||||||
2. El **Ingestor** mantiene el "Último Valor Conocido" (Last Value Buffer) en memoria.
|
|
||||||
3. Cada **10 minutos**, un cron-job o timer en el Ingestor toma ese valor y lo inserta en **TimescaleDB**.
|
|
||||||
* **Ventaja**: Almacenamiento optimizado. Cumplimiento legal garantizado.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Estrategias de Optimización
|
|
||||||
|
|
||||||
### Deadbanding (Reporte por Excepción)
|
|
||||||
Para no saturar la red con datos que no cambian (ej: una temperatura estable), el **Edge Agent** implementará un umbral de cambio:
|
|
||||||
- Si el valor de la variable cambia menos de un **0.5%**, el agente no publica.
|
|
||||||
- Si el cambio es mayor al umbral, se publica inmediatamente para actualizar la gráfica.
|
|
||||||
|
|
||||||
### Retención de Datos (TTL)
|
|
||||||
TimescaleDB permite configurar políticas de retención automáticas:
|
|
||||||
- **Datos de Alta Resolución**: Se pueden guardar por 24 horas para permitir al usuario ver "qué pasó hace una hora" con detalle.
|
|
||||||
- **Datos de Norma (10 min)**: Se guardan de forma permanente para el historial anual.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Integración Edge Agent & Node-RED (Persistence Layer)
|
|
||||||
|
|
||||||
La inteligencia de reporte se delega a Node-RED, mientras que la seguridad del dato se delega al Agente de Borde.
|
|
||||||
|
|
||||||
### A. Lógica en Node-RED (Origen)
|
|
||||||
* **Tópico `/telemetry`**: Node-RED envía datos por cambio (Deadbanding) para alimentar el Hot Path.
|
|
||||||
* **Tópico `/compliance`**: Node-RED envía un "snapshot" consolidado exactamente cada 10 minutos (ANH 0651) para el Cold Path.
|
|
||||||
|
|
||||||
### B. Gestión en el Agente de Borde (Filtro)
|
|
||||||
El Agente actúa como un **Gateway Inteligente** entre Node-RED y la Nube:
|
|
||||||
1. **Tráfico `/telemetry`**: Se reenvía a la nube con prioridad de tiempo real. **No se persiste en disco local** (evitando desgaste innecesario del SSD/HDD y optimizando espacio).
|
|
||||||
2. **Tráfico `/compliance`**: Se cifra y se guarda en la base de datos local (**Store & Forward**) antes de intentarse enviar. Esto garantiza que el registro normativo nunca se pierda, incluso ante caídas de internet prolongadas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Beneficios del Enfoque
|
|
||||||
1. **Escalabilidad**: Soporta cientos de agentes enviando datos sin degradar el rendimiento del dashboard.
|
|
||||||
2. **Cuidado del Hardware**: Protege la vida útil del almacenamiento en el PC de borde al evitar escrituras constantes de datos en tiempo real.
|
|
||||||
3. **Seguridad Normativa**: Garantiza al 100% que los datos de la ANH siempre tienen un respaldo encriptado local mediante Store & Forward.
|
|
||||||
4. **Experiencia de Usuario**: Sensación de control total "en vivo" sobre los activos industriales con latencia SCADA.
|
|
||||||
588
docs/presentation.html
Normal file
588
docs/presentation.html
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OmniOil — Plataforma SCADA</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--black: #0a0a0a;
|
||||||
|
--white: #ffffff;
|
||||||
|
--gray-100: #f5f5f5;
|
||||||
|
--gray-200: #e8e8e8;
|
||||||
|
--gray-400: #9a9a9a;
|
||||||
|
--gray-600: #555555;
|
||||||
|
--accent: #c8a96e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--black);
|
||||||
|
color: var(--white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck { width: 100vw; height: 100vh; position: relative; }
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 72px 96px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .4s ease;
|
||||||
|
}
|
||||||
|
.slide.active { opacity: 1; pointer-events: all; }
|
||||||
|
|
||||||
|
.slide--cover { background: var(--black); }
|
||||||
|
.slide--dark { background: #111111; }
|
||||||
|
.slide--light { background: var(--gray-100); color: var(--black); }
|
||||||
|
.slide--accent { background: var(--accent); color: var(--black); }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: .72rem;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.slide--light .eyebrow { color: var(--gray-600); }
|
||||||
|
.slide--accent .eyebrow { color: rgba(0,0,0,.45); }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2.4rem, 4vw, 3.6rem);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
h1 strong { font-weight: 700; }
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(1.6rem, 2.5vw, 2.4rem);
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h2 strong { font-weight: 600; }
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--gray-400);
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.slide--light .lead { color: var(--gray-600); }
|
||||||
|
.slide--accent .lead { color: rgba(0,0,0,.6); }
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
width: 48px; height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.slide--light .rule { background: var(--black); }
|
||||||
|
.slide--accent .rule { background: rgba(0,0,0,.25); }
|
||||||
|
|
||||||
|
/* GRID */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 44px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #161616;
|
||||||
|
padding: 32px 28px;
|
||||||
|
}
|
||||||
|
.card__icon { font-size: 1.3rem; margin-bottom: 18px; opacity: .75; }
|
||||||
|
.card__title {
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.card__body {
|
||||||
|
font-size: .88rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FLOW */
|
||||||
|
.flow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 52px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 28px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.step:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 13px; right: 0;
|
||||||
|
width: 20px; height: 1px;
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
.step__num {
|
||||||
|
font-size: .68rem;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.step__title { font-size: .95rem; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.step__body { font-size: .84rem; line-height: 1.6; color: var(--gray-400); }
|
||||||
|
|
||||||
|
/* STATS */
|
||||||
|
.stats { display: flex; gap: 60px; margin-top: 52px; }
|
||||||
|
.stat__num {
|
||||||
|
font-size: clamp(2rem, 3.5vw, 3rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.slide--accent .stat__num { color: var(--black); }
|
||||||
|
.stat__label {
|
||||||
|
font-size: .75rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
.slide--accent .stat__label { color: rgba(0,0,0,.5); }
|
||||||
|
|
||||||
|
/* TABLE */
|
||||||
|
.table { width: 100%; border-collapse: collapse; margin-top: 40px; font-size: .88rem; }
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: .68rem;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
.table td {
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
color: var(--gray-400);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.table td:first-child { color: var(--white); font-weight: 500; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: .62rem;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(200,169,110,.12);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLOSE */
|
||||||
|
.close-line {
|
||||||
|
font-size: clamp(2rem, 3.5vw, 3rem);
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 640px;
|
||||||
|
margin-bottom: 44px;
|
||||||
|
}
|
||||||
|
.close-line strong { font-weight: 700; }
|
||||||
|
.cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: .82rem;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
padding: 14px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CHROME */
|
||||||
|
.logo {
|
||||||
|
position: fixed; top: 40px; left: 56px;
|
||||||
|
font-size: .75rem; letter-spacing: .22em; text-transform: uppercase;
|
||||||
|
color: rgba(255,255,255,.25); z-index: 100;
|
||||||
|
}
|
||||||
|
.slide-num {
|
||||||
|
position: fixed; bottom: 40px; left: 56px;
|
||||||
|
font-size: .68rem; letter-spacing: .12em;
|
||||||
|
color: rgba(255,255,255,.2); z-index: 100;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
position: fixed; bottom: 36px; right: 56px;
|
||||||
|
display: flex; align-items: center; gap: 20px; z-index: 100;
|
||||||
|
}
|
||||||
|
.nav__dots { display: flex; gap: 8px; }
|
||||||
|
.dot {
|
||||||
|
width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,.18); cursor: pointer;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.dot.active { background: var(--accent); }
|
||||||
|
.nav__btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255,255,255,.12);
|
||||||
|
color: rgba(255,255,255,.4);
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
cursor: pointer; font-size: 1rem;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: border-color .2s, color .2s;
|
||||||
|
}
|
||||||
|
.nav__btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="deck" id="deck">
|
||||||
|
|
||||||
|
<!-- 01 COVER -->
|
||||||
|
<div class="slide slide--cover active">
|
||||||
|
<p class="eyebrow">Plataforma SCADA · Resolución ANH 0651/2026</p>
|
||||||
|
<h1><strong>OmniOil</strong><br>Telemetría industrial<br>como servicio.</h1>
|
||||||
|
<div class="rule"></div>
|
||||||
|
<p class="lead">Monitoreo en tiempo real, cumplimiento automatizado y continuidad operativa para operaciones de hidrocarburos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 02 BENEFITS -->
|
||||||
|
<div class="slide slide--dark">
|
||||||
|
<p class="eyebrow">Beneficios</p>
|
||||||
|
<h2>Lo que <strong>OmniOil entrega</strong></h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__icon">⚡</div>
|
||||||
|
<div class="card__title">Tiempo real</div>
|
||||||
|
<div class="card__body">Conexión en tiempo real del estado del servicio con latencia mínima y alertas automáticas ante anomalías.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__icon">📄</div>
|
||||||
|
<div class="card__title">Reporte ANH automático</div>
|
||||||
|
<div class="card__body">Generación y firma digital del JSON ANH sin intervención manual, utilizando funciones criptográficas SHA-256.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__icon">🔌</div>
|
||||||
|
<div class="card__title">Equipos existentes</div>
|
||||||
|
<div class="card__body">Compatible con Modbus TCP, OPC-UA y PROFINET. No reemplaza infraestructura de campo, la conecta.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__icon">📡</div>
|
||||||
|
<div class="card__title">Resiliencia offline</div>
|
||||||
|
<div class="card__body">El agente de borde persiste datos localmente. Sin pérdida de mediciones ante cortes de conectividad.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__icon">🔒</div>
|
||||||
|
<div class="card__title">Seguridad enterprise</div>
|
||||||
|
<div class="card__body">Cifrado TLS en tránsito, credenciales Argon2id y aislamiento multi-tenant por ACL dinámica por proyecto.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__icon">☁</div>
|
||||||
|
<div class="card__title">SaaS sin fricción</div>
|
||||||
|
<div class="card__body">Sin servidores propios que operar. Actualizaciones automáticas. Escala por proyectos activos.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 03 FLOW -->
|
||||||
|
<div class="slide slide--dark">
|
||||||
|
<p class="eyebrow">Arquitectura</p>
|
||||||
|
<h2>Del <strong>campo al reporte,</strong><br>sin intervención manual.</h2>
|
||||||
|
<div class="flow">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__num">01</div>
|
||||||
|
<div class="step__title">Adquisición</div>
|
||||||
|
<div class="step__body">El Edge Agent lee instrumentos de campo vía Modbus TCP, OPC-UA o PROFINET y persiste localmente.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__num">02</div>
|
||||||
|
<div class="step__title">Transmisión</div>
|
||||||
|
<div class="step__body">Datos enviados cifrados a la plataforma vía MQTT. El agente reintenta automáticamente ante cortes.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__num">03</div>
|
||||||
|
<div class="step__title">Procesamiento</div>
|
||||||
|
<div class="step__body">Los datos son validados, procesados y almacenados con optimización para consultas históricas de alta frecuencia.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__num">04</div>
|
||||||
|
<div class="step__title">Reporte ANH</div>
|
||||||
|
<div class="step__body">Generación automática del JSON firmado con hash SHA-256, listo para envío a la ANH.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 04 COMPLIANCE -->
|
||||||
|
<div class="slide slide--dark">
|
||||||
|
<p class="eyebrow">Cumplimiento regulatorio</p>
|
||||||
|
<h2>Diseñado para la <strong>Resolución 0651/2026</strong></h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Requisito ANH</th>
|
||||||
|
<th>Solución OmniOil</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Telemetría continua de pozos</td>
|
||||||
|
<td>Edge Agent + MQTT Broker</td>
|
||||||
|
<td><span class="badge">Cubierto</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Reportes diarios en formato JSON</td>
|
||||||
|
<td>Generador automático con firma digital</td>
|
||||||
|
<td><span class="badge">Cubierto</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Integridad de datos certificable</td>
|
||||||
|
<td>SHA-256 por reporte. Outbox transaccional: garantía de que ningún reporte queda sin enviarse ante fallas técnicas.</td>
|
||||||
|
<td><span class="badge">Cubierto</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Alineación horaria UTC / ISO 8601</td>
|
||||||
|
<td>Timestamps normalizados en todo el stack</td>
|
||||||
|
<td><span class="badge">Cubierto</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Auditoría y trazabilidad</td>
|
||||||
|
<td>Logs inmutables con histórico completo de eventos, trazabilidad de cada medición desde campo hasta el reporte final</td>
|
||||||
|
<td><span class="badge">Cubierto</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 05 MADUREZ NORMATIVA -->
|
||||||
|
<div class="slide slide--dark">
|
||||||
|
<p class="eyebrow">Madurez normativa</p>
|
||||||
|
<h2>Posición regulatoria <strong>verificada y documentada.</strong></h2>
|
||||||
|
<table class="table" style="margin-top:36px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Normativa</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Nivel de madurez</th>
|
||||||
|
<th>Observación clave</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>ANH Res. 0651</td>
|
||||||
|
<td><span class="badge">Cumple</span></td>
|
||||||
|
<td style="color:var(--accent); font-weight:600;">95%</td>
|
||||||
|
<td>Arquitectura diseñada específicamente para este reporte.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>ISO 27001:2022</td>
|
||||||
|
<td><span class="badge" style="background:rgba(200,169,110,.06); color:#b89a55;">Parcial</span></td>
|
||||||
|
<td style="color:#b89a55; font-weight:600;">79%</td>
|
||||||
|
<td>Sólida base técnica. Brecha en formalización documental.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>IEC 62443</td>
|
||||||
|
<td><span class="badge">Cumple</span></td>
|
||||||
|
<td style="color:var(--accent); font-weight:600;">85%</td>
|
||||||
|
<td>Segmentación OT/IT y cifrado de borde implementado.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Ley 1581 (Datos)</td>
|
||||||
|
<td><span class="badge">Cumple</span></td>
|
||||||
|
<td style="color:var(--accent); font-weight:600;">90%</td>
|
||||||
|
<td>Roles granulares y Audit Log forense.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 06 STATS -->
|
||||||
|
<div class="slide slide--accent">
|
||||||
|
<p class="eyebrow">Por qué OmniOil</p>
|
||||||
|
<h2>Construido para<br><strong>operaciones críticas.</strong></h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div>
|
||||||
|
<div class="stat__num">3</div>
|
||||||
|
<div class="stat__label">Protocolos industriales</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat__num">0</div>
|
||||||
|
<div class="stat__label">Pérdida de datos offline</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat__num">100%</div>
|
||||||
|
<div class="stat__label">Cobertura ANH 0651</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat__num">SaaS</div>
|
||||||
|
<div class="stat__label">Sin infraestructura propia</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 06 VALOR AGREGADO -->
|
||||||
|
<div class="slide slide--accent">
|
||||||
|
<p class="eyebrow">Valor agregado</p>
|
||||||
|
<h2>Más allá del <strong>cumplimiento regulatorio</strong></h2>
|
||||||
|
<div class="grid" style="margin-top:36px;">
|
||||||
|
<div class="card" style="background:rgba(0,0,0,.08);">
|
||||||
|
<div class="card__title" style="color:rgba(0,0,0,.5);">Visibilidad operativa</div>
|
||||||
|
<div class="card__body" style="color:rgba(0,0,0,.65);">Acceso centralizado al estado de todos los pozos desde un solo panel. Decisiones basadas en datos en tiempo real, no en reportes manuales del día anterior.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="background:rgba(0,0,0,.08);">
|
||||||
|
<div class="card__title" style="color:rgba(0,0,0,.5);">Reducción de costos operativos</div>
|
||||||
|
<div class="card__body" style="color:rgba(0,0,0,.65);">Eliminación de procesos manuales de recolección y digitalización de datos. Menos personal dedicado a tareas administrativas de reporte.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="background:rgba(0,0,0,.08);">
|
||||||
|
<div class="card__title" style="color:rgba(0,0,0,.5);">Detección temprana de fallas</div>
|
||||||
|
<div class="card__body" style="color:rgba(0,0,0,.65);">Alertas automáticas ante comportamientos anómalos permiten anticipar fallas de equipo antes de que se conviertan en paradas no programadas.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="background:rgba(0,0,0,.08);">
|
||||||
|
<div class="card__title" style="color:rgba(0,0,0,.5);">Histórico auditable</div>
|
||||||
|
<div class="card__body" style="color:rgba(0,0,0,.65);">Años de datos de producción disponibles para análisis, auditorías internas y presentación ante entes reguladores sin depender de archivos locales.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="background:rgba(0,0,0,.08);">
|
||||||
|
<div class="card__title" style="color:rgba(0,0,0,.5);">Escalabilidad sin fricción</div>
|
||||||
|
<div class="card__body" style="color:rgba(0,0,0,.65);">Nuevos pozos o proyectos se incorporan a la plataforma sin cambios de infraestructura. El sistema crece con la operación.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="background:rgba(0,0,0,.08);">
|
||||||
|
<div class="card__title" style="color:rgba(0,0,0,.5);">Posición competitiva</div>
|
||||||
|
<div class="card__body" style="color:rgba(0,0,0,.65);">Operadores con telemetría en tiempo real y cumplimiento automatizado tienen ventaja en licitaciones y renovaciones de contrato ante la ANH.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 07 PRICING -->
|
||||||
|
<div class="slide slide--dark">
|
||||||
|
<p class="eyebrow">Modelo de precios</p>
|
||||||
|
<h2>Transparente y <strong>escalable por operación.</strong></h2>
|
||||||
|
<p class="lead" style="margin-bottom:40px;">Precio por tag conectado al año. Sin costos ocultos de infraestructura.</p>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rango</th>
|
||||||
|
<th>Tags conectados</th>
|
||||||
|
<th>Precio por tag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Rango inicial</td>
|
||||||
|
<td>Hasta 50 tags</td>
|
||||||
|
<td>Precio base</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Rango medio</td>
|
||||||
|
<td>51 – 150 tags</td>
|
||||||
|
<td>Descuento por volumen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Rango avanzado</td>
|
||||||
|
<td>151 – 400 tags</td>
|
||||||
|
<td>Mayor descuento por escala</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Enterprise</td>
|
||||||
|
<td>400+ tags</td>
|
||||||
|
<td>A negociar según operación</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:.75rem; color:var(--gray-400); margin-top:20px;">* El precio se define por rango de tags conectados. A mayor escala, mejor tarifa. Incluye plataforma, Edge Agent, reportes ANH y soporte.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 08 CAPACIDADES AVANZADAS -->
|
||||||
|
<div class="slide slide--dark">
|
||||||
|
<p class="eyebrow">Capacidades avanzadas</p>
|
||||||
|
<h2>Una plataforma <strong>lista para escalar.</strong></h2>
|
||||||
|
<div class="grid" style="margin-top:36px;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__title">Aplicación móvil (PWA)</div>
|
||||||
|
<div class="card__body">Los operadores de campo registran mediciones y movimientos desde zonas sin conectividad. La aplicación funciona completamente offline y sincroniza los datos automáticamente cuando vuelve la red. Se instala desde el navegador, sin pasar por ninguna tienda de aplicaciones.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__title">Actualizaciones OTA</div>
|
||||||
|
<div class="card__body">El Edge Agent instalado en campo se actualiza automáticamente desde la plataforma. Sin visitas técnicas ni intervención manual.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__title">2FA — Doble factor</div>
|
||||||
|
<div class="card__body">Autenticación de dos factores mediante TOTP en todos los accesos. Ningún usuario accede a la plataforma con solo una contraseña.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__title">ISO/IEC 27001:2022</div>
|
||||||
|
<div class="card__body">Plataforma alineada con el estándar internacional de seguridad de la información. Controles documentados, matriz de riesgos y trazabilidad de accesos.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__title">Alta disponibilidad</div>
|
||||||
|
<div class="card__body">Arquitectura activa/pasiva con réplica en zona geográfica diferente. Failover automático sin pérdida de datos ni interrupción del servicio.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__title">Monitoreo de plataforma</div>
|
||||||
|
<div class="card__body">Métricas en tiempo real de todos los servicios con Prometheus y Grafana. Visibilidad operativa completa del stack para el equipo técnico.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 09 CLOSE -->
|
||||||
|
<div class="slide slide--cover">
|
||||||
|
<p class="eyebrow">Próximos pasos</p>
|
||||||
|
<div class="close-line">
|
||||||
|
Implementación en <strong>semanas,</strong><br>no en meses.
|
||||||
|
</div>
|
||||||
|
<p class="lead" style="margin-bottom:40px;">Onboarding asistido, integración con equipos de campo existentes y soporte continuo incluido.</p>
|
||||||
|
<p style="font-size:.75rem; letter-spacing:.12em; text-transform:uppercase; color:rgba(255,255,255,.2); border-top:1px solid rgba(255,255,255,.08); padding-top:20px; margin-top:48px;">Kyrbot Innovations · OmniOil Platform · 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logo">OmniOil</div>
|
||||||
|
<div class="slide-num" id="slideNum">01 / 10</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav__dots" id="dots"></div>
|
||||||
|
<button class="nav__btn" id="prev">‹</button>
|
||||||
|
<button class="nav__btn" id="next">›</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const slides = document.querySelectorAll('.slide');
|
||||||
|
const dotsEl = document.getElementById('dots');
|
||||||
|
const numEl = document.getElementById('slideNum');
|
||||||
|
let cur = 0;
|
||||||
|
|
||||||
|
slides.forEach((_, i) => {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'dot' + (i === 0 ? ' active' : '');
|
||||||
|
d.addEventListener('click', () => goTo(i));
|
||||||
|
dotsEl.appendChild(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
function goTo(n) {
|
||||||
|
slides[cur].classList.remove('active');
|
||||||
|
dotsEl.children[cur].classList.remove('active');
|
||||||
|
cur = (n + slides.length) % slides.length;
|
||||||
|
slides[cur].classList.add('active');
|
||||||
|
dotsEl.children[cur].classList.add('active');
|
||||||
|
numEl.textContent = String(cur + 1).padStart(2,'0') + ' / ' + String(slides.length).padStart(2,'0');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('prev').addEventListener('click', () => goTo(cur - 1));
|
||||||
|
document.getElementById('next').addEventListener('click', () => goTo(cur + 1));
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') goTo(cur + 1);
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') goTo(cur - 1);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Repair Plan — Registro de Usuarios (Resumen)
|
|
||||||
|
|
||||||
## Contexto
|
|
||||||
- Registro fallaba en producción desde la PWA y vía API pública.
|
|
||||||
- Repositorio ya ajustado: reactivado endpoint de registro, mapeado `mobile.omnioil.app` y CORS mejorado (lista múltiple).
|
|
||||||
|
|
||||||
## Problemas Detectados
|
|
||||||
- 502 en PWA: Nginx de la PWA intentaba proxy interno a `backend-api` (no resolvible en Dokploy).
|
|
||||||
- 500 en API pública: gateway externo exige “key/túnel” para rutas de auth (bloquea `/api/auth/register`).
|
|
||||||
- CORS desalineado: backend devolvía origen por defecto (localhost) en prod.
|
|
||||||
|
|
||||||
## Cambios Requeridos (Producción)
|
|
||||||
- PWA: construir con `VITE_API_BASE=https://api.omnioil.app` (consumo por dominio público, no proxy interno).
|
|
||||||
- Backend API: `ALLOWED_ORIGINS=https://mobile.omnioil.app,https://app.omnioil.app` y redeploy.
|
|
||||||
- Gateway `api.omnioil.app`: permitir sin secreto/mTLS estas rutas públicas:
|
|
||||||
- `/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`
|
|
||||||
- Mantener protección para el resto de rutas.
|
|
||||||
- Desplegar: rebuild y restart de `backend-api`, `frontend-pwa` y `nginx` (si aplica).
|
|
||||||
|
|
||||||
## Validación
|
|
||||||
- PWA Registro: `POST https://mobile.omnioil.app/api/auth/register` → 201 `{id,email}`; repetido → 409.
|
|
||||||
- PWA Login: `POST https://mobile.omnioil.app/api/auth/login` → 200 con tokens.
|
|
||||||
- API directa: `POST https://api.omnioil.app/api/auth/register` → 201/409 (si gateway ajustado).
|
|
||||||
- Salud: `GET https://api.omnioil.app/` devuelve banner de API.
|
|
||||||
|
|
||||||
## Endurecimiento (Opcional)
|
|
||||||
- Flag `ENABLE_PUBLIC_REGISTER=false` en prod (registro solo por invitación).
|
|
||||||
- CORS estricto (solo domininios productivos).
|
|
||||||
- Mantener gateway con protección para rutas no públicas.
|
|
||||||
|
|
||||||
## Cambios ya aplicados en repo
|
|
||||||
- Rehabilitado `POST /api/auth/register` en backend.
|
|
||||||
- `mobile.omnioil.app` mapeado a PWA en gateway interno.
|
|
||||||
- Backend soporta `ALLOWED_ORIGINS` como lista separada por comas.
|
|
||||||
219
docs/speech-presentacion.md
Normal file
219
docs/speech-presentacion.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Speech de Presentación — OmniOil SCADA
|
||||||
|
**Audience:** Clientes del sector hidrocarburos
|
||||||
|
**Duración estimada:** 20–25 minutos
|
||||||
|
**Formato:** Slide por slide. Las transiciones están marcadas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 01 — Portada
|
||||||
|
*[Esperar a que todos estén atentos. Hablar despacio y con seguridad.]*
|
||||||
|
|
||||||
|
Gracias por su tiempo.
|
||||||
|
|
||||||
|
Lo que voy a mostrarles hoy resuelve un problema concreto que toda operación de hidrocarburos en Colombia va a enfrentar en 2026: la Resolución ANH 0651 exige telemetría continua y reporte automatizado, y la mayoría de los operadores todavía no tiene una solución en producción.
|
||||||
|
|
||||||
|
OmniOil es esa solución.
|
||||||
|
|
||||||
|
No es un sistema nuevo que reemplaza lo que ya tienen. Es la capa que conecta sus instrumentos de campo con el cumplimiento regulatorio — automáticamente, sin intervención manual, y sin que tengan que operar servidores propios.
|
||||||
|
|
||||||
|
Telemetría industrial como servicio.
|
||||||
|
|
||||||
|
*[Pausa breve. Avanzar a la siguiente diapositiva.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 02 — Beneficios
|
||||||
|
*[Señalar las tarjetas a medida que se mencionan.]*
|
||||||
|
|
||||||
|
Lo que OmniOil entrega, en concreto:
|
||||||
|
|
||||||
|
**Tiempo real.** El estado de cada pozo disponible en el instante en que ocurre, con alertas automáticas ante cualquier anomalía. Sin esperar el reporte del día siguiente.
|
||||||
|
|
||||||
|
**Reportes ANH automáticos.** El JSON que exige la regulación se genera y firma digitalmente sin que ningún operador tenga que tocarlo. El sistema lo hace por ustedes, con firma criptográfica SHA-256.
|
||||||
|
|
||||||
|
**Equipos existentes.** OmniOil no reemplaza su infraestructura de campo. Se conecta a ella. Modbus TCP, OPC-UA, PROFINET — los protocolos industriales que ya tienen instalados.
|
||||||
|
|
||||||
|
**Resiliencia offline.** Si se cae la conectividad, los datos no se pierden. El agente de borde los guarda localmente y los sincroniza cuando la conexión se restablece.
|
||||||
|
|
||||||
|
**Seguridad enterprise.** Cifrado TLS, credenciales con Argon2id, y aislamiento completo entre proyectos. Cada operación es independiente.
|
||||||
|
|
||||||
|
**Y es SaaS.** Sin servidores que mantener. Sin actualizaciones manuales. Sin costos de infraestructura ocultos.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 03 — Arquitectura
|
||||||
|
*[Seguir el flujo de izquierda a derecha mientras se habla.]*
|
||||||
|
|
||||||
|
Permítanme mostrarles cómo funciona el sistema de punta a punta.
|
||||||
|
|
||||||
|
**Paso uno: Adquisición.** El Edge Agent — un software que se instala en campo — lee directamente los instrumentos vía Modbus, OPC-UA o PROFINET. Los datos quedan almacenados localmente desde el primer instante.
|
||||||
|
|
||||||
|
**Paso dos: Transmisión.** Esos datos viajan cifrados a la plataforma mediante MQTT. Si hay un corte de red, el agente reintenta automáticamente. Nada se pierde.
|
||||||
|
|
||||||
|
**Paso tres: Procesamiento.** En la plataforma, los datos se validan y almacenan optimizados para consultas históricas. No es solo un buffer — es una base de datos de serie de tiempo diseñada para operaciones de alta frecuencia.
|
||||||
|
|
||||||
|
**Paso cuatro: Reporte ANH.** El sistema genera el JSON requerido, lo firma con SHA-256, y lo deja listo para envío. Sin que ningún operador tenga que intervenir.
|
||||||
|
|
||||||
|
Del campo al reporte regulatorio, sin fricción.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 04 — Cumplimiento regulatorio
|
||||||
|
*[Recorrer la tabla fila por fila. No apresurar.]*
|
||||||
|
|
||||||
|
La Resolución 0651 tiene requisitos muy específicos. Voy a mostrarles cómo OmniOil los cubre, uno por uno.
|
||||||
|
|
||||||
|
**Telemetría continua de pozos** — cubierto con el Edge Agent y el broker MQTT. Lectura permanente, sin huecos.
|
||||||
|
|
||||||
|
**Reportes diarios en formato JSON** — cubierto con el generador automático y firma digital. Sin trabajo manual.
|
||||||
|
|
||||||
|
**Integridad de datos certificable** — cada reporte tiene su hash SHA-256. Y algo importante: usamos un patrón de outbox transaccional. Eso significa que si el sistema falla en el momento de enviar un reporte, no se pierde — se reintenta garantizando que ningún reporte quede sin enviarse.
|
||||||
|
|
||||||
|
**Alineación horaria UTC / ISO 8601** — todos los timestamps están normalizados en todo el stack. Nada queda desincronizado.
|
||||||
|
|
||||||
|
**Auditoría y trazabilidad** — logs inmutables. Cada medición tiene trazabilidad desde que sale del instrumento de campo hasta que aparece en el reporte final.
|
||||||
|
|
||||||
|
La regulación no es un obstáculo para OmniOil — es el caso de uso para el que fue diseñado.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 05 — Madurez normativa
|
||||||
|
*[Tono seguro. Esta diapositiva proyecta confianza técnica.]*
|
||||||
|
|
||||||
|
Voy a ser transparente con ustedes sobre el estado regulatorio de la plataforma — porque la transparencia es parte del valor que ofrecemos.
|
||||||
|
|
||||||
|
**ANH Resolución 0651:** cumplimiento al 95%. La arquitectura fue diseñada específicamente para este reporte. Es el caso central, no un agregado de último momento.
|
||||||
|
|
||||||
|
**ISO 27001:2022:** cumplimiento al 79%. Tenemos una base técnica sólida. La brecha está en la formalización documental, que está en proceso.
|
||||||
|
|
||||||
|
**IEC 62443 — el estándar de seguridad para sistemas de control industrial:** cumplimiento al 85%. Segmentación OT/IT implementada, cifrado en el borde.
|
||||||
|
|
||||||
|
**Ley 1581 de protección de datos:** cumplimiento al 90%. Roles granulares, audit log forense.
|
||||||
|
|
||||||
|
No venimos a decirles que todo está al 100% cuando no lo está. Venimos a mostrarles que la posición regulatoria está verificada, documentada, y en evolución continua.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 06 — Por qué OmniOil
|
||||||
|
*[Dejar que los números hablen. Pausa después de cada uno.]*
|
||||||
|
|
||||||
|
Cuatro números que resumen la plataforma:
|
||||||
|
|
||||||
|
**3** protocolos industriales soportados nativamente. Sus equipos de campo ya están cubiertos.
|
||||||
|
|
||||||
|
**0** pérdida de datos en condiciones offline. Cero.
|
||||||
|
|
||||||
|
**100%** de cobertura de la ANH 0651. Todos los requisitos, todos cubiertos.
|
||||||
|
|
||||||
|
**SaaS.** Sin infraestructura propia que operar. Ustedes se enfocan en la operación del pozo — nosotros nos encargamos de la plataforma.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 07 — Valor agregado
|
||||||
|
*[Este slide va más allá de la regulación. Hablar con energía.]*
|
||||||
|
|
||||||
|
El cumplimiento regulatorio es la línea de entrada. Pero OmniOil entrega mucho más.
|
||||||
|
|
||||||
|
**Visibilidad operativa.** Todos sus pozos en un solo panel, en tiempo real. Las decisiones se toman sobre datos del momento, no sobre un reporte del día anterior.
|
||||||
|
|
||||||
|
**Reducción de costos operativos.** Cada proceso manual de recolección y digitalización de datos que hoy consume tiempo de su equipo — ese proceso desaparece.
|
||||||
|
|
||||||
|
**Detección temprana de fallas.** Las alertas automáticas permiten anticipar un problema de equipo antes de que se convierta en una parada no programada. Y una parada no programada en un pozo cuesta órdenes de magnitud más que la plataforma entera.
|
||||||
|
|
||||||
|
**Histórico auditable.** Años de datos de producción disponibles para análisis, auditorías internas o presentación ante la ANH — sin depender de archivos locales ni de que alguien los haya guardado bien.
|
||||||
|
|
||||||
|
**Escalabilidad sin fricción.** Un pozo nuevo, un proyecto nuevo — se incorpora a la plataforma sin cambiar infraestructura. El sistema crece con ustedes.
|
||||||
|
|
||||||
|
**Y posición competitiva.** Los operadores con telemetría en tiempo real y cumplimiento automatizado tienen una ventaja real en licitaciones y renovaciones de contrato.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 08 — Modelo de precios
|
||||||
|
*[Tono directo y claro. Sin rodeos.]*
|
||||||
|
|
||||||
|
El modelo de precios es simple: se paga por tag conectado al año. Sin costos ocultos de infraestructura, sin sorpresas.
|
||||||
|
|
||||||
|
Un tag es una variable — una medición — que viene de un instrumento de campo. Presión, temperatura, caudal — cada uno es un tag.
|
||||||
|
|
||||||
|
El precio escala por rango: a mayor volumen de tags, mejor tarifa por unidad. Operaciones más grandes pagan menos por tag.
|
||||||
|
|
||||||
|
Para operaciones de 400 tags o más, la tarifa se negocia directamente según la escala de la operación.
|
||||||
|
|
||||||
|
Todo incluido: plataforma, Edge Agent, reportes ANH y soporte.
|
||||||
|
|
||||||
|
No hay sorpresas en la factura.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 09 — Capacidades avanzadas
|
||||||
|
*[Este slide proyecta madurez técnica. Hablar con confianza.]*
|
||||||
|
|
||||||
|
Más allá del cumplimiento regulatorio, OmniOil es una plataforma lista para crecer.
|
||||||
|
|
||||||
|
**Aplicación móvil PWA.** Diseñada para zonas sin conectividad. Los operadores registran mediciones y movimientos completamente offline — los datos quedan guardados en el dispositivo y se sincronizan solos cuando vuelve la red. Se instala desde el navegador, sin App Store ni Google Play.
|
||||||
|
|
||||||
|
**Actualizaciones OTA.** El Edge Agent instalado en campo se actualiza automáticamente. Sin visitas técnicas. Sin intervención manual. La plataforma siempre está actualizada en campo.
|
||||||
|
|
||||||
|
**Doble factor de autenticación.** TOTP en todos los accesos. Ningún usuario entra con solo una contraseña. Cumplimiento con estándares de seguridad de acceso.
|
||||||
|
|
||||||
|
**ISO/IEC 27001:2022.** Controles documentados, matriz de riesgos, trazabilidad de accesos. La plataforma está alineada con el estándar internacional de seguridad de la información.
|
||||||
|
|
||||||
|
**Alta disponibilidad.** Arquitectura activa/pasiva con réplica en zona geográfica diferente. Failover automático. Sin pérdida de datos, sin interrupción del servicio.
|
||||||
|
|
||||||
|
**Monitoreo de plataforma.** Prometheus y Grafana. El equipo técnico tiene visibilidad completa del stack en tiempo real.
|
||||||
|
|
||||||
|
*[Avanzar.]*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SLIDE 10 — Cierre / Próximos pasos
|
||||||
|
*[Hablar despacio. Mirar a los interlocutores. Cerrar con convicción.]*
|
||||||
|
|
||||||
|
La implementación de OmniOil se mide en semanas, no en meses.
|
||||||
|
|
||||||
|
El onboarding es asistido — nuestro equipo acompaña la integración con los equipos de campo existentes. No es una caja negra que les entregamos y los dejamos solos. El soporte continuo está incluido.
|
||||||
|
|
||||||
|
La regulación tiene fecha. La ANH 0651 está vigente. Y las operaciones que lleguen sin una solución operativa van a tener un problema concreto con la autoridad regulatoria.
|
||||||
|
|
||||||
|
OmniOil está listo para conectarse a su operación ahora.
|
||||||
|
|
||||||
|
La pregunta que les dejo es simple: ¿prefieren resolver esto hoy, con tiempo y orden, o cuando ya no quede margen?
|
||||||
|
|
||||||
|
Estoy disponible para responder cualquier pregunta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Respuestas a preguntas frecuentes
|
||||||
|
|
||||||
|
**"¿Cuánto tiempo lleva la instalación en campo?"**
|
||||||
|
La instalación del Edge Agent en campo es cuestión de horas si el equipo tiene conectividad. La integración con los instrumentos — dependiendo de la cantidad de tags — puede tomar entre uno y tres días por sitio.
|
||||||
|
|
||||||
|
**"¿Qué pasa si no tenemos Modbus? ¿Funciona igual?"**
|
||||||
|
Si tienen OPC-UA o PROFINET, funciona igual. Si tienen un protocolo propietario, evaluamos la integración — la mayoría de los fabricantes industriales tienen un driver disponible.
|
||||||
|
|
||||||
|
**"¿Quién opera la plataforma?"**
|
||||||
|
Nosotros. Es SaaS. El cliente opera el negocio, nosotros operamos la plataforma.
|
||||||
|
|
||||||
|
**"¿Qué garantía hay de disponibilidad?"**
|
||||||
|
Arquitectura multi-región con failover automático. El SLA se define por contrato.
|
||||||
|
|
||||||
|
**"¿Cuándo está disponible el 100% de ISO 27001?"**
|
||||||
|
La brecha actual es documental, no técnica. La formalización está en curso y proyectamos cierre en el segundo semestre de 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Kyrbot Innovations · OmniOil Platform · 2026*
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
password = b'admin123'
|
|
||||||
salt = os.urandom(16)
|
|
||||||
iterations = 100000
|
|
||||||
hash_bytes = hashlib.pbkdf2_hmac('sha512', password, salt, iterations, dklen=64)
|
|
||||||
|
|
||||||
salt_b64 = base64.b64encode(salt).decode()
|
|
||||||
hash_b64 = base64.b64encode(hash_bytes).decode()
|
|
||||||
|
|
||||||
print(f'PBKDF2$sha512${iterations}${salt_b64}${hash_b64}')
|
|
||||||
245
infrastructure/monitoring/grafana/dashboards/omnioil-api.json
Normal file
245
infrastructure/monitoring/grafana/dashboards/omnioil-api.json
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [],
|
||||||
|
"__requires": [],
|
||||||
|
"annotations": { "list": [] },
|
||||||
|
"description": "OmniOil SCADA platform API health and business metrics",
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Active Projects",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "omnioil_active_projects",
|
||||||
|
"legendFormat": "Projects",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [{ "color": "green", "value": null }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Active Tags",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "omnioil_active_tags",
|
||||||
|
"legendFormat": "Tags",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [{ "color": "blue", "value": null }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Telemetry Messages Total",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "omnioil_telemetry_messages_total",
|
||||||
|
"legendFormat": "Messages",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [{ "color": "purple", "value": null }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Alarms Triggered",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "omnioil_alarms_triggered_total",
|
||||||
|
"legendFormat": "Alarms",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 1 },
|
||||||
|
{ "color": "red", "value": 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "HTTP Request Rate",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(axum_http_requests_total[5m])) by (method, endpoint)",
|
||||||
|
"legendFormat": "{{method}} {{endpoint}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"unit": "reqps"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "HTTP Error Rate (5xx)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(axum_http_requests_total{http_status_code=~\"5..\"}[5m])) by (endpoint)",
|
||||||
|
"legendFormat": "5xx {{endpoint}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "fixed", "fixedColor": "red" },
|
||||||
|
"unit": "reqps"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"title": "Request Duration p95",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(axum_http_requests_duration_seconds_bucket[5m])) by (le, endpoint))",
|
||||||
|
"legendFormat": "p95 {{endpoint}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"unit": "s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"title": "Telemetry Rate",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "rate(omnioil_telemetry_messages_total[5m])",
|
||||||
|
"legendFormat": "msg/s",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "fixed", "fixedColor": "purple" },
|
||||||
|
"unit": "mps"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"tags": ["omnioil", "api", "production"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"name": "datasource",
|
||||||
|
"options": [],
|
||||||
|
"query": "prometheus",
|
||||||
|
"refresh": 1,
|
||||||
|
"type": "datasource",
|
||||||
|
"label": "Datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": { "from": "now-1h", "to": "now" },
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "America/Bogota",
|
||||||
|
"title": "OmniOil — API Health",
|
||||||
|
"uid": "omnioil-api-health",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [],
|
||||||
|
"__requires": [],
|
||||||
|
"annotations": { "list": [] },
|
||||||
|
"description": "OmniOil SCADA platform — operational business metrics",
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Active Tags",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "omnioil_active_tags",
|
||||||
|
"legendFormat": "Tags",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [{ "color": "blue", "value": null }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Alarms Last 24h",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "increase(omnioil_alarms_triggered_total[24h])",
|
||||||
|
"legendFormat": "Alarms (24h)",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 5 },
|
||||||
|
{ "color": "red", "value": 20 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Telemetry Rate Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "rate(omnioil_telemetry_messages_total[5m])",
|
||||||
|
"legendFormat": "msg/s",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "fixed", "fixedColor": "purple" },
|
||||||
|
"unit": "mps"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Alarms Rate Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "rate(omnioil_alarms_triggered_total[5m])",
|
||||||
|
"legendFormat": "alarms/s",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "fixed", "fixedColor": "red" },
|
||||||
|
"unit": "ops"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": { "tooltip": { "mode": "multi" } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"tags": ["omnioil", "operations"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"name": "datasource",
|
||||||
|
"options": [],
|
||||||
|
"query": "prometheus",
|
||||||
|
"refresh": 1,
|
||||||
|
"type": "datasource",
|
||||||
|
"label": "Datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": { "from": "now-1h", "to": "now" },
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "America/Bogota",
|
||||||
|
"title": "OmniOil — Operaciones",
|
||||||
|
"uid": "omnioil-operations",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: 'OmniOil'
|
||||||
|
orgId: 1
|
||||||
|
folder: 'OmniOil'
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
15
infrastructure/monitoring/prometheus.yml
Normal file
15
infrastructure/monitoring/prometheus.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'omnioil-api'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['backend-api:8000']
|
||||||
|
metrics_path: '/metrics'
|
||||||
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
- job_name: 'node-exporter'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['node-exporter:9100']
|
||||||
|
scrape_interval: 30s
|
||||||
@@ -5,8 +5,9 @@ FROM iegomez/mosquitto-go-auth:3.0.0-mosquitto_2.0.18
|
|||||||
COPY mosquitto.conf.template /etc/mosquitto/mosquitto.conf.template
|
COPY mosquitto.conf.template /etc/mosquitto/mosquitto.conf.template
|
||||||
COPY entrypoint.sh /etc/mosquitto/entrypoint.sh
|
COPY entrypoint.sh /etc/mosquitto/entrypoint.sh
|
||||||
|
|
||||||
# Aseguramos permisos de ejecución
|
# Aseguramos permisos de ejecución y limpiamos finales de línea de Windows
|
||||||
RUN chmod +x /etc/mosquitto/entrypoint.sh
|
RUN sed -i 's/\r$//' /etc/mosquitto/entrypoint.sh && \
|
||||||
|
chmod +x /etc/mosquitto/entrypoint.sh
|
||||||
|
|
||||||
# Exponemos los puertos
|
# Exponemos los puertos
|
||||||
EXPOSE 1883 9883
|
EXPOSE 1883 9883
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# 🛡️ [OmniOil] Ensure data and log directories have correct permissions
|
||||||
|
# Mosquitto runs as UID 1883, we need to make sure it can write to the volumes
|
||||||
|
echo "🔐 [OmniOil] Fixing permissions for /mosquitto/data and /mosquitto/log..."
|
||||||
|
mkdir -p /mosquitto/data /mosquitto/log
|
||||||
|
chown -R mosquitto:mosquitto /mosquitto/data /mosquitto/log
|
||||||
|
|
||||||
# Generar el archivo final a partir de la plantilla
|
# Generar el archivo final a partir de la plantilla
|
||||||
TEMPLATE="/etc/mosquitto/mosquitto.conf.template"
|
TEMPLATE="/etc/mosquitto/mosquitto.conf.template"
|
||||||
CONFIG="/etc/mosquitto/mosquitto.conf"
|
CONFIG="/etc/mosquitto/mosquitto.conf"
|
||||||
|
|||||||
@@ -35,10 +35,15 @@ auth_opt_pg_aclquery SELECT topic FROM mqtt_acls WHERE username = $1 AND rw >= $
|
|||||||
|
|
||||||
# General Plugin Options
|
# General Plugin Options
|
||||||
auth_opt_pg_sslmode disable
|
auth_opt_pg_sslmode disable
|
||||||
auth_opt_check_interval 60
|
auth_opt_check_interval 10
|
||||||
auth_opt_hasher bcrypt
|
auth_opt_hasher bcrypt
|
||||||
auth_opt_log_level info
|
auth_opt_log_level info
|
||||||
|
|
||||||
# Persistence
|
# Persistence
|
||||||
persistence true
|
persistence true
|
||||||
persistence_location /mosquitto/data/
|
persistence_location /mosquitto/data
|
||||||
|
|
||||||
|
# Límite de tamaño de paquetes MQTT
|
||||||
|
# 256KB: suficiente para payloads de provisioning comprimidos y telemetría
|
||||||
|
# (rumqttc también está configurado con 256KB)
|
||||||
|
message_size_limit 262144
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
# Nginx Gateway: Subdomain Routing for OmniOil SaaS Ecosystem
|
# Nginx Gateway: Subdomain Routing for OmniOil SaaS Ecosystem
|
||||||
|
# ─── Bloque de cabeceras de seguridad compartidas (HIGH-7) ────────────────────
|
||||||
|
map $sent_http_content_type $csp_header {
|
||||||
|
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss:; frame-ancestors 'none';";
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name omnioil.app www.omnioil.app localhost;
|
server_name omnioil.app www.omnioil.app localhost;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend-api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://landing:80;
|
proxy_pass http://landing:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -14,6 +33,29 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name app.omnioil.app;
|
server_name app.omnioil.app;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy $csp_header always;
|
||||||
|
# Dokploy gestiona TLS en el puerto 443. El browser accede siempre por HTTPS.
|
||||||
|
# HSTS le indica al browser que solo use HTTPS para este dominio (HIGH-6).
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend-api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
|
||||||
|
# Soporte para WebSockets
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend-admin:80;
|
proxy_pass http://frontend-admin:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -30,10 +72,62 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name console.omnioil.app;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy $csp_header always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend-api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend-console:80;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name mobile.omnioil.app;
|
server_name mobile.omnioil.app;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy $csp_header always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend-api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
|
||||||
|
# Soporte para WebSockets
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend-pwa:80;
|
proxy_pass http://frontend-pwa:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -50,14 +144,6 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy pass for Backend API (Global)
|
# api.omnioil.app eliminado (MED-9): el backend nunca debe estar
|
||||||
server {
|
# expuesto directamente al exterior. Todo el tráfico pasa por los
|
||||||
listen 80;
|
# bloques app.omnioil.app / mobile.omnioil.app con el prefijo /api.
|
||||||
server_name api.omnioil.app;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://backend-api:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ async function main() {
|
|||||||
console.log(' ✓ admin@omnioil.com (Admin123!)')
|
console.log(' ✓ admin@omnioil.com (Admin123!)')
|
||||||
console.log(' ✓ operador@omnioil.com (Campo123!)')
|
console.log(' ✓ operador@omnioil.com (Campo123!)')
|
||||||
|
|
||||||
|
// ─── 1b. Proyecto por Defecto ───────────────────────────
|
||||||
|
console.log('\n📦 Creando proyecto por defecto...')
|
||||||
|
const projectRes = await client.query(`
|
||||||
|
INSERT INTO edge_projects (name, client, operator_name, contract_number, description)
|
||||||
|
VALUES ('Proyecto Piloto Norte', 'OmniOil Corp', 'OmniOil Operator', 'CONT-2026-001', 'Campo de pruebas principal')
|
||||||
|
ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description
|
||||||
|
RETURNING id
|
||||||
|
`)
|
||||||
|
const projectId = projectRes.rows[0].id
|
||||||
|
|
||||||
|
// Vincular admin al proyecto
|
||||||
|
const adminUser = await client.query("SELECT id FROM users WHERE email = 'admin@omnioil.com'")
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO user_project_access (user_id, project_id, project_role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [adminUser.rows[0].id, projectId])
|
||||||
|
|
||||||
// ─── 2. Assets ─────────────────────────────────────────
|
// ─── 2. Assets ─────────────────────────────────────────
|
||||||
console.log('\n📦 Insertando assets...')
|
console.log('\n📦 Insertando assets...')
|
||||||
|
|
||||||
@@ -62,7 +80,8 @@ async function main() {
|
|||||||
const assetIds = {}
|
const assetIds = {}
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const result = await client.query(`
|
const result = await client.query(`
|
||||||
INSERT INTO assets (code, name, asset_type, metadata) VALUES ($1, $2, $3, $4)
|
INSERT INTO edge_assets (code, name, asset_type, metadata, project_id)
|
||||||
|
SELECT $1, $2, $3, $4, id FROM edge_projects LIMIT 1
|
||||||
ON CONFLICT (code) DO UPDATE SET name = $2
|
ON CONFLICT (code) DO UPDATE SET name = $2
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, [asset.code, asset.name, asset.type, JSON.stringify(asset.meta)])
|
`, [asset.code, asset.name, asset.type, JSON.stringify(asset.meta)])
|
||||||
@@ -109,9 +128,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO telemetry_raw (time, asset_id, data) VALUES ($1::timestamptz, $2::uuid, $3::jsonb)
|
`INSERT INTO telemetry_raw (time, project_id, data)
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING`,
|
SELECT $1::timestamptz, id, $2::jsonb FROM edge_projects LIMIT 1`,
|
||||||
[time.toISOString(), assetId, JSON.stringify(data)]
|
[time.toISOString(), JSON.stringify(data)]
|
||||||
)
|
)
|
||||||
insertCount++
|
insertCount++
|
||||||
}
|
}
|
||||||
@@ -154,7 +173,7 @@ async function main() {
|
|||||||
for (const mov of movements) {
|
for (const mov of movements) {
|
||||||
const assetId = assetIds[mov.assetCode]
|
const assetId = assetIds[mov.assetCode]
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO operations_movements (asset_id, movement_type, start_time, end_time, volumen_neto, details)
|
INSERT INTO operation_movements (asset_id, movement_type, start_time, end_time, volumen_neto, details)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
`, [assetId, mov.type, mov.start.toISOString(), mov.end.toISOString(), mov.volumen, JSON.stringify(mov.details)])
|
`, [assetId, mov.type, mov.start.toISOString(), mov.end.toISOString(), mov.volumen, JSON.stringify(mov.details)])
|
||||||
console.log(` ✓ ${mov.type} — ${mov.assetCode}: ${mov.volumen} BBL`)
|
console.log(` ✓ ${mov.type} — ${mov.assetCode}: ${mov.volumen} BBL`)
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# OmniOil - Marco de Pruebas de Infraestructura (SCADA)
|
|
||||||
|
|
||||||
Este documento describe la suite de pruebas diseñada para validar la comunicación, seguridad y persistencia de datos entre los **Agentes de Borde** y el **Servidor Central**.
|
|
||||||
|
|
||||||
## 1. Misión de las Pruebas
|
|
||||||
Garantizar que el flujo de datos desde el sensor (simulado) hasta la base de datos central sea íntegro, seguro y autenticado.
|
|
||||||
|
|
||||||
## 2. Inventario de Pruebas
|
|
||||||
|
|
||||||
| Script | Módulo Probado | Descripción | Validación Esperada |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| `simulate_agent.py` | Edge Agent -> MQTT | Simula un agente de campo enviando telemetría viva. | El servidor MQTT debe aceptar la conexión y el backend debe loguear el mensaje. |
|
|
||||||
| `test_mqtt_auth.py` | Mosquitto Auth Plugin | Intenta conexiones con/sin credenciales. | Debe rechazar conexiones `Anonymous` y aceptar `omnioil_admin`. |
|
|
||||||
|
|
||||||
## 📋 Reporte de Resultados (Sesión Actual)
|
|
||||||
|
|
||||||
### 1. Validación de Seguridad (MQTT Auth)
|
|
||||||
**Script**: `test_mqtt_auth.py`
|
|
||||||
**Resultado**: ✅ PASADO
|
|
||||||
**Detalles**:
|
|
||||||
- Conexión con `omnioil_admin`: **ESTABLECIDA** (CONNACK 0, 0)
|
|
||||||
- Conexión Anónima: **DENEGADA** (CONNACK 0, 5) - *Seguridad OK*
|
|
||||||
|
|
||||||
### 2. Simulación de Flujo de Datos (Agente de Campo)
|
|
||||||
**Script**: `simulate_agent.py`
|
|
||||||
**Resultado**: ✅ PASADO
|
|
||||||
**Logs de salida**:
|
|
||||||
```text
|
|
||||||
✅ Simulación de Agente Conectada a Central.
|
|
||||||
🚀 Enviando telemetría POZO-01: {"timestamp": "2026-04-06T21:19:04", "asset_id": "POZO-01", "telemetry": {...}}
|
|
||||||
🚀 Enviando telemetría POZO-01: {"timestamp": "2026-04-06T21:19:09", "asset_id": "POZO-01", "telemetry": {...}}
|
|
||||||
⏹️ Simulación detenida por el usuario.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
*Reporte generado automáticamente por Antigravity*
|
|
||||||
|
|
||||||
## 3. Guía de Ejecución
|
|
||||||
|
|
||||||
### Prerrequisitos
|
|
||||||
- Tener `python3` instalado localmente.
|
|
||||||
- Instalar cliente MQTT para Python: `pip install paho-mqtt`.
|
|
||||||
- La infraestructura de Docker debe estar corriendo (`docker compose up -d`).
|
|
||||||
|
|
||||||
### Ejecución de Pruebas
|
|
||||||
Ejecuta los scripts desde la carpeta raíz del proyecto para que puedan leer el puerto configurado:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pip install paho-mqtt
|
|
||||||
python infrastructure/tests/test_mqtt_auth.py
|
|
||||||
python infrastructure/tests/simulate_agent.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Estas pruebas son esenciales después de cualquier cambio en la configuración de `mosquitto-go-auth` o en las tablas de ACLs de la base de datos.
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Design: Align Console/Admin/PWA Visual Language
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
Use Landing as the visual reference only, then align each Vite app through local CSS tokens and existing local UI primitives. Phase 1 updates Console first because it still uses teal/blue hardcodes in `globals.css`, `PlatformLayout`, `LoginPage`, and primitive `button/card/badge`. Phase 2 applies the same token baseline to Admin's shadcn-style primitives and shell. Phase 3 applies the baseline to PWA conservatively, preserving mobile/offline affordances and large touch targets.
|
||||||
|
|
||||||
|
Reference baseline from `services/landing/src/app/globals.css`: `#0a0a0a`, `#0d1117`, `rgba(255,255,255,.06)`, `#f97316`, `#ea580c`, orange muted/glow states.
|
||||||
|
|
||||||
|
## Architecture Decisions / ADRs
|
||||||
|
|
||||||
|
| ADR | Choice | Alternatives considered / rejected | Rationale |
|
||||||
|
|-----|--------|------------------------------------|-----------|
|
||||||
|
| ADR-1 Local tokens first | Define/adjust repeated tokens inside each app stylesheet (`globals.css`) and map Tailwind theme vars locally. | Shared design-system package now. | Keeps phases independently reviewable and rollback-safe; package extraction needs usage evidence first. |
|
||||||
|
| ADR-2 Landing is read-only | Copy visual values/patterns from Landing; do not modify `services/landing`. | Refactor Landing as source package. | Spec requires reference-only; avoids Next.js/Framer dependencies leaking into Vite apps. |
|
||||||
|
| ADR-3 Preserve semantics | Orange becomes brand/primary; red/amber/green/blue remain allowed for status when they communicate state. | Replace all non-orange colors. | Operational UIs need recognizable errors, warnings, success, sync/offline states. |
|
||||||
|
| ADR-4 Existing primitives stay local | Console keeps simple local components; Admin/PWA keep cva/shadcn-style components. | Normalize all components to one implementation. | Avoids behavior churn and unnecessary cross-app coupling. |
|
||||||
|
| ADR-5 Asset usage is explicit | Use tracked Landing logo assets by copying approved files into each app `public/` only when a phase needs an actual logo. | Depend on Landing public paths or use loose/untracked assets. | Vite apps need independent deployability; untracked assets break reproducibility. |
|
||||||
|
|
||||||
|
## Data / Visual Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Landing tokens/assets ──reference/copy──> App-local globals.css
|
||||||
|
└──> Local UI primitives
|
||||||
|
└──> Shells, auth, cards, forms
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `services/frontend-console/src/styles/globals.css` | Modify | Replace teal/blue body gradients and `.glass-card` accents with orange/dark tokens. |
|
||||||
|
| `services/frontend-console/src/components/ui/{button,card,badge}.tsx` | Modify | Align primary, secondary, focus, card, and badge defaults with orange/dark baseline. |
|
||||||
|
| `services/frontend-console/src/app/layouts/PlatformLayout.tsx` | Modify | Rebrand shell/sidebar/nav active states; keep routes and logout behavior. |
|
||||||
|
| `services/frontend-console/src/features/auth/pages/LoginPage.tsx` | Modify | Restyle auth hero, 2FA panel, inputs, and CTA only. |
|
||||||
|
| `services/frontend-admin/src/styles/globals.css` | Modify | Convert primary/accent/ring/glass/scrollbar tokens from emerald to orange. |
|
||||||
|
| `services/frontend-admin/src/components/ui/{button,card,badge}.tsx` | Modify | Tune local cva variants for orange primary and dark cards. |
|
||||||
|
| `services/frontend-admin/src/app/layouts/AdminLayout.tsx` | Modify | Align sidebar/header/project card accents without route/permission changes. |
|
||||||
|
| `services/frontend-admin/src/features/auth/pages/{LoginPage,SetupPage}.tsx` | Modify | Align auth/setup surfaces; preserve submit/redirect/setup behavior. |
|
||||||
|
| `services/frontend-pwa/src/styles/globals.css` | Modify | Add orange brand tokens while retaining status tokens. |
|
||||||
|
| `services/frontend-pwa/src/app/layouts/FieldLayout.tsx` | Modify | Replace cyan/emerald brand accents in header/nav; keep sync badge visible. |
|
||||||
|
| `services/frontend-pwa/src/features/auth/pages/LoginPage.tsx` | Modify | Align brand colors but avoid dense decorative effects on small screens. |
|
||||||
|
| `services/frontend-pwa/src/features/field/**/{MeasurementForm,MovementForm,SyncStatusBadge}.tsx` | Modify | Align form CTAs/surfaces only where touch size, sync/offline, and warnings remain clear. |
|
||||||
|
| `services/frontend-{console,admin,pwa}/public/omnioil-logo-transparente.svg` | Create if needed | Copy from tracked Landing asset for app-local branding. |
|
||||||
|
|
||||||
|
## Interfaces / Contracts
|
||||||
|
|
||||||
|
No API/data contracts change. Visual contract: each phase must expose local variables for background, foreground, card, border, primary, primary-muted/glow, ring, destructive, warning, success, info; no shared package import is allowed.
|
||||||
|
|
||||||
|
## Testing / Verification Plan
|
||||||
|
|
||||||
|
| Layer | What to Test | Approach |
|
||||||
|
|-------|--------------|----------|
|
||||||
|
| Unit | Existing auth/routing/store tests remain green. | `npm run test` per changed app; do not rewrite behavior tests for styling only. |
|
||||||
|
| Static | Type/lint validity and no unintended dependency additions. | `npm run lint`; inspect `package.json` for no new design/runtime libs. |
|
||||||
|
| Visual manual | Shell, login, primitives, cards, badges, forms match dark/orange baseline. | Compare against Landing tokens; audit hardcoded teal/cyan/emerald/blue except semantic states. |
|
||||||
|
| PWA usability | Mobile tap targets, focus/disabled/loading, offline/sync/warning visibility. | Mobile viewport review of field layout, measurement/movement forms, sync/offline states. |
|
||||||
|
|
||||||
|
## Migration / Rollout
|
||||||
|
|
||||||
|
No data migration. Roll out as three isolated PR phases: Console, Admin, PWA. Each phase must be independently revertible by restoring that app's local files.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Hardcoded colors may survive in deep screens; mitigate with per-phase color audit.
|
||||||
|
- PWA decorative orange/glow may reduce field readability; keep effects subtle and status colors distinct.
|
||||||
|
- Copying assets can diverge; document copied source in phase review notes.
|
||||||
|
- Phase size can exceed review budget; split by app and avoid broad screen redesigns.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None blocking.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Proposal: Align Console/Admin/PWA Visual Language
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Align `frontend-console`, `frontend-admin`, and `frontend-pwa` with the dark/orange OmniOil language already established in `services/landing`, reducing brand drift while keeping the work phased, reviewable, and local to each app.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- Phase visual alignment by app: Console first, then Admin, then PWA.
|
||||||
|
- Adapt landing-inspired local tokens: near-black backgrounds, dark cards, subtle white borders, orange accents/CTAs, glow/hover patterns, and real OmniOil branding where appropriate.
|
||||||
|
- Align shells, auth/login surfaces, and primitive UI components before deeper internal screens.
|
||||||
|
- Document repeated local tokens per app; no shared package yet.
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- Creating a shared design system/component package.
|
||||||
|
- Rebuilding user flows, IA, routing, auth, offline logic, or data behavior.
|
||||||
|
- Changing `services/landing` visual language except as reference.
|
||||||
|
- Touching unrelated local/untracked work such as `console-passkeys-2fa` or loose landing assets.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `frontend-visual-language`: Visual language requirements for Console/Admin/PWA alignment with Landing.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- None — no existing `openspec/specs/` capabilities are present.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Use a phased shell-and-token migration. Phase 1 targets `frontend-console` because it diverges most: replace teal/blue styling with landing-compatible local tokens, align buttons/cards/badges, login, and platform shell/sidebar. Phase 2 applies the same language to `frontend-admin` without redesigning flows. Phase 3 applies it to `frontend-pwa` with extra attention to mobile field usability, contrast, tap targets, and existing offline contexts. Avoid bringing landing-only libraries such as `framer-motion` into Vite apps just for aesthetics.
|
||||||
|
|
||||||
|
## Affected Areas
|
||||||
|
|
||||||
|
| Area | Impact | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `services/landing/src/**` | Reference | Source visual language and brand assets. |
|
||||||
|
| `services/frontend-console/src/styles/globals.css` | Modified | Replace divergent teal/blue tokens. |
|
||||||
|
| `services/frontend-console/src/app/layouts/PlatformLayout.tsx` | Modified | Align shell/sidebar branding. |
|
||||||
|
| `services/frontend-console/src/features/auth/pages/LoginPage.tsx` | Modified | First aligned auth surface. |
|
||||||
|
| `services/frontend-console/src/components/ui/{button,card,badge}.tsx` | Modified | Primitive visual alignment. |
|
||||||
|
| `services/frontend-admin/src/**` | Modified | Phase 2 token, shell, login, primitive updates. |
|
||||||
|
| `services/frontend-pwa/src/**` | Modified | Phase 3 mobile-safe token/header/form updates. |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|------|------------|------------|
|
||||||
|
| Review scope grows too large | High | Split implementation by app/phase. |
|
||||||
|
| PWA aesthetics hurt field usability | Medium | Preserve contrast, tap targets, and legibility. |
|
||||||
|
| Token-only changes miss hardcoded colors | High | Audit hardcodes during each phase. |
|
||||||
|
| Asset handling differs across apps | Medium | Decide per app whether to copy or reference brand assets. |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
Revert one phase/app at a time by restoring that app's local tokens and touched UI surfaces. Because no shared package is introduced, rollback remains isolated per frontend.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing landing visual references and brand assets.
|
||||||
|
- No new shared package or runtime dependency planned.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Console, Admin, and PWA share recognizable OmniOil dark/orange visual language.
|
||||||
|
- [ ] Each app remains independently buildable and testable after its phase.
|
||||||
|
- [ ] PWA field screens retain mobile usability and accessible contrast.
|
||||||
|
- [ ] No unrelated local/untracked changes are modified.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Delta for Brand Assets and Tokens
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Landing-Inspired Local Token Baseline
|
||||||
|
|
||||||
|
Each target frontend SHALL define or reuse local styling tokens for OmniOil-inspired near-black backgrounds, dark cards, subtle light borders, orange accents, and readable foreground text without introducing a shared design-system package.
|
||||||
|
|
||||||
|
#### Scenario: Local app tokens exist per phase
|
||||||
|
|
||||||
|
- GIVEN a frontend phase is implemented
|
||||||
|
- WHEN its styling is reviewed
|
||||||
|
- THEN repeated OmniOil colors and surfaces SHALL be represented by local app tokens or local reusable styles
|
||||||
|
- AND the phase SHALL NOT require a shared cross-app package
|
||||||
|
|
||||||
|
#### Scenario: Landing remains reference-only
|
||||||
|
|
||||||
|
- GIVEN the target apps use the landing visual language as reference
|
||||||
|
- WHEN visual alignment is implemented
|
||||||
|
- THEN `services/landing` SHALL NOT be changed to satisfy this change
|
||||||
|
- AND landing-only runtime libraries SHALL NOT be added solely for aesthetic parity
|
||||||
|
|
||||||
|
### Requirement: Brand Asset Handling
|
||||||
|
|
||||||
|
Brand asset usage SHALL be explicit per app and SHALL avoid unrelated loose or untracked assets unless they are intentionally brought into scope.
|
||||||
|
|
||||||
|
#### Scenario: App uses approved OmniOil branding
|
||||||
|
|
||||||
|
- GIVEN an app needs logo or brand imagery
|
||||||
|
- WHEN assets are selected for the phase
|
||||||
|
- THEN the chosen assets SHALL come from existing tracked brand sources or be explicitly added for that app
|
||||||
|
- AND their usage SHALL be documented in the phase review notes
|
||||||
|
|
||||||
|
#### Scenario: Unrelated assets remain untouched
|
||||||
|
|
||||||
|
- GIVEN unrelated local or untracked assets exist in the workspace
|
||||||
|
- WHEN visual alignment work is performed
|
||||||
|
- THEN those assets SHALL NOT be modified or depended on
|
||||||
|
- AND the phase SHALL remain rollback-safe by app
|
||||||
|
|
||||||
|
### Requirement: Phased Visual Consistency Without Shared System
|
||||||
|
|
||||||
|
The three frontend apps SHALL converge visually through local implementations, not through a mandatory shared component or token package.
|
||||||
|
|
||||||
|
#### Scenario: Apps can align at different times
|
||||||
|
|
||||||
|
- GIVEN only some frontend phases are complete
|
||||||
|
- WHEN the completed apps are compared with Landing
|
||||||
|
- THEN completed apps SHALL share recognizable dark/orange OmniOil cues
|
||||||
|
- AND incomplete apps SHALL NOT block acceptance of completed phases
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Delta for Frontend Admin Visual Language
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin Phase 2 Visual Alignment
|
||||||
|
|
||||||
|
The Admin frontend SHALL adopt OmniOil dark/orange visual language after the Console phase, focusing on shell, login/auth surfaces, and primitive UI usage while preserving existing Admin workflows.
|
||||||
|
|
||||||
|
#### Scenario: Admin shell aligns with OmniOil brand
|
||||||
|
|
||||||
|
- GIVEN an authenticated Admin user
|
||||||
|
- WHEN the Admin shell renders
|
||||||
|
- THEN it SHALL use near-black backgrounds, dark cards, subtle light borders, and orange accents
|
||||||
|
- AND existing navigation, routing, and permissions behavior SHALL remain unchanged
|
||||||
|
|
||||||
|
#### Scenario: Admin auth surface preserves behavior
|
||||||
|
|
||||||
|
- GIVEN an unauthenticated Admin user
|
||||||
|
- WHEN the Admin login or auth surface renders
|
||||||
|
- THEN it SHALL present the OmniOil dark/orange visual language
|
||||||
|
- AND authentication fields, errors, and submission behavior SHALL remain unchanged
|
||||||
|
|
||||||
|
### Requirement: Admin Local Token Consistency
|
||||||
|
|
||||||
|
Admin styling SHALL use local app tokens or local style conventions for repeated colors, surfaces, borders, and accents without depending on a shared package.
|
||||||
|
|
||||||
|
#### Scenario: Repeated Admin styling uses local tokens
|
||||||
|
|
||||||
|
- GIVEN multiple Admin screens use dark surfaces and orange actions
|
||||||
|
- WHEN their styles are reviewed
|
||||||
|
- THEN repeated visual values SHALL be represented through local Admin tokens or local reusable styles
|
||||||
|
- AND no cross-app design-system package SHALL be required
|
||||||
|
|
||||||
|
#### Scenario: Hardcoded divergent colors are audited
|
||||||
|
|
||||||
|
- GIVEN an Admin screen contains legacy blue, teal, or incompatible accent colors
|
||||||
|
- WHEN the Phase 2 visual pass reaches that surface
|
||||||
|
- THEN those colors SHALL be replaced or explicitly justified as semantic exceptions
|
||||||
|
- AND semantic status colors MAY remain when they communicate state
|
||||||
|
|
||||||
|
### Requirement: Admin Phase Boundary
|
||||||
|
|
||||||
|
Admin visual alignment SHALL be reviewable independently from PWA visual alignment.
|
||||||
|
|
||||||
|
#### Scenario: Admin phase does not require PWA changes
|
||||||
|
|
||||||
|
- GIVEN Console has been aligned and Admin is being aligned
|
||||||
|
- WHEN Phase 2 is reviewed
|
||||||
|
- THEN PWA changes SHALL NOT be required for Admin acceptance
|
||||||
|
- AND Admin SHALL remain independently buildable and testable
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Delta for Frontend Console Visual Language
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Console Phase 1 Visual Alignment
|
||||||
|
|
||||||
|
The Console SHALL adopt OmniOil dark/orange visual language on its shell, auth surface, and primitive UI components while preserving existing navigation, routing, auth behavior, and data behavior.
|
||||||
|
|
||||||
|
#### Scenario: Console shell uses OmniOil visual language
|
||||||
|
|
||||||
|
- GIVEN an authenticated Console user
|
||||||
|
- WHEN the platform layout renders
|
||||||
|
- THEN the shell SHALL use near-black backgrounds, dark surfaces, subtle light borders, orange accents, and OmniOil branding cues
|
||||||
|
- AND existing navigation destinations SHALL remain unchanged
|
||||||
|
|
||||||
|
#### Scenario: Console auth surface aligns without flow changes
|
||||||
|
|
||||||
|
- GIVEN an unauthenticated Console user
|
||||||
|
- WHEN the login page renders
|
||||||
|
- THEN the page SHALL use OmniOil dark/orange styling and branded visual cues
|
||||||
|
- AND the login form fields, validation behavior, and submission flow SHALL remain unchanged
|
||||||
|
|
||||||
|
### Requirement: Console Primitive Component Alignment
|
||||||
|
|
||||||
|
Console buttons, cards, and badges SHALL expose visually consistent dark/orange variants for existing Console usage without requiring a shared design-system package.
|
||||||
|
|
||||||
|
#### Scenario: Primary action uses orange emphasis
|
||||||
|
|
||||||
|
- GIVEN a Console screen with a primary action button
|
||||||
|
- WHEN the button renders
|
||||||
|
- THEN it SHALL use orange emphasis compatible with the landing visual language
|
||||||
|
- AND hover/focus states SHALL remain visible against dark surfaces
|
||||||
|
|
||||||
|
#### Scenario: Informational surfaces remain readable
|
||||||
|
|
||||||
|
- GIVEN Console cards or badges with operational content
|
||||||
|
- WHEN they render on dark backgrounds
|
||||||
|
- THEN text and status labels SHALL remain legible
|
||||||
|
- AND no existing semantic status meaning SHALL be changed by color-only restyling
|
||||||
|
|
||||||
|
### Requirement: Console Phase Boundary
|
||||||
|
|
||||||
|
Console visual alignment SHALL be deliverable independently of Admin and PWA visual alignment.
|
||||||
|
|
||||||
|
#### Scenario: Console phase is isolated
|
||||||
|
|
||||||
|
- GIVEN only the Console phase is implemented
|
||||||
|
- WHEN Admin and PWA are not yet updated
|
||||||
|
- THEN Console SHALL still be buildable and usable
|
||||||
|
- AND no Admin or PWA code SHALL be required for the Console phase to pass review
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Delta for Frontend PWA Visual Language
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: PWA Phase 3 Mobile-Safe Visual Alignment
|
||||||
|
|
||||||
|
The PWA SHALL adopt OmniOil dark/orange visual language only where it preserves field usability, mobile readability, tap targets, and existing offline-related behavior.
|
||||||
|
|
||||||
|
#### Scenario: PWA shell/header aligns safely
|
||||||
|
|
||||||
|
- GIVEN a field user opens the PWA on a mobile viewport
|
||||||
|
- WHEN the shell, header, or main surfaces render
|
||||||
|
- THEN they SHALL use OmniOil dark surfaces, subtle borders, and orange emphasis where appropriate
|
||||||
|
- AND primary content SHALL remain readable in field conditions
|
||||||
|
|
||||||
|
#### Scenario: Existing offline contexts remain visible
|
||||||
|
|
||||||
|
- GIVEN the PWA shows offline, sync, warning, or error context
|
||||||
|
- WHEN visual alignment is applied
|
||||||
|
- THEN those states SHALL remain visually distinguishable from normal content
|
||||||
|
- AND their behavior SHALL NOT be changed by the styling phase
|
||||||
|
|
||||||
|
### Requirement: PWA Touch and Contrast Protection
|
||||||
|
|
||||||
|
PWA visual changes SHALL NOT reduce practical mobile usability for field workflows.
|
||||||
|
|
||||||
|
#### Scenario: Interactive controls remain usable
|
||||||
|
|
||||||
|
- GIVEN a PWA form or action screen on a mobile viewport
|
||||||
|
- WHEN controls are restyled
|
||||||
|
- THEN tap targets SHALL remain at least as usable as before
|
||||||
|
- AND focus, pressed, disabled, and loading states SHALL remain visible
|
||||||
|
|
||||||
|
#### Scenario: Text contrast remains accessible
|
||||||
|
|
||||||
|
- GIVEN operational text appears on dark PWA surfaces
|
||||||
|
- WHEN the screen is visually aligned
|
||||||
|
- THEN labels, values, helper text, and errors SHALL remain legible
|
||||||
|
- AND decorative glow or gradient effects SHALL NOT obscure field data
|
||||||
|
|
||||||
|
### Requirement: PWA Phase Boundary
|
||||||
|
|
||||||
|
PWA alignment SHALL be the final visual phase and SHALL not require redesigning PWA flows.
|
||||||
|
|
||||||
|
#### Scenario: PWA phase preserves workflow structure
|
||||||
|
|
||||||
|
- GIVEN Phase 3 is implemented
|
||||||
|
- WHEN field workflows are exercised
|
||||||
|
- THEN routing, forms, offline behavior, and data submission semantics SHALL remain unchanged
|
||||||
|
- AND only visual language changes SHALL be in scope
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Tasks: Align Console/Admin/PWA Visual Language
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | 650-1,100 across three apps |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes |
|
||||||
|
| Suggested split | PR 1 Console → PR 2 Admin → PR 3 PWA; split assets/tokens with owning app |
|
||||||
|
| Delivery strategy | ask-on-risk |
|
||||||
|
|
||||||
|
Decision needed before apply: Yes
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
400-line budget risk: High
|
||||||
|
|
||||||
|
If any app diff grows beyond review comfort, keep each app in a separate PR/commit and do not mix phases.
|
||||||
|
|
||||||
|
### Suggested Work Units
|
||||||
|
|
||||||
|
| Unit | Goal | Likely PR | Notes |
|
||||||
|
|------|------|-----------|-------|
|
||||||
|
| 1 | Align `services/frontend-console` shell/auth/primitives | PR 1 | Includes Console lint/test/manual review |
|
||||||
|
| 2 | Align `services/frontend-admin` shell/auth/primitives | PR 2 | Starts after Console baseline is accepted |
|
||||||
|
| 3 | Align `services/frontend-pwa` mobile-safe field UI | PR 3 | Preserve offline/sync/status affordances |
|
||||||
|
|
||||||
|
## Baseline / Verificación
|
||||||
|
|
||||||
|
- [ ] 0.1 Review `services/landing/src/app/globals.css` and record local baseline values: `#0a0a0a`, `#0d1117`, borders, orange accents, glow states.
|
||||||
|
- [ ] 0.2 Audit each target app for hardcoded teal/cyan/emerald/blue in `src/**`, excluding semantic status colors.
|
||||||
|
- [ ] 0.3 Confirm no `services/landing` code, unrelated loose assets, routing, auth, API, offline, or data behavior is in scope.
|
||||||
|
|
||||||
|
## Assets / Tokens
|
||||||
|
|
||||||
|
- [ ] A.1 Define/reuse local tokens in each app `src/styles/globals.css` for background, foreground, card, border, primary, muted/glow, ring, status colors.
|
||||||
|
- [ ] A.2 If a logo is needed, copy only tracked approved branding to `services/frontend-{console,admin,pwa}/public/omnioil-logo-transparente.svg` per app.
|
||||||
|
- [ ] A.3 Verify no shared design-system package or landing-only runtime dependency is added.
|
||||||
|
|
||||||
|
## Fase 1: `frontend-console`
|
||||||
|
|
||||||
|
- [x] 1.1 Update `services/frontend-console/src/styles/globals.css` tokens, body gradients, and `.glass-card` accents to dark/orange.
|
||||||
|
- [x] 1.2 Align `services/frontend-console/src/components/ui/{button,card,badge}.tsx` primary, secondary, focus, card, and badge styling.
|
||||||
|
- [x] 1.3 Restyle `services/frontend-console/src/app/layouts/PlatformLayout.tsx` shell/sidebar/nav branding without route/logout changes.
|
||||||
|
- [x] 1.4 Restyle `services/frontend-console/src/features/auth/pages/LoginPage.tsx` auth hero, 2FA panel, inputs, and CTA without flow changes.
|
||||||
|
- [x] 1.5 Run Console verification from `services/frontend-console`: `npm run lint`; run `npm run test` if available.
|
||||||
|
|
||||||
|
## Fase 2: `frontend-admin`
|
||||||
|
|
||||||
|
- [x] 2.1 Update `services/frontend-admin/src/styles/globals.css` primary/accent/ring/glass/scrollbar tokens from emerald to orange.
|
||||||
|
- [x] 2.2 Align `services/frontend-admin/src/components/ui/{button,card,badge}.tsx` cva variants and dark card surfaces.
|
||||||
|
- [x] 2.3 Restyle `services/frontend-admin/src/app/layouts/AdminLayout.tsx` sidebar/header/project accents without permission or route changes.
|
||||||
|
- [x] 2.4 Restyle `services/frontend-admin/src/features/auth/pages/{LoginPage,SetupPage}.tsx` without submit/redirect/setup behavior changes.
|
||||||
|
- [x] 2.5 Run Admin verification from `services/frontend-admin`: `npm run lint`; run `npm run test` if available.
|
||||||
|
|
||||||
|
## Fase 3: `frontend-pwa`
|
||||||
|
|
||||||
|
- [ ] 3.1 Update `services/frontend-pwa/src/styles/globals.css` with orange brand tokens while preserving status/offline tokens.
|
||||||
|
- [ ] 3.2 Restyle `services/frontend-pwa/src/app/layouts/FieldLayout.tsx` header/nav while keeping sync badge visibility.
|
||||||
|
- [ ] 3.3 Restyle `services/frontend-pwa/src/features/auth/pages/LoginPage.tsx` with mobile-safe dark/orange cues.
|
||||||
|
- [ ] 3.4 Align `services/frontend-pwa/src/features/field/**/{MeasurementForm,MovementForm,SyncStatusBadge}.tsx` surfaces/CTAs without reducing tap targets.
|
||||||
|
- [ ] 3.5 Run PWA verification from `services/frontend-pwa`: `npm run lint`; run `npm run test` if available; manually review mobile contrast, focus, disabled/loading, sync/offline/warning states.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Archive Report: console-tech-debt-cleanup
|
||||||
|
|
||||||
|
**Change**: `console-tech-debt-cleanup`
|
||||||
|
**Project**: `services/frontend-console`
|
||||||
|
**Archived**: 2026-05-07
|
||||||
|
**Status**: DONE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The `console-tech-debt-cleanup` change is COMPLETE and CLOSED. All 28 tests pass green (23/23 PR2 + baseline). All phases (0–9) implemented across two PRs with full strict-TDD validation. Three warnings from PR1 closure resolved. Implementation spans auth correctness (single token field, aligned types), API client robustness (no null-as-T casts, typed 204 path, 401 retry + dedupe), access-requests error UX, platform-shell error boundary, and Vitest testing scaffolding. Zero outstanding tasks; only project-rule skip (pnpm build) noted. Change is ready for deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifacts & Traceability
|
||||||
|
|
||||||
|
| Phase | Engram Topic Key | Observation ID | Content |
|
||||||
|
|-------|------------------|----------------|---------|
|
||||||
|
| Proposal | `sdd/console-tech-debt-cleanup/proposal` | #152 | P0/P1/P2 bundling, correctness debt drivers, risk analysis, success criteria |
|
||||||
|
| Spec | `sdd/console-tech-debt-cleanup/spec` | #155 | Delta specs: 5 capabilities (auth, api-client, access-requests, platform-shell, testing) |
|
||||||
|
| Design | `sdd/console-tech-debt-cleanup/design` | #156 | 8 ADRs, no new layers, refactor over existing structure, TDD ordering |
|
||||||
|
| Tasks | `sdd/console-tech-debt-cleanup/tasks` | #157 | 10 phases, 28 total work items; PR1 phases 0–5, PR2 phases 6–9, phase 10 validation |
|
||||||
|
| Apply-Progress | `sdd/console-tech-debt-cleanup/apply-progress` | #159 | PR1 + PR2 completion matrix; 28 tests pass; warning closure (W1/W2/W3) |
|
||||||
|
| Verify-Report | `sdd/console-tech-debt-cleanup/verify-report` | #160 | PR1 PASS + PR2 PASS; 3 WARNINGs (all addressed in warning closure); 0 CRITICAL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Summary
|
||||||
|
|
||||||
|
### Completed Phases
|
||||||
|
|
||||||
|
| Phase | Description | Status | Tests | Notes |
|
||||||
|
|-------|-------------|--------|-------|-------|
|
||||||
|
| 0 | Pre-flight audits (R1/A2/A4) | DONE | N/A | Grep audit confirmed safe refactor surface |
|
||||||
|
| 1 | Vitest scaffolding (config + setup) | DONE | 0 | Config-only; W1 warning: --passWithNoTests removed in closure |
|
||||||
|
| 2 | Auth store: drop token field | DONE | 2 | GREEN: setSession writes accessToken; logout clears |
|
||||||
|
| 3 | Auth API: LoginResponse shape | DONE | 3 | GREEN: access_token required, token required, refresh_token removed |
|
||||||
|
| 4 | API client: split + dedupe + retry | DONE | 5 | GREEN: apiClient/apiClientVoid, SessionCoordinator dedupe, 401 retry |
|
||||||
|
| 5 | ProtectedRoute: cold-visit heuristic | DONE | 2 | GREEN: hasHydrated guard + user-based refresh (no first-load flash) |
|
||||||
|
| 6 | AccessRequestsPage: error UI | DONE | 3 | GREEN: isError → error card + Reintentar (W3 closure: refetch verified) |
|
||||||
|
| 7 | AccessRequestsPage: mutations | DONE | 6 | GREEN: approve/reject/review success + error; toast + invalidation (W2 closure: reject/review tested) |
|
||||||
|
| 8 | AccessRequestsPage: isActionable consolidation | DONE | 4 | GREEN: no inline includes; isActionable enforced |
|
||||||
|
| 9 | PlatformLayout: ErrorBoundary | DONE | 3 | GREEN: class component, Outlet wrapped, logout fallback, sidebar safe |
|
||||||
|
| 10 | Final validation | DONE | Phase 10.1/10.3 only | 28/28 tests PASS, lint clean, tsc --noEmit clean; 10.2 build skipped |
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
**PR1 (Phases 0–5)**: 11 files
|
||||||
|
- `package.json` (test script + 5 devDeps)
|
||||||
|
- `vite.config.ts` (test block)
|
||||||
|
- `src/test/setup.ts` (new)
|
||||||
|
- `src/features/auth/stores/auth-store.ts` (drop token)
|
||||||
|
- `src/features/auth/stores/auth-store.test.ts` (new)
|
||||||
|
- `src/features/auth/api/auth-api.ts` (LoginResponse shape)
|
||||||
|
- `src/features/auth/api/auth-api.test.ts` (new)
|
||||||
|
- `src/features/auth/pages/LoginPage.tsx` (use accessToken)
|
||||||
|
- `src/app/router/ProtectedRoute.tsx` (hasHydrated + user heuristic)
|
||||||
|
- `src/app/router/ProtectedRoute.test.tsx` (new)
|
||||||
|
- `src/lib/api/client.ts` (executeWithRefresh, apiClientVoid, no null-as-T)
|
||||||
|
- `src/lib/api/client.test.ts` (new)
|
||||||
|
|
||||||
|
**PR2 (Phases 6–9)**: 5 files
|
||||||
|
- `src/features/access-requests/pages/AccessRequestsPage.tsx` (error UI + isActionable)
|
||||||
|
- `src/features/access-requests/pages/AccessRequestsPage.test.tsx` (13 tests)
|
||||||
|
- `src/app/layouts/ErrorBoundary.tsx` (new)
|
||||||
|
- `src/app/layouts/ErrorBoundary.test.tsx` (new)
|
||||||
|
- `src/app/layouts/PlatformLayout.tsx` (wrap Outlet)
|
||||||
|
|
||||||
|
**Total**: 16 files changed (11 PR1 + 5 PR2)
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
- **Total tests**: 28 (passing)
|
||||||
|
- **Test files**: 6
|
||||||
|
- `src/features/auth/api/auth-api.test.ts` (3 tests)
|
||||||
|
- `src/features/auth/stores/auth-store.test.ts` (2 tests)
|
||||||
|
- `src/lib/api/client.test.ts` (5 tests)
|
||||||
|
- `src/app/router/ProtectedRoute.test.tsx` (2 tests)
|
||||||
|
- `src/features/access-requests/pages/AccessRequestsPage.test.tsx` (13 tests)
|
||||||
|
- `src/app/layouts/ErrorBoundary.test.tsx` (3 tests)
|
||||||
|
- **Lint**: exits 0 (clean)
|
||||||
|
- **TypeScript**: tsc --noEmit exits 0 (clean)
|
||||||
|
- **Build**: skipped per project rule "Never build after changes"
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
| Gate | Status | Evidence |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Test coverage (P0 surface) | PASS | 28/28 tests pass, strict TDD ordering enforced |
|
||||||
|
| No regressions | PASS | All 12 PR1 tests still pass in PR2 verify |
|
||||||
|
| Spec compliance | PASS | 5 capabilities (auth, api-client, access-requests, platform-shell, testing) compliant per verify-report matrix |
|
||||||
|
| Lint clean | PASS | pnpm lint exits 0 |
|
||||||
|
| Type safety | PASS | tsc --noEmit exits 0, zero null-as-T casts |
|
||||||
|
| TDD ordering | PASS | Tests RED first, then GREEN after refactor (strict TDD mode) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warnings & Closure
|
||||||
|
|
||||||
|
### PR1 Warning Closure
|
||||||
|
|
||||||
|
| Warning | Raised | Closed | Resolution |
|
||||||
|
|---------|--------|--------|------------|
|
||||||
|
| W1 | Phase 1 | Phase 10 (apply final) | Removed `--passWithNoTests` from package.json test script; 28 tests exist, flag unnecessary |
|
||||||
|
| W2 | Phase 7 | Phase 10 (apply final) | Added 4 tests (7.2a reject success, 7.2b reject error, 7.3a review success, 7.3b review error) to AccessRequestsPage.test.tsx; all pass |
|
||||||
|
| W3 | Phase 6 | Phase 10 (apply final) | Added test 6.1c: Reintentar click calls `listAccessRequests()` again (refetch verified) |
|
||||||
|
|
||||||
|
All warnings closed. No outstanding issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred Items (Spec Suggestions)
|
||||||
|
|
||||||
|
The verify-report noted 6 suggestions; these remain deferred and tracked separately:
|
||||||
|
|
||||||
|
- **S1**: Add `pnpm run typecheck` script to package.json (structural — not blocking; PR can land without it)
|
||||||
|
- **S2**: ProtectedRoute warm-visit setTimeout proxy (optimization — not required for correctness)
|
||||||
|
- **S3**: Remove unused `refreshToken` state field from auth-store (cleanup; token field already removed)
|
||||||
|
- **S4**: Install `@testing-library/jest-dom` for RTL `.toBeDisabled()` matcher (testing polish; current `.disabled` works)
|
||||||
|
- **S5**: Use named expect matchers instead of `expect.anything()` for TQ v5 context arg (testing polish)
|
||||||
|
- **S6**: Revisar button consolidation — currently checks `status !== 'pending'` directly, not `isActionable()` (deliberate; edge-case behavior difference)
|
||||||
|
|
||||||
|
None of these block closure. They are suitable for future tech-debt or polish PRs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation | Status |
|
||||||
|
|------|------------|--------|
|
||||||
|
| Silent token-rename regression on sign-in | P0 test scaffolding (phases 0–5) landed first, validates behavior before refactor | MITIGATED |
|
||||||
|
| Bracket-string `'token'` reads not caught by TypeScript | Pre-flight R1 audit confirmed zero bracket-string reads | MITIGATED |
|
||||||
|
| LoginResponse type does not match backend runtime | Spec requirement aligned with backend `auth.rs` contract (access_token + token emitted) | MITIGATED |
|
||||||
|
| 204 endpoints break on refactor | A2 pre-flight confirmed zero 204 callsites; TypeScript will catch any new uses | MITIGATED |
|
||||||
|
| RTL flakiness on AccessRequestsPage mutations | waitFor + findBy* patterns used; modal helpers robust | MITIGATED |
|
||||||
|
|
||||||
|
All risks from design document addressed and closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
None. Change is complete, all tasks done, all tests pass, all warnings closed. Ready for production deployment.
|
||||||
|
|
||||||
|
The change can be merged to main and deployed. No follow-up work items remain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archive Metadata
|
||||||
|
|
||||||
|
- **Created**: 2026-05-07
|
||||||
|
- **Change ID**: `console-tech-debt-cleanup`
|
||||||
|
- **Git commits**: 2 PRs (PR1 phases 0–5, PR2 phases 6–9) + warning closure batch
|
||||||
|
- **Total lines changed**: ~1200 (implementation + tests)
|
||||||
|
- **Test files added**: 6
|
||||||
|
- **Breaking changes**: None
|
||||||
|
- **New dependencies**: vitest@^3.2.4, jsdom@^26.1.0, @testing-library/* (3 packages)
|
||||||
|
- **Database migrations**: None
|
||||||
|
- **API changes**: None (internal refactor only)
|
||||||
|
- **Config changes**: vite.config.ts (test block), package.json (test script + devDeps)
|
||||||
|
- **Documentation updated**: None (inline spec in code + ADR trail in design document)
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
# Design — `console-tech-debt-cleanup`
|
||||||
|
|
||||||
|
> **Phase**: design (HOW at architectural level)
|
||||||
|
> **Project**: omnioilpersonal
|
||||||
|
> **Surface**: `services/frontend-console`
|
||||||
|
> **Approach**: targeted refactor over the existing layered structure (`features/<domain>/{api,pages,stores}` + `lib/api` + `app/{layouts,router}`). No new architecture, no new layers — eliminate duplications, tighten contracts, install the safety net.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Architecture context (recap)
|
||||||
|
|
||||||
|
`frontend-console` already follows the standard React + Vite + Zustand + React Query layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
layouts/PlatformLayout.tsx <- shell (sidebar + Outlet + Toaster)
|
||||||
|
router/{index.tsx, ProtectedRoute.tsx}
|
||||||
|
features/
|
||||||
|
auth/ (stores, api, schemas, pages)
|
||||||
|
access-requests/ (api, pages)
|
||||||
|
lib/
|
||||||
|
api/client.ts <- apiClient + SessionCoordinator (the choke point)
|
||||||
|
utils/cn.ts
|
||||||
|
components/ui/...
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime contract that `apiClient` talks to (Rust backend in `services/backend-api`) is the source of truth. We do NOT change the contract; we change how the frontend models it.
|
||||||
|
|
||||||
|
Backend login response shape (verified): `services/backend-api/src/handlers/auth.rs:232-242` returns
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "...",
|
||||||
|
"token": "...", // legacy duplicate, comment "Compatibilidad con clientes legacy"
|
||||||
|
"role": "...",
|
||||||
|
"email": "...",
|
||||||
|
"projects": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same dual emission for refresh at `auth.rs:500-509`. **The backend will keep emitting both `access_token` and `token` for the foreseeable future** — that is a backend decision, not ours. Our job here is to stop the frontend from caring about `token`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ADR-001 — Single auth token field: `accessToken`
|
||||||
|
|
||||||
|
**Decision**: drop the legacy `token` field from `useAuthStore` state. `accessToken` becomes the single source of truth. Components, route guards, and effects read `accessToken` only.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Today `auth-store.ts:32,39,44` stores both `accessToken` AND `token` (mirrored). Consumers split: `ProtectedRoute.tsx:9-29` and `LoginPage.tsx:16,24,29` read `state.token`, while `client.ts:31` reads `state.accessToken`. Two fields, two readers, same value — pure duplication that drifted because nobody noticed they were equal.
|
||||||
|
- The backend already keeps the dual emission for legacy clients. We do not need a second field on the frontend to "track legacy"; the fallback lives on the wire.
|
||||||
|
- Frontend-admin (the reference app) already uses only `accessToken` for live state. Confirmed at `services/frontend-admin/src/lib/api/client.test.ts:124,146` — it asserts `state.accessToken === 'fresh-token'` and that on logout `accessToken: null, refreshToken: null, token: null`. Note: frontend-admin still keeps `token: null` in the store as a vestigial slot. We will go further and **remove the slot entirely** since console has no such legacy consumers outside the two files we control.
|
||||||
|
|
||||||
|
**Migration plan** (execution belongs to tasks, not design):
|
||||||
|
1. **Audit step before any rename** — run a regex sweep:
|
||||||
|
- `useAuthStore.*\.token\b` and `state\.token` for selector reads
|
||||||
|
- `\['token'\]` and `\["token"\]` for bracket-string reads (THE landmine)
|
||||||
|
- `token:` inside object literals destructured from the store
|
||||||
|
2. Replace selectors: `state.token` → `state.accessToken`. Update dependency arrays accordingly.
|
||||||
|
3. Remove the `token` slot from `AuthState`, the `set({ token: ... })` mirror writes, and the initial `token: null`.
|
||||||
|
4. TypeScript will catch every typed read site at build. Bracket-string reads are NOT caught by TS — that's why step 1 is mandatory before rename.
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *Keep both fields, document `accessToken` as canonical*: doesn't fix the bug, just adds a comment. Future refactors will keep splitting.
|
||||||
|
- *Rename to `token` instead of `accessToken`*: contradicts the backend field name and the rest of the codebase (admin uses `accessToken`).
|
||||||
|
|
||||||
|
**Risk**: A bracket-string read like `useAuthStore.getState()['token']` would silently start returning `undefined`. Mitigation = step 1 audit. Acceptance criterion = grep returns zero matches before merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ADR-002 — `LoginResponse` type matches the runtime exactly
|
||||||
|
|
||||||
|
**Decision**: redefine `LoginResponse` in `src/features/auth/api/auth-api.ts` as
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string // required — backend always sends it
|
||||||
|
refresh_token?: string // optional in JSON body (backend prefers HttpOnly cookie)
|
||||||
|
role: string
|
||||||
|
email: string
|
||||||
|
projects?: Array<{ id: string; name: string }> // present in admin runtime; opaque to console
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `token`. No `??` fallback in `LoginPage.onSubmit`.
|
||||||
|
|
||||||
|
**Runtime contract source**: `services/backend-api/src/handlers/auth.rs:232-242` (login) and `:500-509` (refresh). The fields confirmed present: `access_token`, `token` (legacy mirror — we choose to ignore it), `role`, `email`, `projects`. `refresh_token` is NOT in the JSON body for login (it goes into the `Set-Cookie` header at line 223-225); it IS present in some refresh paths. Marking it optional in our type is honest.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Today `auth-api.ts:4-9` declares `token: string` REQUIRED and `access_token?` OPTIONAL — the exact opposite of the runtime. The current code only works because `LoginPage.tsx:45` does `response.access_token ?? response.token`, papering over the lying type.
|
||||||
|
- A type that lies about the runtime is worse than no type. It actively misleads autocomplete and code review.
|
||||||
|
|
||||||
|
**Where verified**: `services/backend-api/src/handlers/auth.rs:232-242`. Documenting this in the spec so the next refactor doesn't have to re-derive it.
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *Keep `token` required for backward compat with the legacy emission*: we already chose not to consume the legacy field (ADR-001). Keeping it in the type just to "match the wire" leaks legacy concerns into TS.
|
||||||
|
- *Make everything optional*: then `LoginPage` must handle every undefined case at runtime. That's defensive coding against our own backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ADR-003 — Split `apiClient`: `apiClient<T>` + `apiClientVoid`
|
||||||
|
|
||||||
|
**Decision**: option **(a)** from the proposal. Two named exports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function apiClient<T>(endpoint: string, options?: FetchOptions): Promise<T>
|
||||||
|
export async function apiClientVoid(endpoint: string, options?: FetchOptions): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
`apiClient<T>` throws if the response is 204 (no body to parse — caller asked for a body). `apiClientVoid` returns `void` and ignores the body for 2xx, including 204.
|
||||||
|
|
||||||
|
Both share an internal `executeWithRefresh(endpoint, options): Promise<Response>` that owns the 401 → refresh → retry flow currently embedded in `apiClient`. The two public functions are thin wrappers that decide what to do with the final `Response`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Today `client.ts:96,111` returns `null as T` for 204. Every caller that types `<{ ok: boolean }>` gets `null` at runtime, which the type system happily lies about. The "fix" in the test (`client.test.ts:144` — `.rejects.toThrow`) only catches the rejection path, not the success-with-204 path.
|
||||||
|
- Option (a) makes the void-vs-body decision explicit at the **call site**, where the caller already knows whether the endpoint returns a body.
|
||||||
|
- Mechanical migration: every existing call returns a body today (login, listAccessRequests, approve/reject/review). Zero call sites need to switch to `apiClientVoid` in this change. We add the function for **future** mutation endpoints that return 204.
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *(b) Generic with overload `apiClient<T | void>`*: TypeScript overloads on generics are fragile and confuse autocomplete. Caller still has to remember which overload they invoked.
|
||||||
|
- *(c) Always return `T | null` and let callers narrow*: every caller writes `if (data === null) ...` for endpoints that NEVER return null. That's the worst-of-both-worlds — keeps the lie AND adds boilerplate.
|
||||||
|
- *Throw on 204 inside `apiClient<T>` only, no second function*: works but leaves no migration path for legitimate void endpoints; first call site that needs 204 will reintroduce the `null as T` cast.
|
||||||
|
|
||||||
|
**Internal shape** (illustrative — implementation lives in tasks/apply):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function executeWithRefresh(endpoint, options): Promise<Response> {
|
||||||
|
const { response } = await executeRequest(endpoint, options)
|
||||||
|
if (response.status === 401 && !options._retry && !isBypassedEndpoint(endpoint)) {
|
||||||
|
const refreshed = await SessionCoordinator.refresh()
|
||||||
|
if (!refreshed) {
|
||||||
|
useAuthStore.getState().logout()
|
||||||
|
redirectToLogin()
|
||||||
|
throw new Error('Sesion expirada')
|
||||||
|
}
|
||||||
|
const { response: retried } = await executeRequest(endpoint, { ...options, token: refreshed, _retry: true })
|
||||||
|
if (!retried.ok) {
|
||||||
|
// same fail-open behavior as today
|
||||||
|
const err = await retried.json().catch(() => ({ error: 'Request failed' }))
|
||||||
|
throw new Error(err.error ?? `HTTP ${retried.status}`)
|
||||||
|
}
|
||||||
|
return retried
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({ error: 'Request failed' }))
|
||||||
|
throw new Error(err.error ?? err.message ?? `HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiClient<T>(endpoint, options = {}): Promise<T> {
|
||||||
|
const r = await executeWithRefresh(endpoint, options)
|
||||||
|
if (r.status === 204) throw new Error(`apiClient: endpoint ${endpoint} returned 204; use apiClientVoid`)
|
||||||
|
return r.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiClientVoid(endpoint, options = {}): Promise<void> {
|
||||||
|
await executeWithRefresh(endpoint, options)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The 204-throws-in-`apiClient` is a **dev-time guard**. No production endpoint we currently call returns 204; if one ever does, it's a contract violation we want to see loudly, not silently coerce to null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ADR-004 — `ProtectedRoute` first-load: persisted `user` as the heuristic
|
||||||
|
|
||||||
|
**Decision**: drive the "should we attempt a refresh on cold load?" decision off the persisted `user` slot. Logic:
|
||||||
|
|
||||||
|
```
|
||||||
|
on mount:
|
||||||
|
if accessToken exists in memory: render <Outlet/> (or redirect on bad role) — done
|
||||||
|
else if persisted user exists (rehydrated from localStorage): show "Restaurando sesion" + call refresh()
|
||||||
|
else: render <Navigate to="/login"/> immediately — no refresh attempt, no flash
|
||||||
|
```
|
||||||
|
|
||||||
|
Today `ProtectedRoute.tsx:9-34` ALWAYS attempts a refresh when there's no token, regardless of whether the user ever logged in. That produces the "Restaurando sesion..." flash for first-time visitors.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The persist allowlist (`auth-store.ts:54`) already saves `user` to localStorage. If `user` rehydrated → there was a prior session → refresh might restore it (cookie still valid). If `user` did not rehydrate → cold visitor → no point hitting `/auth/refresh`.
|
||||||
|
- Zero changes to `partialize` — the heuristic uses what's already persisted.
|
||||||
|
- Eliminates the spurious refresh request AND the cosmetic flash in one move.
|
||||||
|
|
||||||
|
**Implementation sketch**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const accessToken = useAuthStore(s => s.accessToken)
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const hasHydrated = useAuthStore.persist.hasHydrated()
|
||||||
|
// 1. wait for hydration before deciding
|
||||||
|
const [phase, setPhase] = useState<'pending'|'restoring'|'ready'>(hasHydrated ? 'ready' : 'pending')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasHydrated) return
|
||||||
|
if (accessToken) { setPhase('ready'); return }
|
||||||
|
if (!user) { setPhase('ready'); return } // cold visitor — no refresh
|
||||||
|
setPhase('restoring')
|
||||||
|
let cancelled = false
|
||||||
|
SessionCoordinator.refresh().finally(() => { if (!cancelled) setPhase('ready') })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [hasHydrated, accessToken, user])
|
||||||
|
|
||||||
|
if (phase !== 'ready') return <RestoringSession />
|
||||||
|
if (!accessToken || !user) return <Navigate to="/login" replace />
|
||||||
|
if (!PLATFORM_ROLES.has(user.role)) return <Navigate to="/login" replace />
|
||||||
|
return <Outlet />
|
||||||
|
```
|
||||||
|
|
||||||
|
(Actual code goes in tasks; this sketch is the contract design enforces.)
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *Persist `accessToken` in localStorage*: leaks JWT to XSS. The whole point of the cookie-based refresh is that the access token lives in memory only.
|
||||||
|
- *Skip refresh attempt entirely on every cold load*: breaks the legitimate "I closed the tab, came back, refresh cookie still valid" path.
|
||||||
|
- *Add a `hasEverLoggedIn` boolean to the store*: redundant — `user` already serves that role.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ADR-005 — `ErrorBoundary` placement: inside `PlatformLayout`, around `<Outlet />`
|
||||||
|
|
||||||
|
**Decision**: a class-based error boundary lives inside `PlatformLayout.tsx`, wrapping ONLY the `<Outlet />`. Sidebar, logout button, and Toaster stay outside the boundary so they survive a page-level crash.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- A render error in `AccessRequestsPage` (or any future child route) should NOT take down the shell. The user must still be able to navigate elsewhere or sign out.
|
||||||
|
- Wrapping at the `RouterProvider` level would crash the whole app — same regression as today.
|
||||||
|
- The login route is a single form with no children; React Router's natural error handling is enough. No boundary on `/login`.
|
||||||
|
|
||||||
|
**Library choice**: native React class component. No dependency added. Frontend-console's only state libs are React Query + Zustand — both have their own error surfaces (`isError`, error boundaries via `ErrorBoundary` is the standard React pattern). Adding `react-error-boundary` would be one more devdep for a 30-line class.
|
||||||
|
|
||||||
|
**Shape**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
class ShellErrorBoundary extends React.Component<{children: React.ReactNode}, {error: Error|null}> {
|
||||||
|
state = { error: null as Error | null }
|
||||||
|
static getDerivedStateFromError(error: Error) { return { error } }
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
// log to console for now — we don't have Sentry/observability in console yet
|
||||||
|
console.error('[ShellErrorBoundary]', error, info)
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return <FallbackPanel error={this.state.error} onReset={() => this.setState({ error: null })} />
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The fallback shows the error message + a "Reintentar" button that resets the boundary state. This forces a remount of the route subtree.
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *Boundary at `RouterProvider` level*: kills the shell — rejected.
|
||||||
|
- *Per-route boundary*: more flexible but premature. Console has 2 routes today.
|
||||||
|
- *Add `react-error-boundary`*: extra dep for negligible ergonomic gain at this surface size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ADR-006 — `AccessRequestsPage` error UX: inline card + soft-fail stat cards
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- When `requestsQuery.isError` is true, replace the table region (the `<Card className="overflow-hidden">` in `AccessRequestsPage.tsx:176-213`) with an inline error card containing the error message and a "Reintentar" button that calls `requestsQuery.refetch()`.
|
||||||
|
- When `allRequestsQuery.isError` is true, the four `StatCard` components show `0` (using `?? 0` on `getStatusCount`). Stats failing must NOT block the table.
|
||||||
|
- The detail panel (`RequestDetail`) keeps rendering its empty state when `selected` is null — no change.
|
||||||
|
- Loading state stays as today (centered spinner). Error state replaces what would otherwise be a silently-empty table.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Today both queries fail invisibly (`AccessRequestsPage.tsx:71-72`: `requestsQuery.data ?? []`). The user sees an empty table and no signal that the network failed. That's a correctness bug for a page that approves/rejects platform access.
|
||||||
|
- Stats and the table are independent fetches with independent failure modes. Coupling them (e.g., refusing to render the table because stats failed) is the wrong UX.
|
||||||
|
- `refetch` on the inline button is the canonical React Query recovery path. No need to invent retry logic.
|
||||||
|
|
||||||
|
**Shape** (illustrative):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{requestsQuery.isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : requestsQuery.isError ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-3 py-10 text-center">
|
||||||
|
<p className="text-sm text-red-300">{(requestsQuery.error as Error).message}</p>
|
||||||
|
<Button variant="secondary" onClick={() => requestsQuery.refetch()}>Reintentar</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<TableAndDetail ... />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *Toast on every fetch error*: users lose the recovery affordance the moment the toast dismisses. Also noisy.
|
||||||
|
- *Use the global ErrorBoundary*: the boundary is for render errors, not for query failures. Mixing them blurs the contract — users would see "something went wrong" for a transient network blip.
|
||||||
|
- *Block the page on stat failure*: stats are a side panel. Failing them should not break the primary workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ADR-007 — Vitest scaffolding: mirror frontend-admin's inline `vite.config.ts` test block
|
||||||
|
|
||||||
|
**Decision**: do NOT add a separate `vitest.config.ts`. Mirror the frontend-admin pattern, which puts the `test:` block inside `vite.config.ts` (verified at `services/frontend-admin/vite.config.ts:28-33`). Add the test setup file at `src/test/setup.ts` (mirror of admin's `services/frontend-admin/src/test/setup.ts`).
|
||||||
|
|
||||||
|
**Final config additions** (delta against current state):
|
||||||
|
|
||||||
|
`services/frontend-console/vite.config.ts` — add test block:
|
||||||
|
```ts
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
restoreMocks: true,
|
||||||
|
clearMocks: true,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
`services/frontend-console/src/test/setup.ts` — verbatim copy of admin's setup. Stubs `localStorage`/`sessionStorage`, sets `IS_REACT_ACT_ENVIRONMENT`, clears storage `beforeEach`, unstubs globals `afterEach`.
|
||||||
|
|
||||||
|
`services/frontend-console/package.json` — add:
|
||||||
|
- `"test": "vitest run"` to scripts
|
||||||
|
- devDeps: `vitest@^3.2.4`, `jsdom@^26.1.0` (matching admin versions to avoid two majors in one repo)
|
||||||
|
- The proposal also mentions `@testing-library/react`, `@testing-library/user-event`, `@vitest/coverage-v8`. Frontend-admin does NOT have these — its tests are pure logic + RTL-free. **Recommendation: only add what we will actually use in this change**. The test priorities (next ADR) cover store + coordinator + apiClient + a page. The page test (`AccessRequestsPage`) needs RTL. Coverage is nice-to-have, not required to land tests.
|
||||||
|
- **Add now**: `@testing-library/react@^16`, `@testing-library/dom@^10`, `@testing-library/user-event@^14`
|
||||||
|
- **Defer**: `@vitest/coverage-v8` until someone asks for coverage gating
|
||||||
|
- **Defer**: `msw` — for the page test we mock the api module directly (`vi.mock('@/features/access-requests/api/access-requests-api')`), simpler than network interception
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *Separate `vitest.config.ts`*: divergent from admin without a reason. Two configs to maintain.
|
||||||
|
- *Add `msw` upfront*: heavier setup; the api modules in console are thin enough that module-level mocking is sufficient and faster.
|
||||||
|
- *Add coverage from day one*: coverage is meaningless until there's a baseline of tests AND a target threshold. Premature.
|
||||||
|
|
||||||
|
**Diff snapshot for tasks phase** (so the breakdown is mechanical):
|
||||||
|
|
||||||
|
```
|
||||||
|
M services/frontend-console/package.json (scripts.test, devDependencies)
|
||||||
|
M services/frontend-console/vite.config.ts (+ test block)
|
||||||
|
+ services/frontend-console/src/test/setup.ts
|
||||||
|
+ services/frontend-console/src/features/auth/stores/auth-store.test.ts
|
||||||
|
+ services/frontend-console/src/lib/api/client.test.ts
|
||||||
|
+ services/frontend-console/src/features/access-requests/pages/AccessRequestsPage.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ADR-008 — Test ordering (TDD-first sequencing)
|
||||||
|
|
||||||
|
**Decision**: tests land BEFORE the corresponding refactor, in this exact order:
|
||||||
|
|
||||||
|
1. **`auth-store.test.ts`** — `setSession` writes `accessToken`, `refreshToken`, `user`; `logout` clears all of them; `isAuthenticated()` reflects state. **Pure unit, no I/O, no jsdom features beyond what setup.ts provides.** This test must PASS against the current store (with the legacy `token` field still present). After ADR-001 lands, the test still passes — we only assert on `accessToken`, never on `token`.
|
||||||
|
2. **`SessionCoordinator.refresh` dedupe** — race test using mocked `fetch`: two concurrent `refresh()` calls, assert one network call, both promises resolve to the same token. Mirror admin's `client.test.ts:44-86`. Must PASS against current code (the dedupe already works — this test pins the behavior so the apiClient split doesn't break it).
|
||||||
|
3. **`apiClient` 401 retry path** — mock `fetch` to return 401, then 200 on the refresh, then 200 on the retry. Assert headers, the `Authorization: Bearer fresh-token` on retry, and final body. Mirror admin's `client.test.ts:88-126`. Also assert: `apiClient<{ok:boolean}>` on a 204 throws (this is the new contract from ADR-003 — test goes red until ADR-003 ships).
|
||||||
|
4. **`AccessRequestsPage.test.tsx`** — happy path render with mocked api module: status filter changes, refetch button calls both queries, approve/reject mutations show success toast, **AND** the new error UI from ADR-006 renders when the query mock rejects. Some assertions go red until ADR-006 ships.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Tests 1-2 pin existing correct behavior FIRST. They protect against accidental regressions during the rename in ADR-001.
|
||||||
|
- Tests 3-4 contain assertions that intentionally fail today and pass once the refactor lands. That is the TDD signal. Strict TDD mode is active for this project — this is non-negotiable.
|
||||||
|
- This ordering means the rename in ADR-001 has a green safety net before it runs, exactly as the proposal demands.
|
||||||
|
|
||||||
|
**Rejected alternatives**:
|
||||||
|
- *All tests after the refactor*: defeats the point of strict TDD. Also leaves the rename uncovered during the highest-risk window.
|
||||||
|
- *Page test first*: too much surface; would require the apiClient split AND the error UI AND mocks all at once. Bottom-up is faster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Component & data-flow map (post-change)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ RouterProvider (root) │
|
||||||
|
│ ┌───────────────────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ │ /login → LoginPage │ │ /app/platform/* tree │ │
|
||||||
|
│ │ reads: accessToken,user │ │ ProtectedRoute (gate) │ │
|
||||||
|
│ │ writes: setSession() │ │ reads: accessToken,user │ │
|
||||||
|
│ │ │ │ persist.hasHyd.. │ │
|
||||||
|
│ └───────────────────────────┘ │ decides: redirect / │ │
|
||||||
|
│ │ restoring / │ │
|
||||||
|
│ │ render Outlet │ │
|
||||||
|
│ │ ┌────────────────────┐ │ │
|
||||||
|
│ │ │ PlatformLayout │ │ │
|
||||||
|
│ │ │ sidebar + logout │ │ │
|
||||||
|
│ │ │ ┌──────────────┐ │ │ │
|
||||||
|
│ │ │ │ ErrorBoundary │ │ │ │
|
||||||
|
│ │ │ │ <Outlet/> │ │ │ │
|
||||||
|
│ │ │ └──────────────┘ │ │ │
|
||||||
|
│ │ └────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
useAuthStore (Zustand, persist:{ user })
|
||||||
|
▲ ▲ ▲ ▲
|
||||||
|
│ │ │ │
|
||||||
|
ProtectedR. LoginPage PlatformL. apiClient
|
||||||
|
(reads accessToken)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ apiClient<T> | apiClientVoid
|
||||||
|
│ ↓ executeWithRefresh │
|
||||||
|
│ ↓ 401 → SessionCoord. │
|
||||||
|
│ ↓ /auth/refresh│
|
||||||
|
└──────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
backend-api (Rust)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration points** (unchanged contracts):
|
||||||
|
- `POST /auth/login` → returns `{access_token, role, email, projects, refresh_token?}` (cookie also set)
|
||||||
|
- `POST /auth/refresh` → returns `{access_token, role, email}` (rotated cookie)
|
||||||
|
- `GET /platform/access-requests` → `AccessRequest[]`
|
||||||
|
- `POST /platform/access-requests/:id/{review,approve,reject}` → `AccessRequest`
|
||||||
|
|
||||||
|
None of these contracts change in this refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Architectural risks & assumptions
|
||||||
|
|
||||||
|
| ID | Risk / assumption | Severity | Mitigation |
|
||||||
|
|----|-------------------|----------|------------|
|
||||||
|
| R1 | Bracket-string read of `state['token']` exists somewhere TS can't see | medium | Mandatory regex audit (ADR-001 step 1) before rename; CI grep guard optional |
|
||||||
|
| R2 | Backend stops sending `access_token` and only sends `token` | low | Backend code (`auth.rs:232-236`) sends both today and the comment marks `token` as the legacy one; type contract aligned with what's primary |
|
||||||
|
| R3 | `ProtectedRoute` heuristic mis-detects "cold visitor" if `user` got persisted but cleared via direct localStorage manipulation | low | Acceptable — reduces to current behavior (refresh attempt) |
|
||||||
|
| R4 | `apiClient<T>` 204 throw breaks an existing call we missed | low | Grep: every existing call site expects a body. Add a smoke pass during apply |
|
||||||
|
| R5 | RTL test for `AccessRequestsPage` is flaky on async mutations | medium | Use `findBy*` queries + `waitFor`; mock api at the module boundary, not the network |
|
||||||
|
| R6 | Vitest version drift between admin and console | low | Pin both to `^3.2.4` (admin's version) |
|
||||||
|
| R7 | ErrorBoundary swallows real bugs in dev | low | `componentDidCatch` logs to console; React's default red overlay still surfaces in dev mode |
|
||||||
|
|
||||||
|
**Unresolved decisions**: none. All eight items have a concrete decision above.
|
||||||
|
|
||||||
|
**Assumptions requiring validation in the apply phase**:
|
||||||
|
- A1: No bracket-string `'token'` reads exist in the console source tree (validate via grep before rename).
|
||||||
|
- A2: No current caller of `apiClient<T>` hits a 204 endpoint (validate by inspecting all four call sites: `loginApi`, `listAccessRequests`, `markAccessRequestInReview`, `approveAccessRequest`, `rejectAccessRequest`, `SessionCoordinator.performRefresh`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Out of scope (explicit non-goals for this design)
|
||||||
|
|
||||||
|
- Refresh token rotation rework
|
||||||
|
- Migrating frontend-admin or landing apps
|
||||||
|
- E2E tests
|
||||||
|
- Coverage gating
|
||||||
|
- Observability (Sentry, etc.) wiring into the ErrorBoundary
|
||||||
|
- Removing the legacy `token` emission from the backend
|
||||||
|
- Project/tenant selection state (admin has it; console does not need it yet)
|
||||||
|
|
||||||
|
These belong in future changes if/when the need is real.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Proposal: console-tech-debt-cleanup
|
||||||
|
|
||||||
|
**Change name:** `console-tech-debt-cleanup`
|
||||||
|
**Target service:** `services/frontend-console`
|
||||||
|
**Stack:** React 19 + Vite 7 + TypeScript 5.9, Zustand (persist), TanStack Query 5
|
||||||
|
**Type:** Technical debt + safety net (no feature work, no backend changes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The console has accumulated correctness debt concentrated in the **auth layer** and the **API client typing**, on top of a **completely untested mutation surface** that approves/rejects platform access. Each item alone is small; together they form a risk cluster:
|
||||||
|
|
||||||
|
- **Two sources of truth for the access token** (`token` and `accessToken` fields in the auth store) silently diverged across consumers — the next refactor touching auth has a real chance of breaking sign-in.
|
||||||
|
- **`LoginResponse` types do not match runtime** — the login page falls back to `response.access_token ?? response.token`, which means either the type or the runtime contract is wrong. The compiler currently lies about the shape.
|
||||||
|
- **`apiClient` casts `null as T`** on 204 responses, defeating TypeScript's ability to catch consumers that forgot to handle "no body".
|
||||||
|
- **First-load UX flashes "Restaurando sesion…"** for users who never had a session, because the heuristic uses an unpersisted field.
|
||||||
|
- **Failed queries are invisible** on `AccessRequestsPage` — an operator sees an empty table on API failure with no way to distinguish "no requests" from "backend down".
|
||||||
|
- **No `ErrorBoundary` at the shell** — any unhandled render error blanks the whole console.
|
||||||
|
- **Zero tests in this app**, despite Vitest being the project default. The approve/reject mutations on `AccessRequestsPage` directly gate platform access. Shipping refactors against this surface without a baseline suite is unacceptable.
|
||||||
|
|
||||||
|
This proposal pays down that debt and lays the testing foundation so future changes land safely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changes
|
||||||
|
|
||||||
|
Eight discrete fixes, grouped into three bundles by priority. None alter the network contract; the backend is untouched.
|
||||||
|
|
||||||
|
### Bundle P0 — Correctness, types, and test scaffolding
|
||||||
|
|
||||||
|
1. **Unify access token field in auth store.** Keep `accessToken` as the single source of truth. Rename consumers in `ProtectedRoute.tsx` and `LoginPage.tsx` to read `accessToken`. Drop the duplicated `token` field from `auth-store.ts`.
|
||||||
|
2. **Align `LoginResponse` with the actual backend response.** Determine the real shape (likely `access_token` snake_case from the API). Either make the type match exactly and remove the `?? response.token` fallback, or, if both shapes are legitimately possible, mark both as optional and guard explicitly. No silent `??` chains across non-equivalent fields.
|
||||||
|
3. **Remove `null as T` casts in `apiClient`.** Split into two call paths: the default `apiClient<T>` returns `T` and throws on 204 (or refuses to be called on void endpoints), and a sibling `apiClientVoid` returns `void` for endpoints that legitimately respond 204. Callers update accordingly.
|
||||||
|
4. **Bootstrap Vitest + Testing Library** in `frontend-console` (it is the project default per `sdd-init` but not yet wired up here). Add `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, a `vitest.config.ts` aligned with `frontend-admin`, a `setupTests.ts`, and an `npm script` (`pnpm test`). Write the **baseline suite** that protects every P0/P1 change:
|
||||||
|
- `SessionCoordinator.refresh` — dedupes concurrent refresh calls.
|
||||||
|
- `apiClient` — 401 triggers refresh-and-retry; 204 path is type-safe.
|
||||||
|
- `auth-store` — `setSession` and `logout` behave correctly (single token field).
|
||||||
|
- `AccessRequestsPage` — approve, reject, and "mark as review" mutations on the happy path; error path surfaces a toast.
|
||||||
|
|
||||||
|
### Bundle P1 — UX and robustness
|
||||||
|
|
||||||
|
5. **Fix the "Restaurando sesion…" flash on first load.** In `ProtectedRoute.tsx`, base the "previous session existed, attempt refresh" heuristic on persisted `user` presence, not on the in-memory `token`. Users without a prior session go straight to login with no flash.
|
||||||
|
6. **Surface query failures on `AccessRequestsPage`.** Read `requestsQuery.isError` and render an explicit error state (retry CTA + message). No more empty-table-on-failure ambiguity.
|
||||||
|
7. **Add an `ErrorBoundary` at the platform shell** so render errors render a recoverable error screen instead of a blank console.
|
||||||
|
|
||||||
|
### Bundle P2 — DRY hygiene
|
||||||
|
|
||||||
|
8. **Use the existing `isActionable` helper** in `AccessRequestsPage.tsx` instead of inlining `['pending','in_review'].includes(request.status)` three times.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### Files touched (expected)
|
||||||
|
|
||||||
|
- `src/features/auth/stores/auth-store.ts` — drop `token`, keep `accessToken` only.
|
||||||
|
- `src/features/auth/api/auth-api.ts` — `LoginResponse` aligned with runtime.
|
||||||
|
- `src/features/auth/pages/LoginPage.tsx` — read `accessToken`, drop `??` fallback (or make it explicit and typed).
|
||||||
|
- `src/app/router/ProtectedRoute.tsx` — read `accessToken`; refresh heuristic uses persisted `user`.
|
||||||
|
- `src/lib/api/client.ts` — split `apiClient` / `apiClientVoid`; remove `null as T`.
|
||||||
|
- `src/features/access-requests/pages/AccessRequestsPage.tsx` — error UI, use `isActionable`.
|
||||||
|
- `src/app/` (shell) — new `ErrorBoundary`.
|
||||||
|
- `vitest.config.ts`, `src/setupTests.ts`, `package.json` (devDeps + scripts) — test scaffolding.
|
||||||
|
- New `__tests__` files colocated with the units listed in P0.4.
|
||||||
|
|
||||||
|
### Behavioral changes
|
||||||
|
|
||||||
|
- **Internal-only:** the auth store exposes one token field instead of two. Any external code reading `state.token` would break, but no such consumers exist outside the four files listed.
|
||||||
|
- **User-visible:**
|
||||||
|
- First-load no longer flashes "Restaurando sesion…" for users without a prior session.
|
||||||
|
- Failed access-request queries now show an error state instead of an empty table.
|
||||||
|
- Render errors render an error boundary instead of a blank screen.
|
||||||
|
- **Network contract:** unchanged. No backend modification.
|
||||||
|
|
||||||
|
### Tooling
|
||||||
|
|
||||||
|
- New dev dependencies: `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom`. Aligned with what `frontend-admin` already uses.
|
||||||
|
- New script: `pnpm test` (and `pnpm test:watch`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- New features or new modules.
|
||||||
|
- Redesign of `AccessRequestsPage` UX (filtering, pagination, sorting, etc.).
|
||||||
|
- Backend API changes — even if `LoginResponse` shape is awkward, the proposal aligns the **type** to the existing **runtime**, not the other way around.
|
||||||
|
- Migrating other apps (`frontend-admin`, `landing`) — they are out of scope for this change.
|
||||||
|
- Auth flow architectural changes (refresh token rotation, session storage strategy, etc.) — only the duplicated-field bug is addressed.
|
||||||
|
- E2E tests. Only unit/component tests via Vitest + Testing Library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorities
|
||||||
|
|
||||||
|
| Priority | Items | Rationale |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| **P0** | 1, 2, 3, 4 | Correctness + type safety + the test scaffolding required to land everything else without regressions. P0.4 must come first within the bundle so P0.1–3 are protected by tests. |
|
||||||
|
| **P1** | 5, 6, 7 | UX correctness and robustness. Visible-but-non-correctness issues. Land after P0 so the test suite already protects against regressions. |
|
||||||
|
| **P2** | 8 | Pure DRY hygiene. Lowest risk, lowest reward. Can ship in the same PR as P1 or as a tail commit. |
|
||||||
|
|
||||||
|
Recommended sequencing inside P0: **(4) test scaffolding + baseline tests → (1) unify token → (2) align types → (3) split `apiClient`**. Tests come first so the auth and client refactors land green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
**Overall risk: low.** This is a local refactor inside a single app, no backend changes, no network contract changes.
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Renaming `token` → `accessToken` misses a consumer and silently breaks login | Low | High (sign-in broken) | Test scaffolding (P0.4) ships first and includes auth-store + protected-route tests; TypeScript catches structural reads; manual smoke of login + refresh in dev. |
|
||||||
|
| Splitting `apiClient` into `apiClient` / `apiClientVoid` requires touching every void-returning caller | Medium | Low (compile-time) | TypeScript flags every miss at build time; mechanical fix, no runtime ambiguity. |
|
||||||
|
| `LoginResponse` runtime contract turns out to differ from assumption | Low | Medium | Verify against backend handler before changing the type; if both shapes truly exist, model both as optional and document why. |
|
||||||
|
| Test scaffolding diverges from `frontend-admin` conventions | Low | Low | Mirror `frontend-admin`'s `vitest.config.ts` and `setupTests.ts` 1:1 where possible. |
|
||||||
|
| `ErrorBoundary` swallows errors useful for debugging | Low | Low | Boundary logs to console + shows a "reload" CTA; only catches render errors, not async ones. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- The auth store exposes **a single** access-token field; no consumer reads `state.token` (it no longer exists).
|
||||||
|
- `LoginResponse` matches the backend response exactly; the `?? response.token` fallback is gone (or both fields are explicitly optional and guarded).
|
||||||
|
- `apiClient` no longer contains `null as T`. Void endpoints use a dedicated `apiClientVoid` (or equivalent pattern) and TypeScript correctly types the absence of a body.
|
||||||
|
- First-load with no prior session goes **directly to login** — no "Restaurando sesion…" flash.
|
||||||
|
- A failed `AccessRequestsPage` query renders a **visible error state** with a retry, distinguishable from an empty list.
|
||||||
|
- A render error anywhere in the console renders the `ErrorBoundary`, not a blank page.
|
||||||
|
- `AccessRequestsPage` uses the `isActionable` helper everywhere; no inline duplication of the status check.
|
||||||
|
- `pnpm test` runs Vitest in `frontend-console` and the **baseline suite passes green**, covering: `SessionCoordinator.refresh` dedupe, `apiClient` 401 retry + 204 typing, `auth-store` `setSession` / `logout`, `AccessRequestsPage` approve/reject/review mutations (happy + error toast).
|
||||||
|
- `pnpm run lint` is clean.
|
||||||
|
- No conventional-commit trailers reference AI authorship.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Access Requests Specification
|
||||||
|
# Delta for: Access Requests — console-tech-debt-cleanup
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Failed Query Surfaces Visible Error State
|
||||||
|
|
||||||
|
When `requestsQuery.isError` is true, `AccessRequestsPage` MUST render an explicit error state. The error state MUST be visually distinguishable from an empty list. The error state MUST include a retry action that re-executes the query.
|
||||||
|
|
||||||
|
#### Scenario: API 500 on initial load
|
||||||
|
|
||||||
|
- GIVEN the operator navigates to the Access Requests page
|
||||||
|
- WHEN `listAccessRequests` rejects (e.g. HTTP 500)
|
||||||
|
- THEN the table area is replaced by an error state element (not an empty table)
|
||||||
|
- AND the error state contains a retry button
|
||||||
|
- AND clicking the retry button calls `requestsQuery.refetch()`
|
||||||
|
|
||||||
|
#### Scenario: API failure mid-session
|
||||||
|
|
||||||
|
- GIVEN the page has loaded successfully and shows data
|
||||||
|
- WHEN the operator triggers a manual refresh and `listAccessRequests` rejects
|
||||||
|
- THEN the error state renders (replacing the previous data view)
|
||||||
|
- AND the operator can retry without reloading the full page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: isActionable Is the Single Source of Truth for Actionable Status
|
||||||
|
|
||||||
|
The `RequestDetail` component MUST use the `isActionable(request)` helper to determine whether approve, reject, and "mark as review" actions are enabled. Inline status-string arrays (`['pending','in_review'].includes(...)`) MUST NOT appear in `AccessRequestsPage` or `RequestDetail`. The `isActionable` helper is already defined in the file; this requirement mandates it be used exclusively.
|
||||||
|
|
||||||
|
#### Scenario: Pending request — all action buttons enabled
|
||||||
|
|
||||||
|
- GIVEN a request with `status = 'pending'`
|
||||||
|
- WHEN `RequestDetail` renders
|
||||||
|
- THEN the "Revisar" button is enabled
|
||||||
|
- AND the "Aprobar" button is enabled
|
||||||
|
- AND the "Rechazar" button is enabled
|
||||||
|
- AND these states are derived from `isActionable(request)`
|
||||||
|
|
||||||
|
#### Scenario: In-review request — approve and reject enabled, review disabled
|
||||||
|
|
||||||
|
- GIVEN a request with `status = 'in_review'`
|
||||||
|
- WHEN `RequestDetail` renders
|
||||||
|
- THEN "Aprobar" and "Rechazar" are enabled
|
||||||
|
- AND "Revisar" is disabled (status is not `'pending'`)
|
||||||
|
- AND these states are derived from `isActionable(request)`
|
||||||
|
|
||||||
|
#### Scenario: Approved or rejected request — all action buttons disabled
|
||||||
|
|
||||||
|
- GIVEN a request with `status = 'approved'` or `status = 'rejected'`
|
||||||
|
- WHEN `RequestDetail` renders
|
||||||
|
- THEN "Revisar", "Aprobar", and "Rechazar" are all disabled
|
||||||
|
- AND `isActionable(request)` returns false
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Mutation Success Shows Toast and Invalidates Cache
|
||||||
|
|
||||||
|
Each mutation (approve, reject, mark-as-review) MUST show a `toast.success` message on success and MUST invalidate the `['platform-access-requests']` query key. On error, each mutation MUST show a `toast.error` with the error message.
|
||||||
|
|
||||||
|
#### Scenario: Approve mutation success
|
||||||
|
|
||||||
|
- GIVEN a request with status `pending` or `in_review`
|
||||||
|
- WHEN the operator clicks "Aprobar" and `approveAccessRequest` resolves
|
||||||
|
- THEN `toast.success('Solicitud aprobada')` fires
|
||||||
|
- AND `queryClient.invalidateQueries({ queryKey: ['platform-access-requests'] })` is called
|
||||||
|
|
||||||
|
#### Scenario: Reject mutation success
|
||||||
|
|
||||||
|
- GIVEN the rejection modal is open with a reason of at least 3 characters
|
||||||
|
- WHEN the operator clicks "Rechazar" and `rejectAccessRequest` resolves
|
||||||
|
- THEN `toast.success('Solicitud rechazada')` fires
|
||||||
|
- AND the modal closes
|
||||||
|
- AND the query cache is invalidated
|
||||||
|
|
||||||
|
#### Scenario: Mutation error
|
||||||
|
|
||||||
|
- GIVEN any mutation (`approve`, `reject`, or `review`) is triggered
|
||||||
|
- WHEN the API call rejects with an `Error`
|
||||||
|
- THEN `toast.error(err.message)` fires
|
||||||
|
- AND the query cache is NOT invalidated
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# API Client Specification
|
||||||
|
# Delta for: API Client — console-tech-debt-cleanup
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: No null-as-T Casts; Typed 204 Path
|
||||||
|
|
||||||
|
`apiClient<T>` MUST NOT contain `null as T`. For endpoints that legitimately return HTTP 204 (no body), callers MUST use a dedicated `apiClientVoid` (or equivalent) that returns `Promise<void>`. `apiClient<T>` MUST throw or return a typed error if invoked against an endpoint that responds 204. TypeScript MUST be able to statically distinguish body-returning calls from void calls.
|
||||||
|
|
||||||
|
#### Scenario: 200 response with body
|
||||||
|
|
||||||
|
- GIVEN a call `apiClient<User>('/users/1', { method: 'GET' })`
|
||||||
|
- WHEN the server responds HTTP 200 with a JSON body `{ id: "...", email: "..." }`
|
||||||
|
- THEN the function resolves with `User` typed data
|
||||||
|
- AND no cast is required at the call site
|
||||||
|
|
||||||
|
#### Scenario: 204 response via apiClientVoid
|
||||||
|
|
||||||
|
- GIVEN a call `apiClientVoid('/access-requests/123/approve', { method: 'PUT' })`
|
||||||
|
- WHEN the server responds HTTP 204
|
||||||
|
- THEN the function resolves with `void` (no body)
|
||||||
|
- AND the TypeScript return type is `Promise<void>` — not `Promise<null>` or `Promise<undefined>`
|
||||||
|
|
||||||
|
#### Scenario: Caller cannot pass 204-returning endpoint to apiClient
|
||||||
|
|
||||||
|
- GIVEN a developer writes `apiClient<void>('/approve', ...)`
|
||||||
|
- WHEN TypeScript compiles
|
||||||
|
- THEN a compile-time error surfaces (or the function signature prevents the `void` generic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 401 Triggers Single Refresh-and-Retry
|
||||||
|
|
||||||
|
When `apiClient` receives HTTP 401 on the first attempt (non-bypassed endpoint, non-retry call), it MUST invoke `SessionCoordinator.refresh()` exactly once, then replay the original request with the new token. If the retried request succeeds, `apiClient` MUST resolve with the response body. If refresh fails or the retry also fails, `apiClient` MUST call `logout()`, redirect to `/login`, and throw.
|
||||||
|
|
||||||
|
#### Scenario: 401 with refresh success, retry succeeds with body
|
||||||
|
|
||||||
|
- GIVEN an authenticated call returns HTTP 401
|
||||||
|
- AND `SessionCoordinator.refresh()` returns a new access token
|
||||||
|
- WHEN the retried request returns HTTP 200 with body
|
||||||
|
- THEN `apiClient` resolves with the parsed response body
|
||||||
|
- AND only one refresh call was made
|
||||||
|
|
||||||
|
#### Scenario: 401 with refresh success, retry returns 204
|
||||||
|
|
||||||
|
- GIVEN an authenticated call returns HTTP 401
|
||||||
|
- AND `SessionCoordinator.refresh()` returns a new access token
|
||||||
|
- WHEN the retried request returns HTTP 204
|
||||||
|
- THEN `apiClientVoid` resolves with `void`
|
||||||
|
- AND the caller is NOT handed a null value
|
||||||
|
|
||||||
|
#### Scenario: 401 with refresh failure
|
||||||
|
|
||||||
|
- GIVEN an authenticated call returns HTTP 401
|
||||||
|
- AND `SessionCoordinator.refresh()` returns null
|
||||||
|
- THEN `logout()` is called
|
||||||
|
- AND the browser is redirected to `/login`
|
||||||
|
- AND `apiClient` throws with message "Sesion expirada"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: SessionCoordinator Deduplicates Concurrent Refresh Calls
|
||||||
|
|
||||||
|
If multiple in-flight requests trigger a 401 simultaneously, `SessionCoordinator.refresh()` MUST execute the underlying HTTP refresh request exactly once. All concurrent callers MUST await the same `Promise` and receive the same new token.
|
||||||
|
|
||||||
|
#### Scenario: Two concurrent 401s produce one refresh HTTP call
|
||||||
|
|
||||||
|
- GIVEN two `apiClient` calls are in flight simultaneously
|
||||||
|
- WHEN both receive HTTP 401
|
||||||
|
- THEN `SessionCoordinator.performRefresh` is called exactly once (one HTTP POST to `/auth/refresh`)
|
||||||
|
- AND both original requests are retried with the same new token
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Auth Specification
|
||||||
|
# Delta for: Auth — console-tech-debt-cleanup
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Single Access Token Field in Auth Store
|
||||||
|
|
||||||
|
The auth store MUST expose exactly one access-token field: `accessToken`. The `token` field MUST NOT exist in `AuthState`. `setSession` MUST write only `accessToken`. `logout` MUST clear only `accessToken` (plus `refreshToken` and `user`). `isAuthenticated` MUST derive its result from `accessToken`.
|
||||||
|
|
||||||
|
All consumers (`ProtectedRoute`, `LoginPage`, `apiClient`) MUST read `state.accessToken`; they MUST NOT reference `state.token`.
|
||||||
|
|
||||||
|
#### Scenario: Login success stores token in accessToken only
|
||||||
|
|
||||||
|
- GIVEN a user submits valid credentials
|
||||||
|
- WHEN `loginApi` resolves and `login()` is called with the response
|
||||||
|
- THEN `state.accessToken` holds the received JWT
|
||||||
|
- AND `state.token` does not exist on the state object
|
||||||
|
|
||||||
|
#### Scenario: Refresh success updates accessToken only
|
||||||
|
|
||||||
|
- GIVEN a valid refresh cookie exists and `SessionCoordinator.refresh()` succeeds
|
||||||
|
- WHEN `setSession` is called with the new token
|
||||||
|
- THEN `state.accessToken` is updated to the refreshed JWT
|
||||||
|
- AND `state.token` does not exist on the state object
|
||||||
|
|
||||||
|
#### Scenario: Logout clears accessToken and user
|
||||||
|
|
||||||
|
- GIVEN a user is authenticated (`state.accessToken` is non-null)
|
||||||
|
- WHEN `logout()` is called
|
||||||
|
- THEN `state.accessToken` is `null`
|
||||||
|
- AND `state.user` is `null`
|
||||||
|
- AND the persisted key `omnioil-console-auth` is removed from `localStorage`
|
||||||
|
|
||||||
|
#### Scenario: ProtectedRoute reads accessToken
|
||||||
|
|
||||||
|
- GIVEN the store has `accessToken = "eyJ..."` and `token` field is absent
|
||||||
|
- WHEN `ProtectedRoute` evaluates the session
|
||||||
|
- THEN the component reads `state.accessToken` without referencing `state.token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: LoginResponse Matches Backend Runtime Contract
|
||||||
|
|
||||||
|
The `LoginResponse` type MUST declare both `access_token: string` (primary) and `token: string` (legacy compatibility field), because the backend always emits both. The `??` fallback across non-equivalent fields MUST be removed; `LoginPage` MUST read `response.access_token` directly. The `refresh_token` field is NOT present in the login JSON body (it is delivered as an HttpOnly cookie only) and MUST NOT be declared on `LoginResponse`.
|
||||||
|
|
||||||
|
#### Scenario: Login page reads access_token directly
|
||||||
|
|
||||||
|
- GIVEN the backend returns `{ access_token: "eyJ...", token: "eyJ...", role: "platform_admin", email: "op@x.com", projects: [] }`
|
||||||
|
- WHEN `loginApi` resolves and `onSubmit` processes the response
|
||||||
|
- THEN `login()` is called with `accessToken: response.access_token` (no `?? response.token` fallback)
|
||||||
|
|
||||||
|
#### Scenario: LoginResponse type has no refresh_token field
|
||||||
|
|
||||||
|
- GIVEN the `LoginResponse` interface is the only type representing a login API response
|
||||||
|
- WHEN a developer reads or extends `LoginResponse`
|
||||||
|
- THEN the interface contains `access_token`, `token`, `role`, and `email` — no `refresh_token`
|
||||||
|
|
||||||
|
#### Scenario: Refresh endpoint response aligned in RefreshResponse
|
||||||
|
|
||||||
|
- GIVEN `SessionCoordinator.performRefresh` receives the refresh response
|
||||||
|
- WHEN the backend returns `{ access_token: "eyJ...", token: "eyJ...", role: "...", email: "..." }`
|
||||||
|
- THEN `performRefresh` reads `data.access_token` as the canonical field; the `?? data.token` fallback MAY remain as a defensive guard but MUST NOT silently mask a missing `access_token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: ProtectedRoute Uses Persisted User for Refresh Heuristic
|
||||||
|
|
||||||
|
`ProtectedRoute` MUST decide whether to attempt a refresh based on the persisted `user` field (which survives hydration), NOT on `accessToken` (which is intentionally not persisted). When `user` is null, the component MUST navigate to `/login` immediately without showing "Restaurando sesion...".
|
||||||
|
|
||||||
|
#### Scenario: Cold visit — no persisted user, no flash
|
||||||
|
|
||||||
|
- GIVEN no prior session exists (`user` is null, `accessToken` is null)
|
||||||
|
- WHEN the user navigates to a protected route
|
||||||
|
- THEN the component renders `<Navigate to="/login" replace />` immediately
|
||||||
|
- AND "Restaurando sesion..." is never rendered
|
||||||
|
|
||||||
|
#### Scenario: Warm visit — persisted user, expired token, refresh succeeds
|
||||||
|
|
||||||
|
- GIVEN `user` is non-null (persisted) but `accessToken` is null (in-memory only)
|
||||||
|
- WHEN `ProtectedRoute` mounts
|
||||||
|
- THEN it renders the "Restaurando sesion..." indicator while `SessionCoordinator.refresh()` is pending
|
||||||
|
- AND when refresh resolves with a new token, `setSession` is called and the outlet renders
|
||||||
|
|
||||||
|
#### Scenario: Warm visit — persisted user, refresh fails
|
||||||
|
|
||||||
|
- GIVEN `user` is non-null (persisted) but `accessToken` is null
|
||||||
|
- WHEN `SessionCoordinator.refresh()` rejects or returns null
|
||||||
|
- THEN `ProtectedRoute` navigates to `/login` after the refresh attempt completes
|
||||||
|
- AND "Restaurando sesion..." disappears before the redirect
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Platform Shell Specification
|
||||||
|
# Delta for: Platform Shell — console-tech-debt-cleanup
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: ErrorBoundary at Platform Shell
|
||||||
|
|
||||||
|
The platform shell layout component MUST be wrapped in a React `ErrorBoundary`. When any child component throws an unhandled render error, the boundary MUST render a fallback screen inside the platform layout (not a blank page). The fallback MUST include a logout link or button so the operator can recover without a manual browser reload.
|
||||||
|
|
||||||
|
#### Scenario: Child component throws — fallback renders
|
||||||
|
|
||||||
|
- GIVEN the platform shell is mounted with a child component that throws during render
|
||||||
|
- WHEN the error propagates up
|
||||||
|
- THEN the ErrorBoundary catches the error
|
||||||
|
- AND the fallback screen renders in place of the blank page
|
||||||
|
- AND the fallback is visible within the platform layout (not a full white screen)
|
||||||
|
|
||||||
|
#### Scenario: Fallback includes recovery action
|
||||||
|
|
||||||
|
- GIVEN the ErrorBoundary fallback is visible
|
||||||
|
- WHEN the operator clicks the logout/recovery link in the fallback
|
||||||
|
- THEN `logout()` is called and the user is redirected to `/login`
|
||||||
|
- AND the operator can start a new session without refreshing the browser manually
|
||||||
|
|
||||||
|
#### Scenario: ErrorBoundary does not catch async errors
|
||||||
|
|
||||||
|
- GIVEN a TanStack Query mutation or async operation throws
|
||||||
|
- WHEN the error is not a render-phase throw
|
||||||
|
- THEN the ErrorBoundary does NOT intercept it
|
||||||
|
- AND the existing per-component error handling (toast, `isError` state) remains responsible
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Testing Baseline Specification
|
||||||
|
# New Spec for: Testing Infrastructure — console-tech-debt-cleanup
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Establishes Vitest + Testing Library as the test runner in `services/frontend-console` and defines the minimum baseline suite that must pass before any P0/P1 change is merged.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Vitest + Testing Library Configured
|
||||||
|
|
||||||
|
`services/frontend-console` MUST have Vitest and `@testing-library/react` installed and configured. The configuration MUST mirror `services/frontend-admin` (`jsdom` environment, `setupTests.ts` importing `@testing-library/jest-dom`). `pnpm test` MUST execute the suite and exit non-zero on failure. `pnpm test:watch` MUST run in watch mode.
|
||||||
|
|
||||||
|
#### Scenario: pnpm test runs green on a clean checkout
|
||||||
|
|
||||||
|
- GIVEN a developer runs `pnpm install && pnpm test` inside `services/frontend-console`
|
||||||
|
- WHEN all baseline tests pass
|
||||||
|
- THEN the process exits with code 0
|
||||||
|
- AND no test is skipped
|
||||||
|
|
||||||
|
#### Scenario: pnpm test fails on a broken test
|
||||||
|
|
||||||
|
- GIVEN a test assertion fails
|
||||||
|
- WHEN `pnpm test` is run
|
||||||
|
- THEN the process exits with non-zero code
|
||||||
|
- AND the failing test name and assertion are printed to stdout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: SessionCoordinator Refresh Deduplication Test
|
||||||
|
|
||||||
|
The test suite MUST include a test that verifies `SessionCoordinator.refresh()` calls `fetch` (the HTTP POST to `/auth/refresh`) exactly once when two concurrent invocations are made.
|
||||||
|
|
||||||
|
#### Scenario: Two concurrent refresh calls share one HTTP request
|
||||||
|
|
||||||
|
- GIVEN `fetch` is mocked to resolve after a short delay with `{ access_token: "new-token", role: "platform_admin", email: "op@x.com" }`
|
||||||
|
- WHEN `SessionCoordinator.refresh()` is called twice concurrently (without awaiting the first)
|
||||||
|
- THEN `fetch` mock is called exactly once
|
||||||
|
- AND both calls resolve with the same token string
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: apiClient 401 Retry Path Test
|
||||||
|
|
||||||
|
The test suite MUST include tests for `apiClient`'s 401 handling covering: single refresh-and-retry success, and refresh failure leading to logout + redirect.
|
||||||
|
|
||||||
|
#### Scenario: apiClient retries after 401 and resolves on success
|
||||||
|
|
||||||
|
- GIVEN `fetch` returns 401 on first call and 200 with body on retry
|
||||||
|
- AND `SessionCoordinator.refresh` resolves with a new token
|
||||||
|
- WHEN `apiClient('/resource', { method: 'GET' })` is called
|
||||||
|
- THEN the resolved value equals the parsed body from the 200 response
|
||||||
|
- AND `fetch` was called twice total
|
||||||
|
|
||||||
|
#### Scenario: apiClient calls logout on refresh failure
|
||||||
|
|
||||||
|
- GIVEN `fetch` returns 401 and `SessionCoordinator.refresh` returns null
|
||||||
|
- WHEN `apiClient('/resource', { method: 'GET' })` is called
|
||||||
|
- THEN `useAuthStore.getState().logout` is called
|
||||||
|
- AND the function throws
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Auth Store setSession and logout Tests
|
||||||
|
|
||||||
|
The test suite MUST cover `setSession` writing to `accessToken` (and only `accessToken` for the token), and `logout` resetting `accessToken` and `user` to null.
|
||||||
|
|
||||||
|
#### Scenario: setSession writes accessToken
|
||||||
|
|
||||||
|
- GIVEN the store is freshly initialized
|
||||||
|
- WHEN `setSession({ accessToken: 'tok', refreshToken: null, role: 'platform_admin', email: 'op@x.com' })` is called
|
||||||
|
- THEN `getState().accessToken` equals `'tok'`
|
||||||
|
- AND `getState().user` equals `{ email: 'op@x.com', role: 'platform_admin' }`
|
||||||
|
- AND there is no `token` field with a non-null value on the state (field absent or null)
|
||||||
|
|
||||||
|
#### Scenario: logout clears state
|
||||||
|
|
||||||
|
- GIVEN the store has a non-null `accessToken` and `user`
|
||||||
|
- WHEN `logout()` is called
|
||||||
|
- THEN `getState().accessToken` is null
|
||||||
|
- AND `getState().user` is null
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: AccessRequestsPage Mutation Baseline Tests
|
||||||
|
|
||||||
|
The test suite MUST include component tests for `AccessRequestsPage` covering the happy path and error path for each of the three mutations (approve, reject, mark-as-review).
|
||||||
|
|
||||||
|
#### Scenario: Approve mutation — success toast and invalidation
|
||||||
|
|
||||||
|
- GIVEN `AccessRequestsPage` renders with a request in `pending` status
|
||||||
|
- WHEN the operator selects that request and clicks "Aprobar"
|
||||||
|
- AND `approveAccessRequest` resolves successfully
|
||||||
|
- THEN `toast.success` is called with a message containing "aprobada"
|
||||||
|
- AND `queryClient.invalidateQueries` is called with key `['platform-access-requests']`
|
||||||
|
|
||||||
|
#### Scenario: Reject mutation — success toast, modal closes, invalidation
|
||||||
|
|
||||||
|
- GIVEN the rejection modal is open with a valid reason
|
||||||
|
- WHEN `rejectAccessRequest` resolves successfully
|
||||||
|
- THEN `toast.success` is called with a message containing "rechazada"
|
||||||
|
- AND the rejection modal is no longer visible
|
||||||
|
- AND the query cache is invalidated
|
||||||
|
|
||||||
|
#### Scenario: Review mutation — success toast and invalidation
|
||||||
|
|
||||||
|
- GIVEN a request with `status = 'pending'` is selected
|
||||||
|
- WHEN the operator clicks "Revisar"
|
||||||
|
- AND `markAccessRequestInReview` resolves successfully
|
||||||
|
- THEN `toast.success` is called with a message containing "revision"
|
||||||
|
- AND the query cache is invalidated
|
||||||
|
|
||||||
|
#### Scenario: Mutation error — error toast, no invalidation
|
||||||
|
|
||||||
|
- GIVEN any mutation is triggered
|
||||||
|
- WHEN the API call rejects with `new Error('Network error')`
|
||||||
|
- THEN `toast.error` is called with `'Network error'`
|
||||||
|
- AND `queryClient.invalidateQueries` is NOT called
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Tasks: console-tech-debt-cleanup
|
||||||
|
|
||||||
|
## Pre-Apply Audit Findings
|
||||||
|
|
||||||
|
### R1 — Token Bracket-String Audit (mandatory pre-P0 check)
|
||||||
|
|
||||||
|
Grep pattern: `\.token\b|['"]token['"]|state\[['"]token['"]\]`
|
||||||
|
Hits in `services/frontend-console/src`:
|
||||||
|
|
||||||
|
| File | Line | Finding |
|
||||||
|
|------|------|---------|
|
||||||
|
| `src/app/router/ProtectedRoute.tsx` | 9 | `useAuthStore((state) => state.token)` — AuthState field read |
|
||||||
|
| `src/features/auth/pages/LoginPage.tsx` | 16 | `useAuthStore((state) => state.token)` — AuthState field read |
|
||||||
|
| `src/features/auth/pages/LoginPage.tsx` | 24,29 | `token` variable from store used in guard conditions |
|
||||||
|
| `src/features/auth/pages/LoginPage.tsx` | 45 | `response.access_token ?? response.token` — wire fallback (LoginResponse) |
|
||||||
|
| `src/lib/api/client.ts` | 66 | `data.access_token ?? data.token` — RefreshResponse wire read (client-local type, not AuthState) |
|
||||||
|
|
||||||
|
All hits are in the two files already targeted by ADR-001/ADR-002. No bracket-string (`state['token']`) reads found. TypeScript rename is sufficient — no hidden runtime reads.
|
||||||
|
|
||||||
|
### A2 — apiClient 204 Callsite Audit
|
||||||
|
|
||||||
|
All `apiClient<T>` callsites return typed body (`AccessRequest`, `AccessRequest[]`, `LoginResponse`). Zero callsites hit a 204 endpoint today. The `null as T` cast at `client.ts:96,111` is dead code in production. Split to `apiClient<T>` + `apiClientVoid` carries zero migration risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | 300-450 |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes |
|
||||||
|
| Suggested split | PR 1 (P0: foundation + auth) → PR 2 (P1: UX + DRY) |
|
||||||
|
| Delivery strategy | ask-on-risk |
|
||||||
|
|
||||||
|
Decision needed before apply: Yes
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
400-line budget risk: High
|
||||||
|
|
||||||
|
### Suggested Work Units
|
||||||
|
|
||||||
|
| Unit | Goal | Likely PR | Notes |
|
||||||
|
|------|------|-----------|-------|
|
||||||
|
| PR 1 | Vitest scaffold + auth correctness (ADR-007, ADR-001, ADR-002, ADR-003, ADR-004) | PR 1 | All P0 items; self-contained; green CI required |
|
||||||
|
| PR 2 | UX robustness + DRY (ADR-006, ADR-005, ADR-008) | PR 2 | Depends on PR 1 merged; can be reviewed independently |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Pre-Apply Checks (sequential, one-time)
|
||||||
|
|
||||||
|
- [x] 0.1 Confirm R1 audit above (already done — document in PR 1 description: only `ProtectedRoute.tsx` and `LoginPage.tsx` read `state.token`; no bracket-string reads).
|
||||||
|
- [x] 0.2 Confirm A2 audit above (already done — document in PR 1 description: zero 204 callsites; safe to split `apiClient`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Vitest Scaffolding (ADR-007) [PR 1 — sequential]
|
||||||
|
|
||||||
|
- [x] 1.1 Install devDeps in `services/frontend-console/package.json`: add `vitest@^3.2.4`, `jsdom@^26.1.0`, `@testing-library/react@^16`, `@testing-library/dom@^10`, `@testing-library/user-event@^14` under `devDependencies`; add `"test": "vitest run"` to `scripts`. [W1 CLOSED: removed --passWithNoTests flag]
|
||||||
|
- [x] 1.2 Add `test` block to `services/frontend-console/vite.config.ts`: `{ environment: 'jsdom', setupFiles: './src/test/setup.ts', restoreMocks: true, clearMocks: true }` — mirror admin pattern verbatim.
|
||||||
|
- [x] 1.3 Create `services/frontend-console/src/test/setup.ts` — verbatim copy of `services/frontend-admin/src/test/setup.ts` (localStorage/sessionStorage mock + `IS_REACT_ACT_ENVIRONMENT`).
|
||||||
|
- [x] 1.4 Run `pnpm test` — must exit 0 with no test files (empty run validates config). **Blocks all subsequent RED tasks.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Auth Store Tests + Refactor (ADR-001) [PR 1 — sequential after 1.4]
|
||||||
|
|
||||||
|
- [x] 2.1 **RED** — Create `src/features/auth/stores/auth-store.test.ts`. Cases: (a) `setSession` writes `accessToken` and `user`, `token` field is `undefined` or absent; (b) `logout` sets `accessToken` and `user` to `null`. Run `pnpm test` — expect failures on (a) because `token: accessToken` is still set.
|
||||||
|
- [x] 2.2 **GREEN** — Edit `src/features/auth/stores/auth-store.ts`: remove `token` from `AuthState` interface; remove `token: null` from initial state; remove `token: accessToken` from `setSession`; remove `token: null` from `logout`. Run `pnpm test` — auth-store tests pass.
|
||||||
|
- [x] 2.3 **GREEN (ripple)** — Edit `src/app/router/ProtectedRoute.tsx` line 9: replace `state.token` with `state.accessToken`; update `hasCheckedRefresh` init and `useEffect` dep to use `accessToken`. Edit `src/features/auth/pages/LoginPage.tsx` lines 16,24,29: replace `token` selector with `accessToken`. Run `pnpm test` and `pnpm build` — must pass (TypeScript will catch any missed reads).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — LoginResponse Alignment (ADR-002) [PR 1 — sequential after 2.3]
|
||||||
|
|
||||||
|
- [x] 3.1 **RED** — Add test to `src/features/auth/stores/auth-store.test.ts` (or a separate `src/features/auth/api/auth-api.test.ts`): assert `LoginResponse` type has `access_token: string` and `token: string`; assert no `refresh_token` field. This is a compile-time type test — use `expectTypeOf` from vitest. Run `pnpm test` — fails because current `LoginResponse` has `access_token?: string` (optional) and `refresh_token?: string`.
|
||||||
|
- [x] 3.2 **GREEN** — Edit `src/features/auth/api/auth-api.ts`: set `access_token: string` (required), `token: string` (required, legacy compat), remove `refresh_token`. Edit `src/features/auth/pages/LoginPage.tsx` line 45: remove `?? response.token` fallback — read `response.access_token` directly. Run `pnpm test` — passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — SessionCoordinator + apiClient Split (ADR-003) [PR 1 — sequential after 3.2]
|
||||||
|
|
||||||
|
- [x] 4.1 **RED** — Create `src/lib/api/client.test.ts`. Cases for SessionCoordinator: (a) two concurrent `refresh()` calls → `fetch` spy called exactly once, both promises resolve with the same token value; (b) `refresh()` when server returns non-ok → returns `null`. Run `pnpm test` — (a) should already pass (deduplication logic exists); (b) may already pass. Document baseline.
|
||||||
|
- [x] 4.2 **RED (apiClient)** — Add cases to `src/lib/api/client.test.ts`: (c) `apiClient<T>` on 200 resolves with body; (d) `apiClient<T>` called when endpoint returns 204 after retry → must NOT silently return `null as T` (assert it throws or that `apiClientVoid` is the correct path — per spec, `apiClient<T>` on 204 is a dev-time guard). Run `pnpm test` — (d) fails because today `null as T` is returned silently.
|
||||||
|
- [x] 4.3 **GREEN** — Edit `src/lib/api/client.ts`: extract shared `executeWithRefresh` logic; rename existing `apiClient<T>` 204 branch to throw `new Error('Use apiClientVoid for 204 endpoints')`; add `export async function apiClientVoid(endpoint, options): Promise<void>` that returns `void` on 204 and throws on non-ok. Remove `null as T` casts at lines 96 and 111. Run `pnpm test` — all passes.
|
||||||
|
- [x] 4.4 Confirm no callers need migration (A2 audit showed zero 204 callsites) — run `pnpm build` for TypeScript validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — ProtectedRoute Hydration (ADR-004) [PR 1 — sequential after 4.4]
|
||||||
|
|
||||||
|
- [x] 5.1 **RED** — Add cases to `src/lib/api/client.test.ts` or a new `src/app/router/ProtectedRoute.test.tsx`: (a) cold visit with `user: null` → renders `<Navigate to="/login"/>` immediately, no "Restaurando" text; (b) warm visit with `user` set and `accessToken` null → renders loading indicator then resolves after refresh. Run `pnpm test` — (a) fails because current code always shows "Restaurando" flash on cold visit.
|
||||||
|
- [x] 5.2 **GREEN** — Edit `src/app/router/ProtectedRoute.tsx`: replace `token`-based heuristic with `useAuthStore.persist.hasHydrated()` guard. Flow: wait for `hasHydrated()`; if `accessToken` → render `<Outlet/>`; else if `user` (persisted) → attempt refresh + show indicator; else → `<Navigate to="/login"/>` immediately. Use `useAuthStore.persist.onFinishHydration` or `rehydrate` to trigger hydration detection. Run `pnpm test` — passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — AccessRequestsPage Error UI (ADR-006) [PR 2 — sequential, depends on PR 1 merged]
|
||||||
|
|
||||||
|
- [x] 6.1 **RED** — Create `src/features/access-requests/pages/AccessRequestsPage.test.tsx`. Cases: (a) `requestsQuery` in error state → renders error card with "Reintentar" button, NOT empty table; (b) `allRequestsQuery` in error state → stat cards show `0`, no crash. Run `pnpm test` — (a) fails because error branch is missing from current JSX. [W3 CLOSED: test 6.1c added — Reintentar click triggers refetch]
|
||||||
|
- [x] 6.2 **GREEN** — Edit `src/features/access-requests/pages/AccessRequestsPage.tsx`: after the `requestsQuery.isLoading` check, add `requestsQuery.isError` branch rendering an inline error card with retry button (`requestsQuery.refetch()`); update stat card values to use `?? 0` guards for `allRequestsQuery.isError`. Run `pnpm test` — passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — AccessRequestsPage Mutation Tests (ADR-008 / spec) [PR 2 — sequential after 6.2]
|
||||||
|
|
||||||
|
- [x] 7.1 **RED** — Add cases to `src/features/access-requests/pages/AccessRequestsPage.test.tsx`: (a) approve mutation success → `toast.success` called + `queryClient.invalidateQueries` called with `['platform-access-requests']`; (b) reject mutation success → same; (c) review mutation success → same; (d) any mutation error → `toast.error(err.message)`, no `invalidateQueries`. Run `pnpm test` — (d) may pass (already wired), but (a-c) need verification. [W2 CLOSED: tests 7.2a/7.2b (reject success/error) + 7.3a/7.3b (review success/error) added — 28/28 pass]
|
||||||
|
- [x] 7.2 **GREEN** — Verify mutation `onSuccess` handlers in `AccessRequestsPage.tsx` match spec exactly (`invalidateRequests()` calls `queryClient.invalidateQueries({ queryKey: ['platform-access-requests'] })`). Adjust if needed. Run `pnpm test` — passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 — isActionable Consolidation (ADR-008) [PR 2 — sequential after 7.2]
|
||||||
|
|
||||||
|
- [x] 8.1 **RED** — Add case to `AccessRequestsPage.test.tsx`: `RequestDetail` Approve and Reject buttons are disabled when `request.status === 'approved'`; enabled when `'pending'` or `'in_review'`. Run `pnpm test` — fails because `RequestDetail` at line 315-316 inlines `['pending', 'in_review'].includes(request.status)` instead of using `isActionable`.
|
||||||
|
- [x] 8.2 **GREEN** — Edit `src/features/access-requests/pages/AccessRequestsPage.tsx` lines 315-316: replace `!['pending', 'in_review'].includes(request.status)` with `!isActionable(request)`. Run `pnpm test` — passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 — ErrorBoundary in PlatformLayout (ADR-005) [PR 2 — sequential after 8.2]
|
||||||
|
|
||||||
|
- [x] 9.1 Create `src/app/layouts/ErrorBoundary.tsx`: native React class component with `state: { hasError: boolean; error: Error | null }`; `static getDerivedStateFromError`; fallback renders error message + logout button (reads `useAuthStore.getState().logout()` + `window.location.replace('/login')`).
|
||||||
|
- [x] 9.2 Edit `src/app/layouts/PlatformLayout.tsx`: wrap `<Outlet />` in `<ErrorBoundary>`; sidebar and `<Toaster/>` remain outside boundary. Run `pnpm test` — ensure no regressions.
|
||||||
|
- [x] 9.3 **RED/GREEN** — Add `src/app/layouts/ErrorBoundary.test.tsx`: (a) child throws → fallback renders; (b) fallback contains logout link/button. Run `pnpm test` — passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10 — Final Validation [both PRs]
|
||||||
|
|
||||||
|
- [x] 10.1 Run `pnpm test` from `services/frontend-console` — all tests green, zero skipped. (23/23 tests pass)
|
||||||
|
- [ ] 10.2 Run `pnpm build` — TypeScript reports zero errors (validates removed `token` field, `LoginResponse` shape, `apiClient` signature). (skipped per project rule)
|
||||||
|
- [x] 10.3 Verify `pnpm lint` passes — no new ESLint warnings. (exits 0)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Verify Report: console-tech-debt-cleanup
|
||||||
|
|
||||||
|
**Change**: console-tech-debt-cleanup
|
||||||
|
**Mode**: Strict TDD
|
||||||
|
**Date**: 2026-05-07 (PR1) / 2026-05-08 (PR2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR1 — Phases 0-5 (P0 band)
|
||||||
|
|
||||||
|
> Originally verified on 2026-05-07. Full details in engram #160.
|
||||||
|
|
||||||
|
All 16 PR1 tasks complete. 12/12 tests passed. Lint + TypeScript exit 0. No CRITICAL issues. One WARNING (--passWithNoTests flag, see PR2 section). Three SUGGESTIONS (no typecheck script, setTimeout proxy in warm-visit test, unused refreshToken state).
|
||||||
|
|
||||||
|
Verdict: PASS WITH WARNINGS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR2 — Phases 6-9 (P1+P2 band)
|
||||||
|
|
||||||
|
**Scope**: Phases 6-9 + regression check of PR1 files.
|
||||||
|
**Date**: 2026-05-08
|
||||||
|
|
||||||
|
### Completeness
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| PR2 tasks total | 8 |
|
||||||
|
| PR2 tasks complete | 8 |
|
||||||
|
| PR2 tasks incomplete | 0 |
|
||||||
|
|
||||||
|
### Build & Tests
|
||||||
|
|
||||||
|
**Tests**: 23/23 passed, 0 failed, 0 skipped
|
||||||
|
|
||||||
|
- auth-api.test.ts (3) PASS
|
||||||
|
- auth-store.test.ts (2) PASS
|
||||||
|
- client.test.ts (5) PASS
|
||||||
|
- ErrorBoundary.test.tsx (3) PASS
|
||||||
|
- ProtectedRoute.test.tsx (2) PASS
|
||||||
|
- AccessRequestsPage.test.tsx (8) PASS
|
||||||
|
|
||||||
|
**Lint**: pnpm lint exits 0, zero warnings.
|
||||||
|
**TypeScript**: pnpm exec tsc --noEmit exits 0, zero type errors.
|
||||||
|
|
||||||
|
### PR1 Regression Check
|
||||||
|
|
||||||
|
All PR1 files (auth-store.ts, auth-api.ts, client.ts, ProtectedRoute.tsx, vite.config.ts) unchanged. All 12 PR1 tests still pass.
|
||||||
|
|
||||||
|
### Spec Compliance Matrix — P1+P2
|
||||||
|
|
||||||
|
#### Phase 6 — AccessRequestsPage Error UI (ADR-006)
|
||||||
|
|
||||||
|
| Requirement | Scenario | Test | Result |
|
||||||
|
|-------------|----------|------|--------|
|
||||||
|
| Failed Query Surfaces Error | requestsQuery.isError -> error card, no table | 6.1a | COMPLIANT |
|
||||||
|
| Failed Query Surfaces Error | Reintentar button present | 6.1a | PARTIAL — rendered but refetch() not click-asserted |
|
||||||
|
| Stat Cards Fail Soft | allRequestsQuery.isError -> 0 counts, no crash | 6.1b | COMPLIANT |
|
||||||
|
|
||||||
|
#### Phase 7 — Mutation Behavior (ADR-008)
|
||||||
|
|
||||||
|
| Requirement | Scenario | Test | Result |
|
||||||
|
|-------------|----------|------|--------|
|
||||||
|
| Mutation Success | approve success -> toast.success | 7.1a | COMPLIANT |
|
||||||
|
| Mutation Success | reject success -> toast.success | (none) | UNTESTED |
|
||||||
|
| Mutation Success | review success -> toast.success | (none) | UNTESTED |
|
||||||
|
| Mutation Error | approve error -> toast.error | 7.1b | COMPLIANT |
|
||||||
|
| Mutation Error | reject/review error paths | (none) | UNTESTED |
|
||||||
|
|
||||||
|
Compliance: 2/5 scenarios (3 UNTESTED)
|
||||||
|
|
||||||
|
#### Phase 8 — isActionable Consolidation (ADR-008)
|
||||||
|
|
||||||
|
| Requirement | Scenario | Test | Result |
|
||||||
|
|-------------|----------|------|--------|
|
||||||
|
| No inline includes(...) | grep audit | static audit | COMPLIANT |
|
||||||
|
| isActionable | pending -> approve+reject enabled | 8.1 | COMPLIANT |
|
||||||
|
| isActionable | in_review -> approve+reject enabled | 8.1 | COMPLIANT |
|
||||||
|
| isActionable | approved -> approve+reject disabled | 8.1 | COMPLIANT |
|
||||||
|
| isActionable | rejected -> approve+reject disabled | 8.1 | COMPLIANT |
|
||||||
|
|
||||||
|
#### Phase 9 — ErrorBoundary (ADR-005)
|
||||||
|
|
||||||
|
| Requirement | Scenario | Test | Result |
|
||||||
|
|-------------|----------|------|--------|
|
||||||
|
| ErrorBoundary.tsx exists as class component | getDerivedStateFromError implemented | code + 9.3a | COMPLIANT |
|
||||||
|
| PlatformLayout wraps Outlet | ErrorBoundary around Outlet only | code inspection | COMPLIANT |
|
||||||
|
| Child throws -> fallback renders | no child content visible | 9.3a | COMPLIANT |
|
||||||
|
| Fallback has logout button | button present | 9.3b | COMPLIANT |
|
||||||
|
| No throw -> child renders normally | child content present | 9.3c | COMPLIANT |
|
||||||
|
| Login NOT wrapped | /login route has no ErrorBoundary | router/index.tsx | COMPLIANT |
|
||||||
|
| Sidebar and Toaster outside boundary | layout structure confirmed | code inspection | COMPLIANT |
|
||||||
|
|
||||||
|
### Issues Found (PR2)
|
||||||
|
|
||||||
|
**CRITICAL**: None
|
||||||
|
|
||||||
|
**WARNING**:
|
||||||
|
|
||||||
|
- W1 (promoted from PR1): package.json test script contains --passWithNoTests. With 23 tests present this flag no longer serves its bootstrap purpose. Remove before final merge — it masks zero-test CI scenarios from misconfigured imports or bad globs.
|
||||||
|
|
||||||
|
- W2 — Phase 7 mutation gap: Spec explicitly requires reject success, review success, and corresponding error paths as testable scenarios. Only approve success and approve error are covered. The three mutation handlers share identical onSuccess/onError structure and are implemented correctly in source, but the spec requires behavioral evidence for all three. Classification: WARNING (not CRITICAL) — handlers provably correct, approve test proves the pattern, but contract evidence is incomplete.
|
||||||
|
|
||||||
|
- W3 — Reintentar click not asserted: Test 6.1a confirms button presence but does not click it and verify requestsQuery.refetch() was called. Presence evidence only.
|
||||||
|
|
||||||
|
**SUGGESTION**:
|
||||||
|
|
||||||
|
- S1: Add typecheck script to package.json so tsc --noEmit runs in CI.
|
||||||
|
- S2: ProtectedRoute warm-visit test uses setTimeout proxy; does not exercise Zustand onFinishHydration callback directly.
|
||||||
|
- S3: refreshToken field in auth-store never populated (always null); leftover from pre-HttpOnly-cookie design.
|
||||||
|
- S4: @testing-library/jest-dom not installed; btn.disabled used instead of toBeDisabled(). Functionally correct, idiomatic deviation.
|
||||||
|
- S5: expect.anything() for TQ v5 mutation context arg; could be tightened with typed matcher.
|
||||||
|
- S6: Revisar button uses request.status !== 'pending' directly rather than isActionable() — deliberate design choice (review only valid from pending) but untested.
|
||||||
|
|
||||||
|
### R2 Root Cause Finding — Explicit cleanup() is Correct
|
||||||
|
|
||||||
|
RTL auto-cleanup only self-registers when Vitest runs with globals: true. Both frontend-console and frontend-admin omit globals: true in their Vitest configs. Neither project setup.ts calls cleanup(). Therefore, explicit afterEach(cleanup) in test files is the correct and necessary pattern for this project configuration. This is not a deviation — it is the right implementation. No changes needed to setup.ts or vite.config.ts unless globals: true is added.
|
||||||
|
|
||||||
|
### Cross-cutting Checks
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| null as T in src/ | 0 matches — PASS |
|
||||||
|
| bracket-string store reads | 0 matches — PASS |
|
||||||
|
| Inline includes pending/in_review | 0 matches — PASS |
|
||||||
|
| Login page NOT wrapped in ErrorBoundary | Confirmed — PASS |
|
||||||
|
| PR1 test suite intact | 12/12 pass — PASS |
|
||||||
|
|
||||||
|
### Verdict (PR2)
|
||||||
|
|
||||||
|
**PASS WITH WARNINGS**
|
||||||
|
|
||||||
|
23/23 tests pass. Lint and TypeScript exit 0. PR1 files not regressed. Phases 6, 8, and 9 are fully compliant. Phase 7 has 3 untested mutation scenarios — WARNING, not CRITICAL. Ready for sdd-archive.
|
||||||
116
openspec/changes/console-totp-2fa/proposal.md
Normal file
116
openspec/changes/console-totp-2fa/proposal.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Proposal: Console TOTP 2FA — Complete Security Baseline
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
The provider console (`services/frontend-console`) approves tenant onboarding, manages billing, and gates the entire control plane. A compromise of any `platform_admin` or `platform_operator` account is a full control-plane breach. Today login is password-only. TOTP exists half-wired in `auth.rs` and the schema, but enrollment, recovery, replay protection, and at-rest encryption are missing — shipping it as-is would be irresponsible. Passkeys are the eventual destination (see deferred `console-passkeys-2fa`); TOTP is the right next step to close the gap fast with industry-standard hygiene.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- Backend (`services/backend-api`):
|
||||||
|
- Migration: drop plaintext `users.two_factor_secret`, add `two_factor_secret_encrypted BYTEA`, `two_factor_secret_nonce BYTEA`, `last_totp_step_used BIGINT`, `totp_locked_until TIMESTAMPTZ`. New table `two_factor_recovery_codes(id, user_id, code_hash, used_at, created_at)`.
|
||||||
|
- New `crypto/totp_secret.rs`: AES-256-GCM `encrypt_secret` / `decrypt_secret` keyed by `TOTP_SECRET_ENCRYPTION_KEY` (32 bytes base64 from env). Per-secret random 12-byte nonce.
|
||||||
|
- New `handlers/totp.rs`: `setup_start`, `setup_verify`, `disable`, `regenerate_recovery_codes`, `admin_reset` (platform_admin only).
|
||||||
|
- `auth.rs`: new `AuthError` variants (`TwoFactorRequired` → 401 + `requires_2fa: true`, `TwoFactorFailed`, `TwoFactorAlreadyEnabled`, `TwoFactorNotEnabled`, `RecoveryCodeInvalid`).
|
||||||
|
- Login handler: emit `TwoFactorRequired` instead of 500; verify TOTP with replay check (`step > last_totp_step_used`); accept `recovery_code` as alternative; lockout after 5 failures in 15 min for 30 min.
|
||||||
|
- Audit-log entries for every event.
|
||||||
|
- Frontend (`services/frontend-console`):
|
||||||
|
- `loginSchema` adds optional `two_factor_code` and `recovery_code`; `LoginResponse` adds `requires_2fa?`.
|
||||||
|
- `LoginPage` two-step UI: TOTP field appears after first 401 with `requires_2fa: true`; tabs for "Authenticator code" / "Recovery code".
|
||||||
|
- New `src/features/security/`: `SecuritySettingsPage`, enrollment (QR + base32 secret + 6-digit confirm + recovery codes display once with explicit "I saved them" gate), disable, regenerate.
|
||||||
|
- `platform_admin` reset UI (placement decided in design phase — likely an admin panel, not access-requests).
|
||||||
|
- Route `/app/platform/security`. Update `AUTH_BYPASS_ENDPOINTS` only if needed (all 2FA endpoints require either pre-2fa context via login OR an access token).
|
||||||
|
- Docs: `docs/DESPLIEGUE.md` documents `TOTP_SECRET_ENCRYPTION_KEY` env var and generation procedure.
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- 2FA for tenant users (`frontend-admin`) — separate change.
|
||||||
|
- SMS/email OTP (insecure / phishable).
|
||||||
|
- Passkeys (deferred — `console-passkeys-2fa`).
|
||||||
|
- Encryption-key rotation tooling (P2 follow-up; gap named in R1).
|
||||||
|
- Email notification on admin reset (P2 follow-up).
|
||||||
|
- Audit-log dashboard hooks beyond writing entries (existing infra reads them).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `console-totp`: TOTP enrollment, verification, recovery codes, lockout, and admin reset for the platform console. Covers `setup_start`, `setup_verify`, `disable`, `regenerate_recovery_codes`, `admin_reset` endpoints and the `crypto/totp_secret` module.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `auth`: login response gains structured `requires_2fa: true` 401 instead of 500; accepts `totp_code` or `recovery_code`; enforces replay-step check and lockout; new `AuthError` variants and audit actions.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
**Approach 2 from exploration — complete industry baseline.** Anchored decisions:
|
||||||
|
|
||||||
|
1. **Single-shot login (Option A)**. Frontend posts `{email, password, totp_code?, recovery_code?}`. If `two_factor_enabled` and code missing/wrong → 401 `{ error, requires_2fa: true }`. No pre-auth token. Halves implementation surface vs Option B; sufficient for an internal high-trust console.
|
||||||
|
2. **App-layer AES-256-GCM** for the TOTP secret. Master key from `TOTP_SECRET_ENCRYPTION_KEY` env. Per-secret 12-byte nonce stored alongside ciphertext. `pgcrypto` stays out — different threat model.
|
||||||
|
3. **Replay protection** via `users.last_totp_step_used BIGINT`. On verify, accept only if `current_step > last_totp_step_used` within the ±1 skew window; then update.
|
||||||
|
4. **Recovery codes**: 8 codes at enrollment, base32 10-char alphanumeric, displayed once, stored as Argon2id hashes, one-time-use enforced by row deletion on redemption. Regeneration invalidates old codes.
|
||||||
|
5. **Lockout** (resolved Q2): 5 failed attempts in 15 min lock the account for 30 min via `users.totp_locked_until`. Defense-in-depth on top of route rate limiter.
|
||||||
|
6. **Admin reset trail** (resolved Q1): audit-log entry with actor, target, timestamp. Email notification deferred to P2.
|
||||||
|
7. **Recovery regeneration** (resolved Q3): user-initiated from security page; old codes invalidated.
|
||||||
|
8. **Migration is destructive** — drops the plaintext column. Verified safe: no enrollment endpoints existed, so no production user has a real TOTP setup. Migration NULLs any seed/test values defensively. Comment in SQL flags the destructive nature.
|
||||||
|
9. **Delivery**: two chained PRs, each ≤ 400 lines. PR1 backend (migration, crypto, endpoints, login fix, audit). PR2 frontend (login two-step, SecuritySettingsPage, admin reset UI).
|
||||||
|
10. **Crate**: `totp-rs` v5.7.1 already present with `qr` feature; add `aes-gcm` (or reuse `chacha20poly1305` if already in tree — design phase confirms).
|
||||||
|
|
||||||
|
## Affected Areas
|
||||||
|
|
||||||
|
| Area | Impact | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `services/backend-api/Cargo.toml` | Modified | Add `aes-gcm` (or confirm `chacha20poly1305`). `totp-rs` already present. |
|
||||||
|
| `services/backend-api/migrations/2026XXXXXXXXXX_totp_2fa.sql` | New | Drop plaintext column; add encrypted/nonce/last_step/locked_until columns; create `two_factor_recovery_codes` table. Destructive — comment in SQL. |
|
||||||
|
| `services/backend-api/src/crypto/totp_secret.rs` | New | `encrypt_secret` / `decrypt_secret` AES-256-GCM. |
|
||||||
|
| `services/backend-api/src/handlers/totp.rs` | New | `setup_start`, `setup_verify`, `disable`, `regenerate_recovery_codes`, `admin_reset`. |
|
||||||
|
| `services/backend-api/src/handlers/auth.rs` | Modified | Login: `TwoFactorRequired` 401, replay-step check, recovery-code path, lockout enforcement, audit-log new actions. |
|
||||||
|
| `services/backend-api/src/auth.rs` | Modified | New `AuthError` variants and HTTP mappings. |
|
||||||
|
| `services/backend-api/src/main.rs` | Modified | Register `/api/auth/2fa/*` routes inside `auth_routes` under same rate limiter. |
|
||||||
|
| `services/frontend-console/src/features/auth/schemas/login-schema.ts` | Modified | Optional `two_factor_code`, `recovery_code`. |
|
||||||
|
| `services/frontend-console/src/features/auth/api/auth-api.ts` | Modified | `LoginResponse.requires_2fa?`. |
|
||||||
|
| `services/frontend-console/src/features/auth/pages/LoginPage.tsx` | Modified | Two-step UI with TOTP / recovery tabs. |
|
||||||
|
| `services/frontend-console/src/features/security/` | New | `SecuritySettingsPage`, enrollment / disable / regenerate flows, admin reset UI. |
|
||||||
|
| `services/frontend-console/src/app/router/index.tsx` | Modified | `/app/platform/security` route. |
|
||||||
|
| `services/frontend-console/src/lib/api/client.ts` | Verified | Confirm no new bypass endpoints needed (all 2FA paths require token or login context). |
|
||||||
|
| `docker-compose.yml`, `docs/DESPLIEGUE.md` | Modified | Wire and document `TOTP_SECRET_ENCRYPTION_KEY`. |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|------|------------|------------|
|
||||||
|
| **R1** `TOTP_SECRET_ENCRYPTION_KEY` leak/loss = total bypass or total lockout | Med | Document generation (`openssl rand -base64 32`) and storage (env, not repo) in `DESPLIEGUE.md`. Backend refuses to start without it. Key rotation tooling explicitly named as P2 gap, not built here. |
|
||||||
|
| **R2** Destructive migration drops `two_factor_secret` | Low | Verified no production user has a working enrollment (no setup endpoints exist). Migration NULLs any seed values defensively. SQL header comment flags destructiveness. Include pre-flight check in PR description. |
|
||||||
|
| **R3** TOCTOU between password and TOTP verification | Low | Both checks read the same `users` row inside one request; document recommended `READ COMMITTED` isolation; race window is ≤ 1 request, attacker would need to disable 2FA mid-login (impossible without already authenticated). |
|
||||||
|
| **R4** Recovery codes shown once → user loses them | Med | Frontend requires explicit checkbox confirmation ("I have saved my recovery codes") before enabling close/navigation. Regeneration path always available from security page. |
|
||||||
|
| **R5** Malicious frontend silently re-submitting password | Low | Server-side validation is the source of truth; documented as non-issue. No client-trust assumptions in the protocol. |
|
||||||
|
| **R6** Audit-log gaps would hide credential abuse | Med | Every event (`enable`, `verify_setup`, `disable`, `login_with_code`, `login_with_recovery`, `admin_reset`, `regenerate_codes`, `lockout`) hits `audit_logs` via `crate::audit::log()`. Surfaces as a `sdd-verify` checklist item. |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
1. Revert backend deploy: previous binary lacks `/api/auth/2fa/*` routes; login returns to prior shape.
|
||||||
|
2. SQL rollback: re-add `two_factor_secret TEXT NULL` column. New encrypted columns are additive — leave them in place (or drop in a follow-up migration). Set `users.two_factor_enabled = false` for any platform users who enrolled during the rollout window.
|
||||||
|
3. Drop `two_factor_recovery_codes` table only if confirmed unused.
|
||||||
|
4. Frontend rollback (Vercel/static) restores password-only `LoginPage` and removes `/app/platform/security`.
|
||||||
|
5. Notify operators in the platform channel; users keep accounts but lose 2FA state (must re-enroll after rollforward).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `aes-gcm` crate (or confirm `chacha20poly1305` already in tree) — design phase locks the choice.
|
||||||
|
- `totp-rs` v5.7.1 with `qr` feature (already in `Cargo.toml`).
|
||||||
|
- `argon2` crate for recovery-code hashing (already used for password hashing — confirm in design).
|
||||||
|
- New env var `TOTP_SECRET_ENCRYPTION_KEY` (base64 32 bytes) wired in `docker-compose.yml` and prod deploy docs.
|
||||||
|
- Existing `auth_routes` rate limiter applied to new endpoints.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Platform user logs in with password → 401 `{ requires_2fa: true }`.
|
||||||
|
- [ ] Same user re-submits with valid TOTP code → 200 with `access_token`.
|
||||||
|
- [ ] Same user re-submits with valid recovery code → 200 with `access_token`; that code is gone from the table.
|
||||||
|
- [ ] Same user re-submits with invalid code → 401 `TwoFactorFailed`.
|
||||||
|
- [ ] Replay of a just-used valid code within 30 s → 401 `TwoFactorFailed`.
|
||||||
|
- [ ] DB dump exposes no plaintext TOTP secrets (column is `BYTEA` ciphertext only).
|
||||||
|
- [ ] 5 failed TOTP attempts in 15 min → `totp_locked_until` set; further attempts return 401 until lock expires.
|
||||||
|
- [ ] Every event lands in `audit_logs` (enable, verify_setup, disable, login_with_code, login_with_recovery, admin_reset, regenerate_codes, lockout).
|
||||||
|
- [ ] Tenant user login flow is byte-for-byte unchanged.
|
||||||
|
- [ ] Non-2FA platform users (pre-enrollment) log in normally with email + password.
|
||||||
|
- [ ] Backend refuses to boot without `TOTP_SECRET_ENCRYPTION_KEY`.
|
||||||
|
- [ ] Each PR ≤ 400 changed lines (chained delivery).
|
||||||
|
- [ ] `cargo test` and `pnpm --dir services/frontend-console test` are green.
|
||||||
144
openspec/changes/console-totp-2fa/specs/auth/spec.md
Normal file
144
openspec/changes/console-totp-2fa/specs/auth/spec.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Delta for Auth — console-totp-2fa
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Login Response Shape
|
||||||
|
|
||||||
|
The login endpoint MUST return a structured 401 with `requires_2fa: true` when `two_factor_enabled = true` and no TOTP or recovery code was supplied. It MUST NOT return HTTP 500 for this case.
|
||||||
|
|
||||||
|
When `two_factor_enabled = true` and a valid `totp_code` or `recovery_code` is supplied, the endpoint MUST return 200 with `access_token` and set the refresh cookie.
|
||||||
|
|
||||||
|
When `two_factor_enabled = false`, existing login behavior MUST remain unchanged.
|
||||||
|
|
||||||
|
(Previously: absent code returned HTTP 500 with an unstructured Spanish error message; no `requires_2fa` field existed.)
|
||||||
|
|
||||||
|
#### Scenario: 2FA enabled — code absent → structured 401
|
||||||
|
|
||||||
|
- GIVEN a user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/login` is called with valid email + password but no `totp_code` or `recovery_code`
|
||||||
|
- THEN the response is HTTP 401
|
||||||
|
- AND the body contains `{ "error": "...", "requires_2fa": true }`
|
||||||
|
|
||||||
|
#### Scenario: 2FA enabled — valid TOTP code → 200
|
||||||
|
|
||||||
|
- GIVEN a user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/login` is called with valid credentials and a valid 6-digit `totp_code`
|
||||||
|
- THEN the response is HTTP 200 with `access_token`
|
||||||
|
- AND the refresh cookie is set
|
||||||
|
|
||||||
|
#### Scenario: 2FA enabled — invalid TOTP code → 401 TwoFactorFailed
|
||||||
|
|
||||||
|
- GIVEN a user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/login` is called with valid credentials but a wrong `totp_code`
|
||||||
|
- THEN the response is HTTP 401 with error code `TwoFactorFailed`
|
||||||
|
|
||||||
|
#### Scenario: 2FA enabled — valid recovery code → 200, code consumed
|
||||||
|
|
||||||
|
- GIVEN a user with `two_factor_enabled = true` and at least one unused recovery code
|
||||||
|
- WHEN `POST /api/auth/login` is called with `recovery_code` matching a stored hash
|
||||||
|
- THEN the response is HTTP 200 with `access_token`
|
||||||
|
- AND that recovery code row is DELETED from `two_factor_recovery_codes`
|
||||||
|
|
||||||
|
#### Scenario: 2FA enabled — already-used recovery code → 401
|
||||||
|
|
||||||
|
- GIVEN a user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/login` is called with a `recovery_code` that has already been consumed
|
||||||
|
- THEN the response is HTTP 401 with error code `RecoveryCodeInvalid`
|
||||||
|
|
||||||
|
#### Scenario: 2FA disabled — login unchanged
|
||||||
|
|
||||||
|
- GIVEN a user with `two_factor_enabled = false`
|
||||||
|
- WHEN `POST /api/auth/login` is called with valid email + password
|
||||||
|
- THEN the response is HTTP 200 with `access_token` (existing behavior preserved)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Lockout After Repeated TOTP Failures
|
||||||
|
|
||||||
|
The system MUST track failed TOTP attempts per user. After 5 consecutive failures within 15 minutes, `users.totp_locked_until` MUST be set to `now() + 30 minutes`.
|
||||||
|
|
||||||
|
While `users.totp_locked_until > now()`, all login attempts for that user MUST return HTTP 401 with error code `AccountLocked` — regardless of whether the password is correct (to avoid leaking password validity during lockout).
|
||||||
|
|
||||||
|
On a successful TOTP verification, the failure counter MUST be reset.
|
||||||
|
|
||||||
|
#### Scenario: Lockout threshold reached → account locked
|
||||||
|
|
||||||
|
- GIVEN a user has submitted 5 wrong TOTP codes within 15 minutes
|
||||||
|
- WHEN the 5th failure is processed
|
||||||
|
- THEN `users.totp_locked_until` is set to approximately `now() + 30 minutes`
|
||||||
|
|
||||||
|
#### Scenario: Login while locked → 401 AccountLocked (correct password)
|
||||||
|
|
||||||
|
- GIVEN `users.totp_locked_until > now()` for a user
|
||||||
|
- WHEN `POST /api/auth/login` is called with the correct password
|
||||||
|
- THEN the response is HTTP 401 with error code `AccountLocked`
|
||||||
|
- AND no information about password correctness is revealed
|
||||||
|
|
||||||
|
#### Scenario: Login while locked → 401 AccountLocked (wrong password)
|
||||||
|
|
||||||
|
- GIVEN `users.totp_locked_until > now()` for a user
|
||||||
|
- WHEN `POST /api/auth/login` is called with an incorrect password
|
||||||
|
- THEN the response is HTTP 401 with error code `AccountLocked`
|
||||||
|
|
||||||
|
#### Scenario: Successful TOTP resets failure counter
|
||||||
|
|
||||||
|
- GIVEN a user has 3 previous TOTP failures within 15 minutes
|
||||||
|
- WHEN `POST /api/auth/login` succeeds with a valid TOTP code
|
||||||
|
- THEN the TOTP failure counter for that user is reset to 0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: TOTP Replay Protection
|
||||||
|
|
||||||
|
The system MUST record `users.last_totp_step_used` on every successful TOTP verification.
|
||||||
|
|
||||||
|
A TOTP code MUST only be accepted if the step it validates is strictly greater than `last_totp_step_used` (or `last_totp_step_used` is NULL), within the ±1 skew window.
|
||||||
|
|
||||||
|
#### Scenario: First successful TOTP stores step
|
||||||
|
|
||||||
|
- GIVEN `users.last_totp_step_used` is NULL
|
||||||
|
- WHEN a valid TOTP code is accepted
|
||||||
|
- THEN `users.last_totp_step_used` is set to the current step value
|
||||||
|
|
||||||
|
#### Scenario: Re-use of same step within 30s window → 401
|
||||||
|
|
||||||
|
- GIVEN `users.last_totp_step_used = S` for some user
|
||||||
|
- WHEN `POST /api/auth/login` is submitted with a code that maps to step S (or S-1)
|
||||||
|
- THEN the response is HTTP 401 with error code `TwoFactorFailed`
|
||||||
|
- AND `last_totp_step_used` is NOT updated
|
||||||
|
|
||||||
|
#### Scenario: New step (S+1 or later) accepted
|
||||||
|
|
||||||
|
- GIVEN `users.last_totp_step_used = S`
|
||||||
|
- WHEN a code mapping to step S+1 (or later) within ±1 skew is submitted
|
||||||
|
- THEN the code is accepted and `last_totp_step_used` is updated to the new step
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: TOTP Secret Encryption Invariant
|
||||||
|
|
||||||
|
`users.two_factor_secret_encrypted` MUST NEVER contain plaintext. The secret MUST be encrypted with AES-256-GCM using `TOTP_SECRET_ENCRYPTION_KEY` before writing to the database.
|
||||||
|
|
||||||
|
The application MUST read `TOTP_SECRET_ENCRYPTION_KEY` from the environment at startup. If the key is absent or invalid, the application MUST refuse to start.
|
||||||
|
|
||||||
|
Decrypted secrets MUST be held in-memory only for the duration of the operation and MUST NOT be logged or persisted.
|
||||||
|
|
||||||
|
#### Scenario: Startup without encryption key → boot failure
|
||||||
|
|
||||||
|
- GIVEN `TOTP_SECRET_ENCRYPTION_KEY` is not set in the environment
|
||||||
|
- WHEN the backend application starts
|
||||||
|
- THEN the process exits with a non-zero status code before accepting connections
|
||||||
|
|
||||||
|
#### Scenario: Startup with valid key → normal boot
|
||||||
|
|
||||||
|
- GIVEN `TOTP_SECRET_ENCRYPTION_KEY` is a valid 32-byte base64 string
|
||||||
|
- WHEN the backend application starts
|
||||||
|
- THEN the process boots normally
|
||||||
|
|
||||||
|
#### Scenario: DB dump never exposes plaintext
|
||||||
|
|
||||||
|
- GIVEN a user has `two_factor_enabled = true`
|
||||||
|
- WHEN the `users` table is dumped from the database
|
||||||
|
- THEN `two_factor_secret_encrypted` contains ciphertext (BYTEA), never a readable TOTP seed
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Console Login 2FA Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Frontend login flow for users who have TOTP enabled. Covers the two-step UX (password → code), the recovery-code fallback toggle, and lockout messaging.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Two-Step Login UI
|
||||||
|
|
||||||
|
`LoginPage` MUST perform login in two phases:
|
||||||
|
|
||||||
|
1. First submission: email + password only.
|
||||||
|
2. On a 401 response with `requires_2fa: true`: render a TOTP code input field. The user MUST NOT see a "wrong password" error at this step — the credentials are valid and only the second factor is missing.
|
||||||
|
3. Second submission: re-submit email + password along with the TOTP code (or recovery code).
|
||||||
|
|
||||||
|
The page MUST NOT show a TOTP field before the first 401 with `requires_2fa: true`.
|
||||||
|
|
||||||
|
#### Scenario: First submit — 2FA required → TOTP field appears
|
||||||
|
|
||||||
|
- GIVEN the user fills email and password and submits
|
||||||
|
- WHEN the server responds HTTP 401 with `{ requires_2fa: true }`
|
||||||
|
- THEN the TOTP code input field becomes visible
|
||||||
|
- AND no error message indicating wrong credentials is shown
|
||||||
|
|
||||||
|
#### Scenario: Second submit — valid TOTP → logged in
|
||||||
|
|
||||||
|
- GIVEN the TOTP field is visible and the user enters a valid 6-digit code
|
||||||
|
- WHEN the form is submitted again
|
||||||
|
- THEN the request includes `{ email, password, totp_code }`
|
||||||
|
- AND on HTTP 200 the user is navigated to the authenticated area
|
||||||
|
|
||||||
|
#### Scenario: Second submit — invalid TOTP → TwoFactorFailed error shown
|
||||||
|
|
||||||
|
- GIVEN the TOTP field is visible
|
||||||
|
- WHEN the user submits an invalid code
|
||||||
|
- THEN the server returns HTTP 401 with `TwoFactorFailed`
|
||||||
|
- AND the page shows a 2FA-specific error (not a generic "wrong password" message)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Recovery Code Toggle on Login
|
||||||
|
|
||||||
|
The login page MUST offer a "use recovery code instead" toggle when the TOTP field is visible. Activating the toggle replaces the TOTP input with a recovery code input field. The submitted request uses `recovery_code` instead of `totp_code`.
|
||||||
|
|
||||||
|
#### Scenario: Toggle switches to recovery code input
|
||||||
|
|
||||||
|
- GIVEN the TOTP field is visible after `requires_2fa: true`
|
||||||
|
- WHEN the user activates the "use recovery code" toggle
|
||||||
|
- THEN the TOTP input is replaced by a recovery code input
|
||||||
|
|
||||||
|
#### Scenario: Recovery code submitted correctly
|
||||||
|
|
||||||
|
- GIVEN the recovery code input is active and the user enters a valid code
|
||||||
|
- WHEN the form is submitted
|
||||||
|
- THEN the request includes `{ email, password, recovery_code }`
|
||||||
|
- AND on HTTP 200 the user is navigated to the authenticated area
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: AccountLocked Messaging
|
||||||
|
|
||||||
|
When the server returns error code `AccountLocked`, the login page MUST display a locked-account message. The message MUST NOT suggest the password was wrong.
|
||||||
|
|
||||||
|
The message SHOULD communicate that the account is temporarily locked due to too many failed attempts.
|
||||||
|
|
||||||
|
#### Scenario: AccountLocked response → locked message shown
|
||||||
|
|
||||||
|
- GIVEN a user is locked (`totp_locked_until > now()`)
|
||||||
|
- WHEN the login response is HTTP 401 with error code `AccountLocked`
|
||||||
|
- THEN the page renders a lock/error message indicating the account is temporarily locked
|
||||||
|
- AND no "wrong password" language is shown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Login Schema Extension
|
||||||
|
|
||||||
|
`loginSchema` (Zod) MUST accept optional fields `two_factor_code` and `recovery_code`. `LoginResponse` MUST include an optional `requires_2fa?: boolean` field. These fields are absent for non-2FA users and MUST NOT break existing validation.
|
||||||
|
|
||||||
|
#### Scenario: Schema accepts two_factor_code
|
||||||
|
|
||||||
|
- GIVEN a form value object `{ email, password, two_factor_code: "123456" }`
|
||||||
|
- WHEN parsed through `loginSchema`
|
||||||
|
- THEN validation passes
|
||||||
|
|
||||||
|
#### Scenario: Schema accepts recovery_code
|
||||||
|
|
||||||
|
- GIVEN a form value object `{ email, password, recovery_code: "ABCD12EFGH" }`
|
||||||
|
- WHEN parsed through `loginSchema`
|
||||||
|
- THEN validation passes
|
||||||
|
|
||||||
|
#### Scenario: Existing schema behavior unchanged
|
||||||
|
|
||||||
|
- GIVEN a form value object `{ email, password }` (no 2FA fields)
|
||||||
|
- WHEN parsed through `loginSchema`
|
||||||
|
- THEN validation passes (no regression)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Console Security Settings Page Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
A dedicated settings page at `/app/platform/security` for managing 2FA enrollment, disabling, and recovery code regeneration for platform console users.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Security Settings Route
|
||||||
|
|
||||||
|
The application MUST register the route `/app/platform/security` accessible to authenticated users with a `platform_*` role. The route MUST be reachable from the sidebar navigation or the user menu.
|
||||||
|
|
||||||
|
The page MUST display the user's current 2FA state: enabled or disabled.
|
||||||
|
|
||||||
|
#### Scenario: Authenticated platform user visits security page
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with a `platform_*` role
|
||||||
|
- WHEN they navigate to `/app/platform/security`
|
||||||
|
- THEN the page renders without error
|
||||||
|
- AND the current 2FA state (enabled/disabled) is displayed
|
||||||
|
|
||||||
|
#### Scenario: Unauthenticated user redirected
|
||||||
|
|
||||||
|
- GIVEN no active session
|
||||||
|
- WHEN `/app/platform/security` is accessed directly
|
||||||
|
- THEN the user is redirected to `/login`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: TOTP Enable Flow (Enrollment UI)
|
||||||
|
|
||||||
|
When 2FA is disabled, the page MUST offer an "Enable 2FA" action that initiates the enrollment flow:
|
||||||
|
|
||||||
|
1. Call `setup-start` → display QR code image (PNG rendered from `qr_code_png_base64`) AND the `secret_base32` in a copyable text field.
|
||||||
|
2. Present a 6-digit input for the first confirmation code.
|
||||||
|
3. On submit, call `setup-verify`. On success, display the 8 recovery codes.
|
||||||
|
4. The recovery codes screen MUST require an explicit "I have saved my recovery codes" confirmation before the user can dismiss it.
|
||||||
|
5. After confirmation, return to the security settings page (2FA now shown as enabled).
|
||||||
|
|
||||||
|
#### Scenario: QR and manual secret displayed after setup-start
|
||||||
|
|
||||||
|
- GIVEN the user clicks "Enable 2FA"
|
||||||
|
- WHEN `setup-start` responds with `{ secret_base32, qr_code_png_base64, otpauth_uri }`
|
||||||
|
- THEN an `<img>` rendered from the base64 PNG is shown
|
||||||
|
- AND the `secret_base32` is shown in a copyable field
|
||||||
|
|
||||||
|
#### Scenario: Valid 6-digit code confirms enrollment
|
||||||
|
|
||||||
|
- GIVEN the QR step is displayed and the user enters the correct code
|
||||||
|
- WHEN the code is submitted
|
||||||
|
- THEN `setup-verify` is called
|
||||||
|
- AND on success the recovery codes screen is shown with 8 codes
|
||||||
|
|
||||||
|
#### Scenario: Recovery codes require save confirmation
|
||||||
|
|
||||||
|
- GIVEN the recovery codes screen is displayed
|
||||||
|
- WHEN the user has NOT checked the confirmation checkbox
|
||||||
|
- THEN the dismiss/continue button is disabled
|
||||||
|
|
||||||
|
#### Scenario: After confirmation, page shows 2FA enabled
|
||||||
|
|
||||||
|
- GIVEN the user confirmed they saved the recovery codes
|
||||||
|
- WHEN they dismiss the screen
|
||||||
|
- THEN the security page shows 2FA state as "enabled"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: TOTP Disable Flow
|
||||||
|
|
||||||
|
When 2FA is enabled, the page MUST offer a "Disable 2FA" action that renders a form requiring both the current password and a current valid TOTP code. On successful submission, the page returns to the security settings view with 2FA shown as disabled.
|
||||||
|
|
||||||
|
#### Scenario: Correct password + TOTP disables 2FA
|
||||||
|
|
||||||
|
- GIVEN the user enters their correct password and a valid TOTP code
|
||||||
|
- WHEN the disable form is submitted
|
||||||
|
- THEN `POST /api/auth/2fa/disable` is called
|
||||||
|
- AND on HTTP 200 the page shows 2FA state as "disabled"
|
||||||
|
|
||||||
|
#### Scenario: Incorrect inputs show error
|
||||||
|
|
||||||
|
- GIVEN the user submits wrong credentials on the disable form
|
||||||
|
- WHEN the server returns HTTP 401
|
||||||
|
- THEN an inline error is shown
|
||||||
|
- AND 2FA state remains "enabled"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Recovery Code Regeneration Flow
|
||||||
|
|
||||||
|
When 2FA is enabled, the page MUST offer a "Regenerate recovery codes" action. The action renders a form requiring a current valid TOTP code. On success, the new 8 codes are displayed once with the same save-confirmation gate as during enrollment.
|
||||||
|
|
||||||
|
#### Scenario: Valid TOTP → new codes displayed once
|
||||||
|
|
||||||
|
- GIVEN the user enters a valid TOTP code in the regenerate form
|
||||||
|
- WHEN `POST /api/auth/2fa/regenerate-recovery-codes` returns 200
|
||||||
|
- THEN the page displays the 8 new recovery codes with save-confirmation gate
|
||||||
|
|
||||||
|
#### Scenario: Invalid TOTP → error shown, codes unchanged
|
||||||
|
|
||||||
|
- GIVEN the user enters an invalid TOTP code
|
||||||
|
- WHEN the server returns HTTP 401
|
||||||
|
- THEN an inline error is shown and no new codes are displayed
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Console TOTP Admin UI Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Admin interface for `platform_admin` users to view 2FA enrollment status across platform users and to reset a user's 2FA when required.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Platform Users List with 2FA Status
|
||||||
|
|
||||||
|
The platform users management area MUST display each user's 2FA enrollment status (enabled / disabled). This information MUST be fetched from the backend and MUST reflect the current `two_factor_enabled` value.
|
||||||
|
|
||||||
|
#### Scenario: User list shows 2FA status column
|
||||||
|
|
||||||
|
- GIVEN a `platform_admin` visits the platform users list
|
||||||
|
- WHEN the list renders
|
||||||
|
- THEN each user row displays their 2FA enrollment state (e.g. "Enabled" or "Disabled")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Admin 2FA Reset Action with Confirmation
|
||||||
|
|
||||||
|
For each user in the list, the UI MUST provide a "Reset 2FA" action. Activating the action MUST present a confirmation modal before calling the backend. The modal MUST identify the target user. Only on confirmation does the UI call `POST /api/platform/users/:id/2fa-reset`.
|
||||||
|
|
||||||
|
On a successful reset (HTTP 204), the UI MUST display a success toast notification and refresh the user list to reflect the updated 2FA state.
|
||||||
|
|
||||||
|
#### Scenario: Reset action opens confirmation modal
|
||||||
|
|
||||||
|
- GIVEN a `platform_admin` is viewing the user list
|
||||||
|
- WHEN they activate "Reset 2FA" for a user
|
||||||
|
- THEN a modal is displayed asking for confirmation
|
||||||
|
- AND the target user's name or email is visible in the modal
|
||||||
|
|
||||||
|
#### Scenario: Cancel closes modal without calling API
|
||||||
|
|
||||||
|
- GIVEN the confirmation modal is open
|
||||||
|
- WHEN the admin clicks "Cancel"
|
||||||
|
- THEN the modal closes
|
||||||
|
- AND `POST /api/platform/users/:id/2fa-reset` is NOT called
|
||||||
|
|
||||||
|
#### Scenario: Confirm triggers reset and refreshes list
|
||||||
|
|
||||||
|
- GIVEN the confirmation modal is open
|
||||||
|
- WHEN the admin clicks "Confirm"
|
||||||
|
- THEN `POST /api/platform/users/:id/2fa-reset` is called
|
||||||
|
- AND on HTTP 204 a success toast is shown
|
||||||
|
- AND the user list refreshes and shows the target user's 2FA as "Disabled"
|
||||||
|
|
||||||
|
#### Scenario: API error shown to admin
|
||||||
|
|
||||||
|
- GIVEN the confirmation modal is open and the admin confirms
|
||||||
|
- WHEN the API returns a non-204 error
|
||||||
|
- THEN an error toast is displayed
|
||||||
|
- AND the modal closes without changing the list state
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Console TOTP Admin Reset Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Allows a `platform_admin` to clear a user's 2FA enrollment when they are locked out or the authenticator device is lost. Includes audit trail.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin 2FA Reset Endpoint
|
||||||
|
|
||||||
|
The system MUST provide `POST /api/platform/users/:id/2fa-reset` restricted to callers with the `platform_admin` role.
|
||||||
|
|
||||||
|
On success, the system MUST atomically clear all 2FA state for the target user: `two_factor_enabled`, `two_factor_secret_encrypted`, `two_factor_secret_nonce`, `last_totp_step_used`, `totp_locked_until`, and ALL rows in `two_factor_recovery_codes`.
|
||||||
|
|
||||||
|
After clearing, the system MUST write an audit log entry containing: actor user ID, target user ID, action `user.2fa_reset`, and timestamp. The endpoint MUST return HTTP 204.
|
||||||
|
|
||||||
|
#### Scenario: Admin resets target user's 2FA → cleared + audited
|
||||||
|
|
||||||
|
- GIVEN a `platform_admin` caller and a target user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/platform/users/:id/2fa-reset` is called
|
||||||
|
- THEN the response is HTTP 204
|
||||||
|
- AND `users.two_factor_enabled` is `false` for the target
|
||||||
|
- AND `users.two_factor_secret_encrypted` and `two_factor_secret_nonce` are NULL
|
||||||
|
- AND `users.last_totp_step_used` and `users.totp_locked_until` are NULL
|
||||||
|
- AND all rows in `two_factor_recovery_codes` for the target are deleted
|
||||||
|
- AND one audit log row is written with actor, target, and action `user.2fa_reset`
|
||||||
|
|
||||||
|
#### Scenario: Non-admin caller → 403
|
||||||
|
|
||||||
|
- GIVEN an authenticated user without `platform_admin` role
|
||||||
|
- WHEN `POST /api/platform/users/:id/2fa-reset` is called
|
||||||
|
- THEN the response is HTTP 403
|
||||||
|
- AND no data is modified
|
||||||
|
|
||||||
|
#### Scenario: Target user does not exist → 404
|
||||||
|
|
||||||
|
- GIVEN a `platform_admin` caller
|
||||||
|
- WHEN `POST /api/platform/users/:id/2fa-reset` is called with an ID that does not exist
|
||||||
|
- THEN the response is HTTP 404
|
||||||
|
- AND no data is modified
|
||||||
|
|
||||||
|
#### Scenario: Target user already has 2FA disabled → 204 idempotent
|
||||||
|
|
||||||
|
- GIVEN a `platform_admin` caller and a target user with `two_factor_enabled = false`
|
||||||
|
- WHEN `POST /api/platform/users/:id/2fa-reset` is called
|
||||||
|
- THEN the response is HTTP 204
|
||||||
|
- AND an audit log row is still written
|
||||||
|
|
||||||
|
#### Scenario: Unauthenticated caller → 401
|
||||||
|
|
||||||
|
- GIVEN a request with no valid session token
|
||||||
|
- WHEN `POST /api/platform/users/:id/2fa-reset` is called
|
||||||
|
- THEN the response is HTTP 401
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Console TOTP Recovery Codes Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
One-time recovery codes allow a user to authenticate when their authenticator app is unavailable. Covers generation, storage, redemption, and regeneration.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Recovery Code Generation at Enrollment
|
||||||
|
|
||||||
|
At successful enrollment (setup-verify), the system MUST generate exactly 8 recovery codes. Each code MUST be base32 alphanumeric, 10 characters long. Each code MUST be hashed with Argon2id before storage. Codes MUST be returned to the client in plaintext exactly once, in the setup-verify response, and MUST NOT be stored in plaintext anywhere.
|
||||||
|
|
||||||
|
#### Scenario: Enrollment produces exactly 8 codes
|
||||||
|
|
||||||
|
- GIVEN a user completes setup-verify with a valid code
|
||||||
|
- WHEN the enrollment transaction commits
|
||||||
|
- THEN exactly 8 rows exist in `two_factor_recovery_codes` for that user
|
||||||
|
- AND each row contains an Argon2id hash, not the raw code
|
||||||
|
|
||||||
|
#### Scenario: Recovery codes returned once in plaintext
|
||||||
|
|
||||||
|
- GIVEN a user completes setup-verify
|
||||||
|
- WHEN the HTTP 200 response is received
|
||||||
|
- THEN `recovery_codes` in the body contains exactly 8 base32 10-char strings
|
||||||
|
- AND no subsequent endpoint exposes these codes again
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Recovery Code Redemption at Login
|
||||||
|
|
||||||
|
During login, `recovery_code` in the request body MUST be checked against stored hashes using Argon2id comparison. On a match, the matched row MUST be deleted (one-time use). No partial-use is permitted.
|
||||||
|
|
||||||
|
#### Scenario: Valid recovery code consumed and deleted
|
||||||
|
|
||||||
|
- GIVEN a user has 8 unused recovery codes
|
||||||
|
- WHEN `POST /api/auth/login` is submitted with one of those codes
|
||||||
|
- THEN authentication succeeds (HTTP 200)
|
||||||
|
- AND that code's row is removed from `two_factor_recovery_codes`
|
||||||
|
- AND 7 rows remain
|
||||||
|
|
||||||
|
#### Scenario: Already-consumed recovery code rejected
|
||||||
|
|
||||||
|
- GIVEN a recovery code has been used (its row deleted)
|
||||||
|
- WHEN `POST /api/auth/login` is submitted with the same code
|
||||||
|
- THEN the response is HTTP 401 with error code `RecoveryCodeInvalid`
|
||||||
|
|
||||||
|
#### Scenario: Unknown recovery code rejected
|
||||||
|
|
||||||
|
- GIVEN no row in `two_factor_recovery_codes` matches the submitted code
|
||||||
|
- WHEN `POST /api/auth/login` is submitted with that code
|
||||||
|
- THEN the response is HTTP 401 with error code `RecoveryCodeInvalid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Recovery Code Regeneration
|
||||||
|
|
||||||
|
The system MUST provide `POST /api/auth/2fa/regenerate-recovery-codes` for authenticated users with `two_factor_enabled = true`.
|
||||||
|
|
||||||
|
The request MUST include a currently valid TOTP code. On success, all existing recovery code rows for the user MUST be deleted and 8 new codes generated (same generation rules as enrollment). New codes MUST be returned in plaintext once in the response.
|
||||||
|
|
||||||
|
When the TOTP code is invalid, the endpoint MUST return HTTP 401 and MUST NOT alter existing codes.
|
||||||
|
|
||||||
|
#### Scenario: Valid TOTP → old codes deleted, 8 new codes returned
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with `two_factor_enabled = true` and existing recovery code rows
|
||||||
|
- WHEN `POST /api/auth/2fa/regenerate-recovery-codes` is called with a valid TOTP code
|
||||||
|
- THEN the response is HTTP 200 with `{ recovery_codes: [8 strings] }`
|
||||||
|
- AND all previous recovery code rows for that user are deleted
|
||||||
|
- AND 8 new hashed rows are inserted
|
||||||
|
|
||||||
|
#### Scenario: Invalid TOTP → 401, codes unchanged
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with `two_factor_enabled = true` and existing recovery code rows
|
||||||
|
- WHEN `POST /api/auth/2fa/regenerate-recovery-codes` is called with a wrong TOTP code
|
||||||
|
- THEN the response is HTTP 401
|
||||||
|
- AND existing recovery code rows are unchanged
|
||||||
100
openspec/changes/console-totp-2fa/specs/console-totp/spec.md
Normal file
100
openspec/changes/console-totp-2fa/specs/console-totp/spec.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Console TOTP Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TOTP enrollment, verification, and disable flows for platform console users. Covers the three-endpoint lifecycle: setup-start, setup-verify, and disable.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: TOTP Enrollment Start
|
||||||
|
|
||||||
|
The system MUST provide `POST /api/auth/2fa/setup-start` accessible only to authenticated users holding a `platform_*` role.
|
||||||
|
|
||||||
|
When the user has no active enrollment (`two_factor_enabled = false`), the endpoint MUST return a freshly generated TOTP secret, its QR code as a base64-encoded PNG, and the `otpauth://` provisioning URI.
|
||||||
|
|
||||||
|
When the user already has `two_factor_enabled = true`, the endpoint MUST return HTTP 409.
|
||||||
|
|
||||||
|
#### Scenario: No prior enrollment → returns enrollment data
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with `two_factor_enabled = false`
|
||||||
|
- WHEN `POST /api/auth/2fa/setup-start` is called
|
||||||
|
- THEN the response is HTTP 200 with `{ secret_base32, qr_code_png_base64, otpauth_uri }`
|
||||||
|
- AND no DB write is made at this stage (secret is pending verification)
|
||||||
|
|
||||||
|
#### Scenario: Already enrolled → 409
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/2fa/setup-start` is called
|
||||||
|
- THEN the response is HTTP 409 with error code `TwoFactorAlreadyEnabled`
|
||||||
|
|
||||||
|
#### Scenario: Unauthenticated caller → 401
|
||||||
|
|
||||||
|
- GIVEN a request with no valid session token
|
||||||
|
- WHEN `POST /api/auth/2fa/setup-start` is called
|
||||||
|
- THEN the response is HTTP 401
|
||||||
|
|
||||||
|
#### Scenario: Non-platform role → 403
|
||||||
|
|
||||||
|
- GIVEN an authenticated user without a `platform_*` role
|
||||||
|
- WHEN `POST /api/auth/2fa/setup-start` is called
|
||||||
|
- THEN the response is HTTP 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: TOTP Enrollment Verify
|
||||||
|
|
||||||
|
The system MUST provide `POST /api/auth/2fa/setup-verify` for confirming a pending enrollment.
|
||||||
|
|
||||||
|
When the submitted 6-digit code is valid against the in-progress secret, the endpoint MUST set `two_factor_enabled = true`, encrypt and store the secret, and return exactly 8 one-time recovery codes (plaintext, shown once).
|
||||||
|
|
||||||
|
When the code is invalid, the endpoint MUST return HTTP 401 and MUST NOT write anything to the database.
|
||||||
|
|
||||||
|
#### Scenario: Valid code → enrollment complete, recovery codes returned
|
||||||
|
|
||||||
|
- GIVEN a user who completed setup-start and has a pending secret
|
||||||
|
- WHEN `POST /api/auth/2fa/setup-verify` is called with a currently-valid 6-digit code
|
||||||
|
- THEN the response is HTTP 200 with `{ recovery_codes: [8 strings] }`
|
||||||
|
- AND `users.two_factor_enabled` is set to `true`
|
||||||
|
- AND `users.two_factor_secret_encrypted` holds the AES-256-GCM ciphertext of the secret
|
||||||
|
- AND 8 hashed recovery code rows are inserted into `two_factor_recovery_codes`
|
||||||
|
|
||||||
|
#### Scenario: Invalid code → 401, no DB writes
|
||||||
|
|
||||||
|
- GIVEN a user who completed setup-start
|
||||||
|
- WHEN `POST /api/auth/2fa/setup-verify` is called with a wrong 6-digit code
|
||||||
|
- THEN the response is HTTP 401 with error code `TwoFactorFailed`
|
||||||
|
- AND `users.two_factor_enabled` remains `false`
|
||||||
|
- AND no rows are written to `two_factor_recovery_codes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: TOTP Disable
|
||||||
|
|
||||||
|
The system MUST provide `POST /api/auth/2fa/disable` for an authenticated user to remove their TOTP enrollment.
|
||||||
|
|
||||||
|
The request MUST supply both the current password and a current valid TOTP code. Both MUST be verified server-side. If either fails, the endpoint returns HTTP 401 and makes no DB changes.
|
||||||
|
|
||||||
|
On success, the system MUST clear `two_factor_enabled`, `two_factor_secret_encrypted`, `two_factor_secret_nonce`, `last_totp_step_used`, `totp_locked_until`, and all rows in `two_factor_recovery_codes` for that user.
|
||||||
|
|
||||||
|
#### Scenario: Valid password + valid TOTP → disabled
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/2fa/disable` is called with correct password and valid TOTP code
|
||||||
|
- THEN the response is HTTP 200
|
||||||
|
- AND `users.two_factor_enabled` is `false`
|
||||||
|
- AND `users.two_factor_secret_encrypted` is NULL
|
||||||
|
- AND all rows in `two_factor_recovery_codes` for that user are deleted
|
||||||
|
|
||||||
|
#### Scenario: Valid password, invalid TOTP → 401, no change
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/2fa/disable` is called with correct password but wrong TOTP code
|
||||||
|
- THEN the response is HTTP 401
|
||||||
|
- AND `users.two_factor_enabled` remains `true`
|
||||||
|
|
||||||
|
#### Scenario: Invalid password → 401, no change
|
||||||
|
|
||||||
|
- GIVEN an authenticated user with `two_factor_enabled = true`
|
||||||
|
- WHEN `POST /api/auth/2fa/disable` is called with an incorrect password
|
||||||
|
- THEN the response is HTTP 401
|
||||||
|
- AND `users.two_factor_enabled` remains `true`
|
||||||
58
openspec/changes/console-totp-2fa/tasks.md
Normal file
58
openspec/changes/console-totp-2fa/tasks.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Tasks: Console TOTP 2FA
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | 650-900 total; split into ~350-450 backend and ~300-450 frontend |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes |
|
||||||
|
| Suggested split | PR 1 backend → PR 2 frontend |
|
||||||
|
| Delivery strategy | auto-chain per proposal |
|
||||||
|
|
||||||
|
Decision needed before apply: No
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
400-line budget risk: High
|
||||||
|
|
||||||
|
### Suggested Work Units
|
||||||
|
|
||||||
|
| Unit | Goal | Likely PR | Notes |
|
||||||
|
|------|------|-----------|-------|
|
||||||
|
| 1 | Backend 2FA baseline | PR 1 | Migration, crypto, endpoints, login, tests, env/docs |
|
||||||
|
| 2 | Console 2FA UX | PR 2 | Login two-step, security page, admin reset UI, tests |
|
||||||
|
|
||||||
|
## Phase 1: PR1 Backend Foundation / RED Tests
|
||||||
|
|
||||||
|
- [ ] 1.1 Add failing backend tests for login `requires_2fa`, valid/invalid TOTP, recovery-code consumption, replay rejection, lockout, and unchanged non-2FA login.
|
||||||
|
- [x] 1.2 Add migration `services/backend-api/migrations/*_totp_2fa.sql` dropping plaintext secret, adding encrypted/nonce/last-step/lock columns, and creating `two_factor_recovery_codes`.
|
||||||
|
- [x] 1.3 Wire `TOTP_SECRET_ENCRYPTION_KEY` startup config in `services/backend-api` and `docker-compose.yml`; fail boot on missing/invalid base64 32-byte key.
|
||||||
|
|
||||||
|
## Phase 2: PR1 Backend Implementation
|
||||||
|
|
||||||
|
- [x] 2.1 Add `services/backend-api/src/crypto/totp_secret.rs` with AES-256-GCM encrypt/decrypt and no secret logging.
|
||||||
|
- [x] 2.2 Extend `services/backend-api/src/auth.rs` error variants/mappings for 2FA required/failed/not-enabled/already-enabled/recovery invalid/account locked.
|
||||||
|
- [x] 2.3 Add recovery-code generation/hash/verify helpers using Argon2id and one-time deletion semantics.
|
||||||
|
- [x] 2.4 Add `services/backend-api/src/handlers/totp.rs` endpoints: setup-start, setup-verify, disable, regenerate recovery codes, admin reset.
|
||||||
|
- [x] 2.5 Update `services/backend-api/src/handlers/auth.rs` login for TOTP/recovery validation, replay-step update, failure-window lockout, and audit events.
|
||||||
|
- [x] 2.6 Register `/api/auth/2fa/*` and `POST /api/platform/users/:id/2fa-reset` in `services/backend-api/src/main.rs` under existing auth/rate-limit rules.
|
||||||
|
|
||||||
|
## Phase 3: PR1 Backend Verification / Docs
|
||||||
|
|
||||||
|
- [ ] 3.1 Make backend tests pass for all auth, recovery, admin reset, crypto, startup-config, and audit scenarios.
|
||||||
|
- [x] 3.2 Update `docs/DESPLIEGUE.md` with `TOTP_SECRET_ENCRYPTION_KEY` generation/storage and migration pre-flight note.
|
||||||
|
|
||||||
|
## Phase 4: PR2 Frontend Foundation / RED Tests
|
||||||
|
|
||||||
|
- [ ] 4.1 Add failing frontend tests for login two-step, recovery toggle, AccountLocked messaging, security route, enrollment, disable, regeneration, and admin reset modal.
|
||||||
|
- [x] 4.2 Update `services/frontend-console/src/features/auth/schemas/login-schema.ts` and auth API types to accept optional `two_factor_code`/`recovery_code` and `requires_2fa?`.
|
||||||
|
|
||||||
|
## Phase 5: PR2 Frontend Implementation
|
||||||
|
|
||||||
|
- [x] 5.1 Update `services/frontend-console/src/features/auth/pages/LoginPage.tsx` with password-first flow, TOTP/recovery second step, and API payload mapping to `totp_code`/`recovery_code`.
|
||||||
|
- [x] 5.2 Create `services/frontend-console/src/features/security/` with API client, `SecuritySettingsPage`, QR/manual secret enrollment, save-gated recovery codes, disable, and regenerate flows.
|
||||||
|
- [x] 5.3 Register `/app/platform/security` in `services/frontend-console/src/app/router/index.tsx` and navigation without changing unrelated local `frontend-console` work.
|
||||||
|
- [x] 5.4 Add platform admin users-list 2FA status and reset confirmation UI calling `POST /api/platform/users/:id/2fa-reset` with success/error toasts and refresh.
|
||||||
|
|
||||||
|
## Phase 6: Final Verification
|
||||||
|
|
||||||
|
- [x] 6.1 Run targeted `cargo test` and `pnpm --dir services/frontend-console test`; verify specs and keep PR diffs near the 400-line budget.
|
||||||
308
openspec/changes/notify-approved-access-requests/design.md
Normal file
308
openspec/changes/notify-approved-access-requests/design.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# Design: Provision Approved Access Requests
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
Turn approval into a durable provisioning transaction followed by a recoverable email side effect. The backend owns all state transitions: approve request, create `edge_projects`, create/link customer `admin`, create `user_project_access`, create `subscriptions(status='trial')`, create activation invitation hash, then attempt SMTP delivery. Console reflects provisioning/email state; Admin owns `/activate` because customers must never land in provider Console.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- `access_requests` has review fields but no provisioning linkage.
|
||||||
|
- `approve_access_request` only updates `status='approved'` and writes audit.
|
||||||
|
- Customer identity/access currently uses `users`, `roles`, `edge_projects`, and `user_project_access`.
|
||||||
|
- Customer admin role is `admin`; provider Console roles are `platform_admin` and `platform_operator`.
|
||||||
|
- `frontend-admin` routes include `/login` and `/setup`, but no `/activate`.
|
||||||
|
- `frontend-admin` `apiClient` bypasses `/auth/login`, `/auth/register`, `/auth/request-access`, `/auth/refresh`, and `/auth/setup`; activation endpoints must be added to the bypass list.
|
||||||
|
- `subscriptions` already supports `status='trial'` and `trial_ends_at`, but `get_or_create_subscription()` currently auto-creates missing subscriptions as `active`. Approval must create a trial subscription before billing usage reads it.
|
||||||
|
- `notifier.rs` already sends SMTP email for alarms via `lettre` and `SMTP_*` env vars.
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
| ID | Decision | Rationale | Alternative Rejected |
|
||||||
|
|----|----------|-----------|----------------------|
|
||||||
|
| ADR-1 | Approval auto-creates an `edge_projects` row. | User chose automatic provisioning; current schema has no tenant table. | Approval modal requiring operator-selected project. |
|
||||||
|
| ADR-2 | Request contact becomes/links to customer role `admin`. | Customer must enter `frontend-admin`; provider roles are internal only. | Creating `platform_admin` from customer request. |
|
||||||
|
| ADR-3 | Existing email links existing user to the new project. | Prevent duplicate accounts and preserve active users. | Blocking approval on duplicate email. |
|
||||||
|
| ADR-4 | New users are inserted inactive until activation. | Avoid temporary passwords and email credential leaks. | Sending generated password by email. |
|
||||||
|
| ADR-5 | Activation token is opaque and hash-only at rest. | Simple revocation and no JWT leakage. | JWT invitation token. |
|
||||||
|
| ADR-6 | Activation lives at `app.omnioil.app/activate`. | Customer app owns customer account activation. | Console or landing activation. |
|
||||||
|
| ADR-7 | SMTP failure does not rollback provisioning. | Approval/provisioning is durable; email is recoverable. | Transaction rollback on SMTP failure. |
|
||||||
|
| ADR-8 | Resend revokes unconsumed prior tokens. | Prevent multiple valid links in the wild. | Multiple parallel valid tokens. |
|
||||||
|
| ADR-9 | Approved projects start with 7-day trial subscription. | Existing billing table supports trial; payment enforcement is later. | Implement full billing/payments now. |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Add provisioning linkage to `access_requests`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE access_requests
|
||||||
|
ADD COLUMN provisioned_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN provisioned_project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN provisioned_at TIMESTAMPTZ;
|
||||||
|
```
|
||||||
|
|
||||||
|
Create activation invitations:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE access_request_invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
access_request_id UUID NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
email_status VARCHAR(30) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (email_status IN ('pending', 'sent', 'failed')),
|
||||||
|
email_sent_at TIMESTAMPTZ,
|
||||||
|
email_error TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_reason TEXT,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_access_request_invitations_request_latest
|
||||||
|
ON access_request_invitations(access_request_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX idx_access_request_invitations_token_hash
|
||||||
|
ON access_request_invitations(token_hash)
|
||||||
|
WHERE consumed_at IS NULL AND revoked_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
No `activation_status` column is added initially. Pending activation is represented by `users.is_active = false` plus an unconsumed invitation. This keeps schema churn low.
|
||||||
|
|
||||||
|
### Project Defaults
|
||||||
|
|
||||||
|
The request form does not collect all `edge_projects` required fields. Approval uses deterministic defaults:
|
||||||
|
|
||||||
|
| `edge_projects` field | Source |
|
||||||
|
|-----------------------|--------|
|
||||||
|
| `name` | Sanitized `access_requests.company`; if conflict, append short request id suffix. |
|
||||||
|
| `client` | `access_requests.company` |
|
||||||
|
| `operator_name` | `access_requests.company` |
|
||||||
|
| `contract_number` | `PENDING-{access_request.id short}` |
|
||||||
|
| `description` | `message` or `Access request from {full_name}` |
|
||||||
|
| `metadata` | JSON with `source='access_request'`, request id, nit, contact, phone, position. |
|
||||||
|
|
||||||
|
The project remains operationally minimal. Assets/devices/variables are out of scope.
|
||||||
|
|
||||||
|
### Trial Subscription
|
||||||
|
|
||||||
|
Inside the approval transaction, insert:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO subscriptions (project_id, tier, status, trial_ends_at, notes)
|
||||||
|
VALUES ($project_id, 'starter', 'trial', NOW() + INTERVAL '7 days', 'Created from approved access request')
|
||||||
|
ON CONFLICT (project_id) DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `get_or_create_subscription()` currently creates `active` subscriptions by default, approval must create the trial row before any billing read path touches the project.
|
||||||
|
|
||||||
|
## Token Design
|
||||||
|
|
||||||
|
- Generate 32 random bytes with a cryptographically secure RNG.
|
||||||
|
- Encode raw token as base64url without padding for the email link.
|
||||||
|
- Store `hex(SHA-256(raw_token))` or equivalent stable SHA-256 digest in `token_hash`.
|
||||||
|
- Never log the raw token.
|
||||||
|
- Never return raw token from API except internally to the email construction path.
|
||||||
|
- `expires_at = now + 7 days`.
|
||||||
|
- `consumed_at` marks successful activation.
|
||||||
|
- `revoked_at`/`revoked_reason` marks resend invalidation.
|
||||||
|
|
||||||
|
Activation validation checks token hash, then rejects if expired, consumed, revoked, request not approved, or user/project no longer exists.
|
||||||
|
|
||||||
|
## Backend Design
|
||||||
|
|
||||||
|
### New Modules
|
||||||
|
|
||||||
|
- `handlers/invitations.rs`: public activation endpoint and provider resend endpoint, unless keeping access-request resend in `handlers/access_requests.rs` is simpler.
|
||||||
|
- `mailer.rs`: generic SMTP helper with `send_email(to, subject, body)` reused by alarm notifier later or wrapped for approval emails.
|
||||||
|
- `access_request_provisioning.rs` or private functions in `access_requests.rs`: provisioning transaction helpers.
|
||||||
|
|
||||||
|
### Endpoint Changes
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| `POST` | `/api/platform/access-requests/{id}/approve` | `PlatformClaims` | Approve, provision, create trial, create/send activation. |
|
||||||
|
| `POST` | `/api/platform/access-requests/{id}/invitation/resend` | `PlatformClaims` | Revoke previous unconsumed tokens, create/send new activation token. |
|
||||||
|
| `POST` | `/api/auth/invitations/activate` | Public | Validate token, set password, activate user. |
|
||||||
|
| `GET` | `/api/auth/invitations/validate?token=...` | Public | Optional but recommended for frontend preflight display. |
|
||||||
|
|
||||||
|
### Approval Transaction
|
||||||
|
|
||||||
|
```text
|
||||||
|
BEGIN
|
||||||
|
lock access_requests row FOR UPDATE
|
||||||
|
require status IN ('pending', 'in_review')
|
||||||
|
create edge_projects from request data
|
||||||
|
find existing user by lower(email)
|
||||||
|
if user exists:
|
||||||
|
use existing user id
|
||||||
|
do not change password or is_active
|
||||||
|
else:
|
||||||
|
create user role=admin, is_active=false, password_hash=random unusable hash
|
||||||
|
insert/reactivate user_project_access user/project project_role='owner'
|
||||||
|
insert subscriptions project_id status='trial' trial_ends_at=now+7d
|
||||||
|
update access_requests status='approved', provisioned_user_id, provisioned_project_id, reviewed_by, reviewed_at, provisioned_at
|
||||||
|
revoke any older unconsumed invitations for request
|
||||||
|
create invitation row with token_hash, expires_at=now+7d
|
||||||
|
audit approval/provisioning records
|
||||||
|
COMMIT
|
||||||
|
send email if user is inactive/pending activation
|
||||||
|
or send access-ready notification if existing active user
|
||||||
|
update latest invitation email_status sent/failed
|
||||||
|
audit email sent/failed
|
||||||
|
return approved row with provisioning/invitation summary
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a generated unusable password hash for inactive users because `users.password_hash` is currently `NOT NULL`. Design implementation should prefer a random high-entropy value hashed with the existing Argon2 helper, not a fixed sentinel.
|
||||||
|
|
||||||
|
### Existing User Handling
|
||||||
|
|
||||||
|
- Existing active user: link project; send “access ready” email; do not create activation token requirement for login. An invitation row may still be created for email tracking, but activation should not be required.
|
||||||
|
- Existing inactive user: link project; create activation invitation so they can set password/activate.
|
||||||
|
- Existing platform role user with same email: block approval with a conflict unless design explicitly supports dual customer/provider identity. Default: block and surface operator error. This avoids accidentally granting customer access to provider identity.
|
||||||
|
|
||||||
|
### Resend Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
BEGIN
|
||||||
|
lock request row
|
||||||
|
require request status='approved'
|
||||||
|
require provisioned_user_id exists
|
||||||
|
revoke unconsumed invitations for request
|
||||||
|
create new invitation with token_hash expires_at=now+7d
|
||||||
|
audit resend
|
||||||
|
COMMIT
|
||||||
|
send activation/access email
|
||||||
|
update email_status sent/failed
|
||||||
|
```
|
||||||
|
|
||||||
|
If the linked user is already active, resend sends access-ready notification rather than password activation.
|
||||||
|
|
||||||
|
### Activation Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/auth/invitations/activate
|
||||||
|
body: { token, password }
|
||||||
|
|
||||||
|
hash token
|
||||||
|
BEGIN
|
||||||
|
select invitation + user + request FOR UPDATE
|
||||||
|
require not expired, not consumed, not revoked
|
||||||
|
require request.status='approved'
|
||||||
|
hash submitted password
|
||||||
|
update users set password_hash, is_active=true, updated_at=NOW()
|
||||||
|
update invitation consumed_at=NOW()
|
||||||
|
audit activation
|
||||||
|
COMMIT
|
||||||
|
return { status: 'activated' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Password validation should reuse existing policy, but raise the floor to at least 8 characters if compatible with current frontend schemas. Do not auto-login after activation in this change; redirect to `/login`.
|
||||||
|
|
||||||
|
## Frontend Design
|
||||||
|
|
||||||
|
### Console
|
||||||
|
|
||||||
|
- Extend `AccessRequest` type with provisioning fields:
|
||||||
|
- `provisioned_user_id`
|
||||||
|
- `provisioned_project_id`
|
||||||
|
- `provisioned_at`
|
||||||
|
- `invitation_email_status`
|
||||||
|
- `invitation_email_sent_at`
|
||||||
|
- `invitation_expires_at`
|
||||||
|
- `invitation_consumed_at`
|
||||||
|
- Detail panel shows project/admin/invitation status after approval.
|
||||||
|
- Approve button can remain single-click because product decision is automatic project creation.
|
||||||
|
- If approval fails due project name conflict edge case not handled by suffix, show backend error toast.
|
||||||
|
- Show resend as primary only when email failed or invitation expired/unconsumed; secondary otherwise.
|
||||||
|
|
||||||
|
### Admin Activation
|
||||||
|
|
||||||
|
- Add route `/activate` before protected app routes.
|
||||||
|
- Add `features/auth/pages/ActivatePage.tsx`.
|
||||||
|
- Add `activateInvitationApi` and optional `validateInvitationApi`.
|
||||||
|
- Add `/auth/invitations` to `AUTH_BYPASS_ENDPOINTS`.
|
||||||
|
- Page states:
|
||||||
|
- loading token validation
|
||||||
|
- invalid/expired/revoked token
|
||||||
|
- password form
|
||||||
|
- success with link to `/login`
|
||||||
|
- Do not store activation token in global auth state.
|
||||||
|
|
||||||
|
## Email Design
|
||||||
|
|
||||||
|
### Activation Email
|
||||||
|
|
||||||
|
Subject: `Tu acceso a OmniOil fue aprobado`
|
||||||
|
|
||||||
|
Body includes:
|
||||||
|
- Company name.
|
||||||
|
- Activation link.
|
||||||
|
- Expiration: 7 days.
|
||||||
|
- Security note: link is single-use and no password was sent.
|
||||||
|
- Support contact.
|
||||||
|
|
||||||
|
### Existing Active User Email
|
||||||
|
|
||||||
|
Subject: `Nuevo proyecto habilitado en OmniOil`
|
||||||
|
|
||||||
|
Body includes:
|
||||||
|
- Company/project name.
|
||||||
|
- Login link: `https://app.omnioil.app/login`.
|
||||||
|
- Note that existing credentials remain unchanged.
|
||||||
|
|
||||||
|
## Sequence
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Console
|
||||||
|
participant API
|
||||||
|
participant DB
|
||||||
|
participant SMTP
|
||||||
|
participant AdminApp
|
||||||
|
|
||||||
|
Console->>API: POST /api/platform/access-requests/{id}/approve
|
||||||
|
API->>DB: BEGIN + lock request
|
||||||
|
API->>DB: create project, user/link, access, trial, invitation hash
|
||||||
|
API->>DB: update request approved + audit
|
||||||
|
API->>DB: COMMIT
|
||||||
|
API->>SMTP: send activation/access email
|
||||||
|
SMTP-->>API: accepted or failed
|
||||||
|
API->>DB: update email_status + audit
|
||||||
|
API-->>Console: approved + provisioning status
|
||||||
|
Customer->>AdminApp: GET /activate?token=raw
|
||||||
|
AdminApp->>API: POST /api/auth/invitations/activate
|
||||||
|
API->>DB: validate hash, set password, activate, consume token
|
||||||
|
API-->>AdminApp: activated
|
||||||
|
AdminApp-->>Customer: redirect/link to /login
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Backend unit/integration tests:
|
||||||
|
- new user approval provisions project/user/access/trial/invitation.
|
||||||
|
- existing active user links project and does not reset password.
|
||||||
|
- closed request cannot be approved.
|
||||||
|
- token hash stored, raw token absent from persisted fields.
|
||||||
|
- expired/consumed/revoked token activation fails.
|
||||||
|
- resend revokes previous unconsumed tokens.
|
||||||
|
- SMTP failure keeps approved/provisioned state with failed email status.
|
||||||
|
- Frontend Console tests:
|
||||||
|
- approved detail shows provisioning state.
|
||||||
|
- failed invitation shows resend action.
|
||||||
|
- Frontend Admin tests:
|
||||||
|
- `/activate` renders password form for valid token.
|
||||||
|
- invalid/expired token state is shown.
|
||||||
|
- successful activation redirects or links to login.
|
||||||
|
|
||||||
|
## Rollout Notes
|
||||||
|
|
||||||
|
- Add `INVITATION_BASE_URL=https://app.omnioil.app` to deployment env.
|
||||||
|
- Reuse `SMTP_*` env vars; if unset, dev may stub/log email but production should report failed status clearly.
|
||||||
|
- Deploy backend before frontend activation route is linked in emails.
|
||||||
|
- No destructive migration. Rollback leaves inactive users/invitations and trial subscriptions in DB for manual cleanup.
|
||||||
84
openspec/changes/notify-approved-access-requests/prd.md
Normal file
84
openspec/changes/notify-approved-access-requests/prd.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# PRD: Provision Approved Access Requests
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When OmniOil/Kyrbot approves a public access request, the system currently only changes an internal review status. It does not create the customer admin account, does not bind access to an operational scope, and does not notify the requester with an activation path. This leaves onboarding dependent on manual backoffice steps and makes it easy for approved customers to get stuck.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Approving an access request from Console should complete the first operational onboarding step: create the customer admin account, connect it to the approved operational scope, generate a secure activation link, and email the customer without exposing passwords.
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
- Platform operator: reviews requests and approves/rejects them from Console.
|
||||||
|
- Platform admin: has the same flow plus future ability to resolve provisioning conflicts.
|
||||||
|
- Customer admin: the business contact who requested access and will activate the first customer-side admin account.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
- As a platform operator, I want approval to provision the customer's initial admin access so I do not manually create users after every approval.
|
||||||
|
- As a platform operator, I want to see whether the activation email was sent or failed so I know whether follow-up is needed.
|
||||||
|
- As a platform operator, I want to resend a failed or expired activation invitation without approving the request again.
|
||||||
|
- As a customer admin, I want to receive a secure activation email so I can set my own password and enter OmniOil.
|
||||||
|
- As OmniOil/Kyrbot, we want an audit trail for every approval, provisioning action, activation email, resend, and activation.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
1. Approval shall only apply to requests in `pending` or `in_review`.
|
||||||
|
2. Approval shall create or link the customer operational scope before emailing the customer.
|
||||||
|
3. Approval shall create the initial customer admin user with role `admin`.
|
||||||
|
4. Approval shall not create `platform_admin` or `platform_operator` users for customers.
|
||||||
|
5. The created customer admin shall remain unable to log in until activation is completed.
|
||||||
|
6. The system shall generate a single-use activation token with expiration.
|
||||||
|
7. The system shall store only a token hash, never the raw token.
|
||||||
|
8. The email shall include an activation link, expiration notice, company name, and support contact.
|
||||||
|
9. The email shall not include a password.
|
||||||
|
10. If SMTP fails, approval/provisioning shall remain durable and Console shall show the failure.
|
||||||
|
11. Console shall allow resending/rotating the activation invitation for approved provisioned requests.
|
||||||
|
12. Activation shall let the customer admin set a password and then mark the user active.
|
||||||
|
13. The system shall audit approval, provisioning, email sent, email failed, resend, and activation.
|
||||||
|
14. Approval shall create an initial 7-day project trial using the existing project subscription model.
|
||||||
|
15. Activation-token expiry and project-trial expiry shall remain separate concepts.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
- Security: activation tokens must be high entropy, expiring, single-use, and hash-only at rest.
|
||||||
|
- Reliability: SMTP failure must not corrupt approval/provisioning state.
|
||||||
|
- Auditability: all state transitions must be visible in audit logs.
|
||||||
|
- Minimality: reuse existing `users`, `roles`, `edge_projects`, and `user_project_access` unless design proves a dedicated tenant table is required now.
|
||||||
|
- Billing boundary: use existing `subscriptions.status = 'trial'` and `trial_ends_at` for the initial trial, but do not implement full payment enforcement in this change.
|
||||||
|
- Operability: deployment docs must explain SMTP and activation URL env vars.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Resolved: approval creates a new `edge_projects` row automatically from the access request data.
|
||||||
|
2. Resolved: activation uses `app.omnioil.app/activate` because it belongs to the customer Admin app, not the provider Console or public landing.
|
||||||
|
3. Resolved: if `users.email` already exists, approval links the existing user to the newly created project instead of creating a duplicate user.
|
||||||
|
4. Resolved: activation links expire after 7 days.
|
||||||
|
5. Resolved: resending invalidates all previous unconsumed activation tokens and creates a fresh token.
|
||||||
|
|
||||||
|
## Product Decisions
|
||||||
|
|
||||||
|
- Auto-provisioning: approval automatically creates the first `edge_projects` record using request-derived data.
|
||||||
|
- Activation URL: `INVITATION_BASE_URL=https://app.omnioil.app`, route `/activate`.
|
||||||
|
- Existing user behavior: link existing user by email to the new project; do not duplicate accounts.
|
||||||
|
- Expiration: activation tokens expire after 7 days.
|
||||||
|
- Resend behavior: rotate token and invalidate previous unconsumed invitations.
|
||||||
|
- Trial: provisioned projects start with a 7-day trial subscription.
|
||||||
|
- Billing enforcement: suspending projects after non-payment/trial expiry is a later billing lifecycle change, not part of activation-token validation.
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- An approved request results in a provisioned customer admin without manual DB/user creation.
|
||||||
|
- Operators can tell from Console whether the activation email was sent.
|
||||||
|
- Customers can activate without receiving or sharing passwords.
|
||||||
|
- Failed SMTP delivery can be recovered through resend.
|
||||||
|
- No raw activation token appears in database, logs, or API responses after email construction.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Full tenant/account hierarchy redesign.
|
||||||
|
- Bulk user import.
|
||||||
|
- Creating production assets/devices/variables.
|
||||||
|
- Billing automation.
|
||||||
|
- Marketing email automation.
|
||||||
180
openspec/changes/notify-approved-access-requests/proposal.md
Normal file
180
openspec/changes/notify-approved-access-requests/proposal.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Proposal: Provision Approved Access Requests
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Close the provider intake loop by provisioning the approved customer admin account and notifying the business contact when a platform operator approves an access request. Today approval changes the internal status only; the customer does not receive access or a next step, so operations must manually create users/follow up outside Console. That creates delay, duplicated work, and poor auditability.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- Backend (`services/backend-api`):
|
||||||
|
- Persist provisioning, activation, and email delivery metadata for approved access requests.
|
||||||
|
- Create the customer operational scope automatically for the request. In the current schema this means creating an `edge_projects` row plus `user_project_access`; if a future `tenants` table is introduced, the design must map this flow to that table explicitly.
|
||||||
|
- Create the initial customer admin user from the request contact using role `admin`, not `platform_admin`; if a user with the same email already exists, link that existing user to the new project instead of creating a duplicate.
|
||||||
|
- Store the new user as inactive/pending activation until the invite is consumed.
|
||||||
|
- Generate a single-use, expiring activation token after approval.
|
||||||
|
- Create an initial 7-day trial subscription for the provisioned project using the existing `subscriptions` table.
|
||||||
|
- Send an approval/activation email to the request contact using SMTP configuration.
|
||||||
|
- Record audit events for project provisioning, admin user creation, activation invitation creation, email sent, email failed, and resend.
|
||||||
|
- Add provider-only resend/rotate-invitation endpoint for failed/expired activation emails.
|
||||||
|
- Add a public activation endpoint that validates the token and lets the customer set their password.
|
||||||
|
- Frontend Console (`services/frontend-console`):
|
||||||
|
- On approval, collect/confirm the minimum provisioning data that cannot be safely inferred from the request.
|
||||||
|
- Show provisioned project/admin user and activation email status in the selected request detail.
|
||||||
|
- Surface a `Reenviar invitacion` action when email delivery failed or the activation invitation expired.
|
||||||
|
- Keep reject/review flow unchanged except for showing provisioning context.
|
||||||
|
- Frontend Admin (`services/frontend-admin`):
|
||||||
|
- Add or reuse an activation route where the invited customer admin sets their password and activates the account.
|
||||||
|
- Deployment/docs:
|
||||||
|
- Document required SMTP env vars and invitation base URL.
|
||||||
|
- Keep secrets out of repository examples.
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
- Building a full email template CMS.
|
||||||
|
- Sending passwords by email.
|
||||||
|
- Creating full production assets/devices/variables for the customer.
|
||||||
|
- Creating multiple customer users during first approval.
|
||||||
|
- Creating `platform_admin` or `platform_operator` accounts for customers.
|
||||||
|
- Replacing existing alarm notification code wholesale.
|
||||||
|
- Customer self-service registration redesign beyond the activation link target required by this flow.
|
||||||
|
- Bulk resends or marketing emails.
|
||||||
|
- Payment enforcement and project suspension after trial expiry.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `access-request-provisioning`: When an access request is approved, the system SHALL provision the customer admin account, bind it to the approved operational scope, create a secure activation invitation, and notify the business contact by email.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `access-requests`: Approval responses SHALL expose provisioning and notification/invitation status so Console operators can see whether access was created and whether the customer was notified.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Use approval as an explicit provisioning workflow, not just a status change. The backend must create durable internal access first: approved request, customer admin user, operational scope link, and activation invitation metadata. Only after that durable state exists should it attempt SMTP delivery. If SMTP fails, the request remains approved/provisioned and the failure is stored for operator follow-up. Console then shows the failure and exposes a resend action.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
1. **Create a customer `admin`, not a platform user**. The requester becomes an `admin` role user for the customer side (`frontend-admin`), never `platform_admin`/`platform_operator`.
|
||||||
|
2. **Pending activation over temporary password**. The created user must not receive a password by email. Use inactive/pending state until the activation token is consumed.
|
||||||
|
3. **Bind access to a new operational scope**. In the current schema, users see data through `user_project_access`; approval creates a new `edge_projects` row from request data and links the customer admin to it.
|
||||||
|
4. **Do not send passwords**. The email contains a time-limited activation link only.
|
||||||
|
5. **Store token hashes, not raw tokens**. Generate a high-entropy token, store only its hash, and put the raw token only in the outbound link. Activation links expire after 7 days.
|
||||||
|
6. **Provisioning is not rolled back by SMTP failure**. Email problems are operational, not domain approval problems.
|
||||||
|
7. **Reuse SMTP config, extract generic mailer**. `notifier.rs` already uses `lettre` and `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, `SMTP_PORT`, `SMTP_FROM`; this change should extract or add a small generic mailer instead of duplicating SMTP setup.
|
||||||
|
8. **Operator-visible provisioning and delivery status**. Console must make it obvious whether the admin user/project were created and whether the customer was notified.
|
||||||
|
9. **Manual resend is provider-only and rotates tokens**. Resend must require `PlatformClaims`, invalidate previous unconsumed activation tokens, create a fresh token, and write audit metadata.
|
||||||
|
10. **Activation belongs to Admin, not Console**. The email link targets `https://app.omnioil.app/activate?token=...`; Console remains internal provider-only.
|
||||||
|
11. **Trial is separate from activation**. Provisioned projects start with `subscriptions.status = 'trial'` and `trial_ends_at = now() + 7 days`. Activation-token expiry does not suspend projects; billing lifecycle enforcement is a later change.
|
||||||
|
|
||||||
|
## Affected Areas
|
||||||
|
|
||||||
|
| Area | Impact | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `services/backend-api/migrations/*_access_request_provisioning.sql` | New | Add provisioning/invitation metadata. Preferred: separate `access_request_invitations` table plus columns linking request to `provisioned_user_id` and `provisioned_project_id`, or a single provisioning table. |
|
||||||
|
| `services/backend-api/src/handlers/access_requests.rs` | Modified | Approval provisions customer admin/project access, creates activation invitation, sends email, returns provisioning status; new resend endpoint. |
|
||||||
|
| `services/backend-api/src/main.rs` | Modified | Register provider-only resend route under `/api/platform/access-requests/{id}/invitation/resend` and public activation route. |
|
||||||
|
| `services/backend-api/src/handlers/auth.rs` or new `handlers/invitations.rs` | Modified/New | Public activation endpoint validates token and sets password. |
|
||||||
|
| `services/backend-api/src/mailer.rs` or `services/backend-api/src/email/*` | New | Generic SMTP email sender and approval/activation email template. |
|
||||||
|
| `services/backend-api/src/notifier.rs` | Modified | Reuse generic mailer for alarm emails or leave alarm path intact and share only SMTP helper. |
|
||||||
|
| `services/backend-api/src/handlers/billing*` or approval service | Modified/New | Create initial trial subscription for the provisioned project. |
|
||||||
|
| `services/frontend-console/src/features/access-requests/api/access-requests-api.ts` | Modified | Add notification fields and resend API call. |
|
||||||
|
| `services/frontend-console/src/features/access-requests/pages/AccessRequestsPage.tsx` | Modified | Show provisioning/email/invitation status, collect/confirm project data, and expose resend action. |
|
||||||
|
| `services/frontend-admin/src/**` | Modified | Activation page accepts token, sets password, and redirects to login. |
|
||||||
|
| `docker-compose.yml`, `.env.example`, `docs/DESPLIEGUE.md` | Modified | Document SMTP and `INVITATION_BASE_URL`/`CONSOLE_APP_ORIGIN` style env. |
|
||||||
|
|
||||||
|
## Data Model Sketch
|
||||||
|
|
||||||
|
Preferred additive model:
|
||||||
|
|
||||||
|
1. Add provisioning linkage to access requests, or keep it in a separate provisioning table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE access_requests
|
||||||
|
ADD COLUMN provisioned_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN provisioned_project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN provisioned_at TIMESTAMPTZ;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add activation invitation metadata:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE access_request_invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
access_request_id UUID NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
email_status VARCHAR(30) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (email_status IN ('pending', 'sent', 'failed')),
|
||||||
|
email_sent_at TIMESTAMPTZ,
|
||||||
|
email_error TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_reason TEXT,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. User activation state:
|
||||||
|
|
||||||
|
The current `users.is_active` boolean can represent pending activation by inserting the customer admin with `is_active = false`, then setting it to `true` after password setup. If we need clearer semantics, design may add `activation_status VARCHAR(30)`; default recommendation is to start minimal with `is_active = false` plus invitation state to avoid unnecessary schema churn.
|
||||||
|
|
||||||
|
The design phase should confirm whether one invitation per request is enough or whether resends should create new rows. Default recommendation: create a new token on resend and keep previous rows for audit, marking older unconsumed invitations as superseded if that status is added.
|
||||||
|
|
||||||
|
4. Initial trial state:
|
||||||
|
|
||||||
|
The existing `subscriptions` table already has `status IN ('active','suspended','cancelled','trial')` and `trial_ends_at`. Approval should insert a `subscriptions` row for the new project with `status = 'trial'` and `trial_ends_at = NOW() + INTERVAL '7 days'`. Payment enforcement and automatic suspension after trial expiry are explicitly deferred.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|------|------------|------------|
|
||||||
|
| SMTP outage blocks onboarding | Medium | Do not rollback approval; store `email_status = failed`; expose resend. |
|
||||||
|
| Invitation token leak | Medium | Store only hash, expire tokens, single-use consumption, HTTPS-only base URL. |
|
||||||
|
| Duplicate emails on retry | Medium | Make resend explicit; approval should not repeatedly send if already sent. |
|
||||||
|
| Duplicate user email | Medium | Approval links the existing user by email to the newly created project; no duplicate account is created. |
|
||||||
|
| Project/tenant ambiguity | Medium | Approval creates `edge_projects` from request-derived data; design must define deterministic defaults and metadata for fields not present in the request. |
|
||||||
|
| Email sent before DB commit | Low | Persist approval/invitation first; send after durable state exists. |
|
||||||
|
| Customer receives link but account flow missing | Medium | Implement activation endpoint/page in same change; do not ship email-only notification. |
|
||||||
|
| Trial confused with activation expiry | Medium | Store token expiry and subscription trial separately; token validation must not decide billing access. |
|
||||||
|
| SMTP secrets accidentally committed | Low | Document env vars only; never commit real `.env`. |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
1. Disable email delivery by unsetting `SMTP_HOST` or feature flagging resend/approval email behavior if introduced.
|
||||||
|
2. Revert frontend Console changes; approval status remains readable through existing request fields.
|
||||||
|
3. Revert backend code paths to approve without provisioning/email if necessary.
|
||||||
|
4. Leave additive invitation/provisioning columns or tables in place during rollback to avoid destructive data loss; drop them only in a later migration after confirming no active invitations exist.
|
||||||
|
5. For users created during the rollout window, keep them inactive unless already activated; operators can manually activate/reset via existing admin tooling if required.
|
||||||
|
6. Notify operators that approved requests during rollback require manual customer follow-up.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing `lettre` dependency used by `services/backend-api/src/notifier.rs`.
|
||||||
|
- SMTP env vars: `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, `SMTP_PORT`, `SMTP_FROM`.
|
||||||
|
- New public base URL env for links: `INVITATION_BASE_URL=https://app.omnioil.app`, route `/activate`.
|
||||||
|
- Existing provider auth via `PlatformClaims`.
|
||||||
|
- Existing audit helper `crate::audit::log`.
|
||||||
|
- Existing roles: customer admin role is `admin`; provider console roles are `platform_admin` and `platform_operator`.
|
||||||
|
- Existing data access model: `users` gain operational access through `user_project_access` rows tied to `edge_projects`.
|
||||||
|
- Product decisions: auto-create `edge_projects`, link existing user emails, 7-day activation expiry, resend invalidates previous unconsumed tokens.
|
||||||
|
- Existing billing model: `subscriptions` supports `status = 'trial'` and `trial_ends_at` per project.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Approving a pending/in-review request persists `approved` status, provisions a customer admin user with role `admin`, binds operational access, and creates an activation token hash with expiration.
|
||||||
|
- [ ] The provisioned project receives a `trial` subscription with `trial_ends_at` 7 days after approval.
|
||||||
|
- [ ] The customer admin is not a platform user and cannot access Console-only provider routes.
|
||||||
|
- [ ] Approval email is sent to `access_requests.email` with company name, activation link, expiry, and support contact.
|
||||||
|
- [ ] The activation link lets the customer set their password, activates the user, and redirects to login.
|
||||||
|
- [ ] Raw invitation token is never stored in the database or logs.
|
||||||
|
- [ ] If SMTP fails, the request remains approved/provisioned and Console shows email failure with a resend action.
|
||||||
|
- [ ] Resend creates or rotates an invitation token and writes an audit event.
|
||||||
|
- [ ] Already approved requests cannot be re-approved to spam emails.
|
||||||
|
- [ ] Console displays provisioning and notification status for approved requests.
|
||||||
|
- [ ] Backend tests cover approval success, user/project provisioning, SMTP failure, resend, expired token, activation, duplicate email handling, and no raw-token persistence.
|
||||||
|
- [ ] Frontend tests cover visible provisioning/delivery status, approval data capture, activation page, and resend button state.
|
||||||
|
- [ ] Docs explain required SMTP and invitation URL env vars.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Delta for Access Request Provisioning
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Approve Request Provisions Customer Access
|
||||||
|
The system SHALL convert an approved access request into real customer access by creating an operational project, creating or linking the customer admin user, binding that user to the project, and creating an activation invitation.
|
||||||
|
|
||||||
|
#### Scenario: Approving a pending request provisions new customer admin
|
||||||
|
- GIVEN an access request is `pending`
|
||||||
|
- AND no user exists with the request email
|
||||||
|
- WHEN a platform operator approves the request
|
||||||
|
- THEN the system SHALL set the request status to `approved`
|
||||||
|
- AND SHALL create a new `edge_projects` record using request-derived customer data
|
||||||
|
- AND SHALL create a user with the request email and role `admin`
|
||||||
|
- AND SHALL keep the user inactive until activation is completed
|
||||||
|
- AND SHALL create `user_project_access` for the new user and project
|
||||||
|
- AND SHALL create an activation invitation linked to the request, user, and project
|
||||||
|
- AND SHALL audit the approval and provisioning actions
|
||||||
|
|
||||||
|
#### Scenario: Approving an in-review request provisions access
|
||||||
|
- GIVEN an access request is `in_review`
|
||||||
|
- WHEN a platform operator approves the request
|
||||||
|
- THEN the system SHALL perform the same provisioning flow as for a pending request
|
||||||
|
|
||||||
|
#### Scenario: Approving a closed request is rejected
|
||||||
|
- GIVEN an access request is `approved` or `rejected`
|
||||||
|
- WHEN a platform operator approves the request
|
||||||
|
- THEN the system SHALL reject the action
|
||||||
|
- AND SHALL NOT create a user, project, access row, invitation, or email
|
||||||
|
|
||||||
|
### Requirement: Existing User Is Linked, Not Duplicated
|
||||||
|
The system SHALL link an existing user by email to the newly created project instead of creating a duplicate account.
|
||||||
|
|
||||||
|
#### Scenario: Existing user email is approved again
|
||||||
|
- GIVEN a user already exists with the access request email
|
||||||
|
- WHEN a platform operator approves the request
|
||||||
|
- THEN the system SHALL NOT create a duplicate user
|
||||||
|
- AND SHALL create a new `edge_projects` record for the approved request
|
||||||
|
- AND SHALL create or reactivate `user_project_access` linking the existing user to the new project
|
||||||
|
- AND SHALL create a fresh activation invitation only if the existing user cannot currently log in
|
||||||
|
- AND SHALL audit that an existing user was linked
|
||||||
|
|
||||||
|
#### Scenario: Existing active user is linked
|
||||||
|
- GIVEN a user already exists with the access request email
|
||||||
|
- AND the user is active
|
||||||
|
- WHEN a platform operator approves the request
|
||||||
|
- THEN the system SHALL link the user to the new project
|
||||||
|
- AND SHALL send an access-ready notification rather than requiring password activation
|
||||||
|
- AND SHALL NOT disable or reset the existing user
|
||||||
|
|
||||||
|
### Requirement: Customer Roles Remain Separated From Provider Roles
|
||||||
|
The system SHALL create or link customer-side `admin` users only and SHALL NOT grant provider Console roles to customers.
|
||||||
|
|
||||||
|
#### Scenario: Provisioned customer admin cannot access provider Console
|
||||||
|
- GIVEN an access request has been approved
|
||||||
|
- AND the request contact has been provisioned
|
||||||
|
- WHEN the provisioned user authenticates
|
||||||
|
- THEN their role SHALL be `admin`
|
||||||
|
- AND they SHALL NOT satisfy `PlatformClaims`
|
||||||
|
- AND they SHALL NOT access provider routes guarded by `platform_admin` or `platform_operator`
|
||||||
|
|
||||||
|
### Requirement: Activation Invitation Uses Secure Opaque Token
|
||||||
|
The system SHALL use a high-entropy opaque activation token, store only its hash, and expire it after 7 days.
|
||||||
|
|
||||||
|
#### Scenario: Activation token is created securely
|
||||||
|
- GIVEN an access request approval is provisioned
|
||||||
|
- WHEN the system creates the activation invitation
|
||||||
|
- THEN it SHALL generate a random token with at least 32 bytes of entropy
|
||||||
|
- AND SHALL store only a SHA-256 hash or stronger digest of the token
|
||||||
|
- AND SHALL NOT store the raw token in the database
|
||||||
|
- AND SHALL NOT write the raw token to logs or audit metadata
|
||||||
|
- AND SHALL set `expires_at` to 7 days after creation
|
||||||
|
|
||||||
|
#### Scenario: Activation link targets customer Admin app
|
||||||
|
- GIVEN an activation invitation has been created
|
||||||
|
- WHEN the system builds the email link
|
||||||
|
- THEN the link SHALL target `INVITATION_BASE_URL` with path `/activate`
|
||||||
|
- AND the production value SHALL be `https://app.omnioil.app/activate`
|
||||||
|
- AND it SHALL include the raw token only as a URL parameter for the email recipient
|
||||||
|
|
||||||
|
### Requirement: Activation Sets Password And Enables Login
|
||||||
|
The system SHALL let the customer admin consume a valid activation token once, set their password, and activate the account.
|
||||||
|
|
||||||
|
#### Scenario: Valid activation succeeds
|
||||||
|
- GIVEN an activation token exists
|
||||||
|
- AND it is not expired, consumed, or revoked
|
||||||
|
- WHEN the customer submits the token with a valid password
|
||||||
|
- THEN the system SHALL hash and store the password
|
||||||
|
- AND SHALL mark the user active
|
||||||
|
- AND SHALL mark the invitation consumed
|
||||||
|
- AND SHALL audit activation
|
||||||
|
- AND SHALL return a success response suitable for redirecting to login
|
||||||
|
|
||||||
|
#### Scenario: Expired activation is rejected
|
||||||
|
- GIVEN an activation token exists
|
||||||
|
- AND `expires_at` is in the past
|
||||||
|
- WHEN the customer submits the token with a password
|
||||||
|
- THEN the system SHALL reject activation
|
||||||
|
- AND SHALL NOT update the user's password
|
||||||
|
- AND SHALL NOT mark the user active
|
||||||
|
|
||||||
|
#### Scenario: Consumed activation is rejected
|
||||||
|
- GIVEN an activation token has already been consumed
|
||||||
|
- WHEN the customer submits the same token again
|
||||||
|
- THEN the system SHALL reject activation
|
||||||
|
- AND SHALL NOT update the user's password
|
||||||
|
|
||||||
|
#### Scenario: Revoked activation is rejected
|
||||||
|
- GIVEN an activation token has been revoked by a resend
|
||||||
|
- WHEN the customer submits that token
|
||||||
|
- THEN the system SHALL reject activation
|
||||||
|
- AND SHALL NOT update the user's password
|
||||||
|
|
||||||
|
### Requirement: Approval Email Delivery Is Recoverable
|
||||||
|
The system SHALL send the activation email after durable provisioning and SHALL keep approval/provisioning state if email delivery fails.
|
||||||
|
|
||||||
|
#### Scenario: Email send succeeds
|
||||||
|
- GIVEN provisioning has completed
|
||||||
|
- WHEN SMTP accepts the activation email
|
||||||
|
- THEN the system SHALL store `email_status = 'sent'`
|
||||||
|
- AND SHALL store `email_sent_at`
|
||||||
|
- AND SHALL expose the sent status to Console
|
||||||
|
- AND SHALL audit email delivery
|
||||||
|
|
||||||
|
#### Scenario: Email send fails
|
||||||
|
- GIVEN provisioning has completed
|
||||||
|
- WHEN SMTP delivery fails
|
||||||
|
- THEN the access request SHALL remain `approved`
|
||||||
|
- AND the project/user/access/invitation SHALL remain persisted
|
||||||
|
- AND the system SHALL store `email_status = 'failed'`
|
||||||
|
- AND SHALL store a non-secret error summary
|
||||||
|
- AND SHALL expose resend from Console
|
||||||
|
- AND SHALL audit email failure
|
||||||
|
|
||||||
|
### Requirement: Resend Rotates Activation Tokens
|
||||||
|
The system SHALL let provider operators resend activation invitations and SHALL invalidate previous unconsumed tokens.
|
||||||
|
|
||||||
|
#### Scenario: Resend invalidates previous token
|
||||||
|
- GIVEN an approved request has an unconsumed activation invitation
|
||||||
|
- WHEN a platform operator resends the invitation
|
||||||
|
- THEN the system SHALL set `revoked_at` on all previous unconsumed invitations for that request
|
||||||
|
- AND SHALL create a new activation token with 7-day expiration
|
||||||
|
- AND SHALL send a new activation email
|
||||||
|
- AND SHALL audit the resend
|
||||||
|
|
||||||
|
#### Scenario: Resend is provider-only
|
||||||
|
- GIVEN a non-provider user is authenticated
|
||||||
|
- WHEN they attempt to resend an activation invitation
|
||||||
|
- THEN the system SHALL reject the action
|
||||||
|
- AND SHALL NOT rotate tokens or send email
|
||||||
|
|
||||||
|
### Requirement: Initial Trial Is Created Without Full Billing Automation
|
||||||
|
The system SHALL create an initial 7-day trial marker for the provisioned project while leaving payment enforcement to a later billing change.
|
||||||
|
|
||||||
|
#### Scenario: Approved project starts trial
|
||||||
|
- GIVEN an access request is approved and provisioned
|
||||||
|
- WHEN the project is created
|
||||||
|
- THEN the system SHALL record that the project starts in a 7-day trial state
|
||||||
|
- AND SHALL associate the trial with the provisioned project
|
||||||
|
- AND SHALL NOT require payment before activation
|
||||||
|
|
||||||
|
#### Scenario: Trial expiration does not belong to activation token
|
||||||
|
- GIVEN an activation token expires after 7 days
|
||||||
|
- AND the project trial also lasts 7 days initially
|
||||||
|
- WHEN the activation token expires
|
||||||
|
- THEN token expiration SHALL only block account activation through that token
|
||||||
|
- AND project billing/suspension enforcement SHALL be handled by billing lifecycle rules, not by token validation
|
||||||
|
|
||||||
|
### Requirement: Console Shows Provisioning State
|
||||||
|
The Console SHALL show operators whether an approved request has a project, customer admin, activation invitation, and email delivery status.
|
||||||
|
|
||||||
|
#### Scenario: Approved request detail shows provisioning status
|
||||||
|
- GIVEN a request has been approved and provisioned
|
||||||
|
- WHEN a platform operator views the request detail
|
||||||
|
- THEN Console SHALL show the provisioned project
|
||||||
|
- AND SHALL show the customer admin email
|
||||||
|
- AND SHALL show activation email status
|
||||||
|
- AND SHALL show invitation expiration when available
|
||||||
|
|
||||||
|
#### Scenario: Failed email shows resend action
|
||||||
|
- GIVEN an approved request has `email_status = 'failed'`
|
||||||
|
- WHEN a platform operator views the request detail
|
||||||
|
- THEN Console SHALL show the failure state
|
||||||
|
- AND SHALL enable a resend action
|
||||||
|
|
||||||
|
#### Scenario: Sent email without expiration issue hides urgent resend
|
||||||
|
- GIVEN an approved request has `email_status = 'sent'`
|
||||||
|
- AND its latest invitation is not expired, consumed, or revoked
|
||||||
|
- WHEN a platform operator views the request detail
|
||||||
|
- THEN Console SHOULD not present resend as the primary action
|
||||||
|
- AND MAY expose resend as a secondary recovery action
|
||||||
121
openspec/changes/notify-approved-access-requests/tasks.md
Normal file
121
openspec/changes/notify-approved-access-requests/tasks.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Tasks: Provision Approved Access Requests
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | 900-1,400 across backend, Console, Admin, docs/tests |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes |
|
||||||
|
| Suggested split | PR 1 backend data/provisioning/email -> PR 2 Admin activation -> PR 3 Console status/resend/docs/tests |
|
||||||
|
|
||||||
|
## Phase 0: Baseline And Safety
|
||||||
|
|
||||||
|
- [x] 0.1 Confirm current `access_requests`, `users`, `roles`, `edge_projects`, `user_project_access`, and `subscriptions` schema in migrations.
|
||||||
|
- [x] 0.2 Confirm roles `admin`, `platform_admin`, and `platform_operator` exist in target environments.
|
||||||
|
- [ ] 0.3 Confirm production env can provide `INVITATION_BASE_URL=https://app.omnioil.app` and `SMTP_*` values.
|
||||||
|
- [x] 0.4 Decide implementation location: private helpers in `handlers/access_requests.rs` vs new `access_request_provisioning.rs` module.
|
||||||
|
- [x] 0.5 Keep existing request submission, review, reject, and login behavior unchanged outside this change.
|
||||||
|
|
||||||
|
## Phase 1: Backend Schema
|
||||||
|
|
||||||
|
- [x] 1.1 Add migration for `access_requests.provisioned_user_id`, `provisioned_project_id`, and `provisioned_at`.
|
||||||
|
- [x] 1.2 Add migration for `access_request_invitations` with `token_hash`, email status, expiration, consumed/revoked fields, and indexes.
|
||||||
|
- [x] 1.3 Ensure migration is additive and rollback-safe; do not drop existing request data.
|
||||||
|
- [x] 1.4 Update backend row/response types for access requests to include provisioning and latest invitation summary fields.
|
||||||
|
- [x] 1.5 Add query helper to fetch latest invitation summary per request without exposing raw tokens.
|
||||||
|
|
||||||
|
## Phase 2: Backend Token And Mailer Foundations
|
||||||
|
|
||||||
|
- [x] 2.1 Add token generation helper: 32 random bytes, base64url without padding.
|
||||||
|
- [x] 2.2 Add token hashing helper using SHA-256 or stronger stable digest.
|
||||||
|
- [x] 2.3 Add tests proving stored hash differs from raw token and token length/encoding is URL-safe.
|
||||||
|
- [x] 2.4 Add generic SMTP mailer helper using existing `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, `SMTP_PORT`, and `SMTP_FROM` env vars.
|
||||||
|
- [x] 2.5 Add activation email body builder for inactive/pending users.
|
||||||
|
- [x] 2.6 Add access-ready email body builder for existing active users.
|
||||||
|
- [x] 2.7 Ensure raw activation tokens are never logged or written to audit metadata.
|
||||||
|
|
||||||
|
## Phase 3: Backend Approval Provisioning
|
||||||
|
|
||||||
|
- [x] 3.1 Refactor `approve_access_request` to run a transaction locking the request row.
|
||||||
|
- [x] 3.2 Reject approval unless request status is `pending` or `in_review`.
|
||||||
|
- [x] 3.3 Create `edge_projects` from deterministic request-derived defaults, including metadata with request id, NIT, contact, phone, and position.
|
||||||
|
- [x] 3.4 Resolve project name conflicts by appending a stable short request-id suffix.
|
||||||
|
- [x] 3.5 Lookup existing user by normalized email.
|
||||||
|
- [x] 3.6 For no existing user: create role `admin` user with `is_active=false` and an unusable random Argon2 password hash.
|
||||||
|
- [x] 3.7 For existing active customer user: preserve password and active state.
|
||||||
|
- [x] 3.8 For existing inactive customer user: preserve inactive state and create activation invitation.
|
||||||
|
- [x] 3.9 For existing provider-role user with same email: fail approval with clear conflict error.
|
||||||
|
- [x] 3.10 Insert or reactivate `user_project_access` with `project_role='owner'`.
|
||||||
|
- [x] 3.11 Insert `subscriptions` row with `status='trial'` and `trial_ends_at = now + 7 days` before billing reads can create an active default.
|
||||||
|
- [x] 3.12 Update `access_requests` with approved/provisioned fields and reviewer metadata.
|
||||||
|
- [x] 3.13 Create activation invitation for inactive users with 7-day expiry.
|
||||||
|
- [x] 3.14 For existing active users, decide whether to create an invitation row for email tracking or a separate notification-only record; implement consistently with design.
|
||||||
|
- [x] 3.15 Commit transaction before SMTP send.
|
||||||
|
- [x] 3.16 Send activation/access-ready email and update `email_status`, `email_sent_at`, or `email_error`.
|
||||||
|
- [x] 3.17 Add audit events for approval, project provisioning, user creation/linking, trial creation, invitation creation, email sent, email failed, and activation.
|
||||||
|
|
||||||
|
## Phase 4: Backend Activation And Resend Endpoints
|
||||||
|
|
||||||
|
- [x] 4.1 Register public `POST /api/auth/invitations/activate` route.
|
||||||
|
- [x] 4.2 Optionally register public `GET /api/auth/invitations/validate?token=...` route for frontend preflight.
|
||||||
|
- [x] 4.3 Implement activation token validation: hash lookup, approved request, not expired, not consumed, not revoked, user/project exists.
|
||||||
|
- [x] 4.4 Implement password validation and Argon2 hashing for activation.
|
||||||
|
- [x] 4.5 Activate user and mark invitation `consumed_at` in one transaction.
|
||||||
|
- [x] 4.6 Register provider-only `POST /api/platform/access-requests/{id}/invitation/resend` route.
|
||||||
|
- [x] 4.7 Resend endpoint revokes all previous unconsumed invitations for the request.
|
||||||
|
- [x] 4.8 Resend endpoint creates a fresh 7-day invitation and sends email.
|
||||||
|
- [x] 4.9 Resend endpoint records audit and email status.
|
||||||
|
|
||||||
|
## Phase 5: Backend Tests
|
||||||
|
|
||||||
|
Backend DB integration evidence: `services/backend-api/tests/access_request_db_integration.rs` calls the real `approve_access_request`, `activate_invitation`, and `resend_access_request_invitation` handlers/routes against a migrated disposable Timescale/PostgreSQL fixture. Evidence command passed against Docker test DB `omnioil_access_request_test` on port `55432`: `$env:DATABASE_URL='postgres://omnioil_test:omnioil_test_password@127.0.0.1:55432/omnioil_access_request_test'; cargo test -p backend-api --test access_request_db_integration -- --ignored --nocapture` -> 5/5 passed, including non-provider/customer `admin` denial for the provider resend route without token rotation. Focused unit evidence also covers activation-link construction and platform-role predicates.
|
||||||
|
|
||||||
|
- [x] 5.1 Test approving pending request provisions project, customer admin, project access, trial subscription, and invitation.
|
||||||
|
- [x] 5.2 Test approving in-review request follows same provisioning path.
|
||||||
|
- [x] 5.3 Test approved/rejected request cannot be re-approved.
|
||||||
|
- [x] 5.4 Test existing active customer user is linked without password reset or deactivation.
|
||||||
|
- [x] 5.5 Test existing inactive user gets a fresh activation invitation.
|
||||||
|
- [x] 5.6 Test provider-role duplicate email blocks approval.
|
||||||
|
- [x] 5.7 Test SMTP failure leaves request approved/provisioned and marks email failed.
|
||||||
|
- [x] 5.8 Test raw token is not persisted in invitation rows or audit metadata.
|
||||||
|
- [x] 5.9 Test expired, consumed, and revoked tokens cannot activate.
|
||||||
|
- [x] 5.10 Test resend revokes previous unconsumed tokens.
|
||||||
|
|
||||||
|
## Phase 6: Frontend Admin Activation
|
||||||
|
|
||||||
|
- [x] 6.1 Add `/activate` route before protected admin routes.
|
||||||
|
- [x] 6.2 Add `/auth/invitations` to `AUTH_BYPASS_ENDPOINTS`.
|
||||||
|
- [x] 6.3 Add activation API functions under `features/auth/api`.
|
||||||
|
- [x] 6.4 Add activation schema with password and confirmation validation.
|
||||||
|
- [x] 6.5 Create `ActivatePage` with states: validating token, invalid/expired, password form, success.
|
||||||
|
- [x] 6.6 Ensure activation token is read from URL and not stored in auth store/local storage.
|
||||||
|
- [x] 6.7 On success, show clear path to `/login`; do not auto-login in this change.
|
||||||
|
- [x] 6.8 Add focused tests for valid token form, invalid token, and success state.
|
||||||
|
|
||||||
|
## Phase 7: Frontend Console Provisioning UX
|
||||||
|
|
||||||
|
- [x] 7.1 Extend access-request API types with provisioned user/project and invitation summary fields.
|
||||||
|
- [x] 7.2 Add resend invitation API function.
|
||||||
|
- [x] 7.3 Update request detail panel to show provisioned project, customer admin, activation email status, sent time, expiration, and consumed state.
|
||||||
|
- [x] 7.4 Keep approve button single-click; backend handles automatic project defaults.
|
||||||
|
- [x] 7.5 Show resend as primary action only when email failed or invitation expired/unconsumed.
|
||||||
|
- [x] 7.6 Show successful approval toast that distinguishes provisioned + email sent vs provisioned + email failed.
|
||||||
|
- [x] 7.7 Add focused tests for provisioning status and resend action visibility.
|
||||||
|
|
||||||
|
## Phase 8: Docs And Deployment
|
||||||
|
|
||||||
|
- [x] 8.1 Document `INVITATION_BASE_URL=https://app.omnioil.app` in deployment docs.
|
||||||
|
- [x] 8.2 Document required `SMTP_*` variables for approval/activation email.
|
||||||
|
- [x] 8.3 Document that initial projects start with 7-day trial and payment enforcement is future billing lifecycle work.
|
||||||
|
- [x] 8.4 Update `.env.example` if present; do not commit real secrets.
|
||||||
|
- [x] 8.5 Add operator note: SMTP failure does not rollback approval; use Console resend.
|
||||||
|
|
||||||
|
## Phase 9: Verification
|
||||||
|
|
||||||
|
- [x] 9.1 Run targeted backend tests for access request provisioning and invitation activation.
|
||||||
|
- [x] 9.2 Run targeted frontend-admin tests for activation page.
|
||||||
|
- [x] 9.3 Run targeted frontend-console tests for access request detail/resend.
|
||||||
|
- [x] 9.4 Run lint for touched frontends.
|
||||||
|
- [x] 9.5 Run targeted `cargo test` for backend modules. `cargo fmt -- --check` remains documented as external repo-wide formatting drift outside this feature.
|
||||||
|
- [ ] 9.6 Manually verify local happy path with stubbed SMTP if available: submit request -> approve -> activate -> login.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# Verification Report
|
||||||
|
|
||||||
|
**Change**: notify-approved-access-requests
|
||||||
|
**Version**: N/A
|
||||||
|
**Mode**: Strict TDD
|
||||||
|
**Verified branch/commit**: `backup/local-work-before-remote-update` / working tree after final strict-TDD scenario coverage
|
||||||
|
**Verification date**: 2026-05-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completeness
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Tasks total | 79 |
|
||||||
|
| Tasks complete | 77 |
|
||||||
|
| Tasks incomplete | 2 |
|
||||||
|
|
||||||
|
Incomplete tasks in `openspec/changes/notify-approved-access-requests/tasks.md`:
|
||||||
|
|
||||||
|
- Phase 0: `0.3` production env confirmation for `INVITATION_BASE_URL=https://app.omnioil.app` and `SMTP_*` remains unchecked.
|
||||||
|
- Phase 9: `9.6` manual happy path with stubbed SMTP remains unchecked because this focused verification used automated DB integration evidence, not browser/manual SMTP QA.
|
||||||
|
|
||||||
|
Phase 5 backend DB integration tasks are now complete: the ignored harness ran against the disposable PostgreSQL/Timescale fixture and passed 5/5, covering provisioning, SMTP-failure persistence, invalid activation rejection, resend token rotation, and non-provider/customer `admin` denial through production handlers/routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Tests Execution
|
||||||
|
|
||||||
|
**Build**: ➖ Not run — user constraint says do not build.
|
||||||
|
|
||||||
|
**Tests / targeted verification commands**:
|
||||||
|
|
||||||
|
1. Current focused verification: `$env:DATABASE_URL='postgres://omnioil_test:omnioil_test_password@127.0.0.1:55432/omnioil_access_request_test'; cargo test -p backend-api --test access_request_db_integration -- --ignored --nocapture`
|
||||||
|
- ✅ 5 passed, 0 failed, 0 ignored.
|
||||||
|
- Passed tests:
|
||||||
|
- `approve_pending_and_in_review_requests_provision_customer_access`
|
||||||
|
- `smtp_failure_persists_provisioning_and_failed_email_status`
|
||||||
|
- `invalid_activation_tokens_do_not_change_user_or_invitation_state`
|
||||||
|
- `resend_revokes_previous_unconsumed_tokens_and_creates_fresh_invitation`
|
||||||
|
- `customer_admin_is_forbidden_from_provider_console_resend_route_without_rotating_tokens`
|
||||||
|
- Non-failing warnings: 4 dead-code warnings for helper functions/types used only by unit tests.
|
||||||
|
|
||||||
|
2. Current focused unit evidence: `$env:SQLX_OFFLINE='true'; cargo test -p backend-api access_request_emails`
|
||||||
|
- ✅ 2 passed, 0 failed.
|
||||||
|
|
||||||
|
3. Current focused unit evidence: `$env:SQLX_OFFLINE='true'; cargo test -p backend-api auth`
|
||||||
|
- ✅ 5 passed, 0 failed.
|
||||||
|
|
||||||
|
4. Retained prior backend targeted evidence: `$env:SQLX_OFFLINE='true'; cargo test -p backend-api access_requests`
|
||||||
|
- ✅ 10 passed, 0 failed, 16 filtered.
|
||||||
|
|
||||||
|
5. Retained prior backend targeted evidence: `$env:SQLX_OFFLINE='true'; cargo test -p backend-api invitations`
|
||||||
|
- ✅ 3 passed, 0 failed, 23 filtered.
|
||||||
|
|
||||||
|
6. Retained prior backend targeted evidence: `$env:SQLX_OFFLINE='true'; cargo test -p backend-api invitation_tokens`
|
||||||
|
- ✅ 3 passed, 0 failed, 19 filtered.
|
||||||
|
|
||||||
|
7. Retained prior backend targeted evidence: `$env:SQLX_OFFLINE='true'; cargo test -p backend-api smtp_mailer`
|
||||||
|
- ✅ 2 passed, 0 failed, 20 filtered.
|
||||||
|
|
||||||
|
8. Retained prior frontend evidence: `pnpm --dir services/frontend-admin test -- ActivatePage`
|
||||||
|
- ✅ Focused activation-page tests passed.
|
||||||
|
|
||||||
|
9. Retained prior frontend evidence: `pnpm --dir services/frontend-console test -- AccessRequestsPage`
|
||||||
|
- ✅ 17 passed, 0 failed.
|
||||||
|
|
||||||
|
10. Not rerun in this focused pass: `cargo fmt -- --check`
|
||||||
|
- ⚠️ External waiver candidate. Prior focused verification showed it fails only on unrelated repo-wide Rust formatting drift outside this SDD change. Per maintainer direction, do not mix the global formatting cleanup into this feature and do not mark it as an in-scope feature failure.
|
||||||
|
|
||||||
|
**Coverage**: ➖ Not available (`openspec/config.yaml` marks coverage unavailable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TDD Compliance
|
||||||
|
|
||||||
|
| Check | Result | Details |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| TDD Evidence reported | ✅ | Engram `sdd/notify-approved-access-requests/apply-progress` contains a TDD Cycle Evidence table. |
|
||||||
|
| All tasks have tests | ✅ | The final three previously untested scenarios now have runtime coverage: customer `admin` provider denial, activation-link construction, and non-provider resend rejection. |
|
||||||
|
| RED confirmed (tests exist) | ✅ | Apply-progress records RED for the DB harness/library extraction before `src/lib.rs` existed. |
|
||||||
|
| GREEN confirmed (tests pass) | ✅ | Real DB harness passed 5/5 against disposable PostgreSQL on port `55432`; focused unit suites for link/auth passed. |
|
||||||
|
| Triangulation adequate | ✅ | DB harness covers multiple persisted-state branches: pending/in-review, re-approval rejection, active existing user, provider duplicate, SMTP failure, invalid tokens, and resend rotation. |
|
||||||
|
| Safety Net for modified files | ✅ | Targeted backend unit suites and DB integration harness evidence exist; frontend focused suites are retained from prior verify/apply passes. |
|
||||||
|
|
||||||
|
**TDD Compliance**: 6/6 checks pass for the automated strict-TDD scenario coverage requested in this pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Layer Distribution
|
||||||
|
|
||||||
|
| Layer | Tests | Files | Tools |
|
||||||
|
|-------|-------|-------|-------|
|
||||||
|
| Unit / structural | 22 backend tests in accumulated focused evidence | Rust module tests | `cargo test` |
|
||||||
|
| DB integration | 5 passed scenario tests | `services/backend-api/tests/access_request_db_integration.rs` | `cargo test -p backend-api --test access_request_db_integration -- --ignored --nocapture` with explicit `DATABASE_URL` |
|
||||||
|
| Integration / component | 17 Console tests + focused Admin activation tests retained from prior evidence | TSX test files | Vitest + Testing Library / React DOM |
|
||||||
|
| E2E | 0 | 0 | Available project scripts, not used |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changed File Coverage
|
||||||
|
|
||||||
|
Coverage analysis skipped — no coverage tool detected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assertion Quality
|
||||||
|
|
||||||
|
**Assertion quality**: ✅ No tautology or ghost-loop assertions found in inspected related tests. The DB integration tests assert persisted database state through production handlers instead of smoke checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Metrics
|
||||||
|
|
||||||
|
**Linter**: ➖ Not rerun in this focused pass; prior verify/apply evidence reported frontend lint passing.
|
||||||
|
**Formatter**: ⚠️ External waiver candidate — not rerun in this focused pass; prior evidence showed repo-wide `cargo fmt -- --check` fails only on unrelated Rust formatting drift outside this SDD change.
|
||||||
|
**Type Checker**: ➖ Not run — no build/type-check command executed under the user constraint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec Compliance Matrix
|
||||||
|
|
||||||
|
| Requirement | Scenario | Test | Result |
|
||||||
|
|-------------|----------|------|--------|
|
||||||
|
| Approve Request Provisions Customer Access | Approving a pending request provisions new customer admin | `access_request_db_integration > approve_pending_and_in_review_requests_provision_customer_access` | ✅ COMPLIANT |
|
||||||
|
| Approve Request Provisions Customer Access | Approving an in-review request provisions access | `access_request_db_integration > approve_pending_and_in_review_requests_provision_customer_access` | ✅ COMPLIANT |
|
||||||
|
| Approve Request Provisions Customer Access | Approving a closed request is rejected | `access_request_db_integration > approve_pending_and_in_review_requests_provision_customer_access` re-approval assertion | ✅ COMPLIANT |
|
||||||
|
| Existing User Is Linked, Not Duplicated | Existing user email is approved again | `access_request_db_integration > approve_pending_and_in_review_requests_provision_customer_access` active-customer branch | ✅ COMPLIANT |
|
||||||
|
| Existing User Is Linked, Not Duplicated | Existing active user is linked | `access_request_db_integration > approve_pending_and_in_review_requests_provision_customer_access`; `access_requests > active_user_resend_sends_access_ready_notification_without_invitation_rotation` | ✅ COMPLIANT |
|
||||||
|
| Customer Roles Remain Separated From Provider Roles | Provisioned customer admin cannot access provider Console | `auth > customer_admin_role_does_not_satisfy_platform_claims`; `access_request_db_integration > customer_admin_is_forbidden_from_provider_console_resend_route_without_rotating_tokens` | ✅ COMPLIANT |
|
||||||
|
| Activation Invitation Uses Secure Opaque Token | Activation token is created securely | `invitation_tokens`; `access_requests > provisioning_audit_metadata_never_contains_raw_activation_token`; DB harness asserts invitation rows exist but not raw token persistence directly | ⚠️ PARTIAL |
|
||||||
|
| Activation Invitation Uses Secure Opaque Token | Activation link targets customer Admin app | `access_request_emails > activation_link_uses_configured_invitation_base_url_and_activate_path`; `activation_email_defaults_to_production_admin_activate_url` | ✅ COMPLIANT |
|
||||||
|
| Activation Sets Password And Enables Login | Valid activation succeeds | `ActivatePage` success test; backend activation success path structurally exists, but DB harness focuses invalid-token no-op | ⚠️ PARTIAL |
|
||||||
|
| Activation Sets Password And Enables Login | Expired activation is rejected | `access_request_db_integration > invalid_activation_tokens_do_not_change_user_or_invitation_state`; `invitations > expired_consumed_and_revoked_invitations_cannot_activate` | ✅ COMPLIANT |
|
||||||
|
| Activation Sets Password And Enables Login | Consumed activation is rejected | `access_request_db_integration > invalid_activation_tokens_do_not_change_user_or_invitation_state`; `invitations > expired_consumed_and_revoked_invitations_cannot_activate` | ✅ COMPLIANT |
|
||||||
|
| Activation Sets Password And Enables Login | Revoked activation is rejected | `access_request_db_integration > invalid_activation_tokens_do_not_change_user_or_invitation_state`; `invitations > expired_consumed_and_revoked_invitations_cannot_activate` | ✅ COMPLIANT |
|
||||||
|
| Approval Email Delivery Is Recoverable | Email send succeeds | `access_requests > invitation_email_delivery_update_marks_success_as_sent_and_clears_error`; no SMTP-accepted DB integration test found | ⚠️ PARTIAL |
|
||||||
|
| Approval Email Delivery Is Recoverable | Email send fails | `access_request_db_integration > smtp_failure_persists_provisioning_and_failed_email_status`; Console failed resend UI test | ✅ COMPLIANT |
|
||||||
|
| Resend Rotates Activation Tokens | Resend invalidates previous token | `access_request_db_integration > resend_revokes_previous_unconsumed_tokens_and_creates_fresh_invitation`; Console resend API/UI test | ✅ COMPLIANT |
|
||||||
|
| Resend Rotates Activation Tokens | Resend is provider-only | `auth > customer_admin_role_does_not_satisfy_platform_claims`; `access_request_db_integration > customer_admin_is_forbidden_from_provider_console_resend_route_without_rotating_tokens` | ✅ COMPLIANT |
|
||||||
|
| Initial Trial Is Created Without Full Billing Automation | Approved project starts trial | `access_request_db_integration > approve_pending_and_in_review_requests_provision_customer_access`; `smtp_failure_persists_provisioning_and_failed_email_status` | ✅ COMPLIANT |
|
||||||
|
| Initial Trial Is Created Without Full Billing Automation | Trial expiration does not belong to activation token | Backend activation query ignores subscription/trial state; no runtime test found | ⚠️ PARTIAL |
|
||||||
|
| Console Shows Provisioning State | Approved request detail shows provisioning status | `AccessRequestsPage.test.tsx > shows provisioned project...`; backend `ACCESS_REQUEST_SELECT` unit test | ✅ COMPLIANT |
|
||||||
|
| Console Shows Provisioning State | Failed email shows resend action | `AccessRequestsPage.test.tsx > shows resend action for failed invitation...` | ✅ COMPLIANT |
|
||||||
|
| Console Shows Provisioning State | Sent email without expiration issue hides urgent resend | `AccessRequestsPage.test.tsx > shows provisioned project...` and expired/consumed test | ✅ COMPLIANT |
|
||||||
|
|
||||||
|
**Compliance summary**: 16/21 scenarios compliant, 5/21 partial, 0/21 untested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Correctness (Static — Structural Evidence)
|
||||||
|
|
||||||
|
| Requirement | Status | Notes |
|
||||||
|
|------------|--------|-------|
|
||||||
|
| Approve Request Provisions Customer Access | ✅ Implemented + DB tested | Approval transaction creates project/user/access/trial/invitation and status; DB harness passed. |
|
||||||
|
| Existing User Is Linked, Not Duplicated | ✅ Implemented + DB tested | Existing active user is linked without password reset/deactivation; provider-role duplicate is blocked. |
|
||||||
|
| Customer Roles Remain Separated From Provider Roles | ✅ Implemented + tested | New users use role `admin`; `PlatformClaims` rejects customer roles; DB route test proves customer `admin` receives 403 from provider resend without token rotation. |
|
||||||
|
| Activation Invitation Uses Secure Opaque Token | ✅ Mostly | Token helper uses 32 random bytes and SHA-256 hash; activation-link construction is unit-tested for `INVITATION_BASE_URL`, `/activate`, production default, and raw token query parameter; direct DB raw-token absence remains covered by prior audit/unit evidence. |
|
||||||
|
| Activation Sets Password And Enables Login | ⚠️ Partial | Public activation endpoint validates hash/state, hashes password, activates user, consumes invitation, and audits activation; invalid-token DB safety is tested, valid backend activation DB success is not directly tested. |
|
||||||
|
| Approval Email Delivery Is Recoverable | ✅ Failure path DB tested / ⚠️ success path partial | SMTP failure persists approval/provisioning and marks email failed. SMTP accepted path is helper-tested, not DB/SMTP-integrated. |
|
||||||
|
| Resend Rotates Activation Tokens | ✅ Implemented + DB tested | Provider resend route revokes unconsumed invitations and creates a fresh token for inactive users; active users receive access-ready notification. |
|
||||||
|
| Initial Trial Is Created Without Full Billing Automation | ✅ Implemented + DB tested | DB harness asserts trial subscription rows for provisioned projects. |
|
||||||
|
| Console Shows Provisioning State | ✅ Implemented + UI tested | Backend returns display fields; Console displays state and resend actions. |
|
||||||
|
| Docs And Deployment | ✅ Structural | Deployment docs and env examples document invitation base URL, SMTP variables, 7-day trial, and SMTP failure resend guidance without secrets. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coherence (Design)
|
||||||
|
|
||||||
|
| Decision | Followed? | Notes |
|
||||||
|
|----------|-----------|-------|
|
||||||
|
| ADR-1 auto-create `edge_projects` | ✅ Yes | DB harness proves project row creation; unit tests prove deterministic defaults. |
|
||||||
|
| ADR-2 customer role `admin` | ✅ Yes | New users use `admin`; provider roles are blocked for duplicate email. |
|
||||||
|
| ADR-3 existing email links user | ✅ Yes | DB harness proves existing active customer user is linked and preserved. |
|
||||||
|
| ADR-4 inactive until activation | ✅ Yes | DB harness proves newly provisioned users are inactive. |
|
||||||
|
| ADR-5 hash-only opaque token | ✅ Mostly | Token/hash helpers and audit metadata tests exist; direct DB raw-token absence assertion remains partial. |
|
||||||
|
| ADR-6 Admin `/activate` | ✅ Yes | Backend route and Admin page exist; production URL is documented. |
|
||||||
|
| ADR-7 SMTP failure does not rollback | ✅ Yes | DB harness proves approval/provisioning survive SMTP-disabled failure and invitation status becomes `failed`. |
|
||||||
|
| ADR-8 resend revokes prior tokens | ✅ Yes | DB harness proves previous open invitations are revoked, consumed invitations are preserved, and a fresh open invitation is created. |
|
||||||
|
| ADR-9 7-day trial | ✅ Yes | DB harness proves `subscriptions(status='trial', trial_ends_at IS NOT NULL)` exists. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
### CRITICAL — must fix before strict SDD archive
|
||||||
|
|
||||||
|
None for the final three strict-TDD scenario gaps requested in this pass.
|
||||||
|
|
||||||
|
### WARNING
|
||||||
|
|
||||||
|
1. Phase `0.3` production env confirmation remains unchecked. This is an operational release/deploy readiness item, not a feature-code failure.
|
||||||
|
2. Phase `9.6` manual happy path remains unchecked. Automated DB evidence now covers the backend path, but no browser/manual stubbed-SMTP happy path was performed in this focused verification.
|
||||||
|
3. SMTP accepted delivery is helper-tested but not DB/SMTP-integrated; the failure/recovery path is now DB-tested.
|
||||||
|
|
||||||
|
### EXTERNAL / WAIVER CANDIDATE
|
||||||
|
|
||||||
|
1. Repo-wide Rust formatting drift remains external to this feature. Do not mark it as an in-scope failure and do not fix it inside this change unless explicitly authorized as a separate cleanup.
|
||||||
|
|
||||||
|
### Resolved Since Prior Verify
|
||||||
|
|
||||||
|
- **Phase 5 DB integration blocker resolved** — real disposable PostgreSQL evidence passed 4/4 using the production-handler harness.
|
||||||
|
- **Phase 9.1 complete** — targeted backend DB integration command passed.
|
||||||
|
- **Final three strict-TDD scenario gaps resolved** — customer `admin` provider denial, activation-link target construction, and non-provider resend rejection now have runtime tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**PASS WITH WARNINGS for automated strict-TDD scenario coverage; remaining warnings are release-readiness/manual verification items.**
|
||||||
|
|
||||||
|
There is no remaining Phase 5 DB integration blocker and the three previously untested strict scenarios now have runtime coverage. Repo-wide `cargo fmt` drift remains external. Production env confirmation and manual SMTP happy path remain release-readiness tasks.
|
||||||
20
openspec/changes/optimize-landing-render-memory/proposal.md
Normal file
20
openspec/changes/optimize-landing-render-memory/proposal.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Change: Optimize landing render memory
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Reduce Chrome memory usage and compositor work on the public landing page while preserving the current premium/industrial visual style.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Chrome reports approximately 1.4GB memory usage on `omnioil.app`. The bundled hero image is not large enough to explain this (`services/landing/public/hero-bg.png` is ~1.35MB). The likely pressure comes from many persistent animated layers: `DynamicBackground`, large CSS blurs in hero/compliance sections, scroll-linked transforms, and duplicated hero background effects in CTA.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Optimize `services/landing` visual background/render effects.
|
||||||
|
- Reduce infinite animations, large blur surfaces, duplicated decorative layers, and scroll-driven compositor work.
|
||||||
|
- Keep the general landing aesthetic: depth, gradients, industrial energy, and motion where it adds value.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- Asset compression/replacement unless profiling disproves the diagnosis.
|
||||||
|
- Backend, infra, Rust services, or non-landing frontends.
|
||||||
|
- Full redesign of the landing page.
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
Revert the affected landing component changes if visual regression or conversion-critical sections degrade.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Delta Spec: Landing render memory
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bound decorative render cost
|
||||||
|
The landing page MUST bound the amount of continuously animated decorative DOM/composited layers used for backgrounds.
|
||||||
|
|
||||||
|
#### Scenario: Animated background is present but constrained
|
||||||
|
Given the landing page renders decorative background effects
|
||||||
|
When the page is idle
|
||||||
|
Then background effects MUST NOT rely on large numbers of infinite particle/orb/shape/grid animations.
|
||||||
|
|
||||||
|
### Requirement: Avoid duplicated hero background work
|
||||||
|
Sections MUST NOT duplicate the most expensive hero background variant when a lighter section treatment preserves the visual hierarchy.
|
||||||
|
|
||||||
|
#### Scenario: CTA renders after hero
|
||||||
|
Given the hero has already rendered the primary dynamic background
|
||||||
|
When the CTA section renders
|
||||||
|
Then it SHOULD use a lighter decorative treatment than `DynamicBackground variant="hero"`.
|
||||||
|
|
||||||
|
### Requirement: Reduce expensive blur and scroll compositing
|
||||||
|
The landing page SHOULD reduce oversized blur radii and scroll-linked transforms where they create large repaint/compositor surfaces without essential UX value.
|
||||||
|
|
||||||
|
#### Scenario: Visual depth is preserved
|
||||||
|
Given blur and motion effects are reduced
|
||||||
|
When a user views hero, compliance, and CTA sections
|
||||||
|
Then the page MUST retain the general gradient/depth aesthetic without excessive compositor memory pressure.
|
||||||
|
|
||||||
|
### Requirement: Verify landing quality gate
|
||||||
|
The implementation MUST pass landing linting and MUST NOT require a production build for verification.
|
||||||
|
|
||||||
|
#### Scenario: Landing lint passes
|
||||||
|
Given changes are made under `services/landing`
|
||||||
|
When `npm run lint` runs in `services/landing`
|
||||||
|
Then it MUST complete successfully.
|
||||||
44
openspec/changes/optimize-landing-render-memory/tasks.md
Normal file
44
openspec/changes/optimize-landing-render-memory/tasks.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Tasks: Optimize Landing Render Memory
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | 120-260 |
|
||||||
|
| 400-line budget risk | Low |
|
||||||
|
| Chained PRs recommended | No |
|
||||||
|
| Suggested split | Single PR |
|
||||||
|
| Delivery strategy | ask-on-risk |
|
||||||
|
|
||||||
|
Decision needed before apply: No
|
||||||
|
Chained PRs recommended: No
|
||||||
|
400-line budget risk: Low
|
||||||
|
|
||||||
|
### Suggested Work Units
|
||||||
|
|
||||||
|
| Unit | Goal | Likely PR | Notes |
|
||||||
|
|------|------|-----------|-------|
|
||||||
|
| 1 | Reduce landing decorative render cost | PR 1 | Includes profiling notes and lint verification |
|
||||||
|
|
||||||
|
## Phase 1: Baseline / Constraints
|
||||||
|
|
||||||
|
- [ ] 1.1 Capture a Chrome baseline for `services/landing`: memory, layer count, and visible hotspots for hero, compliance, and CTA.
|
||||||
|
- [x] 1.2 Read `services/landing/AGENTS.md` and local Next.js/Tailwind conventions before editing landing components.
|
||||||
|
|
||||||
|
## Phase 2: Background Cost Reduction
|
||||||
|
|
||||||
|
- [x] 2.1 Update `services/landing/src/components/DynamicBackground.tsx` to cap particle/orb/shape/grid counts and remove nonessential infinite animations.
|
||||||
|
- [x] 2.2 Add or reuse a lighter background variant in `DynamicBackground.tsx` for non-hero sections with fewer animated layers.
|
||||||
|
- [x] 2.3 Update `services/landing/src/components/sections/CTASection.tsx` to stop duplicating `DynamicBackground variant="hero"`; use the lighter treatment instead.
|
||||||
|
|
||||||
|
## Phase 3: Section Compositor Optimization
|
||||||
|
|
||||||
|
- [x] 3.1 Update `services/landing/src/components/sections/HeroSection.tsx` to reduce oversized blur surfaces (`blur-[160px]`, `blur-[120px]`, `blur-[100px]`) while preserving visual depth.
|
||||||
|
- [x] 3.2 Review `HeroSection.tsx` `useScroll`/`useTransform` usage and remove or simplify scroll-linked transforms that do not materially improve UX.
|
||||||
|
- [x] 3.3 Update `services/landing/src/components/sections/ComplianceSection.tsx` to reduce `blur-[180px]` and `blur-[140px]` surfaces to lower compositor memory pressure.
|
||||||
|
|
||||||
|
## Phase 4: Verification
|
||||||
|
|
||||||
|
- [ ] 4.1 Re-profile the landing page in Chrome and compare against the Phase 1 baseline for reduced memory/compositor workload.
|
||||||
|
- [ ] 4.2 Visually verify hero, compliance, and CTA still preserve the intended premium industrial aesthetic.
|
||||||
|
- [x] 4.3 Run `npm run lint` from `services/landing` and record the result; do not run a production build.
|
||||||
64
openspec/config.yaml
Normal file
64
openspec/config.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
|
||||||
|
context: |
|
||||||
|
Tech stack: Rust workspace services + React/Vite frontends + Next.js landing + Docker infra.
|
||||||
|
Architecture: services/ microservices reuse services/shared-lib; infra under infrastructure/; E2E helpers under tests/.
|
||||||
|
Testing: cargo test; Vitest in frontend-admin and frontend-pwa; Python infra/E2E scripts are run individually.
|
||||||
|
Style: rustfmt/cargo fmt, ESLint for frontends, TypeScript configs per frontend.
|
||||||
|
|
||||||
|
strict_tdd: true
|
||||||
|
|
||||||
|
testing:
|
||||||
|
detected: "2026-05-07"
|
||||||
|
strict_tdd: true
|
||||||
|
test_runner:
|
||||||
|
framework: "Cargo test + Vitest"
|
||||||
|
command: "cargo test; pnpm --dir services/frontend-admin test; pnpm --dir services/frontend-pwa test"
|
||||||
|
layers:
|
||||||
|
unit:
|
||||||
|
available: true
|
||||||
|
tool: "cargo test, vitest"
|
||||||
|
integration:
|
||||||
|
available: true
|
||||||
|
tool: "Rust integration tests via cargo; Python infra scripts"
|
||||||
|
e2e:
|
||||||
|
available: true
|
||||||
|
tool: "Python Modbus/MQTT helpers under tests/ and infrastructure/tests/"
|
||||||
|
coverage:
|
||||||
|
available: false
|
||||||
|
command: ""
|
||||||
|
quality:
|
||||||
|
linter:
|
||||||
|
available: true
|
||||||
|
command: "pnpm --dir <frontend> lint"
|
||||||
|
type_checker:
|
||||||
|
available: true
|
||||||
|
command: "cargo check; npx tsc --noEmit -p <frontend>/tsconfig.json"
|
||||||
|
formatter:
|
||||||
|
available: true
|
||||||
|
command: "cargo fmt -- --check"
|
||||||
|
|
||||||
|
rules:
|
||||||
|
proposal:
|
||||||
|
- Include rollback plan for risky changes.
|
||||||
|
- Identify affected services, frontends, and infra modules.
|
||||||
|
specs:
|
||||||
|
- Use Given/When/Then format for scenarios.
|
||||||
|
- Use RFC 2119 keywords: MUST, SHALL, SHOULD, MAY.
|
||||||
|
design:
|
||||||
|
- Include sequence diagrams for complex service/data flows.
|
||||||
|
- Document architecture decisions with rationale.
|
||||||
|
tasks:
|
||||||
|
- Group tasks by phase: infrastructure, implementation, testing.
|
||||||
|
- Use hierarchical numbering: 1.1, 1.2, etc.
|
||||||
|
- Keep tasks small enough to complete in one session.
|
||||||
|
- Do not implement landing optimization unless an explicit SDD change asks for it.
|
||||||
|
apply:
|
||||||
|
- Follow existing Rust and TypeScript conventions from AGENTS.md.
|
||||||
|
- Reuse services/shared-lib for shared Rust domain/data contracts.
|
||||||
|
- For landing changes, read services/landing/AGENTS.md and local Next.js docs first.
|
||||||
|
verify:
|
||||||
|
- Run targeted tests when test infrastructure exists; never rely on unstated manual verification.
|
||||||
|
- Compare implementation against every spec scenario.
|
||||||
|
archive:
|
||||||
|
- Warn before merging destructive deltas or large removals.
|
||||||
469
package-lock.json
generated
Normal file
469
package-lock.json
generated
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
{
|
||||||
|
"name": "omnioil-root",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "omnioil-root",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"cross-env": "^10.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "8.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||||
|
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"shell-quote": "^1.8.1",
|
||||||
|
"spawn-command": "0.0.2",
|
||||||
|
"supports-color": "^8.1.1",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/concurrently.js",
|
||||||
|
"concurrently": "dist/bin/concurrently.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.13.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "2.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||||
|
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.11"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/date-fns"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/spawn-command": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "omnioil-root",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"infra": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --no-deps timescaledb redis minio mosquitto edge-agent-msi-builder",
|
||||||
|
"infra:stop": "docker compose down",
|
||||||
|
"infra:reset": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down --volumes && docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --no-deps timescaledb redis minio mosquitto edge-agent-msi-builder",
|
||||||
|
"db:migrate": "cd services/backend-api && cross-env DATABASE_URL=postgres://adminomnioil:admin123@localhost:5432/omnioil sqlx migrate run",
|
||||||
|
"sqlx:prepare": "cross-env DATABASE_URL=postgres://adminomnioil:admin123@localhost:5432/omnioil cargo sqlx prepare --workspace",
|
||||||
|
"wait:infra": "powershell -Command \"Write-Host '⏳ Esperando que la infraestructura esté lista...'; Start-Sleep -s 10\"",
|
||||||
|
"build:all": "cross-env SQLX_OFFLINE=true cargo build",
|
||||||
|
"srv:api": "cross-env SQLX_OFFLINE=true cargo watch -w services/backend-api -w services/shared-lib -x \"run -p backend-api\"",
|
||||||
|
"srv:telemetry": "cross-env SQLX_OFFLINE=true cargo watch -w services/telemetry-ingestor -w services/shared-lib -x \"run -p telemetry-ingestor\"",
|
||||||
|
"srv:orchestrator": "cross-env SQLX_OFFLINE=true cargo watch -w services/build-orchestrator -w services/shared-lib -x \"run -p build-orchestrator\"",
|
||||||
|
"srv:relay": "cross-env SQLX_OFFLINE=true cargo watch -w services/events-relay -w services/shared-lib -x \"run -p events-relay\"",
|
||||||
|
"srv:json": "cross-env SQLX_OFFLINE=true cargo watch -w services/json-generator -w services/shared-lib -x \"run -p json-generator\"",
|
||||||
|
"srv:collector": "cross-env SQLX_OFFLINE=true cargo watch -w services/data-collector -w services/shared-lib -x \"run -p data-collector\"",
|
||||||
|
"ui:landing": "cd services/landing && pnpm run dev",
|
||||||
|
"ui:admin": "cd services/frontend-admin && pnpm run dev",
|
||||||
|
"ui:console": "cd services/frontend-console && pnpm run dev",
|
||||||
|
"dev": "pnpm run infra && pnpm run wait:infra && pnpm run build:all && npx concurrently --kill-others -n \"API,TELE,ORCH,RELAY,JSON,COLL,ADMIN,CONSOLE\" -c \"cyan,magenta,green,yellow,blue,white,cyan.bold,teal\" \"pnpm run srv:api\" \"pnpm run srv:telemetry\" \"pnpm run srv:orchestrator\" \"pnpm run srv:relay\" \"pnpm run srv:json\" \"pnpm run srv:collector\" \"pnpm run ui:admin\" \"pnpm run ui:console\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"cross-env": "^10.1.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.0.0+sha512.b4106707c7225b1748b61595953ccbebff97b54ad05d002aa3635f633b9c53cd666f7ce9b8bc44704f1fa048b9a49b55371ab2d9e9d667d1efe2ef1514bcd513"
|
||||||
|
}
|
||||||
69
scratch/seed_anh_reports.sql
Normal file
69
scratch/seed_anh_reports.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
-- Seed de datos para pruebas de Reportes ANH
|
||||||
|
|
||||||
|
-- 1. Insertar un reporte de prueba con datos simulados
|
||||||
|
INSERT INTO anh_reports (
|
||||||
|
filename,
|
||||||
|
status,
|
||||||
|
hash_sha384,
|
||||||
|
content,
|
||||||
|
size_bytes,
|
||||||
|
period_start,
|
||||||
|
period_end
|
||||||
|
) VALUES (
|
||||||
|
'ANH_TEST_REPORT_20260501.json',
|
||||||
|
'GENERATED',
|
||||||
|
'c2966580e2f54a8e298e72769416550702d76f8e71887e5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f',
|
||||||
|
'{
|
||||||
|
"cabecera": {
|
||||||
|
"version": "1.0",
|
||||||
|
"operador": "OmniOil Test",
|
||||||
|
"fecha_reporte": "2026-05-01"
|
||||||
|
},
|
||||||
|
"datos": [
|
||||||
|
{
|
||||||
|
"pozo": "WELL-ALPHA-01",
|
||||||
|
"presion_cabeza": 150.5,
|
||||||
|
"temperatura": 85.0,
|
||||||
|
"volumen_bruto": 1200.0,
|
||||||
|
"manual_fields": {
|
||||||
|
"bsw_manual": 0.5,
|
||||||
|
"cloro_ppm": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pozo": "WELL-BETA-02",
|
||||||
|
"presion_cabeza": 142.3,
|
||||||
|
"temperatura": 82.5,
|
||||||
|
"volumen_bruto": 980.0,
|
||||||
|
"manual_fields": {
|
||||||
|
"bsw_manual": 1.2,
|
||||||
|
"cloro_ppm": 115
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}',
|
||||||
|
1024,
|
||||||
|
'2026-05-01 00:00:00+00',
|
||||||
|
'2026-05-01 23:59:59+00'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Insertar un reporte ya "enviado"
|
||||||
|
INSERT INTO anh_reports (
|
||||||
|
filename,
|
||||||
|
status,
|
||||||
|
hash_sha384,
|
||||||
|
content,
|
||||||
|
size_bytes,
|
||||||
|
period_start,
|
||||||
|
period_end,
|
||||||
|
sent_at
|
||||||
|
) VALUES (
|
||||||
|
'ANH_HISTORY_REPORT_20260430.json',
|
||||||
|
'SENT',
|
||||||
|
'a5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f5b61e2a530f',
|
||||||
|
'{"history": "test data"}',
|
||||||
|
512,
|
||||||
|
'2026-04-30 00:00:00+00',
|
||||||
|
'2026-04-30 23:59:59+00',
|
||||||
|
'2026-04-30 23:59:59+00'
|
||||||
|
);
|
||||||
@@ -4,29 +4,43 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
shared-lib = { path = "../shared-lib" }
|
shared-lib = { workspace = true }
|
||||||
axum = { version = "0.8.8", features = ["macros"] }
|
json-generator = { path = "../json-generator" }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
axum = { workspace = true, features = ["macros", "ws"] }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
|
tokio = { workspace = true }
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
sqlx = { workspace = true }
|
||||||
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
|
tower = { workspace = true }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
tower-http = { workspace = true }
|
||||||
serde_json = "1.0.149"
|
serde = { workspace = true }
|
||||||
tracing = "0.1.44"
|
serde_json = { workspace = true }
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing = { workspace = true }
|
||||||
dotenv = "0.15.0"
|
tracing-subscriber = { workspace = true }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
||||||
# rustls = { version = "0.23.12", features = ["ring", "std"] }
|
|
||||||
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
|
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
|
||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { workspace = true }
|
||||||
|
chrono-tz = { workspace = true }
|
||||||
axum-extra = { version = "0.12.5", features = ["typed-header"] }
|
axum-extra = { version = "0.12.5", features = ["typed-header"] }
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { workspace = true }
|
||||||
anyhow = "1.0.102"
|
anyhow = { workspace = true }
|
||||||
rumqttc = "0.25.1"
|
rumqttc = { workspace = true }
|
||||||
bigdecimal = { version = "0.4.7", features = ["serde"] }
|
zstd = { workspace = true }
|
||||||
|
bigdecimal = { workspace = true }
|
||||||
aws-config = "1.5"
|
aws-config = "1.5"
|
||||||
aws-sdk-s3 = "1.50"
|
aws-sdk-s3 = "1.50"
|
||||||
tokio-util = { version = "0.7.18", features = ["io"] }
|
tokio-util = { version = "0.7.18", features = ["io"] }
|
||||||
tower_governor = "0.8.0"
|
tower_governor = "0.8.0"
|
||||||
governor = "0.10.4"
|
governor = "0.10.4"
|
||||||
sha2 = "0.10.8"
|
sha2 = { workspace = true }
|
||||||
|
aes-gcm = { workspace = true }
|
||||||
|
base64 = "0.22"
|
||||||
|
rand = { workspace = true }
|
||||||
|
async-trait = "0.1.87"
|
||||||
|
totp-rs = { version = "5.7.1", features = ["qr"] }
|
||||||
|
validator = { version = "0.19", features = ["derive"] }
|
||||||
|
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls", "smtp-transport", "builder"] }
|
||||||
|
reqwest = { workspace = true, features = ["json", "rustls"] }
|
||||||
|
semver = { workspace = true }
|
||||||
|
axum-prometheus = "0.8"
|
||||||
|
metrics = "0.24"
|
||||||
|
csv = "1.4.0"
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ FROM base AS planner
|
|||||||
# Copiamos solo los archivos que definen dependencias (workspace y miembros)
|
# Copiamos solo los archivos que definen dependencias (workspace y miembros)
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
|
||||||
COPY services/flow-generator/Cargo.toml services/flow-generator/Cargo.toml
|
|
||||||
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
|
||||||
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
|
||||||
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
|
||||||
@@ -30,7 +29,6 @@ COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.to
|
|||||||
|
|
||||||
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
|
# Creamos estructuras dummy para que cargo metadata no falle (muy importante para la caché)
|
||||||
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
RUN mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
|
||||||
mkdir -p services/flow-generator/src && touch services/flow-generator/src/lib.rs && \
|
|
||||||
mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
|
||||||
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
|
||||||
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- OMNIOIL — CONSOLIDATED INITIAL SCHEMA
|
-- OMNIOIL — CONSOLIDATED MASTER SCHEMA (v2026-05-03)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Esquema único consolidado. Reemplaza todas las migraciones anteriores.
|
||||||
|
-- Ejecutar una sola vez en una BD limpia.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
-- MÓDULO 1: SEGURIDAD Y ACCESO
|
-- MÓDULO 1: SEGURIDAD Y ACCESO
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
CREATE TABLE IF NOT EXISTS roles (
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(50) UNIQUE NOT NULL,
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
@@ -13,6 +19,7 @@ CREATE TABLE IF NOT EXISTS roles (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO roles (name, permissions) VALUES
|
INSERT INTO roles (name, permissions) VALUES
|
||||||
|
('superadmin', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": true, "USERS_ADMIN": true}'::jsonb),
|
||||||
('admin', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": true, "USERS_ADMIN": true}'::jsonb),
|
('admin', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": true, "USERS_ADMIN": true}'::jsonb),
|
||||||
('supervisor', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": true, "USERS_ADMIN": false}'::jsonb),
|
('supervisor', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": true, "USERS_ADMIN": false}'::jsonb),
|
||||||
('operador', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": false, "USERS_ADMIN": false}'::jsonb),
|
('operador', '{"SYNC_WRITE": true, "DATA_READ": true, "CAMPO_READ": false, "USERS_ADMIN": false}'::jsonb),
|
||||||
@@ -25,13 +32,14 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
full_name VARCHAR(200),
|
full_name VARCHAR(200),
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
role_id INT NOT NULL REFERENCES roles(id),
|
role_id INT NOT NULL REFERENCES roles(id),
|
||||||
|
two_factor_secret TEXT,
|
||||||
|
two_factor_enabled BOOLEAN DEFAULT false,
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
last_login TIMESTAMPTZ,
|
last_login TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- MÓDULO: REFRESH TOKENS (Soporte para sesiones seguras)
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -41,27 +49,33 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
revoked_at TIMESTAMPTZ
|
revoked_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
-- MÓDULO 2: JERARQUÍA OPERATIVA Y CUMPLIMIENTO ANH
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
-- MÓDULO 2: JERARQUÍA OPERATIVA
|
|
||||||
CREATE TABLE IF NOT EXISTS edge_projects (
|
CREATE TABLE IF NOT EXISTS edge_projects (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
client VARCHAR(150) NOT NULL,
|
client VARCHAR(150) NOT NULL,
|
||||||
|
operator_name VARCHAR(150) NOT NULL,
|
||||||
|
contract_number VARCHAR(100) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
municipality VARCHAR(100),
|
||||||
|
department VARCHAR(100),
|
||||||
|
latitude DOUBLE PRECISION,
|
||||||
|
longitude DOUBLE PRECISION,
|
||||||
metadata JSONB DEFAULT '{}'::jsonb,
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
installer_status VARCHAR(50) DEFAULT 'NOT_STARTED',
|
installer_status VARCHAR(50) DEFAULT 'NOT_STARTED',
|
||||||
installer_url TEXT,
|
installer_url TEXT,
|
||||||
|
mqtt_password TEXT,
|
||||||
|
agent_master_secret TEXT,
|
||||||
|
agent_config_toml TEXT, -- Config TOML del agente nativo (server-side)
|
||||||
last_health_report JSONB DEFAULT '{}'::jsonb,
|
last_health_report JSONB DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
CONSTRAINT check_installer_status CHECK (installer_status IN ('NOT_STARTED', 'QUEUED', 'BUILDING', 'READY', 'COMPLETED', 'FAILED'))
|
CONSTRAINT check_installer_status CHECK (installer_status IN ('NOT_STARTED', 'QUEUED', 'BUILDING', 'READY', 'COMPLETED', 'FAILED'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_projects_client ON edge_projects(client);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_project_access (
|
CREATE TABLE IF NOT EXISTS user_project_access (
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
@@ -77,22 +91,24 @@ CREATE TABLE IF NOT EXISTS edge_assets (
|
|||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
code VARCHAR(50) UNIQUE NOT NULL,
|
code VARCHAR(50) UNIQUE NOT NULL,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
asset_type VARCHAR(20) NOT NULL
|
asset_type VARCHAR(20) NOT NULL CHECK (asset_type IN ('POZO', 'TANQUE', 'MEDIDOR_GAS', 'AGUA', 'OTRO')),
|
||||||
CHECK (asset_type IN ('POZO', 'TANQUE', 'MEDIDOR_GAS', 'AGUA', 'OTRO')),
|
last_calibration_date DATE,
|
||||||
|
next_calibration_date DATE,
|
||||||
|
calibration_certificate_url TEXT,
|
||||||
|
external_api_url TEXT, -- URL de API externa para integración multitenant
|
||||||
|
external_api_key TEXT, -- API key para autenticación externa
|
||||||
|
is_collector_enabled BOOLEAN DEFAULT FALSE, -- Activa el data-collector para este asset
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
metadata JSONB DEFAULT '{}'::jsonb,
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edge_assets_project ON edge_assets(project_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS edge_devices (
|
CREATE TABLE IF NOT EXISTS edge_devices (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
protocol VARCHAR(30) NOT NULL
|
protocol VARCHAR(30) NOT NULL CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET', 'S7')),
|
||||||
CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET')),
|
|
||||||
host VARCHAR(255) NOT NULL,
|
host VARCHAR(255) NOT NULL,
|
||||||
port INTEGER NOT NULL,
|
port INTEGER NOT NULL,
|
||||||
protocol_config JSONB DEFAULT '{}'::jsonb,
|
protocol_config JSONB DEFAULT '{}'::jsonb,
|
||||||
@@ -105,39 +121,107 @@ CREATE TABLE IF NOT EXISTS edge_variables (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
device_id UUID NOT NULL REFERENCES edge_devices(id) ON DELETE CASCADE,
|
device_id UUID NOT NULL REFERENCES edge_devices(id) ON DELETE CASCADE,
|
||||||
address VARCHAR(255) NOT NULL,
|
address VARCHAR(255) NOT NULL,
|
||||||
data_type VARCHAR(30) NOT NULL
|
data_type VARCHAR(30) NOT NULL CHECK (data_type IN ('bool', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64', 'string')),
|
||||||
CHECK (data_type IN ('bool', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64', 'string')),
|
|
||||||
alias VARCHAR(100) NOT NULL,
|
alias VARCHAR(100) NOT NULL,
|
||||||
unit VARCHAR(30),
|
unit VARCHAR(30),
|
||||||
polling_interval_ms INTEGER DEFAULT 5000,
|
polling_interval_ms INTEGER DEFAULT 5000,
|
||||||
byte_order VARCHAR(10) DEFAULT 'BE'
|
byte_order VARCHAR(10) DEFAULT 'BE' CHECK (byte_order IN ('BE', 'LE', 'BE_SWAP', 'LE_SWAP')),
|
||||||
CHECK (byte_order IN ('BE', 'LE', 'BE_SWAP', 'LE_SWAP')),
|
alarm_min DOUBLE PRECISION,
|
||||||
|
alarm_max DOUBLE PRECISION,
|
||||||
|
alarm_enabled BOOLEAN DEFAULT false,
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
is_enabled BOOLEAN DEFAULT true,
|
is_enabled BOOLEAN DEFAULT true,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
-- MÓDULO 3: TELEMETRÍA (TimescaleDB)
|
-- MÓDULO 3: TELEMETRÍA (TimescaleDB)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
CREATE TABLE IF NOT EXISTS telemetry_raw (
|
CREATE TABLE IF NOT EXISTS telemetry_raw (
|
||||||
time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
data JSONB NOT NULL
|
data JSONB NOT NULL
|
||||||
);
|
);
|
||||||
|
SELECT create_hypertable('telemetry_raw', 'time', if_not_exists => TRUE);
|
||||||
|
|
||||||
|
-- Compresión: datos SCADA con alta repetitividad → 90-97% de ahorro en disco
|
||||||
|
ALTER TABLE telemetry_raw SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'time DESC',
|
||||||
|
timescaledb.compress_segmentby = 'project_id, asset_id'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('telemetry_raw', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
|
||||||
|
-- Retención: datos crudos 31 días (dashboard tiempo real)
|
||||||
|
SELECT add_retention_policy('telemetry_raw', INTERVAL '31 days', if_not_exists => TRUE);
|
||||||
|
|
||||||
|
-- Vista continua de 5 minutos — ANH + historial operativo
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS telemetry_5min
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('5 minutes', time) AS bucket,
|
||||||
|
project_id,
|
||||||
|
asset_id,
|
||||||
|
last(data, time) AS last_reading,
|
||||||
|
first(data, time) AS first_reading,
|
||||||
|
count(*) AS reading_count,
|
||||||
|
last(time, time) AS last_seen_at
|
||||||
|
FROM telemetry_raw
|
||||||
|
GROUP BY bucket, project_id, asset_id
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_5min_project ON telemetry_5min (project_id, bucket DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_5min_asset ON telemetry_5min (asset_id, bucket DESC);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy(
|
||||||
|
'telemetry_5min',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '5 minutes',
|
||||||
|
schedule_interval => INTERVAL '5 minutes',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER MATERIALIZED VIEW telemetry_5min SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'bucket DESC',
|
||||||
|
timescaledb.compress_segmentby = 'project_id, asset_id'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('telemetry_5min', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
-- Retención: 5 años (ANH exige 2 años mínimo)
|
||||||
|
SELECT add_retention_policy( 'telemetry_5min', INTERVAL '5 years', if_not_exists => TRUE);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 4: LOGS DEL AGENTE (TimescaleDB)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
CREATE TABLE IF NOT EXISTS agent_logs (
|
CREATE TABLE IF NOT EXISTS agent_logs (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
agent_hostname VARCHAR(255),
|
agent_hostname VARCHAR(255),
|
||||||
|
agent_ip VARCHAR(45), -- IP del agente reportante
|
||||||
level VARCHAR(20),
|
level VARCHAR(20),
|
||||||
category VARCHAR(50),
|
category VARCHAR(50),
|
||||||
message TEXT,
|
message TEXT,
|
||||||
context JSONB,
|
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
timestamp TIMESTAMPTZ DEFAULT NOW()
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
SELECT create_hypertable('agent_logs', 'timestamp', if_not_exists => TRUE, migrate_data => TRUE);
|
||||||
|
-- Hypertables no soportan PRIMARY KEY sin incluir la columna de partición;
|
||||||
|
-- usamos índice simple para lookup por id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_logs_id ON agent_logs (id);
|
||||||
|
|
||||||
SELECT create_hypertable('telemetry_raw', 'time', if_not_exists => TRUE);
|
ALTER TABLE agent_logs SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'timestamp DESC',
|
||||||
|
timescaledb.compress_segmentby = 'project_id'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('agent_logs', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
SELECT add_retention_policy( 'agent_logs', INTERVAL '90 days', if_not_exists => TRUE);
|
||||||
|
|
||||||
-- MÓDULO 4: EDGE AGENT DEPLOYMENTS
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 5: EDGE AGENT DEPLOYMENTS
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
CREATE TABLE IF NOT EXISTS edge_deployments (
|
CREATE TABLE IF NOT EXISTS edge_deployments (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
@@ -153,16 +237,9 @@ CREATE TABLE IF NOT EXISTS edge_deployments (
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- MÓDULO 5: OUTBOX (Patrón de Mensajería)
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
CREATE TABLE IF NOT EXISTS domain_outbox (
|
-- MÓDULO 6: MQTT AUTH & ACL (Mosquitto + go-auth)
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
event_type VARCHAR(100) NOT NULL,
|
|
||||||
payload JSONB NOT NULL,
|
|
||||||
status VARCHAR(20) DEFAULT 'PENDING',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- MÓDULO 6: MQTT AUTH & ACL
|
|
||||||
CREATE TABLE IF NOT EXISTS mqtt_users (
|
CREATE TABLE IF NOT EXISTS mqtt_users (
|
||||||
username VARCHAR(100) PRIMARY KEY,
|
username VARCHAR(100) PRIMARY KEY,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
@@ -174,18 +251,52 @@ CREATE TABLE IF NOT EXISTS mqtt_acls (
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username VARCHAR(100) NOT NULL REFERENCES mqtt_users(username) ON DELETE CASCADE,
|
username VARCHAR(100) NOT NULL REFERENCES mqtt_users(username) ON DELETE CASCADE,
|
||||||
topic VARCHAR(500) NOT NULL,
|
topic VARCHAR(500) NOT NULL,
|
||||||
rw INT DEFAULT 1, -- 1 = read, 2 = write, 3 = readwrite
|
rw INT DEFAULT 1, -- 1=read, 2=write, 4=subscribe, 7=read+write+subscribe
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT mqtt_acls_username_topic_key UNIQUE (username, topic)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- go-auth con Mosquitto v2 usa MOSQ_ACL_SUBSCRIBE=4 → rw=7 cubre todos los permisos
|
||||||
|
INSERT INTO mqtt_acls (username, topic, rw)
|
||||||
|
SELECT 'admin', '#', 7
|
||||||
|
FROM mqtt_users WHERE username = 'admin'
|
||||||
|
ON CONFLICT (username, topic) DO UPDATE SET rw = 7;
|
||||||
|
|
||||||
|
UPDATE mqtt_acls SET rw = 7 WHERE topic LIKE 'omnioil/deploy/%';
|
||||||
|
UPDATE mqtt_acls SET rw = 7 WHERE topic IN ('omnioil/#', 'provision/#');
|
||||||
|
|
||||||
|
|
||||||
-- MÓDULO 7: MOVIMIENTOS OPERATIVOS (ANH)
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 7: PROVISIONING DE AGENTES (P256 + token de un solo uso)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Token embebido en el binario sin secretos — autentica el primer registro
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_provisioning_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
used BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provisioning_tokens_project ON agent_provisioning_tokens(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provisioning_tokens_token ON agent_provisioning_tokens(token);
|
||||||
|
|
||||||
|
-- Clave pública P256 del agente para cifrar futuros re-aprovisionamientos
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_public_keys (
|
||||||
|
project_id UUID PRIMARY KEY REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
public_key_pem TEXT NOT NULL,
|
||||||
|
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 8: MÓDULOS ANH (Operaciones, Pruebas, Laboratorio, Medidores)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
CREATE TABLE IF NOT EXISTS operation_movements (
|
CREATE TABLE IF NOT EXISTS operation_movements (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
movement_type VARCHAR(30) NOT NULL
|
movement_type VARCHAR(30) NOT NULL CHECK (movement_type IN ('VENTA', 'RECIBO', 'TRANSFERENCIA', 'QUEMA', 'CONSUMO')),
|
||||||
CHECK (movement_type IN ('VENTA', 'RECIBO', 'TRANSFERENCIA', 'QUEMA', 'CONSUMO')),
|
|
||||||
start_time TIMESTAMPTZ NOT NULL,
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
end_time TIMESTAMPTZ NOT NULL,
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
volumen_neto DECIMAL(18, 4),
|
volumen_neto DECIMAL(18, 4),
|
||||||
@@ -194,6 +305,247 @@ CREATE TABLE IF NOT EXISTS operation_movements (
|
|||||||
CONSTRAINT check_movement_times CHECK (end_time >= start_time)
|
CONSTRAINT check_movement_times CHECK (end_time >= start_time)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operations_downtime (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
is_planned BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
reason VARCHAR(255) NOT NULL,
|
||||||
|
comments TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS well_tests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
test_date TIMESTAMPTZ NOT NULL,
|
||||||
|
test_type VARCHAR(50) NOT NULL,
|
||||||
|
bopd NUMERIC(10, 2),
|
||||||
|
bwpd NUMERIC(10, 2),
|
||||||
|
gor NUMERIC(10, 2),
|
||||||
|
bsw NUMERIC(5, 2),
|
||||||
|
wellhead_pressure NUMERIC(10, 2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
sample_time TIMESTAMPTZ NOT NULL,
|
||||||
|
results JSONB NOT NULL,
|
||||||
|
lab_technician VARCHAR(150),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meter_readings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
reading_time TIMESTAMPTZ NOT NULL,
|
||||||
|
value NUMERIC(15, 2) NOT NULL,
|
||||||
|
unit VARCHAR(20) NOT NULL,
|
||||||
|
meter_factor NUMERIC(6, 4) DEFAULT 1.0,
|
||||||
|
corrected_value NUMERIC(15, 2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 9: REPORTES ANH (Resolución 0651)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS anh_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
generated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL, -- GENERATED, SENT, FAILED
|
||||||
|
hash_sha384 TEXT NOT NULL,
|
||||||
|
content JSONB NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
period_start TIMESTAMPTZ,
|
||||||
|
period_end TIMESTAMPTZ,
|
||||||
|
sent_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anh_reports_generated_at ON anh_reports (generated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anh_reports_status ON anh_reports (status);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 10: ALERTAS Y NOTIFICACIONES
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS telemetry_alarms (
|
||||||
|
id UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
variable_alias VARCHAR(100),
|
||||||
|
device_name VARCHAR(100),
|
||||||
|
current_value DOUBLE PRECISION,
|
||||||
|
alarm_min DOUBLE PRECISION,
|
||||||
|
alarm_max DOUBLE PRECISION,
|
||||||
|
alarm_type VARCHAR(50) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('telemetry_alarms', 'timestamp', if_not_exists => TRUE, migrate_data => TRUE);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_id ON telemetry_alarms (id);
|
||||||
|
|
||||||
|
-- Habilitar compresión ANTES de registrar la política (requerido por TimescaleDB)
|
||||||
|
ALTER TABLE telemetry_alarms SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'timestamp DESC',
|
||||||
|
timescaledb.compress_segmentby = 'project_id, asset_id'
|
||||||
|
);
|
||||||
|
SELECT add_compression_policy('telemetry_alarms', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
-- Retención de alarmas: 2 años (trazabilidad operativa para auditorías)
|
||||||
|
SELECT add_retention_policy( 'telemetry_alarms', INTERVAL '2 years', if_not_exists => TRUE);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
channel_type VARCHAR(20) NOT NULL,
|
||||||
|
destination TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 11: AUDITORÍA (ISO 27001)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
user_email TEXT,
|
||||||
|
action TEXT NOT NULL, -- e.g. "user.login", "project.create", "deploy.trigger"
|
||||||
|
resource TEXT, -- e.g. "project:uuid", "user:uuid"
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
success BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 12: DOMAIN OUTBOX (Mensajería interna)
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_outbox (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDING',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- ÍNDICES DE RENDIMIENTO GENERALES
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_logs_project ON agent_logs(project_id, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_project ON telemetry_alarms(project_id, timestamp DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_operation_movements_asset ON operation_movements(asset_id);
|
CREATE INDEX IF NOT EXISTS idx_operation_movements_asset ON operation_movements(asset_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_operation_movements_type ON operation_movements(movement_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_operation_movements_time ON operation_movements(start_time);
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 13: REGLAS DE ALERTA Y LOGS DE NOTIFICACIÓN
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Reglas de alerta a nivel de proyecto (complementa alarm_min/max de edge_variables)
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES edge_assets(id) ON DELETE CASCADE,
|
||||||
|
variable_alias TEXT,
|
||||||
|
condition VARCHAR(20) NOT NULL CHECK (condition IN ('ABOVE', 'BELOW', 'EQUAL', 'DELTA')),
|
||||||
|
threshold DOUBLE PRECISION NOT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'WARNING' CHECK (severity IN ('INFO','WARNING','CRITICAL')),
|
||||||
|
message_template TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
cooldown_minutes INT NOT NULL DEFAULT 15,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rules_project ON alert_rules(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rules_asset ON alert_rules(asset_id);
|
||||||
|
|
||||||
|
-- Log de notificaciones enviadas (trazabilidad)
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
alarm_id UUID,
|
||||||
|
project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
channel_type VARCHAR(20) NOT NULL,
|
||||||
|
destination TEXT NOT NULL,
|
||||||
|
subject TEXT,
|
||||||
|
body TEXT,
|
||||||
|
success BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
error_message TEXT,
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_logs_project ON notification_logs(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_logs_sent_at ON notification_logs(sent_at DESC);
|
||||||
|
|
||||||
|
|
||||||
|
-- =════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- MÓDULO 14: BILLING Y FACTURACIÓN
|
||||||
|
-- ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Suscripción activa por proyecto
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL UNIQUE REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
tier VARCHAR(20) NOT NULL DEFAULT 'starter' CHECK (tier IN ('starter','standard','enterprise')),
|
||||||
|
tag_limit INT NOT NULL DEFAULT 50,
|
||||||
|
price_per_tag BIGINT NOT NULL DEFAULT 90000,
|
||||||
|
min_annual_fee BIGINT NOT NULL DEFAULT 4000000,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended','cancelled','trial')),
|
||||||
|
trial_ends_at TIMESTAMPTZ,
|
||||||
|
current_period_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
current_period_end TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '1 year'),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Facturas generadas (por período)
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||||
|
invoice_number VARCHAR(30) NOT NULL UNIQUE,
|
||||||
|
period_start TIMESTAMPTZ NOT NULL,
|
||||||
|
period_end TIMESTAMPTZ NOT NULL,
|
||||||
|
active_tags INT NOT NULL DEFAULT 0,
|
||||||
|
unit_price BIGINT NOT NULL DEFAULT 90000,
|
||||||
|
subtotal BIGINT NOT NULL DEFAULT 0,
|
||||||
|
minimum_applied BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
total_cop BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','paid','overdue','cancelled')),
|
||||||
|
due_date TIMESTAMPTZ,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_project ON invoices(project_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_project ON subscriptions(project_id);
|
||||||
|
|
||||||
|
-- Secuencia para numeración de facturas
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS invoice_number_seq START 1;
|
||||||
|
|
||||||
|
-- Snapshot diario de uso (para facturación precisa)
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_snapshots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
|
||||||
|
snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
active_tags INT NOT NULL DEFAULT 0,
|
||||||
|
active_assets INT NOT NULL DEFAULT 0,
|
||||||
|
active_devices INT NOT NULL DEFAULT 0,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, snapshot_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_snapshots_project ON usage_snapshots(project_id, snapshot_date DESC);
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
-- MIGRACIÓN CONSOLIDADA DE MEJORAS (Seguridad MQTT + Telemetría)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
-- 1. Actualización de Telemetría para soportar AssetID
|
|
||||||
-- (Asumiendo que la tabla base ya existe por el initial_schema)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='telemetry_raw' AND column_name='asset_id') THEN
|
|
||||||
ALTER TABLE telemetry_raw ADD COLUMN asset_id UUID REFERENCES edge_assets(id) ON DELETE CASCADE;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- 2. Asegurar unicidad en ACLs de MQTT
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
-- Limpiar duplicados antes de aplicar el UNIQUE
|
|
||||||
-- Dejamos solo la fila con el ID más alto por cada (username, topic)
|
|
||||||
DELETE FROM mqtt_acls
|
|
||||||
WHERE id NOT IN (
|
|
||||||
SELECT MAX(id)
|
|
||||||
FROM mqtt_acls
|
|
||||||
GROUP BY username, topic
|
|
||||||
);
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'mqtt_acls_username_topic_key') THEN
|
|
||||||
ALTER TABLE mqtt_acls ADD CONSTRAINT mqtt_acls_username_topic_key UNIQUE (username, topic);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
INSERT INTO roles (name, permissions) VALUES
|
||||||
|
('platform_admin', '{"PLATFORM_ACCESS": true, "PLATFORM_BILLING_ADMIN": true}'::jsonb),
|
||||||
|
('platform_operator', '{"PLATFORM_ACCESS": true, "PLATFORM_BILLING_ADMIN": false}'::jsonb)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS access_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
full_name VARCHAR(200) NOT NULL,
|
||||||
|
email VARCHAR(150) NOT NULL,
|
||||||
|
company VARCHAR(200) NOT NULL,
|
||||||
|
nit VARCHAR(30) NOT NULL,
|
||||||
|
position VARCHAR(150) NOT NULL,
|
||||||
|
phone VARCHAR(50) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'in_review', 'approved', 'rejected')),
|
||||||
|
rejection_reason TEXT,
|
||||||
|
reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
reviewed_at TIMESTAMPTZ,
|
||||||
|
request_ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_requests_email ON access_requests(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_requests_nit ON access_requests(nit);
|
||||||
34
services/backend-api/migrations/20260508000000_totp_2fa.sql
Normal file
34
services/backend-api/migrations/20260508000000_totp_2fa.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Console TOTP 2FA migration.
|
||||||
|
-- Destructive by design: the legacy plaintext users.two_factor_secret column is removed.
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS two_factor_secret;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS two_factor_secret_encrypted BYTEA,
|
||||||
|
ADD COLUMN IF NOT EXISTS two_factor_secret_nonce BYTEA,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_totp_step_used BIGINT,
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_locked_until TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_failed_attempts INT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_first_failed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET two_factor_enabled = false,
|
||||||
|
two_factor_secret_encrypted = NULL,
|
||||||
|
two_factor_secret_nonce = NULL,
|
||||||
|
last_totp_step_used = NULL,
|
||||||
|
totp_locked_until = NULL,
|
||||||
|
totp_failed_attempts = 0,
|
||||||
|
totp_first_failed_at = NULL
|
||||||
|
WHERE two_factor_enabled = true
|
||||||
|
AND two_factor_secret_encrypted IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_two_factor_recovery_codes_user_id
|
||||||
|
ON two_factor_recovery_codes(user_id);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Access request provisioning and activation invitations.
|
||||||
|
-- Additive migration: does not modify existing request/user/project data.
|
||||||
|
|
||||||
|
ALTER TABLE access_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS provisioned_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS provisioned_project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS provisioned_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS access_request_invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
access_request_id UUID NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
email_status VARCHAR(30) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (email_status IN ('pending', 'sent', 'failed')),
|
||||||
|
email_sent_at TIMESTAMPTZ,
|
||||||
|
email_error TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_reason TEXT,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_request_invitations_request_latest
|
||||||
|
ON access_request_invitations(access_request_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_request_invitations_token_hash_open
|
||||||
|
ON access_request_invitations(token_hash)
|
||||||
|
WHERE consumed_at IS NULL AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_requests_provisioned_project
|
||||||
|
ON access_requests(provisioned_project_id)
|
||||||
|
WHERE provisioned_project_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_requests_provisioned_user
|
||||||
|
ON access_requests(provisioned_user_id)
|
||||||
|
WHERE provisioned_user_id IS NOT NULL;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user