@bi-analytics/arness-back 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/arness.mjs +2 -2
  3. package/harness/AGENTS.md +6 -2
  4. package/harness/agents/lider.md +1 -0
  5. package/harness/agents/revisor.md +1 -1
  6. package/harness/init.sh +48 -1
  7. package/harness/playbooks/new-endpoint.md +1 -1
  8. package/harness/playbooks/review.md +2 -1
  9. package/harness/playbooks/update-node-lts.md +46 -0
  10. package/harness/reference/configuracion.md +8 -1
  11. package/harness/reference/contrato-http.md +35 -4
  12. package/harness/reference/novedades.json +52 -1
  13. package/harness/skills/nest-base/SKILL.md +8 -7
  14. package/harness/skills/nest-base/templates/config/auth.spec.ts +32 -0
  15. package/harness/skills/nest-base/templates/config/auth.ts +80 -0
  16. package/harness/skills/nest-base/templates/config/configuration.spec.ts +2 -1
  17. package/harness/skills/nest-base/templates/config/configuration.ts +14 -20
  18. package/harness/skills/nest-base/templates/docs/EVOLUTIVO.md +7 -2
  19. package/harness/skills/nest-base/templates/e2e/monitor.e2e-spec.ts +17 -0
  20. package/harness/skills/nest-base/templates/monitor/monitor.controller.ts +24 -3
  21. package/harness/skills/nest-base/templates/security/login-attempts.spec.ts +48 -0
  22. package/harness/skills/nest-base/templates/security/login-attempts.ts +90 -0
  23. package/harness/skills/nest-base/templates/security/security-headers.spec.ts +27 -0
  24. package/harness/skills/nest-base/templates/security/security-headers.ts +21 -0
  25. package/harness/skills/nest-base/templates/security/security.module.ts +20 -0
  26. package/harness/skills/nest-base/templates/security/session-lifetime.spec.ts +21 -0
  27. package/harness/skills/nest-base/templates/security/session-lifetime.ts +28 -0
  28. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
7
7
 
8
8
  ## [Sin publicar]
9
9
 
10
+ ### Agregado
11
+ - **Última LTS de Node** (ADR 0015): `init.sh` compara `.nvmrc` con la última LTS de `nodejs.org` y advierte `toolchain.node-lts` si hay un major más nuevo; sin red lo omite. `NODE_LTS_VERSION` fija la LTS de referencia (`off` desactiva). Playbook `update-node-lts.md`: el agente analiza el impacto, lo presenta y actualiza solo con aprobación humana. AGENTS.md §0 y §16, agente `lider` y pistas de `doctor`.
12
+ - **Endurecimiento de la autenticación** (ADR 0017), sin dependencias nuevas:
13
+ - plantillas `templates/security/` (`LoginAttemptLimiter`, `securityHeaders()`, `sessionClaims` / `isSessionExpired` y `SecurityModule`) y `templates/config/auth.ts` (`parseAuthConfig`, bloque `auth` de la configuración), con specs;
14
+ - límite de intentos por IP en `/auth/login` y `/monitor/login` (`429 TOO_MANY_ATTEMPTS` con `Retry-After`);
15
+ - vida máxima de la sesión con `auth_time` (`401 SESSION_EXPIRED`);
16
+ - cabeceras de seguridad en toda respuesta;
17
+ - variables `AUTH_LOGIN_MAX_ATTEMPTS`, `AUTH_LOGIN_WINDOW_SECONDS`, `AUTH_SESSION_MAX_AGE_SECONDS` y `HTTP_TRUST_PROXY_HOPS`;
18
+ - `init.sh`: `security.headers`, `security.trust-proxy`, `security.login-rate-limit`, y `security.session-max-age`; tres novedades de seguridad en `doctor`;
19
+ - `auth.e2e-spec.ts` exige que la cookie del monitor y el refresh token no sirvan como `Bearer`.
20
+
21
+ ### Cambiado
22
+ - El monitor comparte el límite de intentos del login: `templates/monitor/monitor.controller.ts` y `templates/e2e/monitor.e2e-spec.ts` usan `LoginAttemptLimiter`.
23
+ - `arness sync` solo anuncia las novedades hasta la versión que instala (antes también mostraba las de versiones posteriores presentes en el catálogo).
24
+ - **Evolutivo con hora y responsable** (ADR 0016): cada entrada de `docs/EVOLUTIVO.md` lleva fecha, hora y zona de Bogotá (`AAAA-MM-DD HH:mm -0500`) y el responsable (`git config user.name`). Plantilla, AGENTS.md §12, `new-endpoint`, `review.md`, `revisor` y `nest-base`.
25
+
26
+ ### Para proyectos existentes
27
+ - `init.sh` puede mostrar la advertencia nueva `toolchain.node-lts`. No rompe nada: el agente propone el análisis y solo actualiza Node si lo apruebas. Si no, queda en `docs/PENDIENTES.md`.
28
+ - Las entradas existentes de `docs/EVOLUTIVO.md` no se reescriben: el formato nuevo aplica desde la siguiente entrega. Verifica que cada persona tenga `git config user.name`.
29
+ - **Seguridad:** `init.sh` mostrará `security.*` nuevos como advertencias y `pnpm arness doctor` los lista con sus pasos. Ninguna impide arrancar: los secretos actuales siguen sirviendo. Al adoptar la vida máxima de la sesión, los usuarios con un refresh token anterior deberán volver a iniciar sesión. Detrás de Azure App Service, define `HTTP_TRUST_PROXY_HOPS=1`. Avisa a frontend de `429 TOO_MANY_ATTEMPTS` y `401 SESSION_EXPIRED`.
30
+
10
31
  ## [0.2.0] - 2026-10-23
11
32
 
12
33
  ### Cambiado
package/bin/arness.mjs CHANGED
@@ -319,7 +319,7 @@ function cmdSync(opts) {
319
319
  for (const rel of removed) showDiff(root, rel, null);
320
320
  }
321
321
  const incoming = parseNovelties(next.get(NOVELTIES)?.content.toString('utf8')).filter(
322
- (n) => compareVersions(n.since, manifest.version) > 0,
322
+ (n) => compareVersions(n.since, manifest.version) > 0 && compareVersions(n.since, PKG.version) <= 0,
323
323
  );
324
324
  if (incoming.length) {
325
325
  log(`\nNovedades desde ${manifest.version} (no se activan solas; detalle y pasos: arness doctor):`);
@@ -393,7 +393,7 @@ const DOCTOR_HINTS = {
393
393
  git: 'Usa un repo git propio; si hay secretos versionados, sácalos del índice (git rm --cached) y rota las credenciales.',
394
394
  package: 'Repo fuente del paquete: revisa bin/, CHANGELOG.md y docs/adr/.',
395
395
  install: 'Instala o actualiza el paquete (README de @bi-analytics/arness-back) y ejecuta arness sync.',
396
- toolchain: 'Skill nest-base §1 y §4: .nvmrc, packageManager, lockfile y scripts.',
396
+ toolchain: 'Skill nest-base §1 y §4: .nvmrc, packageManager, lockfile y scripts. Nueva LTS de Node: harness/playbooks/update-node-lts.md (análisis y aprobación humana).',
397
397
  structure: 'AGENTS.md §4 y skill nest-base §4–§5. Adóptalo cuando convenga; no bloquea.',
398
398
  sql: 'AGENTS.md §6: SQL solo en *.sql.ts y sin escrituras.',
399
399
  docs: 'AGENTS.md §12: copia harness/skills/nest-base/templates/docs/ (EVOLUTIVO.md solo avance; PENDIENTES.md lo abierto).',
package/harness/AGENTS.md CHANGED
@@ -15,6 +15,7 @@ Por defecto **los hallazgos son advertencias** (`⚠`, exit 0): el proyecto sigu
15
15
  - Si el usuario pide corregir algo: **máximo 3 intentos**; después vuelve a informar.
16
16
  - Nunca modifiques `init.sh`, reglas de ESLint ni tests, ni silencies chequeos en `harness/arness.config.json`, para que pasen. Silenciar un chequeo es decisión humana y lleva motivo.
17
17
  - Opciones: `--strict`, `--json` (salida para máquinas), `--quick` (sin lint, build, tests ni health), `--e2e`, `--no-health` e `--install`. `pnpm arness doctor` resume, sin modificar nada, las **novedades por adoptar** de cada versión (seguridad, recomendadas y opcionales, con sus pasos) y el resto de los hallazgos.
18
+ - **Nueva LTS de Node (`toolchain.node-lts`):** `init.sh` compara `.nvmrc` con la última LTS publicada (sin red, lo omite). Si hay una más nueva, **no actualices por tu cuenta**: analiza el impacto con `harness/playbooks/update-node-lts.md`, preséntale el análisis al humano y actualiza solo con su aprobación explícita. Si no aprueba, va a `docs/PENDIENTES.md`.
18
19
  - **Después de `pnpm arness sync`:** ejecuta `pnpm arness doctor` e **informa** al humano las novedades por adoptar. No las implementes si no son parte de la tarea; adoptar o declinar una novedad es decisión humana (declinar va con motivo en `harness/arness.config.json` → `declined`).
19
20
  - Al terminar una tarea, `./harness/init.sh` debe terminar en 0 y sin advertencias nuevas respecto al inicio.
20
21
 
@@ -119,7 +120,8 @@ Un reporte es `GET /api/v1/<modulo>/<segmento>` y vive en una carpeta con estos
119
120
  ## 8. Autenticación
120
121
 
121
122
  - **Sin usuarios en BD:** `POST /api/v1/auth/login` recibe `{ password }` y lo compara en tiempo constante con `ACCESS_TOKEN`. Devuelve un access token y un refresh token.
122
- - **Refresh:** `POST /api/v1/auth/refresh` emite un par nuevo.
123
+ - **Refresh:** `POST /api/v1/auth/refresh` emite un par nuevo con el mismo `auth_time` del login: ninguna sesión dura más de `AUTH_SESSION_MAX_AGE_SECONDS` (`401 SESSION_EXPIRED`).
124
+ - **Límite de intentos:** `/auth/login` y `/monitor/login` comparten `LoginAttemptLimiter` (por IP, en memoria, sin Redis): `429 TOO_MANY_ATTEMPTS` con `Retry-After`.
123
125
  - **Tokens:** secretos distintos para access y refresh, y el claim `type` se valida.
124
126
  - **Guard global:** `JwtAuthGuard` protege todo; `@Public()` **solo** en `login`, `refresh` y `health`.
125
127
  - **Monitor:** no es `@Public()`. `JwtAuthGuard` omite las rutas marcadas con `@MonitorRoute()` y las protege `MonitorSessionGuard` con su cookie de sesión (§10). El resto de la API solo acepta `Bearer`.
@@ -132,6 +134,7 @@ Un reporte es `GET /api/v1/<modulo>/<segmento>` y vive en una carpeta con estos
132
134
  - **Éxito:** `{ data, meta? }`. `meta` (`pagination` y `filters`) solo cuando el endpoint pagina o filtra.
133
135
  - **Error:** `{ error: { code, message, details?, requestId, path, timestamp } }` con el status HTTP correcto.
134
136
  - Los errores de PostgreSQL nunca se exponen tal cual.
137
+ - **Cabeceras de seguridad:** `securityHeaders()` en toda respuesta (CSP, `nosniff`, `X-Frame-Options`, `no-store`, HSTS) y sin `X-Powered-By`, sin helmet.
135
138
  - **CORS:** allowlist por entorno en `CORS_ORIGINS` (orígenes exactos, `https://` salvo `localhost`). Nunca `*`, `origin: true` ni reflejar el origen de la petición; sin `credentials`. Métodos `GET`, `POST` y `OPTIONS`.
136
139
  - **Ejemplos, tabla de códigos y CORS:** `harness/reference/contrato-http.md`.
137
140
 
@@ -157,7 +160,7 @@ Crear o cambiar un endpoint actualiza, **en el mismo cambio**:
157
160
  Cambiar `.columns.ts`, los filtros o el envelope es un **cambio de contrato**: actualiza `FRONT.md` y sube la versión de la clave de caché.
158
161
 
159
162
  **Documentación del proyecto (`docs/`)**, plantillas en `harness/skills/nest-base/templates/docs/`:
160
- - **`docs/EVOLUTIVO.md`**: toda entrega (endpoint, módulo, conexión, widget del monitor, cambio de contrato) agrega **en el mismo cambio** una entrada al final: fecha, cliente/módulo, qué se entregó en lenguaje de negocio y referencia. **Solo avance:** nunca pendientes, bloqueos, stoppers ni riesgos. Las entradas anteriores no se reescriben.
163
+ - **`docs/EVOLUTIVO.md`**: toda entrega (endpoint, módulo, conexión, widget del monitor, cambio de contrato) agrega **en el mismo cambio** una entrada al final: fecha y hora de Bogotá (`TZ=America/Bogota date '+%Y-%m-%d %H:%M %z'`), cliente/módulo, **responsable** (`git config user.name` de quien trabaja en ese momento; si está vacío, pregúntalo y nunca pongas el nombre del agente), qué se entregó en lenguaje de negocio y referencia. **Solo avance:** nunca pendientes, bloqueos, stoppers ni riesgos. Las entradas anteriores no se reescriben.
161
164
  - **`docs/PENDIENTES.md`**: lo que quedó abierto (pendientes, bloqueos, recomendaciones, deuda técnica, advertencias de `init.sh` sin corregir), con fecha, contexto y tipo. Al resolverse pasa a *Resueltos*.
162
165
  - **`docs/TECNICO.md`**: arquitectura, módulos, conexiones (sin credenciales), variables propias, despliegue y enlace a las decisiones. Se actualiza cuando cambian.
163
166
  - `FRONT.md` sigue en la raíz.
@@ -215,6 +218,7 @@ Ante ambigüedad de negocio o datos, **pregunta**. No inventes columnas, tablas
215
218
  | Crear un módulo o cliente | `harness/playbooks/new-module.md` |
216
219
  | Agregar una conexión de BD | `harness/playbooks/add-database.md` |
217
220
  | Agregar un widget al monitor | `harness/playbooks/new-monitor-widget.md` |
221
+ | Actualizar Node a la última LTS | `harness/playbooks/update-node-lts.md` |
218
222
  | Revisar un cambio | `harness/playbooks/review.md` |
219
223
  | Variables de entorno | `harness/reference/configuracion.md` |
220
224
  | Envelope, errores, auth, CORS, health, monitor | `harness/reference/contrato-http.md` |
@@ -22,6 +22,7 @@ Sigues `AGENTS.md` y las decisiones `Aceptada` de `harness/decisiones/`. **No es
22
22
  - Si no existe, o su estado es `TERMINADO`, créalo o reinícialo desde `harness/reference/progress-plantilla.md`.
23
23
  - Si está `EN CURSO` o `BLOQUEADO`, muéstrale al humano el resumen y pregúntale si retomar ese trabajo o empezar uno nuevo.
24
24
  - Registra el resultado de `init.sh` en `harness/progress.md` (exit y número de advertencias).
25
+ - Si aparece `toolchain.node-lts`, avísale al humano que hay una LTS de Node más nueva y ofrécele el análisis de `harness/playbooks/update-node-lts.md`. No se actualiza sin su aprobación explícita.
25
26
  - Si el harness se acaba de actualizar (`arness sync`), ejecuta `pnpm arness doctor` y muéstrale al humano las novedades por adoptar. No las conviertas en tareas salvo que el humano lo pida.
26
27
  2. **Contexto:** lee `harness/decisiones/README.md` y las decisiones relevantes.
27
28
  3. **Contrato:** para endpoints, confirma con el humano todo lo de AGENTS.md §15.1 **antes** de delegar. No supongas columnas, tablas ni reglas de negocio.
@@ -34,7 +34,7 @@ Comprueba que:
34
34
  2. Recorre el checklist de `harness/playbooks/review.md`.
35
35
  3. Verifica tú mismo: `pnpm lint:check`, `pnpm test` y `pnpm build`. Al final del ciclo de una tarea, ejecuta `./harness/init.sh --json` y compáralo con la línea base registrada en `harness/progress.md`: una advertencia **nueva** causada por el cambio es `BLOQUEANTE`; las que ya existían no lo son.
36
36
  4. Contrasta el resultado con los criterios de aceptación del encargo.
37
- 5. Verifica que `docs/EVOLUTIVO.md` tiene la entrada de la entrega y **solo** avance: un pendiente, bloqueo o riesgo ahí es `IMPORTANTE` y se mueve a `docs/PENDIENTES.md`.
37
+ 5. Verifica que `docs/EVOLUTIVO.md` tiene la entrada de la entrega, con fecha, hora y zona de Bogotá y el responsable, y **solo** avance: un pendiente, bloqueo o riesgo ahí es `IMPORTANTE` y se mueve a `docs/PENDIENTES.md`.
38
38
 
39
39
  ## Reglas
40
40
 
package/harness/init.sh CHANGED
@@ -22,6 +22,8 @@
22
22
  # Variables opcionales:
23
23
  # HEALTH_URL URL del health (default http://localhost:$PORT/health)
24
24
  # HEALTH_TIMEOUT_S Segundos de espera al arrancar la app (default 30)
25
+ # NODE_LTS_VERSION Última LTS de Node con la que se compara .nvmrc (default: se consulta
26
+ # nodejs.org; sin red se omite). "off" desactiva la comparación
25
27
  #
26
28
  # Salida: 0 = operativo (puede haber advertencias) · 1 = fallo crítico o --strict con hallazgos · 2 = uso incorrecto
27
29
 
@@ -307,10 +309,12 @@ elif [ ! -f package.json ]; then
307
309
  else
308
310
  section "Toolchain"
309
311
 
312
+ want=''
313
+ [ -f .nvmrc ] && want="$(tr -d '[:space:]v' < .nvmrc)"
310
314
  if command -v node >/dev/null 2>&1; then
311
315
  node_v="$(node -v)"
312
316
  if [ -f .nvmrc ]; then
313
- want="$(tr -d '[:space:]v' < .nvmrc)"; have="${node_v#v}"
317
+ have="${node_v#v}"
314
318
  if [ "${want%%.*}" = "${have%%.*}" ]; then ok "Node $node_v (esperado v$want por .nvmrc)"
315
319
  else issue toolchain.node "Node $node_v no coincide con .nvmrc (v$want)"; fi
316
320
  else
@@ -320,6 +324,30 @@ else
320
324
  issue toolchain.node "Node no está instalado"
321
325
  fi
322
326
 
327
+ # Última LTS publicada en nodejs.org. Sin red se omite: nunca bloquea por conectividad.
328
+ case "${want%%.*}" in ''|*[!0-9]*) ;; *)
329
+ if [ "${NODE_LTS_VERSION:-}" = off ]; then
330
+ info "Comparación con la última LTS de Node desactivada (NODE_LTS_VERSION=off)"
331
+ else
332
+ lts="${NODE_LTS_VERSION:-$(curl -s -m 5 https://nodejs.org/dist/index.json 2>/dev/null | node -e '
333
+ let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
334
+ try { const r = JSON.parse(s).find((x) => x.lts); if (r) console.log(r.version); } catch {}
335
+ })' 2>/dev/null)}"
336
+ lts="${lts#v}"
337
+ case "${lts%%.*}" in
338
+ ''|*[!0-9]*) info "No se pudo consultar la última LTS de Node (sin red o respuesta inválida): se omite" ;;
339
+ *)
340
+ if [ "${want%%.*}" -lt "${lts%%.*}" ]; then
341
+ issue toolchain.node-lts "Hay una LTS de Node más nueva: v$lts (.nvmrc fija v$want). No la actualices sin aprobación: analízala con $H/playbooks/update-node-lts.md y preséntale el análisis al humano"
342
+ elif [ "${want%%.*}" -eq "${lts%%.*}" ] && [ "$want" != "$lts" ]; then
343
+ info "Node v$lts es la última versión de la LTS v${lts%%.*}; .nvmrc fija v$want (actualizarla dentro de la misma LTS es opcional)"
344
+ else
345
+ ok ".nvmrc está en la última LTS de Node (v$lts)"
346
+ fi ;;
347
+ esac
348
+ fi ;;
349
+ esac
350
+
323
351
  if command -v pnpm >/dev/null 2>&1; then ok "pnpm $(pnpm -v)"
324
352
  else issue toolchain.pnpm "pnpm no está instalado"; fi
325
353
 
@@ -529,6 +557,25 @@ EOF
529
557
  issue security.cors-open ".env.example: CORS_ORIGINS no admite comodines"
530
558
  fi
531
559
 
560
+ # Endurecimiento de auth (ADR 0017): cabeceras, límite de intentos y vida de la sesión.
561
+ has_ts() { grep -rq --include='*.ts' --exclude='*.spec.ts' "$1" "$2" 2>/dev/null; }
562
+ if [ -f src/main.ts ]; then
563
+ if grep -q 'securityHeaders' src/main.ts; then ok "Cabeceras de seguridad registradas en src/main.ts"
564
+ else issue security.headers "src/main.ts no registra securityHeaders() (contrato-http.md, Cabeceras de seguridad)"; fi
565
+ if grep -qE "trust proxy['\"][[:space:]]*,[[:space:]]*true" src/main.ts; then
566
+ issue security.trust-proxy "src/main.ts confía en cualquier proxy (trust proxy = true): usa HTTP_TRUST_PROXY_HOPS (contrato-http.md, Límite de intentos)"
567
+ fi
568
+ fi
569
+ if [ -d src/common/auth ]; then
570
+ if has_ts LoginAttemptLimiter src/common/auth; then ok "Límite de intentos en /auth/login"
571
+ else issue security.login-rate-limit "/auth/login no usa LoginAttemptLimiter (contrato-http.md, Límite de intentos)"; fi
572
+ if has_ts isSessionExpired src/common/auth; then ok "Vida máxima de la sesión en /auth/refresh"
573
+ else issue security.session-max-age "/auth/refresh no limita la vida de la sesión con auth_time (contrato-http.md, Vida máxima de la sesión)"; fi
574
+ fi
575
+ if [ -d src/common/monitor ] && ! has_ts LoginAttemptLimiter src/common/monitor; then
576
+ issue security.login-rate-limit "/monitor/login no usa LoginAttemptLimiter (contrato-http.md, Monitor)"
577
+ fi
578
+
532
579
  if [ -d src/common/monitor ]; then
533
580
  mon_ctrl="$(grep -rl --include='*.controller.ts' '@Controller' src/common/monitor 2>/dev/null)"
534
581
  if [ -z "$mon_ctrl" ] || grep -L 'MonitorSessionGuard' $mon_ctrl | grep -q .; then
@@ -80,7 +80,7 @@ Agrega el endpoint, su conexión y el origen de datos.
80
80
 
81
81
  ### docs/
82
82
 
83
- - `docs/EVOLUTIVO.md`: agrega al final una entrada `## <fecha> · <cliente>/<modulo>` con lo entregado en lenguaje de negocio (qué reporte quedó disponible), el endpoint y la referencia. Solo avance.
83
+ - `docs/EVOLUTIVO.md`: agrega al final una entrada `## <AAAA-MM-DD HH:mm -0500> · <cliente>/<modulo>` (hora de Bogotá) con el responsable (`git config user.name`), lo entregado en lenguaje de negocio (qué reporte quedó disponible), el endpoint y la referencia. Solo avance.
84
84
  - `docs/PENDIENTES.md`: lo que quedó abierto (por ejemplo, una vista que aún no existe en la BD o un filtro aplazado).
85
85
 
86
86
  ## 4. Cierre
@@ -10,6 +10,7 @@ Revisa el diff contra `AGENTS.md` y reporta cada hallazgo con archivo y línea,
10
10
  - [ ] Un endpoint nuevo sin guard JWT, o un `@Public()` fuera de `login`, `refresh` o `health`
11
11
  - [ ] CORS abierto: `enableCors()` sin opciones, `origin: true`, `'*'`, reflejar el `Origin`, `credentials: true`, o `CORS_ORIGINS` con comodín
12
12
  - [ ] El monitor sin `MonitorSessionGuard`, marcado `@Public()`, dentro de `/api/v1`, con `<script>`, o un widget que muestra secretos, SQL, errores crudos o datos de negocio
13
+ - [ ] `/auth/login` o `/monitor/login` sin `LoginAttemptLimiter`, un refresh que extiende la sesión (no copia `auth_time`), `src/main.ts` sin `securityHeaders()` o con `trust proxy` en `true`
13
14
  - [ ] Tokens, contraseñas o secretos en logs, respuestas, código o commits
14
15
  - [ ] `pino-pretty` en `dependencies` (va en devDependencies) o `LOG_FORMAT=pretty` para producción
15
16
  - [ ] Cambios en `.env`, o `.env.local` incluido en el commit
@@ -33,7 +34,7 @@ Revisa el diff contra `AGENTS.md` y reporta cada hallazgo con archivo y línea,
33
34
 
34
35
  - [ ] Un spec por archivo, incluidos los casos HIT/MISS/BYPASS
35
36
  - [ ] Postman, `FRONT.md` y el README del módulo actualizados
36
- - [ ] Entrada nueva al final de `docs/EVOLUTIVO.md` (sin reescribir las anteriores) y lo abierto en `docs/PENDIENTES.md`
37
+ - [ ] Entrada nueva al final de `docs/EVOLUTIVO.md` (sin reescribir las anteriores), con fecha, hora y zona de Bogotá y el responsable (`git config user.name`) y lo abierto en `docs/PENDIENTES.md`
37
38
  - [ ] `docs/EVOLUTIVO.md` no contiene pendientes, bloqueos, stoppers ni riesgos
38
39
  - [ ] Variables nuevas en `.env.example`, `.env.local`, la validación y `src/config/configuration.ts` (`AppConfig`)
39
40
  - [ ] `./harness/init.sh` termina en 0 sin advertencias nuevas; las preexistentes se reportan, no se corrigen fuera de alcance
@@ -0,0 +1,46 @@
1
+ # Playbook: actualizar Node a la última LTS
2
+
3
+ Referencia: `AGENTS.md` §2 (Node.js en su última LTS) y §13.4. Se usa cuando `./harness/init.sh` reporta `toolchain.node-lts` o cuando el humano pide actualizar Node.
4
+
5
+ **Actualizar Node es decisión humana.** El agente analiza, presenta y espera aprobación explícita. No cambia ningún archivo antes de esa aprobación, no instala Node por su cuenta y no convierte la advertencia en tarea si el humano no lo pide.
6
+
7
+ ## 1. Analizar (solo lectura)
8
+
9
+ 1. **Versiones:** la de `.nvmrc`, la de `node -v` y la última LTS:
10
+ ```bash
11
+ curl -s https://nodejs.org/dist/index.json \
12
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=JSON.parse(s).find(x=>x.lts);console.log(r.version, r.lts)})'
13
+ ```
14
+ 2. **Soporte:** fin de soporte de la versión actual y de la nueva en `https://raw.githubusercontent.com/nodejs/Release/main/schedule.json` (campos `maintenance` y `end`).
15
+ 3. **Dónde está fijada la versión:** `.nvmrc`, `engines.node` de `package.json`, `FROM node:<major>-alpine` del `Dockerfile`, `azure-pipelines.yml` (`versionSpec` o lectura de `.nvmrc`) y `@types/node` en `devDependencies`. Lista cada archivo con su valor actual y el nuevo.
16
+ 4. **Compatibilidad:** `engines` de las dependencias directas en la versión instalada (`pnpm view <paquete>@<versión> engines`), empezando por `@nestjs/core`, `pg`, `ioredis` y `@types/node`. Marca cualquiera que no admita la nueva versión.
17
+ 5. **Cambios incompatibles de Node:** revisa las notas del release del nuevo major (`https://nodejs.org/en/blog`) y busca en `src/` y `test/` las APIs que se eliminan o cambian.
18
+ 6. **Entorno:** si `node -v` no es del nuevo major, el humano debe instalarlo (por ejemplo `nvm install --lts` o `fnm install --lts`) antes de verificar.
19
+
20
+ ## 2. Presentar al humano y pedir aprobación
21
+
22
+ ```
23
+ Actualización de Node: v<actual> → v<nueva LTS> (<codename>)
24
+ Soporte: v<actual> en mantenimiento hasta <fecha>, fin <fecha> · v<nueva> soportada hasta <fecha>
25
+ Archivos: .nvmrc, package.json (engines), Dockerfile, azure-pipelines.yml
26
+ Dependencias: @types/node <actual> → <nueva> (requiere aprobación, AGENTS.md §13.4)
27
+ Compatibilidad: <dependencias que admiten la nueva versión / las que no>
28
+ Riesgos: <APIs de Node afectadas en el código, o "ninguno detectado">
29
+ Recomendación: <actualizar ahora / esperar, y por qué>
30
+ ¿Apruebas la actualización? (sí / no)
31
+ ```
32
+
33
+ No continúes sin un **sí** explícito. La aprobación cubre solo lo listado: si aparece otra dependencia que cambiar, vuelve a preguntar.
34
+
35
+ ## 3. Si el humano aprueba
36
+
37
+ 1. Actualiza `.nvmrc` (versión completa, ej. `v26.11.0`), `engines.node` (`>=<major>`), la imagen del `Dockerfile` y `azure-pipelines.yml` si fija la versión.
38
+ 2. Actualiza `@types/node` al nuevo major con `pnpm add -D @types/node@<major>` y conserva el lockfile en el mismo cambio.
39
+ 3. Verifica con el nuevo Node: `pnpm install --frozen-lockfile`, `./harness/init.sh` (sin `toolchain.node` ni `toolchain.node-lts`), `pnpm test:e2e` y, si hay Docker, `docker build .`.
40
+ 4. Actualiza la versión de Node en `docs/TECNICO.md`.
41
+ 5. Commit: `chore(node): upgrade to Node <major> LTS`.
42
+
43
+ ## 4. Si el humano no aprueba
44
+
45
+ 1. Registra en `docs/PENDIENTES.md` una entrada de tipo recomendación con la fecha, la versión propuesta, el motivo que dio el humano y la fecha de fin de soporte de la versión actual.
46
+ 2. No silencies `toolchain.node-lts` en `harness/arness.config.json`: la advertencia sigue visible para recordarlo. Silenciarla es decisión humana y lleva motivo.
@@ -16,7 +16,7 @@ export function buildConfiguration(env: Env): AppConfig {
16
16
  return {
17
17
  app: { nodeEnv: text(env, 'NODE_ENV', 'development'), port: int(env, 'PORT', 3000) },
18
18
  databases: parseDatabases(env), // DATABASES=MAIN,REPORTS → { MAIN: { host, port, ... }, REPORTS: {...} }
19
- auth: { accessToken: required(env, 'ACCESS_TOKEN'), jwt: { accessSecret: required(env, 'JWT_ACCESS_SECRET'), ... } },
19
+ auth: parseAuthConfig(env), // src/config/auth.ts: secretos, límite de intentos y vida de la sesión
20
20
  // cache, cors, monitor...
21
21
  };
22
22
  }
@@ -80,8 +80,15 @@ JWT_ACCESS_SECRET=change-me
80
80
  JWT_ACCESS_EXPIRES_IN=15m
81
81
  JWT_REFRESH_SECRET=change-me-too # distinto de JWT_ACCESS_SECRET
82
82
  JWT_REFRESH_EXPIRES_IN=7d
83
+ AUTH_SESSION_MAX_AGE_SECONDS=43200 # vida máxima de la sesión aunque se refresque (60–2592000; default 12 h)
84
+ AUTH_LOGIN_MAX_ATTEMPTS=5 # intentos fallidos por IP antes de bloquear el login (1–100)
85
+ AUTH_LOGIN_WINDOW_SECONDS=900 # ventana del conteo y duración del bloqueo (1–86400)
86
+ HTTP_TRUST_PROXY_HOPS=0 # proxies delante de la app (Azure App Service: 1); 0 = IP del socket
83
87
  ```
84
88
 
89
+ - `src/config/auth.ts` (plantilla `templates/config/auth.ts`, `parseAuthConfig`) arma este bloque: los secretos son obligatorios y sin valor por defecto; el resto tiene default.
90
+ - Límite de intentos, vida de la sesión y proxy: `contrato-http.md` (Autenticación).
91
+
85
92
  ## CORS
86
93
 
87
94
  ```dotenv
@@ -40,9 +40,10 @@ Siempre con el status HTTP correcto. `details` es opcional.
40
40
  | Status | `code` | Origen típico |
41
41
  |---|---|---|
42
42
  | 400 | `VALIDATION_ERROR` | DTO inválido |
43
- | 401 | `UNAUTHORIZED` / `TOKEN_EXPIRED` / `INVALID_CREDENTIALS` | guard o login |
43
+ | 401 | `UNAUTHORIZED` / `TOKEN_EXPIRED` / `INVALID_CREDENTIALS` / `SESSION_EXPIRED` | guard, login o refresh |
44
44
  | 403 | `FORBIDDEN` | — |
45
45
  | 404 | `NOT_FOUND` | ruta o recurso |
46
+ | 429 | `TOO_MANY_ATTEMPTS` | login bloqueado por intentos fallidos (con `Retry-After`) |
46
47
  | 503 | `DATABASE_UNAVAILABLE` | pg: conexión rechazada, `08*`, `57P01` |
47
48
  | 504 | `QUERY_TIMEOUT` | pg: `57014` (statement_timeout) |
48
49
  | 500 | `INTERNAL_ERROR` | cualquier otro; sin stack ni SQL |
@@ -55,8 +56,38 @@ Siempre con el status HTTP correcto. `details` es opcional.
55
56
  | `POST /api/v1/auth/refresh` | `{ "refreshToken": "..." }` | `{ data: { accessToken, refreshToken, expiresIn } }` |
56
57
 
57
58
  - `password` se compara con `ACCESS_TOKEN` en tiempo constante: `crypto.timingSafeEqual` sobre el sha256 de ambos valores.
58
- - Los tokens usan secretos distintos; el claim `type` (`access` o `refresh`) se valida.
59
- - Los clientes envían `Authorization: Bearer <accessToken>`. Ante `401 TOKEN_EXPIRED`, llaman a `/auth/refresh` y reintentan una vez.
59
+ - Los tokens usan secretos distintos; el claim `type` (`access` o `refresh`) se valida. Un token de otro tipo (`refresh`, o el `monitor` de la cookie) como `Bearer` → `401 UNAUTHORIZED`; un access token en `/auth/refresh` → `401 UNAUTHORIZED`.
60
+ - Los clientes envían `Authorization: Bearer <accessToken>`. Ante `401 TOKEN_EXPIRED`, llaman a `/auth/refresh` y reintentan una vez. Ante `401 SESSION_EXPIRED` o `429`, vuelven al login.
61
+
62
+ ### Vida máxima de la sesión
63
+
64
+ - `/auth/login` emite el par con el claim `auth_time` (segundos, hora del login). `/auth/refresh` **copia** el `auth_time` del refresh token al par nuevo: refrescar nunca extiende la sesión.
65
+ - Si `ahora - auth_time >= AUTH_SESSION_MAX_AGE_SECONDS` (default 12 h), o el refresh token no trae `auth_time`, el refresh responde `401 SESSION_EXPIRED` y el cliente debe volver a `/auth/login`. Así, un refresh token robado deja de servir como mucho al cumplir esa edad.
66
+ - Para cortar todas las sesiones de inmediato, se rota `JWT_REFRESH_SECRET` (y `JWT_ACCESS_SECRET` si hace falta).
67
+ - Implementación de referencia: `templates/security/session-lifetime.ts` → `src/common/security/` (`sessionClaims`, `isSessionExpired`).
68
+
69
+ ### Límite de intentos
70
+
71
+ - `/auth/login` y `/monitor/login` comparten `LoginAttemptLimiter` (`templates/security/login-attempts.ts` → `src/common/security/`, provisto por `SecurityModule`, global). Cuenta los intentos fallidos **por IP** (`req.ip`) en memoria.
72
+ - Al llegar a `AUTH_LOGIN_MAX_ATTEMPTS` fallos (default 5) dentro de `AUTH_LOGIN_WINDOW_SECONDS` (default 900, contados desde el primer fallo), la IP queda bloqueada hasta que termina la ventana: responde `429 TOO_MANY_ATTEMPTS` con `Retry-After: <segundos>` **sin comparar la contraseña**. Un login correcto reinicia el contador.
73
+ - Orden en el controller: `check(ip)` → si no está permitido, `429`; si la contraseña es incorrecta, `registerFailure(ip)` y `401 INVALID_CREDENTIALS`; si es correcta, `reset(ip)` y el par de tokens.
74
+ - **IP real detrás de un proxy:** con `HTTP_TRUST_PROXY_HOPS=<n>` (Azure App Service o un gateway: `1`), `main.ts` llama a `app.set('trust proxy', n)` y `req.ip` sale de `X-Forwarded-For`. Con `0` (default) se usa la IP del socket. Nunca `true`: permitiría falsificar la IP con la cabecera.
75
+ - El contador es **por instancia** y no depende de Redis: con varias réplicas, cada una cuenta aparte. El map tiene un tope de 10 000 IPs.
76
+
77
+ ## Cabeceras de seguridad
78
+
79
+ `securityHeaders()` (`templates/security/security-headers.ts` → `src/common/security/`) se registra en `src/main.ts` con `app.use(...)` antes de las rutas, sin helmet. En toda respuesta:
80
+
81
+ | Cabecera | Valor |
82
+ |---|---|
83
+ | `Content-Security-Policy` | `default-src 'none'; frame-ancestors 'none'; base-uri 'none'` |
84
+ | `X-Content-Type-Options` | `nosniff` |
85
+ | `X-Frame-Options` | `DENY` |
86
+ | `Referrer-Policy` | `no-referrer` |
87
+ | `Strict-Transport-Security` | `max-age=31536000` |
88
+ | `Cache-Control` | `no-store` (las respuestas llevan datos privados; la caché es la de Redis) |
89
+
90
+ Además quita `X-Powered-By`. El monitor reemplaza la CSP por la suya (sección Monitor).
60
91
 
61
92
  ## CORS
62
93
 
@@ -130,7 +161,7 @@ Tablero de estado para el equipo, **fuera de `/api/v1`** y apagado por defecto.
130
161
  - **Render:** `react-dom/server` (`renderToStaticMarkup`) con `createElement`, sin JSX, sin hidratación, sin bundler y sin `<script>`. Estilos inline.
131
162
  - **Login:** la contraseña se compara con `ACCESS_TOKEN` en tiempo constante, igual que `/auth/login`. Al acertar se emite un JWT con `type: "monitor"` (firmado con `JWT_ACCESS_SECRET`, dura `MONITOR_SESSION_EXPIRES_IN`) en la cookie `arness_monitor`: `HttpOnly; Secure; SameSite=Strict; Path=/monitor; Max-Age=<duración>`. Un access token (`type: "access"`) no sirve como cookie ni la cookie sirve como `Bearer`.
132
163
  - **Guards:** el controller lleva `@MonitorRoute()`, que `JwtAuthGuard` omite, y `MonitorSessionGuard`, que exige la cookie salvo en los handlers `@MonitorLogin()` (formulario, login y logout). No es `@Public()`.
133
- - **Rate limiting:** cuando el harness incorpore límite de intentos para `/auth/login`, el login del monitor lo comparte.
164
+ - **Límite de intentos:** comparte `LoginAttemptLimiter` con `/auth/login` (sección Autenticación). Bloqueado → `429` con `Retry-After` y el formulario con "Demasiados intentos", sin cookie.
134
165
  - **Cabeceras en toda respuesta:** `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`, `Cache-Control: no-store`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` y `Referrer-Policy: no-referrer`.
135
166
  - **Widgets:** `MonitorWidget { id; title; order; collect(): Promise<MonitorValue> }`, registrados en el multi-provider `MONITOR_WIDGETS` de `MonitorModule`. Un widget que lanza o tarda más de 2 s se muestra como "No disponible"; el log registra solo el tipo de error, nunca su mensaje.
136
167
  - **Widget base `endpoints-total`:** cuenta las rutas HTTP con `DiscoveryService` y `MetadataScanner`, excluye `monitor`, `health` y `auth`, y desglosa por el primer segmento de la ruta (el módulo).
@@ -46,7 +46,7 @@
46
46
  "paths": ["src/common/monitor"],
47
47
  "approval": "react y react-dom (aprobadas en AGENTS.md §2 solo para el monitor); registra la adopción en harness/decisiones/ si el proyecto aún no las usa.",
48
48
  "steps": [
49
- "Copia harness/skills/nest-base/templates/monitor/ a src/common/monitor/ y registra MonitorModule.",
49
+ "Copia harness/skills/nest-base/templates/monitor/ a src/common/monitor/ y registra MonitorModule. Requiere SecurityModule (templates/security/), que comparte el límite de intentos del login.",
50
50
  "Excluye monitor del prefijo global y haz que JwtAuthGuard omita IS_MONITOR_ROUTE_KEY.",
51
51
  "Agrega MONITOR_ENABLED=false y MONITOR_SESSION_EXPIRES_IN=15m a .env.example y .env.local."
52
52
  ],
@@ -102,6 +102,57 @@
102
102
  ],
103
103
  "adr": "0011",
104
104
  "reference": "harness/AGENTS.md §12"
105
+ },
106
+ {
107
+ "id": "login-rate-limit",
108
+ "since": "0.3.0",
109
+ "type": "seguridad",
110
+ "title": "Límite de intentos en /auth/login y /monitor/login",
111
+ "why": "Toda la API se protege con una contraseña compartida: sin límite se puede adivinar por fuerza bruta.",
112
+ "checks": ["security.login-rate-limit", "security.trust-proxy"],
113
+ "paths": [],
114
+ "approval": null,
115
+ "steps": [
116
+ "Copia harness/skills/nest-base/templates/security/ a src/common/security/ y registra SecurityModule en app.module.ts. Copia también templates/config/auth.ts (y su spec) y usa auth: parseAuthConfig(env) en configuration.ts.",
117
+ "En el login: check(req.ip) → 429 TOO_MANY_ATTEMPTS con Retry-After; contraseña incorrecta → registerFailure(req.ip); correcta → reset(req.ip). Copia también templates/monitor/monitor.controller.ts si usas el monitor.",
118
+ "Agrega AUTH_LOGIN_MAX_ATTEMPTS, AUTH_LOGIN_WINDOW_SECONDS y HTTP_TRUST_PROXY_HOPS (1 detrás de Azure App Service) y, en main.ts, app.set(\"trust proxy\", hops) solo si hops > 0.",
119
+ "Avisa a frontend: ante 429 se espera lo que indica Retry-After (FRONT.md)."
120
+ ],
121
+ "adr": "0017",
122
+ "reference": "harness/reference/contrato-http.md (Autenticación › Límite de intentos)"
123
+ },
124
+ {
125
+ "id": "session-max-age",
126
+ "since": "0.3.0",
127
+ "type": "seguridad",
128
+ "title": "Vida máxima de la sesión en /auth/refresh",
129
+ "why": "Cada refresh entregaba un par nuevo sin límite: un refresh token robado servía indefinidamente.",
130
+ "checks": ["security.session-max-age"],
131
+ "paths": [],
132
+ "approval": null,
133
+ "steps": [
134
+ "Con src/common/security/ ya copiado, firma el par con sessionClaims(authTime): el login usa nowInSeconds() y el refresh copia el auth_time del refresh token.",
135
+ "En el refresh, si isSessionExpired(payload, sessionMaxAgeSeconds) responde 401 SESSION_EXPIRED.",
136
+ "Agrega AUTH_SESSION_MAX_AGE_SECONDS=43200 a .env.example y .env.local, y avisa a frontend: ante 401 SESSION_EXPIRED vuelve al login. Los refresh tokens emitidos antes no traen auth_time y exigirán un login nuevo."
137
+ ],
138
+ "adr": "0017",
139
+ "reference": "harness/reference/contrato-http.md (Autenticación › Vida máxima de la sesión)"
140
+ },
141
+ {
142
+ "id": "security-headers",
143
+ "since": "0.3.0",
144
+ "type": "seguridad",
145
+ "title": "Cabeceras de seguridad en toda respuesta",
146
+ "why": "Sin CSP, nosniff, X-Frame-Options ni no-store, el navegador puede interpretar o guardar en caché respuestas con datos privados.",
147
+ "checks": ["security.headers"],
148
+ "paths": [],
149
+ "approval": null,
150
+ "steps": [
151
+ "Con src/common/security/ ya copiado, agrega app.use(securityHeaders()) en src/main.ts antes de registrar las rutas.",
152
+ "Verifica en test/ que una respuesta de la API trae las cabeceras y no trae X-Powered-By."
153
+ ],
154
+ "adr": "0017",
155
+ "reference": "harness/reference/contrato-http.md (Cabeceras de seguridad)"
105
156
  }
106
157
  ]
107
158
  }
@@ -118,29 +118,30 @@ Implementa cada pieza con su spec. Sin lógica de negocio y sin módulos de ejem
118
118
 
119
119
  | Ruta | Contenido mínimo |
120
120
  |---|---|
121
- | `src/main.ts` | Logger pino (`bufferLogs`), prefijo `api/v1` excluyendo `health` y `monitor` (`{ exclude: ['health', 'monitor', 'monitor/{*path}'] }`, o la sintaxis de rutas de la versión de Nest), `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`, `app.enableCors(buildCorsOptions(...))` con los valores validados de config, `enableShutdownHooks`, puerto desde `ConfigService` (`app.port`), nunca de `process.env` |
121
+ | `src/main.ts` | Logger pino (`bufferLogs`), prefijo `api/v1` excluyendo `health` y `monitor` (`{ exclude: ['health', 'monitor', 'monitor/{*path}'] }`, o la sintaxis de rutas de la versión de Nest), `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`, `app.enableCors(buildCorsOptions(...))` con los valores validados de config, `app.use(securityHeaders())` antes de las rutas, `app.set('trust proxy', hops)` solo si `app.trustProxyHops > 0` (`NestExpressApplication`), `enableShutdownHooks`, puerto desde `ConfigService` (`app.port`), nunca de `process.env` |
122
122
  | `src/app.module.ts` | Importa config, logging, database, cache, auth, health; registra el guard JWT global y el filtro de errores |
123
- | `src/config/` | `ConfigModule` global con `envFilePath: ['.env.local', '.env']`, `validate` y `load: [configuration]`; validación con class-validator que falla al arrancar, incluida la validación dinámica de `DB_<NOMBRE>_*` por cada nombre en `DATABASES`. `configuration.ts` y `configuration.spec.ts` desde `templates/config/`: **único lugar que lee `process.env`**, exporta `AppConfig` y no da valor por defecto a ningún secreto. `cors.ts` y `cors.spec.ts` desde `templates/cors/`: la validación y `configuration.ts` llaman a `parseCorsOrigins` y `parseCorsMaxAge`, así que la app no arranca sin `CORS_ORIGINS` o con `*`. El resto del código consume `ConfigService<AppConfig, true>` |
123
+ | `src/config/` | `ConfigModule` global con `envFilePath: ['.env.local', '.env']`, `validate` y `load: [configuration]`; validación con class-validator que falla al arrancar, incluida la validación dinámica de `DB_<NOMBRE>_*` por cada nombre en `DATABASES`. `configuration.ts` y `configuration.spec.ts` desde `templates/config/`: **único lugar que lee `process.env`**, exporta `AppConfig` y no da valor por defecto a ningún secreto. `auth.ts` y `auth.spec.ts` desde `templates/config/`: `configuration.ts` llama a `parseAuthConfig` (secretos obligatorios, límite de intentos y vida de la sesión). `cors.ts` y `cors.spec.ts` desde `templates/cors/`: la validación y `configuration.ts` llaman a `parseCorsOrigins` y `parseCorsMaxAge`, así que la app no arranca sin `CORS_ORIGINS` o con `*`. El resto del código consume `ConfigService<AppConfig, true>` |
124
124
  | `src/common/database/` | `DatabaseModule` dinámico: un `Pool` por nombre, `getPoolToken(name)`, sesión con `default_transaction_read_only=on` y `statement_timeout`, cierre en shutdown |
125
125
  | `src/common/cache/` | `CacheService` sobre ioredis (`lazyConnect`, `enableOfflineQueue: false`, `commandTimeout`, reintentos con backoff). No lanza nunca y devuelve `HIT` / `MISS` / `BYPASS`. Hash estable de filtros (claves ordenadas → sha256). Con `CACHE_ENABLED=false` no conecta |
126
- | `src/common/auth/` | `POST auth/login` y `POST auth/refresh` (`@Public()`), `timingSafeEqual` sobre sha256 de ambos valores, secretos distintos y claim `type`, `JwtAuthGuard` como `APP_GUARD`. El guard omite las rutas con `@Public()` y con `IS_MONITOR_ROUTE_KEY` (`@MonitorRoute()`, de `src/common/monitor/monitor.decorators.ts`) |
126
+ | `src/common/security/` | Copia `templates/security/` completo (con sus specs) y registra `SecurityModule` (global) en `app.module.ts`: `LoginAttemptLimiter`, `securityHeaders()` y `sessionClaims` / `isSessionExpired` (contrato-http.md, Autenticación y Cabeceras de seguridad) |
127
+ | `src/common/auth/` | `POST auth/login` y `POST auth/refresh` (`@Public()`), `timingSafeEqual` sobre sha256 de ambos valores, secretos distintos y claim `type`, `JwtAuthGuard` como `APP_GUARD`. Login con `LoginAttemptLimiter` por `req.ip` (`429 TOO_MANY_ATTEMPTS` con `Retry-After`). El par lleva `auth_time` (`sessionClaims`): el login lo fija y el refresh lo copia; `isSessionExpired` → `401 SESSION_EXPIRED`. El guard omite las rutas con `@Public()` y con `IS_MONITOR_ROUTE_KEY` (`@MonitorRoute()`, de `src/common/monitor/monitor.decorators.ts`) |
127
128
  | `src/common/http/` | Tipos `{ data, meta? }`, filtro global de errores `{ error: { code, message, details?, requestId, path, timestamp } }`. Errores de pg: `57014` → 504 `QUERY_TIMEOUT`; errores de conexión o `08*` / `57P01` → 503 `DATABASE_UNAVAILABLE` |
128
129
  | `src/common/logging/` | `logger-options.ts` y su spec desde `templates/logging/`; `LoggerModule.forRootAsync` con `buildLoggerParams(config.get('logging', { infer: true }))`. JSON por defecto, `pretty` en desarrollo con `LOG_FORMAT`, `genReqId` desde `X-Request-Id` seguro o UUID y devuelto en la respuesta, `redact` de authorization, cookies, password, tokens y secretos |
129
130
  | `src/common/health/` | `GET /health` público con terminus. Indicadores `db_<nombre en minúsculas>` por conexión y `cache` con estado `up` / `down` / `disabled`; la caché nunca vuelve el health a 503 |
130
131
  | `src/common/monitor/` | Copia `templates/monitor/` completo (con sus specs) y registra `MonitorModule` en `app.module.ts`. `GET /monitor` fuera de `/api/v1`, `404` si `MONITOR_ENABLED` no es `true`, login con `ACCESS_TOKEN` y cookie de sesión, cabeceras CSP sin scripts, widget base `endpoints-total` (contrato-http.md, Monitor) |
131
- | `test/` | `auth.e2e-spec.ts` (login OK, login inválido, refresh, 401 sin token), `health.e2e-spec.ts` (pools y Redis mockeados con `overrideProvider`), y `cors.e2e-spec.ts` y `monitor.e2e-spec.ts` desde `templates/e2e/` |
132
+ | `test/` | `auth.e2e-spec.ts` (login OK, login inválido, refresh, 401 sin token, `429` con `Retry-After` tras `AUTH_LOGIN_MAX_ATTEMPTS` fallos, `401 SESSION_EXPIRED` al refrescar pasada la vida máxima, refresh token y token del monitor como `Bearer` → 401, access token en `/auth/refresh` → 401, cabeceras de seguridad y sin `X-Powered-By`), `health.e2e-spec.ts` (pools y Redis mockeados con `overrideProvider`), y `cors.e2e-spec.ts` y `monitor.e2e-spec.ts` desde `templates/e2e/` |
132
133
 
133
134
  ### Archivos de soporte
134
135
 
135
- - **`.env.example` y `.env.local`:** las mismas claves. Incluyen `NODE_ENV=development`, `PORT=3000`, `DATABASES=MAIN`, `DB_MAIN_*`, `ACCESS_TOKEN=example123`, `JWT_*`, `CACHE_*`, `REDIS_URL`, `LOG_LEVEL`, `LOG_FORMAT` (`json` en `.env.example`, `pretty` en `.env.local`), `CORS_ORIGINS=http://localhost:4200`, `CORS_MAX_AGE_SECONDS=600`, `MONITOR_ENABLED=false` y `MONITOR_SESSION_EXPIRES_IN=15m`. En `.env.example` todos los secretos llevan valores de ejemplo. **No crees ni toques `.env`.**
136
+ - **`.env.example` y `.env.local`:** las mismas claves. Incluyen `NODE_ENV=development`, `PORT=3000`, `DATABASES=MAIN`, `DB_MAIN_*`, `ACCESS_TOKEN=example123`, `JWT_*`, `AUTH_SESSION_MAX_AGE_SECONDS=43200`, `AUTH_LOGIN_MAX_ATTEMPTS=5`, `AUTH_LOGIN_WINDOW_SECONDS=900`, `HTTP_TRUST_PROXY_HOPS=0`, `CACHE_*`, `REDIS_URL`, `LOG_LEVEL`, `LOG_FORMAT` (`json` en `.env.example`, `pretty` en `.env.local`), `CORS_ORIGINS=http://localhost:4200`, `CORS_MAX_AGE_SECONDS=600`, `MONITOR_ENABLED=false` y `MONITOR_SESSION_EXPIRES_IN=15m`. En `.env.example` todos los secretos llevan valores de ejemplo. **No crees ni toques `.env`.**
136
137
  - **`postman/arness-back.postman_collection.json`** (formato v2.1): variables `baseUrl`, `accessToken` y `refreshToken`, y auth Bearer heredada. Carpeta `Auth` con Login (su script de test guarda los tokens) y Refresh, más la request `Health`. Agrega también `postman/local.postman_environment.json` con `baseUrl=http://localhost:3000`.
137
138
  - **`FRONT.md`:**
138
- - flujo de auth (login, uso del Bearer, refresh ante `401 TOKEN_EXPIRED`);
139
+ - flujo de auth (login, uso del Bearer, refresh ante `401 TOKEN_EXPIRED`, volver al login ante `401 SESSION_EXPIRED`, y ante `429` esperar lo que indica `Retry-After`);
139
140
  - envelope `{ data, meta? }` y formato de error;
140
141
  - significado de `X-Cache`;
141
142
  - CORS: el origen del frontend debe estar en `CORS_ORIGINS` de cada entorno (pedirlo al backend);
142
143
  - endpoints de Auth con su consumo recomendado.
143
- - **`docs/`:** copia `templates/docs/` (`TECNICO.md`, `EVOLUTIVO.md` y `PENDIENTES.md`). Completa en `TECNICO.md` lo que ya se sabe (cliente, conexión `MAIN`, entornos) y deja en `EVOLUTIVO.md` la entrada "Base del proyecto" con la fecha de hoy. Los pendientes del reporte final (§7) van a `PENDIENTES.md`.
144
+ - **`docs/`:** copia `templates/docs/` (`TECNICO.md`, `EVOLUTIVO.md` y `PENDIENTES.md`). Completa en `TECNICO.md` lo que ya se sabe (cliente, conexión `MAIN`, entornos) y deja en `EVOLUTIVO.md` la entrada "Base del proyecto" con la fecha y hora de Bogotá y el responsable (formato de la plantilla). Los pendientes del reporte final (§7) van a `PENDIENTES.md`.
144
145
  - **`Dockerfile`:** multi-stage con `node:<major-lts>-alpine`, `corepack enable`, `pnpm install --frozen-lockfile`, build y una imagen final solo con `dist/` y las dependencias de producción; usuario no root y `HEALTHCHECK` sobre `/health`.
145
146
  - **`azure-pipelines.yml`:** Node desde `.nvmrc`, `corepack enable`, `pnpm install --frozen-lockfile`, `lint:check`, `format:check`, `build`, `test` y `docker build`.
146
147
 
@@ -0,0 +1,32 @@
1
+ // Plantilla del harness → src/config/auth.spec.ts
2
+ import { parseAuthConfig } from './auth';
3
+
4
+ const base = {
5
+ ACCESS_TOKEN: 'example123',
6
+ JWT_ACCESS_SECRET: 'access-secret',
7
+ JWT_REFRESH_SECRET: 'refresh-secret',
8
+ };
9
+
10
+ describe('parseAuthConfig', () => {
11
+ it('applies non-sensitive defaults', () => {
12
+ const auth = parseAuthConfig(base);
13
+ expect(auth.jwt).toEqual({
14
+ accessSecret: 'access-secret',
15
+ accessExpiresIn: '15m',
16
+ refreshSecret: 'refresh-secret',
17
+ refreshExpiresIn: '7d',
18
+ });
19
+ expect(auth.login).toEqual({ maxAttempts: 5, windowSeconds: 900 });
20
+ expect(auth.sessionMaxAgeSeconds).toBe(43200);
21
+ });
22
+
23
+ it.each([
24
+ [{ ACCESS_TOKEN: undefined }, /ACCESS_TOKEN is required/],
25
+ [{ JWT_REFRESH_SECRET: ' ' }, /JWT_REFRESH_SECRET is required/],
26
+ [{ AUTH_LOGIN_MAX_ATTEMPTS: '0' }, /AUTH_LOGIN_MAX_ATTEMPTS must be an integer/],
27
+ [{ AUTH_LOGIN_WINDOW_SECONDS: 'abc' }, /AUTH_LOGIN_WINDOW_SECONDS must be an integer/],
28
+ [{ AUTH_SESSION_MAX_AGE_SECONDS: '30' }, /AUTH_SESSION_MAX_AGE_SECONDS must be an integer/],
29
+ ])('rejects %p', (overrides, message) => {
30
+ expect(() => parseAuthConfig({ ...base, ...overrides })).toThrow(message);
31
+ });
32
+ });
@@ -0,0 +1,80 @@
1
+ // Plantilla del harness → src/config/auth.ts (AGENTS.md §8, harness/reference/configuracion.md "Autenticación").
2
+ // Auth block of AppConfig: secrets (required, no default), login attempt limit and session max age.
3
+ // Errors name the variable, never its value.
4
+
5
+ export const AUTH_DEFAULT_LOGIN_MAX_ATTEMPTS = 5;
6
+ export const AUTH_DEFAULT_LOGIN_WINDOW_SECONDS = 900;
7
+ export const AUTH_DEFAULT_SESSION_MAX_AGE_SECONDS = 43200;
8
+
9
+ export interface AuthConfig {
10
+ accessToken: string;
11
+ jwt: {
12
+ accessSecret: string;
13
+ accessExpiresIn: string;
14
+ refreshSecret: string;
15
+ refreshExpiresIn: string;
16
+ };
17
+ login: { maxAttempts: number; windowSeconds: number };
18
+ sessionMaxAgeSeconds: number;
19
+ }
20
+
21
+ type Env = Record<string, string | undefined>;
22
+
23
+ function required(env: Env, name: string): string {
24
+ const value = env[name];
25
+ if (value === undefined || value.trim() === '') {
26
+ throw new Error(`${name} is required`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function intInRange(env: Env, name: string, fallback: number, min: number, max: number): number {
32
+ const raw = env[name];
33
+ if (raw === undefined || raw.trim() === '') return fallback;
34
+ const value = Number(raw);
35
+ if (!Number.isInteger(value) || value < min || value > max) {
36
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function text(env: Env, name: string, fallback: string): string {
42
+ const value = env[name];
43
+ return value === undefined || value.trim() === '' ? fallback : value.trim();
44
+ }
45
+
46
+ /** Pure: reads and validates the auth block. */
47
+ export function parseAuthConfig(env: Env): AuthConfig {
48
+ return {
49
+ accessToken: required(env, 'ACCESS_TOKEN'),
50
+ jwt: {
51
+ accessSecret: required(env, 'JWT_ACCESS_SECRET'),
52
+ accessExpiresIn: text(env, 'JWT_ACCESS_EXPIRES_IN', '15m'),
53
+ refreshSecret: required(env, 'JWT_REFRESH_SECRET'),
54
+ refreshExpiresIn: text(env, 'JWT_REFRESH_EXPIRES_IN', '7d'),
55
+ },
56
+ login: {
57
+ maxAttempts: intInRange(
58
+ env,
59
+ 'AUTH_LOGIN_MAX_ATTEMPTS',
60
+ AUTH_DEFAULT_LOGIN_MAX_ATTEMPTS,
61
+ 1,
62
+ 100,
63
+ ),
64
+ windowSeconds: intInRange(
65
+ env,
66
+ 'AUTH_LOGIN_WINDOW_SECONDS',
67
+ AUTH_DEFAULT_LOGIN_WINDOW_SECONDS,
68
+ 1,
69
+ 86400,
70
+ ),
71
+ },
72
+ sessionMaxAgeSeconds: intInRange(
73
+ env,
74
+ 'AUTH_SESSION_MAX_AGE_SECONDS',
75
+ AUTH_DEFAULT_SESSION_MAX_AGE_SECONDS,
76
+ 60,
77
+ 2592000,
78
+ ),
79
+ };
80
+ }
@@ -16,7 +16,7 @@ const base: Env = {
16
16
  describe('buildConfiguration', () => {
17
17
  it('groups variables by topic and applies non-sensitive defaults', () => {
18
18
  const config = buildConfiguration(base);
19
- expect(config.app).toEqual({ nodeEnv: 'development', port: 3000 });
19
+ expect(config.app).toEqual({ nodeEnv: 'development', port: 3000, trustProxyHops: 0 });
20
20
  expect(config.logging).toEqual({ level: 'info', format: 'json' });
21
21
  expect(config.databases.MAIN).toEqual({
22
22
  host: 'localhost',
@@ -78,6 +78,7 @@ describe('buildConfiguration', () => {
78
78
  ['PORT', 'abc', /integer/],
79
79
  ['MONITOR_ENABLED', 'yes', /true or false/],
80
80
  ['LOG_FORMAT', 'xml', /json or pretty/],
81
+ ['HTTP_TRUST_PROXY_HOPS', '11', /between 0 and 10/],
81
82
  ])('rejects %s=%p', (name, value, message) => {
82
83
  expect(() => buildConfiguration({ ...base, [name]: value })).toThrow(message);
83
84
  });
@@ -3,6 +3,7 @@
3
3
  // through ConfigService<AppConfig, true>. Variables are validated at startup (env validation in
4
4
  // src/config/); this file only groups them and converts their types.
5
5
  import { type LogFormat, parseLogFormat } from '../common/logging/logger-options';
6
+ import { type AuthConfig, parseAuthConfig } from './auth';
6
7
  import { parseCorsMaxAge, parseCorsOrigins } from './cors';
7
8
 
8
9
  export type Env = Record<string, string | undefined>;
@@ -19,7 +20,7 @@ export interface DatabaseConfig {
19
20
  }
20
21
 
21
22
  export interface AppConfig {
22
- app: { nodeEnv: string; port: number };
23
+ app: { nodeEnv: string; port: number; trustProxyHops: number };
23
24
  logging: { level: string; format: LogFormat };
24
25
  databases: Record<string, DatabaseConfig>;
25
26
  cache: {
@@ -29,15 +30,7 @@ export interface AppConfig {
29
30
  keyPrefix: string;
30
31
  opTimeoutMs: number;
31
32
  };
32
- auth: {
33
- accessToken: string;
34
- jwt: {
35
- accessSecret: string;
36
- accessExpiresIn: string;
37
- refreshSecret: string;
38
- refreshExpiresIn: string;
39
- };
40
- };
33
+ auth: AuthConfig;
41
34
  cors: { origins: string[]; maxAgeSeconds: number };
42
35
  monitor: { enabled: boolean; sessionExpiresIn: string };
43
36
  }
@@ -101,12 +94,21 @@ export function parseDatabases(env: Env): Record<string, DatabaseConfig> {
101
94
  );
102
95
  }
103
96
 
97
+ /** Proxies in front of the app (Azure App Service, gateway): 0 = connect directly, use the socket IP. */
98
+ export function trustProxyHops(env: Env): number {
99
+ const hops = int(env, 'HTTP_TRUST_PROXY_HOPS', 0);
100
+ if (hops < 0 || hops > 10) {
101
+ throw new Error('HTTP_TRUST_PROXY_HOPS must be an integer between 0 and 10');
102
+ }
103
+ return hops;
104
+ }
105
+
104
106
  /** Pure: builds the configuration from an env object (tests pass their own). */
105
107
  export function buildConfiguration(env: Env): AppConfig {
106
108
  const cacheEnabled = bool(env, 'CACHE_ENABLED', false);
107
109
  const nodeEnv = text(env, 'NODE_ENV', 'development');
108
110
  return {
109
- app: { nodeEnv, port: int(env, 'PORT', 3000) },
111
+ app: { nodeEnv, port: int(env, 'PORT', 3000), trustProxyHops: trustProxyHops(env) },
110
112
  logging: {
111
113
  level: text(env, 'LOG_LEVEL', 'info'),
112
114
  format: parseLogFormat(env.LOG_FORMAT, nodeEnv),
@@ -119,15 +121,7 @@ export function buildConfiguration(env: Env): AppConfig {
119
121
  keyPrefix: text(env, 'CACHE_KEY_PREFIX', 'arness'),
120
122
  opTimeoutMs: int(env, 'CACHE_OP_TIMEOUT_MS', 200),
121
123
  },
122
- auth: {
123
- accessToken: required(env, 'ACCESS_TOKEN'),
124
- jwt: {
125
- accessSecret: required(env, 'JWT_ACCESS_SECRET'),
126
- accessExpiresIn: text(env, 'JWT_ACCESS_EXPIRES_IN', '15m'),
127
- refreshSecret: required(env, 'JWT_REFRESH_SECRET'),
128
- refreshExpiresIn: text(env, 'JWT_REFRESH_EXPIRES_IN', '7d'),
129
- },
130
- },
124
+ auth: parseAuthConfig(env),
131
125
  cors: {
132
126
  origins: parseCorsOrigins(env.CORS_ORIGINS),
133
127
  maxAgeSeconds: parseCorsMaxAge(env.CORS_MAX_AGE_SECONDS),
@@ -8,15 +8,20 @@ Trazabilidad de lo que se ha entregado en el proyecto, en orden cronológico. Ca
8
8
  Formato de cada entrada (lenguaje entendible para negocio):
9
9
 
10
10
  ```markdown
11
- ## AAAA-MM-DD · <cliente>/<módulo>
11
+ ## AAAA-MM-DD HH:mm -0500 · <cliente>/<módulo>
12
12
 
13
+ - **Responsable:** <nombre de quien hizo la entrega>.
13
14
  - **Entregado:** <qué se puede hacer ahora, en una o dos frases>.
14
15
  - **Detalle técnico:** <endpoint, conexión, widget o cambio de contrato>.
15
16
  - **Referencia:** <commit, PR o tag>.
16
17
  ```
17
18
 
18
- ## AAAA-MM-DD · Base del proyecto
19
+ - **Fecha y hora:** hora de Bogotá, Colombia (UTC-5), del momento en que se registra la entrega: `TZ=America/Bogota date '+%Y-%m-%d %H:%M %z'`.
20
+ - **Responsable:** la persona que trabaja en ese momento, según `git config user.name`. Si está vacío, se le pregunta; nunca se pone el nombre de un agente.
19
21
 
22
+ ## AAAA-MM-DD HH:mm -0500 · Base del proyecto
23
+
24
+ - **Responsable:** <git config user.name>.
20
25
  - **Entregado:** base del backend lista para agregar reportes: autenticación, salud del servicio y caché.
21
26
  - **Detalle técnico:** proyecto NestJS creado con la skill `nest-base` del harness.
22
27
  - **Referencia:** <commit inicial>.
@@ -7,6 +7,7 @@ import { App } from 'supertest/types';
7
7
  import { MonitorModule } from '../src/common/monitor/monitor.module';
8
8
  import { MONITOR_WIDGETS, MonitorWidget } from '../src/common/monitor/monitor-widget.interface';
9
9
  import { EndpointsTotalWidget } from '../src/common/monitor/widgets/endpoints-total.widget';
10
+ import { SecurityModule } from '../src/common/security/security.module';
10
11
 
11
12
  @Controller('cartera')
12
13
  class CarteraController {
@@ -42,9 +43,11 @@ async function createApp(enabled: boolean): Promise<INestApplication<App>> {
42
43
  MONITOR_SESSION_EXPIRES_IN: '15m',
43
44
  ACCESS_TOKEN: 'example123',
44
45
  JWT_ACCESS_SECRET: 'e2e-access-secret-with-enough-length',
46
+ auth: { login: { maxAttempts: 3, windowSeconds: 60 } },
45
47
  }),
46
48
  ],
47
49
  }),
50
+ SecurityModule,
48
51
  MonitorModule,
49
52
  ],
50
53
  controllers: [CarteraController, HealthController],
@@ -125,6 +128,20 @@ describe('Monitor (e2e)', () => {
125
128
  expect(String(res.headers['set-cookie'])).toContain('arness_monitor=; Max-Age=0');
126
129
  });
127
130
 
131
+ it('answers 429 with Retry-After after too many wrong passwords, even with the right one', async () => {
132
+ const limited = await createApp(true);
133
+ const attempt = (password: string): request.Test =>
134
+ request(limited.getHttpServer()).post('/monitor/login').type('form').send({ password });
135
+ for (let i = 0; i < 3; i += 1) expect((await attempt('wrong')).status).toBe(401);
136
+
137
+ const res = await attempt('example123');
138
+ expect(res.status).toBe(429);
139
+ expect(Number(res.headers['retry-after'])).toBeGreaterThan(0);
140
+ expect(res.headers['set-cookie']).toBeUndefined();
141
+ expect(res.text).toContain('Demasiados intentos');
142
+ await limited.close();
143
+ });
144
+
128
145
  it('answers 404 when MONITOR_ENABLED=false', async () => {
129
146
  const disabled = await createApp(false);
130
147
  await request(disabled.getHttpServer()).get('/monitor').expect(404);
@@ -1,6 +1,7 @@
1
1
  // Plantilla del harness → src/common/monitor/monitor.controller.ts
2
- import { Body, Controller, Get, Post, Res, UseFilters, UseGuards } from '@nestjs/common';
3
- import type { Response } from 'express';
2
+ import { Body, Controller, Get, Post, Req, Res, UseFilters, UseGuards } from '@nestjs/common';
3
+ import type { Request, Response } from 'express';
4
+ import { LoginAttemptLimiter } from '../security/login-attempts';
4
5
  import {
5
6
  MONITOR_HOME_URL,
6
7
  MONITOR_LOGIN_URL,
@@ -28,6 +29,7 @@ export class MonitorController {
28
29
  constructor(
29
30
  private readonly monitor: MonitorService,
30
31
  private readonly session: MonitorSessionService,
32
+ private readonly attempts: LoginAttemptLimiter,
31
33
  ) {}
32
34
 
33
35
  @Get()
@@ -43,11 +45,30 @@ export class MonitorController {
43
45
 
44
46
  @MonitorLogin()
45
47
  @Post('login')
46
- async login(@Body() body: MonitorLoginDto, @Res() res: Response): Promise<void> {
48
+ async login(
49
+ @Body() body: MonitorLoginDto,
50
+ @Req() req: Request,
51
+ @Res() res: Response,
52
+ ): Promise<void> {
53
+ // Same limiter as /auth/login (contrato-http.md, Límite de intentos).
54
+ const client = req.ip ?? 'unknown';
55
+ const status = this.attempts.check(client);
56
+ if (!status.allowed) {
57
+ const minutes = Math.ceil(status.retryAfterSeconds / 60);
58
+ res.setHeader('Retry-After', String(status.retryAfterSeconds));
59
+ sendHtml(
60
+ res,
61
+ 429,
62
+ renderLoginPage(`Demasiados intentos. Intenta de nuevo en ${minutes} min.`),
63
+ );
64
+ return;
65
+ }
47
66
  if (!this.session.verifyPassword(body.password)) {
67
+ this.attempts.registerFailure(client);
48
68
  sendHtml(res, 401, renderLoginPage('Contraseña incorrecta'));
49
69
  return;
50
70
  }
71
+ this.attempts.reset(client);
51
72
  res
52
73
  .set(MONITOR_SECURITY_HEADERS)
53
74
  .setHeader('Set-Cookie', await this.session.createSessionCookie());
@@ -0,0 +1,48 @@
1
+ // Plantilla del harness → src/common/security/login-attempts.spec.ts
2
+ import { LoginAttemptLimiter } from './login-attempts';
3
+
4
+ describe('LoginAttemptLimiter', () => {
5
+ let now: number;
6
+ const limiter = (maxTrackedClients?: number): LoginAttemptLimiter =>
7
+ new LoginAttemptLimiter({ maxAttempts: 3, windowSeconds: 60, maxTrackedClients }, () => now);
8
+
9
+ beforeEach(() => {
10
+ now = 1_000_000;
11
+ });
12
+
13
+ it('blocks a client after maxAttempts failures until the window ends', () => {
14
+ const attempts = limiter();
15
+ expect(attempts.registerFailure('10.0.0.1')).toEqual({ allowed: true, retryAfterSeconds: 0 });
16
+ attempts.registerFailure('10.0.0.1');
17
+ expect(attempts.registerFailure('10.0.0.1')).toEqual({ allowed: false, retryAfterSeconds: 60 });
18
+
19
+ now += 59_500;
20
+ expect(attempts.check('10.0.0.1')).toEqual({ allowed: false, retryAfterSeconds: 1 });
21
+
22
+ now += 500;
23
+ expect(attempts.check('10.0.0.1')).toEqual({ allowed: true, retryAfterSeconds: 0 });
24
+ });
25
+
26
+ it('counts each client separately', () => {
27
+ const attempts = limiter();
28
+ for (let i = 0; i < 3; i += 1) attempts.registerFailure('10.0.0.1');
29
+ expect(attempts.check('10.0.0.1').allowed).toBe(false);
30
+ expect(attempts.check('10.0.0.2').allowed).toBe(true);
31
+ });
32
+
33
+ it('a successful login resets the counter', () => {
34
+ const attempts = limiter();
35
+ attempts.registerFailure('10.0.0.1');
36
+ attempts.registerFailure('10.0.0.1');
37
+ attempts.reset('10.0.0.1');
38
+ expect(attempts.registerFailure('10.0.0.1').allowed).toBe(true);
39
+ });
40
+
41
+ it('never tracks more than maxTrackedClients', () => {
42
+ const attempts = limiter(2);
43
+ for (let i = 0; i < 3; i += 1) attempts.registerFailure('10.0.0.1');
44
+ attempts.registerFailure('10.0.0.2');
45
+ attempts.registerFailure('10.0.0.3');
46
+ expect(attempts.check('10.0.0.1').allowed).toBe(true);
47
+ });
48
+ });
@@ -0,0 +1,90 @@
1
+ // Plantilla del harness → src/common/security/login-attempts.ts (AGENTS.md §8, contrato-http.md "Límite de intentos").
2
+ // Counts failed logins per client in memory: one counter per instance, never depends on Redis.
3
+ // /auth/login and /monitor/login share it.
4
+
5
+ export interface LoginAttemptOptions {
6
+ maxAttempts: number;
7
+ windowSeconds: number;
8
+ /** Upper bound of tracked clients, so an attacker cannot grow the map without limit. */
9
+ maxTrackedClients?: number;
10
+ }
11
+
12
+ export interface LoginAttemptStatus {
13
+ allowed: boolean;
14
+ /** Seconds until the client can try again (0 when allowed). Goes to the Retry-After header. */
15
+ retryAfterSeconds: number;
16
+ }
17
+
18
+ interface Failures {
19
+ count: number;
20
+ resetAt: number;
21
+ }
22
+
23
+ const DEFAULT_MAX_TRACKED_CLIENTS = 10000;
24
+
25
+ export class LoginAttemptLimiter {
26
+ private readonly failures = new Map<string, Failures>();
27
+ private readonly maxTrackedClients: number;
28
+
29
+ constructor(
30
+ private readonly options: LoginAttemptOptions,
31
+ private readonly now: () => number = Date.now,
32
+ ) {
33
+ this.maxTrackedClients = options.maxTrackedClients ?? DEFAULT_MAX_TRACKED_CLIENTS;
34
+ }
35
+
36
+ /** Call before comparing the password: a blocked client gets 429 without checking it. */
37
+ check(client: string): LoginAttemptStatus {
38
+ const entry = this.current(client);
39
+ if (!entry || entry.count < this.options.maxAttempts) {
40
+ return { allowed: true, retryAfterSeconds: 0 };
41
+ }
42
+ return { allowed: false, retryAfterSeconds: this.secondsUntil(entry.resetAt) };
43
+ }
44
+
45
+ /** Call after a wrong password. The window starts at the first failure. */
46
+ registerFailure(client: string): LoginAttemptStatus {
47
+ const entry = this.current(client);
48
+ if (entry) {
49
+ entry.count += 1;
50
+ } else {
51
+ this.makeRoom();
52
+ this.failures.set(client, {
53
+ count: 1,
54
+ resetAt: this.now() + this.options.windowSeconds * 1000,
55
+ });
56
+ }
57
+ return this.check(client);
58
+ }
59
+
60
+ /** Call after a successful login. */
61
+ reset(client: string): void {
62
+ this.failures.delete(client);
63
+ }
64
+
65
+ private current(client: string): Failures | undefined {
66
+ const entry = this.failures.get(client);
67
+ if (entry && entry.resetAt <= this.now()) {
68
+ this.failures.delete(client);
69
+ return undefined;
70
+ }
71
+ return entry;
72
+ }
73
+
74
+ private makeRoom(): void {
75
+ if (this.failures.size < this.maxTrackedClients) return;
76
+ const now = this.now();
77
+ for (const [client, entry] of this.failures) {
78
+ if (entry.resetAt <= now) this.failures.delete(client);
79
+ }
80
+ // Still full: drop the oldest entry (Map keeps insertion order).
81
+ if (this.failures.size >= this.maxTrackedClients) {
82
+ const oldest = this.failures.keys().next().value;
83
+ if (oldest !== undefined) this.failures.delete(oldest);
84
+ }
85
+ }
86
+
87
+ private secondsUntil(timestamp: number): number {
88
+ return Math.max(1, Math.ceil((timestamp - this.now()) / 1000));
89
+ }
90
+ }
@@ -0,0 +1,27 @@
1
+ // Plantilla del harness → src/common/security/security-headers.spec.ts
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { API_SECURITY_HEADERS, securityHeaders } from './security-headers';
5
+
6
+ describe('securityHeaders', () => {
7
+ const app = express();
8
+ app.use(securityHeaders());
9
+ app.get('/api/v1/ping', (_req, res) => {
10
+ res.json({ data: 'pong' });
11
+ });
12
+
13
+ it('sends every security header and hides X-Powered-By', async () => {
14
+ const res = await request(app).get('/api/v1/ping');
15
+ expect(res.status).toBe(200);
16
+ for (const [name, value] of Object.entries(API_SECURITY_HEADERS)) {
17
+ expect(res.headers[name.toLowerCase()]).toBe(value);
18
+ }
19
+ expect(res.headers['x-powered-by']).toBeUndefined();
20
+ });
21
+
22
+ it('also covers responses that are not handled by a route', async () => {
23
+ const res = await request(app).get('/missing');
24
+ expect(res.status).toBe(404);
25
+ expect(res.headers['x-frame-options']).toBe('DENY');
26
+ });
27
+ });
@@ -0,0 +1,21 @@
1
+ // Plantilla del harness → src/common/security/security-headers.ts (AGENTS.md §9, contrato-http.md "Cabeceras de seguridad").
2
+ // Security headers for every response, without helmet. The monitor overwrites the CSP with its own.
3
+ import type { NextFunction, Request, Response } from 'express';
4
+
5
+ export const API_SECURITY_HEADERS: Readonly<Record<string, string>> = Object.freeze({
6
+ 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
7
+ 'X-Content-Type-Options': 'nosniff',
8
+ 'X-Frame-Options': 'DENY',
9
+ 'Referrer-Policy': 'no-referrer',
10
+ 'Strict-Transport-Security': 'max-age=31536000',
11
+ 'Cache-Control': 'no-store',
12
+ });
13
+
14
+ /** Express middleware: app.use(securityHeaders()) in src/main.ts, before any route. */
15
+ export function securityHeaders(): (req: Request, res: Response, next: NextFunction) => void {
16
+ return (_req, res, next) => {
17
+ res.removeHeader('X-Powered-By');
18
+ res.set(API_SECURITY_HEADERS);
19
+ next();
20
+ };
21
+ }
@@ -0,0 +1,20 @@
1
+ // Plantilla del harness → src/common/security/security.module.ts
2
+ // Global: AuthController and MonitorController inject the same LoginAttemptLimiter.
3
+ import { Global, Module } from '@nestjs/common';
4
+ import { ConfigService } from '@nestjs/config';
5
+ import type { AppConfig } from '../../config/configuration';
6
+ import { LoginAttemptLimiter } from './login-attempts';
7
+
8
+ @Global()
9
+ @Module({
10
+ providers: [
11
+ {
12
+ provide: LoginAttemptLimiter,
13
+ useFactory: (config: ConfigService<AppConfig, true>): LoginAttemptLimiter =>
14
+ new LoginAttemptLimiter(config.get('auth.login', { infer: true })),
15
+ inject: [ConfigService],
16
+ },
17
+ ],
18
+ exports: [LoginAttemptLimiter],
19
+ })
20
+ export class SecurityModule {}
@@ -0,0 +1,21 @@
1
+ // Plantilla del harness → src/common/security/session-lifetime.spec.ts
2
+ import { AUTH_TIME_CLAIM, isSessionExpired, nowInSeconds, sessionClaims } from './session-lifetime';
3
+
4
+ describe('session lifetime', () => {
5
+ const maxAge = 3600;
6
+
7
+ it('keeps the session alive until it reaches the max age', () => {
8
+ const claims = sessionClaims(1000);
9
+ expect(claims).toEqual({ [AUTH_TIME_CLAIM]: 1000 });
10
+ expect(isSessionExpired(claims, maxAge, 1000 + maxAge - 1)).toBe(false);
11
+ expect(isSessionExpired(claims, maxAge, 1000 + maxAge)).toBe(true);
12
+ });
13
+
14
+ it('treats tokens without auth_time as expired', () => {
15
+ expect(isSessionExpired({}, maxAge, 1000)).toBe(true);
16
+ });
17
+
18
+ it('converts milliseconds to JWT seconds', () => {
19
+ expect(nowInSeconds(1_700_000_000_999)).toBe(1_700_000_000);
20
+ });
21
+ });
@@ -0,0 +1,28 @@
1
+ // Plantilla del harness → src/common/security/session-lifetime.ts (AGENTS.md §8, contrato-http.md "Autenticación").
2
+ // A session starts at /auth/login and lasts at most AUTH_SESSION_MAX_AGE_SECONDS, however many
3
+ // times it is refreshed: a stolen refresh token stops working when the session reaches that age.
4
+
5
+ /** Claim with the login time (seconds since epoch). The refresh copies it into the new pair. */
6
+ export const AUTH_TIME_CLAIM = 'auth_time';
7
+
8
+ export interface SessionClaims {
9
+ [AUTH_TIME_CLAIM]?: number;
10
+ }
11
+
12
+ /** Current time in seconds, as JWT iat/exp. */
13
+ export const nowInSeconds = (now: number = Date.now()): number => Math.floor(now / 1000);
14
+
15
+ /** Claims for a new pair: login starts a session; refresh keeps the original auth_time. */
16
+ export function sessionClaims(authTime: number): Required<SessionClaims> {
17
+ return { [AUTH_TIME_CLAIM]: authTime };
18
+ }
19
+
20
+ /** True if the refresh must be rejected with 401 SESSION_EXPIRED. Tokens without auth_time count as expired. */
21
+ export function isSessionExpired(
22
+ claims: SessionClaims,
23
+ maxAgeSeconds: number,
24
+ now: number = nowInSeconds(),
25
+ ): boolean {
26
+ const authTime = claims[AUTH_TIME_CLAIM];
27
+ return typeof authTime !== 'number' || now - authTime >= maxAgeSeconds;
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bi-analytics/arness-back",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Harness de agentes IA (Claude Code, Codex, Gemini CLI, Cursor) para backends NestJS de analítica",
5
5
  "repository": {
6
6
  "type": "git",