@bi-analytics/arness-back 0.0.0-stage → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +156 -2
  3. package/bin/arness.mjs +710 -0
  4. package/harness/AGENTS.md +234 -0
  5. package/harness/agents/implementador.md +42 -0
  6. package/harness/agents/lider.md +71 -0
  7. package/harness/agents/revisor.md +62 -0
  8. package/harness/decisiones/README.md +17 -0
  9. package/harness/decisiones/_plantilla.md +24 -0
  10. package/harness/init.sh +696 -0
  11. package/harness/playbooks/add-database.md +16 -0
  12. package/harness/playbooks/new-endpoint.md +88 -0
  13. package/harness/playbooks/new-module.md +21 -0
  14. package/harness/playbooks/new-monitor-widget.md +31 -0
  15. package/harness/playbooks/review.md +40 -0
  16. package/harness/reference/configuracion.md +104 -0
  17. package/harness/reference/contrato-http.md +138 -0
  18. package/harness/reference/novedades.json +107 -0
  19. package/harness/reference/progress-plantilla.md +50 -0
  20. package/harness/skills/commit-hygiene/SKILL.md +137 -0
  21. package/harness/skills/nest-base/SKILL.md +167 -0
  22. package/harness/skills/nest-base/templates/config/configuration.spec.ts +84 -0
  23. package/harness/skills/nest-base/templates/config/configuration.ts +143 -0
  24. package/harness/skills/nest-base/templates/cors/cors.spec.ts +53 -0
  25. package/harness/skills/nest-base/templates/cors/cors.ts +75 -0
  26. package/harness/skills/nest-base/templates/docs/EVOLUTIVO.md +22 -0
  27. package/harness/skills/nest-base/templates/docs/PENDIENTES.md +20 -0
  28. package/harness/skills/nest-base/templates/docs/TECNICO.md +43 -0
  29. package/harness/skills/nest-base/templates/e2e/cors.e2e-spec.ts +60 -0
  30. package/harness/skills/nest-base/templates/e2e/monitor.e2e-spec.ts +134 -0
  31. package/harness/skills/nest-base/templates/editorconfig +12 -0
  32. package/harness/skills/nest-base/templates/eslint.boundaries.mjs +65 -0
  33. package/harness/skills/nest-base/templates/eslint.config.mjs +82 -0
  34. package/harness/skills/nest-base/templates/logging/logger-options.spec.ts +57 -0
  35. package/harness/skills/nest-base/templates/logging/logger-options.ts +85 -0
  36. package/harness/skills/nest-base/templates/monitor/monitor-login-redirect.filter.ts +16 -0
  37. package/harness/skills/nest-base/templates/monitor/monitor-login.dto.ts +8 -0
  38. package/harness/skills/nest-base/templates/monitor/monitor-page.spec.ts +41 -0
  39. package/harness/skills/nest-base/templates/monitor/monitor-page.ts +190 -0
  40. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.spec.ts +46 -0
  41. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.ts +36 -0
  42. package/harness/skills/nest-base/templates/monitor/monitor-session.service.spec.ts +57 -0
  43. package/harness/skills/nest-base/templates/monitor/monitor-session.service.ts +77 -0
  44. package/harness/skills/nest-base/templates/monitor/monitor-widget.interface.ts +18 -0
  45. package/harness/skills/nest-base/templates/monitor/monitor.constants.ts +25 -0
  46. package/harness/skills/nest-base/templates/monitor/monitor.controller.ts +63 -0
  47. package/harness/skills/nest-base/templates/monitor/monitor.decorators.ts +11 -0
  48. package/harness/skills/nest-base/templates/monitor/monitor.module.ts +30 -0
  49. package/harness/skills/nest-base/templates/monitor/monitor.service.spec.ts +55 -0
  50. package/harness/skills/nest-base/templates/monitor/monitor.service.ts +49 -0
  51. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.spec.ts +51 -0
  52. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.ts +53 -0
  53. package/harness/skills/nest-base/templates/prettierignore +6 -0
  54. package/harness/skills/nest-base/templates/prettierrc.json +8 -0
  55. package/package.json +44 -3
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: engram-commit-hygiene
3
+ description: >
4
+ Commit and branch naming standards for Engram contributors, enforced by GitHub rulesets.
5
+ Trigger: Any commit creation, review, or branch cleanup.
6
+ license: Apache-2.0
7
+ metadata:
8
+ author: gentleman-programming
9
+ version: "2.0"
10
+ ---
11
+
12
+ ## When to Use
13
+
14
+ Use this skill when:
15
+ - Creating commits
16
+ - Creating or naming branches
17
+ - Reviewing commit history in a PR
18
+ - Cleaning up staged changes
19
+
20
+ ---
21
+
22
+ ## Critical Rules
23
+
24
+ 1. **Commit messages MUST follow Conventional Commits** — enforced by GitHub ruleset, rejected on push if invalid
25
+ 2. **Branch names MUST follow `type/description` format** — enforced by GitHub ruleset, rejected on push if invalid
26
+ 3. Keep one logical change per commit
27
+ 4. Message should explain **why**, not only what
28
+ 5. **NEVER** include `Co-Authored-By` trailers
29
+ 6. **NEVER** commit paths prohibited by the [Transient Artifact Policy](../../CONTRIBUTING.md#transient-artifact-policy)
30
+
31
+ ---
32
+
33
+ ## Commit Message Format (enforced by GitHub ruleset)
34
+
35
+ **Regex:** `^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9\._-]+\))?!?: .+`
36
+
37
+ ```
38
+ <type>(<optional-scope>): <description>
39
+ <type>(<optional-scope>)!: <description> ← breaking change
40
+ ```
41
+
42
+ ### Allowed Types
43
+
44
+ | Type | Purpose |
45
+ |------|---------|
46
+ | `feat` | New feature |
47
+ | `fix` | Bug fix |
48
+ | `docs` | Documentation only |
49
+ | `refactor` | Code refactoring (no behavior change) |
50
+ | `chore` | Maintenance, dependencies |
51
+ | `style` | Formatting, whitespace |
52
+ | `perf` | Performance improvement |
53
+ | `test` | Adding or fixing tests |
54
+ | `build` | Build system changes |
55
+ | `ci` | CI/CD pipeline changes |
56
+ | `revert` | Revert a previous commit |
57
+
58
+ ### Rules
59
+ - Type MUST be lowercase, one of the listed values
60
+ - Scope is optional, lowercase, allows `a-z`, `0-9`, `.`, `_`, `-`
61
+ - `!` before `:` marks a breaking change
62
+ - Description MUST start after `: ` (colon + space)
63
+ - Description should be imperative mood ("add", not "added" or "adds")
64
+
65
+ ### Valid Examples
66
+ ```
67
+ feat(cli): add --json flag to session list command
68
+ fix(store): prevent duplicate observation insert on retry
69
+ docs(contributing): update workflow documentation
70
+ refactor(internal): extract search query sanitizer
71
+ chore(deps): bump github.com/charmbracelet/bubbletea to v0.26
72
+ style(tui): fix alignment in session detail view
73
+ perf(store): optimize FTS5 query for large datasets
74
+ test(sync): add coverage for conflict resolution
75
+ ci(workflows): split e2e into separate job
76
+ fix!: change session ID format
77
+ ```
78
+
79
+ ### Invalid Examples (push will be rejected)
80
+ ```
81
+ Fix bug ← no type prefix
82
+ feat: Add login ← description should be lowercase
83
+ FEAT(cli): add flag ← type must be lowercase
84
+ feat (cli): add flag ← no space before scope
85
+ feat(CLI): add flag ← scope must be lowercase
86
+ update docs ← no conventional commit format
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Branch Naming Format (enforced by GitHub ruleset)
92
+
93
+ **Regex:** `^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)\/[a-z0-9._-]+$`
94
+
95
+ ```
96
+ <type>/<description>
97
+ ```
98
+
99
+ ### Allowed Types
100
+
101
+ `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `revert`
102
+
103
+ ### Rules
104
+ - Type MUST be one of the listed values
105
+ - Description MUST be lowercase
106
+ - Only `a-z`, `0-9`, `.`, `_`, `-` allowed in description
107
+ - No uppercase, no spaces, no special characters
108
+
109
+ ### Valid Examples
110
+ ```
111
+ feat/json-export-command
112
+ fix/duplicate-observation-insert
113
+ docs/api-reference-update
114
+ refactor/extract-query-sanitizer
115
+ chore/bump-bubbletea-v0.26
116
+ ci/split-e2e-job
117
+ ```
118
+
119
+ ### Invalid Examples (push will be rejected)
120
+ ```
121
+ feature/add-login ← "feature" not allowed, use "feat"
122
+ fix/Add-Login ← uppercase not allowed
123
+ my-branch ← no type prefix
124
+ fix_something ← missing "/" separator
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Pre-Commit Checklist
130
+
131
+ - [ ] Commit message matches conventional commits format
132
+ - [ ] Branch name matches `type/description` format
133
+ - [ ] Diff matches commit scope (no unrelated changes)
134
+ - [ ] No secrets, credentials, or `.env` files
135
+ - [ ] Every changed path complies with the [Transient Artifact Policy](../../CONTRIBUTING.md#transient-artifact-policy)
136
+ - [ ] No `Co-Authored-By` trailers
137
+ - [ ] Tests relevant to the change pass
@@ -0,0 +1,167 @@
1
+ ---
2
+ name: nest-base
3
+ description: Crea la base del proyecto NestJS de este harness (Node.js en su última LTS, NestJS en su última versión estable, pnpm, ESLint y Prettier configurados, y la estructura common/ de AGENTS.md). Úsala solo cuando el repositorio aún no tiene package.json y el humano pide inicializar o crear la base del backend.
4
+ ---
5
+
6
+ # Skill: nest-base
7
+
8
+ Crea el esqueleto NestJS que exige `AGENTS.md`. Esta skill **instala dependencias y consulta la red**, así que solo se ejecuta cuando el humano lo pide expresamente.
9
+
10
+ Referencias: `AGENTS.md` §2 (stack y dependencias aprobadas), §4 (estructura y fronteras), §6 a §10 (BD, caché, auth, contrato HTTP y CORS, logging, health y monitor) y §13 (prohibiciones). Detalle de implementación: `harness/reference/configuracion.md` (variables) y `harness/reference/contrato-http.md` (envelope, errores, auth, CORS, caché, logging, health, monitor).
11
+
12
+ **Plantillas** (`harness/skills/nest-base/templates/`): se copian tal cual al proyecto, sin cambiar su intención. Cada archivo indica su destino en la primera línea; quita ese comentario al copiarlo.
13
+
14
+ **Raíz del proyecto (`ROOT`):** la carpeta que **contiene** `harness/`, no `harness/` ni el directorio actual. Antes de empezar, haz `cd` a `ROOT`; todas las rutas de esta skill son relativas a él. El proyecto NestJS se crea en `ROOT`, nunca dentro de `harness/`.
15
+
16
+ ## 0. Precondiciones: si alguna falla, detente e informa
17
+
18
+ 1. `./harness/init.sh` termina en 0. Sin `package.json` corre en modo "solo harness".
19
+ 2. **No existe `package.json`.** Si existe, la base ya fue creada: no la regeneres; informa y termina.
20
+ 3. `pnpm` está disponible (`pnpm -v`). Nunca uses npm ni yarn.
21
+
22
+ ## 1. Resolver versiones (siempre las actuales, nunca de memoria)
23
+
24
+ ```bash
25
+ # Node.js: última LTS publicada
26
+ curl -fsSL https://nodejs.org/dist/index.json \
27
+ | 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)})'
28
+
29
+ # NestJS: última versión estable (dist-tag "latest", nunca next/beta/rc)
30
+ pnpm view @nestjs/cli version
31
+ pnpm view @nestjs/core version
32
+
33
+ # pnpm: última estable
34
+ pnpm view pnpm version
35
+ ```
36
+
37
+ - NestJS no publica una línea "LTS". En este harness, "última LTS de NestJS" significa **la última versión estable del dist-tag `latest`**.
38
+ - Si el major de `node -v` no coincide con la LTS resuelta, **detente**. Pide al humano que la instale (por ejemplo `nvm install --lts` o `fnm install --lts`). No instales Node por tu cuenta.
39
+ - Informa al humano las versiones resueltas antes de continuar.
40
+
41
+ ## 2. Generar el proyecto sin pisar el harness
42
+
43
+ ```bash
44
+ # ROOT = primera carpeta hacia arriba que contiene harness/AGENTS.md
45
+ ROOT="$(pwd)"; while [ "$ROOT" != "/" ] && [ ! -f "$ROOT/harness/AGENTS.md" ]; do ROOT="$(dirname "$ROOT")"; done
46
+ [ -f "$ROOT/harness/AGENTS.md" ] || { echo "No se encontró harness/AGENTS.md"; exit 1; }
47
+ cd "$ROOT"
48
+ TMP="$(mktemp -d)"
49
+ pnpm dlx @nestjs/cli@<version-cli> new <nombre-paquete> \
50
+ --package-manager pnpm --strict --skip-git --directory "$TMP/app"
51
+ rsync -a --ignore-existing --exclude node_modules "$TMP/app/" "$ROOT/"
52
+ rm -rf "$TMP"
53
+ ```
54
+
55
+ - `<nombre-paquete>`: el nombre del directorio del repo en kebab-case, salvo que el humano indique otro.
56
+ - `--ignore-existing` protege `harness/` y los puentes de la raíz (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.cursor/`, `.claude/` y `.agents/`).
57
+ - Elimina el ejemplo de Nest (`app.controller.ts`, `app.service.ts`, su spec y `test/app.e2e-spec.ts`).
58
+ - Reemplaza el `README.md` generado por uno breve en español que apunte a `AGENTS.md` y a `./harness/init.sh`.
59
+
60
+ ## 3. Dependencias (solo las aprobadas en AGENTS.md §2)
61
+
62
+ ```bash
63
+ pnpm install
64
+ pnpm add @nestjs/config @nestjs/jwt @nestjs/terminus pg ioredis \
65
+ class-validator class-transformer nestjs-pino pino-http react react-dom
66
+ pnpm add -D @types/pg @types/react @types/react-dom pino-pretty
67
+ ```
68
+
69
+ - Sin versiones fijadas a mano: pnpm resuelve la última estable y el lockfile las congela.
70
+ - `react` y `react-dom` son solo para el monitor (`src/common/monitor/`); no se usan en ningún otro lugar.
71
+ - `pino-pretty` va **solo en devDependencies**: formato legible de logs en desarrollo (`LOG_FORMAT=pretty`).
72
+ - Si pnpm avisa que ignoró scripts de build (`pnpm approve-builds`), muestra la lista al humano y espera su aprobación.
73
+ - Agrega el harness como devDependency desde npm, con la versión exacta del manifiesto:
74
+ ```bash
75
+ pnpm add -D "@bi-analytics/arness-back@$(node -p "require('./harness/.arness.json').version")"
76
+ ```
77
+ Si falla (red o registro), detente e informa; no lo quites de la lista.
78
+ - Cualquier paquete adicional requiere aprobación explícita y una decisión en `harness/decisiones/`.
79
+
80
+ ## 4. Toolchain, ESLint y Prettier
81
+
82
+ 1. **Node fijado:** crea `.nvmrc` con la versión LTS resuelta (ej. `v24.11.0`). En `package.json` define:
83
+ - `"engines": { "node": ">=<major>" }`
84
+ - `"packageManager": "pnpm@<version>"`
85
+ 2. **Scripts de `package.json`:** agrega o ajusta estos. `init.sh` depende de `lint:check`, `format:check`, `build`, `test` y `test:e2e`.
86
+ ```json
87
+ {
88
+ "lint": "eslint \"{src,test}/**/*.ts\" --fix",
89
+ "lint:check": "eslint \"{src,test}/**/*.ts\" --max-warnings 0",
90
+ "format": "prettier --write \"{src,test}/**/*.ts\"",
91
+ "format:check": "prettier --check \"{src,test}/**/*.ts\"",
92
+ "verify": "./harness/init.sh",
93
+ "harness:status": "arness status",
94
+ "harness:sync": "arness sync"
95
+ }
96
+ ```
97
+ 3. **ESLint:** reemplaza `eslint.config.mjs` con `harness/skills/nest-base/templates/eslint.config.mjs` y copia `.../templates/eslint.boundaries.mjs` a la raíz (la importa `eslint.config.mjs`). Si la versión generada por Nest usa una API distinta (por ejemplo `defineConfig`), conserva su base y porta el bloque de reglas del harness y los overrides por archivo sin cambiar su intención:
98
+ - no `any`;
99
+ - no `console`;
100
+ - no promesas flotantes;
101
+ - `pg` solo en `common/database` y `*.repository.ts`;
102
+ - texto SQL solo en `*.sql.ts` (también tras saltos de línea o `(`, e incluidas escrituras);
103
+ - nada de escrituras o DDL en `*.sql.ts`;
104
+ - fronteras (`eslint.boundaries.mjs`): `src/common` no importa de `src/modules`, y un módulo no importa de otro módulo ni de otro cliente; solo sale hacia `src/common` y `src/config`. Usa `@typescript-eslint/no-restricted-imports`, separada de la regla de `pg`, sin dependencias nuevas;
105
+ - configuración (`configBoundaries` de `eslint.boundaries.mjs`): `process.env` solo en `src/config/`, salvo specs y `test/`.
106
+ 4. **Prettier:** copia `harness/skills/nest-base/templates/prettierrc.json` → `.prettierrc` y `.../templates/prettierignore` → `.prettierignore`.
107
+ 5. **Editor:** copia `harness/skills/nest-base/templates/editorconfig` → `.editorconfig`.
108
+ 6. **Aislar el harness del proyecto:**
109
+ - `tsconfig.json` y `tsconfig.build.json`: agrega `"harness"` a `exclude`.
110
+ - `.dockerignore`: incluye `harness`, `.agents`, `.claude`, `.cursor`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.env`, `.env.local`, `node_modules`, `dist` y `coverage`.
111
+ - `.prettierignore`: agrega `harness`.
112
+ 7. **`.gitignore`:** asegúrate de que incluye `dist/`, `coverage/`, `node_modules/`, `logs/`, `.env`, `.env.local`, `.env.*.local` y `harness/progress.md`.
113
+ 8. **Convención SQL:** las palabras clave SQL se escriben en MAYÚSCULAS. Así las detectan la regla de ESLint y `init.sh`.
114
+
115
+ ## 5. Estructura base (AGENTS.md §4)
116
+
117
+ Implementa cada pieza con su spec. Sin lógica de negocio y sin módulos de ejemplo: `src/modules/` queda vacío con un `.gitkeep`.
118
+
119
+ | Ruta | Contenido mínimo |
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` |
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>` |
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
+ | `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`) |
127
+ | `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
+ | `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
+ | `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
+ | `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
+
133
+ ### Archivos de soporte
134
+
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
+ - **`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
+ - **`FRONT.md`:**
138
+ - flujo de auth (login, uso del Bearer, refresh ante `401 TOKEN_EXPIRED`);
139
+ - envelope `{ data, meta? }` y formato de error;
140
+ - significado de `X-Cache`;
141
+ - CORS: el origen del frontend debe estar en `CORS_ORIGINS` de cada entorno (pedirlo al backend);
142
+ - 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
+ - **`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
+ - **`azure-pipelines.yml`:** Node desde `.nvmrc`, `corepack enable`, `pnpm install --frozen-lockfile`, `lint:check`, `format:check`, `build`, `test` y `docker build`.
146
+
147
+ ## 6. Verificar (con protección contra bucles)
148
+
149
+ ```bash
150
+ pnpm lint && pnpm format
151
+ ./harness/init.sh --strict
152
+ ```
153
+
154
+ - Un proyecto nuevo nace cumpliendo todo: `./harness/init.sh --strict` debe terminar en 0, incluido `/health`. Los proyectos existentes usan el modo por defecto (advertencias). Si no hay PostgreSQL local disponible, el health informará las BDs caídas: repórtalo al humano y no lo "arregles" desactivando checks.
155
+ - Corrige solo fallos causados por archivos que esta skill creó. **Máximo 3 intentos de corrección.** Si sigue fallando, detente y reporta la salida de `init.sh`.
156
+ - Nunca relajes reglas de ESLint, umbrales ni checks de `init.sh` para pasar.
157
+
158
+ ## 7. Reporte final
159
+
160
+ Informa:
161
+ - las versiones resueltas (Node LTS, NestJS, pnpm);
162
+ - los archivos creados;
163
+ - las dependencias instaladas;
164
+ - el resultado de `./harness/init.sh`;
165
+ - los pendientes (por ejemplo, credenciales reales de BD en `.env.local`).
166
+
167
+ No hagas commit salvo que el humano lo pida.
@@ -0,0 +1,84 @@
1
+ // Plantilla del harness → src/config/configuration.spec.ts
2
+ import { buildConfiguration, type Env } from './configuration';
3
+
4
+ const base: Env = {
5
+ DATABASES: 'MAIN',
6
+ DB_MAIN_HOST: 'localhost',
7
+ DB_MAIN_NAME: 'analytics',
8
+ DB_MAIN_USER: 'readonly_user',
9
+ DB_MAIN_PASSWORD: 'change-me',
10
+ ACCESS_TOKEN: 'example123',
11
+ JWT_ACCESS_SECRET: 'access-secret',
12
+ JWT_REFRESH_SECRET: 'refresh-secret',
13
+ CORS_ORIGINS: 'http://localhost:4200',
14
+ };
15
+
16
+ describe('buildConfiguration', () => {
17
+ it('groups variables by topic and applies non-sensitive defaults', () => {
18
+ const config = buildConfiguration(base);
19
+ expect(config.app).toEqual({ nodeEnv: 'development', port: 3000 });
20
+ expect(config.logging).toEqual({ level: 'info', format: 'json' });
21
+ expect(config.databases.MAIN).toEqual({
22
+ host: 'localhost',
23
+ port: 5432,
24
+ name: 'analytics',
25
+ user: 'readonly_user',
26
+ password: 'change-me',
27
+ ssl: false,
28
+ poolMax: 10,
29
+ statementTimeoutMs: 30000,
30
+ });
31
+ expect(config.cache.enabled).toBe(false);
32
+ expect(config.auth.jwt.accessExpiresIn).toBe('15m');
33
+ expect(config.cors).toEqual({ origins: ['http://localhost:4200'], maxAgeSeconds: 600 });
34
+ expect(config.monitor).toEqual({ enabled: false, sessionExpiresIn: '15m' });
35
+ });
36
+
37
+ it('converts numbers, booleans and one block per database', () => {
38
+ const config = buildConfiguration({
39
+ ...base,
40
+ PORT: '8080',
41
+ DATABASES: 'MAIN, reports',
42
+ DB_MAIN_SSL: 'true',
43
+ DB_REPORTS_HOST: 'replica',
44
+ DB_REPORTS_PORT: '6432',
45
+ DB_REPORTS_NAME: 'reports',
46
+ DB_REPORTS_USER: 'ro',
47
+ DB_REPORTS_PASSWORD: 'x',
48
+ CACHE_ENABLED: 'true',
49
+ REDIS_URL: 'redis://localhost:6379',
50
+ });
51
+ expect(config.app.port).toBe(8080);
52
+ expect(config.databases.MAIN.ssl).toBe(true);
53
+ expect(config.databases.REPORTS.port).toBe(6432);
54
+ expect(config.cache).toMatchObject({ enabled: true, redisUrl: 'redis://localhost:6379' });
55
+ });
56
+
57
+ it.each(['ACCESS_TOKEN', 'JWT_ACCESS_SECRET', 'JWT_REFRESH_SECRET', 'DB_MAIN_PASSWORD'])(
58
+ 'fails without the secret %s and does not print any value',
59
+ (name) => {
60
+ const env = { ...base, [name]: '' };
61
+ expect(() => buildConfiguration(env)).toThrow(`${name} is required`);
62
+ },
63
+ );
64
+
65
+ it('requires REDIS_URL only when the cache is enabled', () => {
66
+ expect(() => buildConfiguration({ ...base, CACHE_ENABLED: 'true' })).toThrow(
67
+ 'REDIS_URL is required',
68
+ );
69
+ });
70
+
71
+ it('does not allow pretty logs in production', () => {
72
+ expect(() =>
73
+ buildConfiguration({ ...base, NODE_ENV: 'production', LOG_FORMAT: 'pretty' }),
74
+ ).toThrow('not allowed');
75
+ });
76
+
77
+ it.each([
78
+ ['PORT', 'abc', /integer/],
79
+ ['MONITOR_ENABLED', 'yes', /true or false/],
80
+ ['LOG_FORMAT', 'xml', /json or pretty/],
81
+ ])('rejects %s=%p', (name, value, message) => {
82
+ expect(() => buildConfiguration({ ...base, [name]: value })).toThrow(message);
83
+ });
84
+ });
@@ -0,0 +1,143 @@
1
+ // Plantilla del harness → src/config/configuration.ts (AGENTS.md §14, harness/reference/configuracion.md).
2
+ // Single place in the project that reads process.env. The rest of the code receives AppConfig
3
+ // through ConfigService<AppConfig, true>. Variables are validated at startup (env validation in
4
+ // src/config/); this file only groups them and converts their types.
5
+ import { type LogFormat, parseLogFormat } from '../common/logging/logger-options';
6
+ import { parseCorsMaxAge, parseCorsOrigins } from './cors';
7
+
8
+ export type Env = Record<string, string | undefined>;
9
+
10
+ export interface DatabaseConfig {
11
+ host: string;
12
+ port: number;
13
+ name: string;
14
+ user: string;
15
+ password: string;
16
+ ssl: boolean;
17
+ poolMax: number;
18
+ statementTimeoutMs: number;
19
+ }
20
+
21
+ export interface AppConfig {
22
+ app: { nodeEnv: string; port: number };
23
+ logging: { level: string; format: LogFormat };
24
+ databases: Record<string, DatabaseConfig>;
25
+ cache: {
26
+ enabled: boolean;
27
+ redisUrl: string | undefined;
28
+ defaultTtlSeconds: number;
29
+ keyPrefix: string;
30
+ opTimeoutMs: number;
31
+ };
32
+ auth: {
33
+ accessToken: string;
34
+ jwt: {
35
+ accessSecret: string;
36
+ accessExpiresIn: string;
37
+ refreshSecret: string;
38
+ refreshExpiresIn: string;
39
+ };
40
+ };
41
+ cors: { origins: string[]; maxAgeSeconds: number };
42
+ monitor: { enabled: boolean; sessionExpiresIn: string };
43
+ }
44
+
45
+ /** Required variable. The error names the variable, never its value. Secrets have no default. */
46
+ export function required(env: Env, name: string): string {
47
+ const value = env[name];
48
+ if (value === undefined || value.trim() === '') {
49
+ throw new Error(`${name} is required`);
50
+ }
51
+ return value;
52
+ }
53
+
54
+ /** Optional text variable with a non-sensitive default. */
55
+ export function text(env: Env, name: string, fallback: string): string {
56
+ const value = env[name];
57
+ return value === undefined || value.trim() === '' ? fallback : value.trim();
58
+ }
59
+
60
+ export function int(env: Env, name: string, fallback: number): number {
61
+ const raw = env[name];
62
+ if (raw === undefined || raw.trim() === '') return fallback;
63
+ const value = Number(raw);
64
+ if (!Number.isInteger(value)) {
65
+ throw new Error(`${name} must be an integer`);
66
+ }
67
+ return value;
68
+ }
69
+
70
+ export function bool(env: Env, name: string, fallback: boolean): boolean {
71
+ const raw = env[name]?.trim().toLowerCase();
72
+ if (raw === undefined || raw === '') return fallback;
73
+ if (raw === 'true') return true;
74
+ if (raw === 'false') return false;
75
+ throw new Error(`${name} must be true or false`);
76
+ }
77
+
78
+ /** DATABASES=MAIN,REPORTS → one block per name, read from DB_<NAME>_*. */
79
+ export function parseDatabases(env: Env): Record<string, DatabaseConfig> {
80
+ const names = required(env, 'DATABASES')
81
+ .split(',')
82
+ .map((name) => name.trim().toUpperCase())
83
+ .filter(Boolean);
84
+ return Object.fromEntries(
85
+ names.map((name): [string, DatabaseConfig] => {
86
+ const key = (part: string): string => `DB_${name}_${part}`;
87
+ return [
88
+ name,
89
+ {
90
+ host: required(env, key('HOST')),
91
+ port: int(env, key('PORT'), 5432),
92
+ name: required(env, key('NAME')),
93
+ user: required(env, key('USER')),
94
+ password: required(env, key('PASSWORD')),
95
+ ssl: bool(env, key('SSL'), false),
96
+ poolMax: int(env, key('POOL_MAX'), 10),
97
+ statementTimeoutMs: int(env, key('STATEMENT_TIMEOUT_MS'), 30000),
98
+ },
99
+ ];
100
+ }),
101
+ );
102
+ }
103
+
104
+ /** Pure: builds the configuration from an env object (tests pass their own). */
105
+ export function buildConfiguration(env: Env): AppConfig {
106
+ const cacheEnabled = bool(env, 'CACHE_ENABLED', false);
107
+ const nodeEnv = text(env, 'NODE_ENV', 'development');
108
+ return {
109
+ app: { nodeEnv, port: int(env, 'PORT', 3000) },
110
+ logging: {
111
+ level: text(env, 'LOG_LEVEL', 'info'),
112
+ format: parseLogFormat(env.LOG_FORMAT, nodeEnv),
113
+ },
114
+ databases: parseDatabases(env),
115
+ cache: {
116
+ enabled: cacheEnabled,
117
+ redisUrl: cacheEnabled ? required(env, 'REDIS_URL') : env.REDIS_URL,
118
+ defaultTtlSeconds: int(env, 'CACHE_DEFAULT_TTL_SECONDS', 300),
119
+ keyPrefix: text(env, 'CACHE_KEY_PREFIX', 'arness'),
120
+ opTimeoutMs: int(env, 'CACHE_OP_TIMEOUT_MS', 200),
121
+ },
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
+ },
131
+ cors: {
132
+ origins: parseCorsOrigins(env.CORS_ORIGINS),
133
+ maxAgeSeconds: parseCorsMaxAge(env.CORS_MAX_AGE_SECONDS),
134
+ },
135
+ monitor: {
136
+ enabled: bool(env, 'MONITOR_ENABLED', false),
137
+ sessionExpiresIn: text(env, 'MONITOR_SESSION_EXPIRES_IN', '15m'),
138
+ },
139
+ };
140
+ }
141
+
142
+ /** Factory for ConfigModule.forRoot({ load: [configuration] }). */
143
+ export default (): AppConfig => buildConfiguration(process.env);
@@ -0,0 +1,53 @@
1
+ // Plantilla del harness → src/config/cors.spec.ts
2
+ import { buildCorsOptions, parseCorsMaxAge, parseCorsOrigins } from './cors';
3
+
4
+ describe('parseCorsOrigins', () => {
5
+ it('returns normalized, unique origins', () => {
6
+ expect(
7
+ parseCorsOrigins(' https://app.example.com ,http://localhost:4200,https://app.example.com/'),
8
+ ).toEqual(['https://app.example.com', 'http://localhost:4200']);
9
+ });
10
+
11
+ it.each([
12
+ ['', /required/],
13
+ [undefined, /required/],
14
+ ['*', /wildcards/],
15
+ ['https://*.example.com', /wildcards/],
16
+ ['not-a-url', /invalid origin/],
17
+ ['https://app.example.com/path', /origins only/],
18
+ ['http://app.example.com', /https/],
19
+ ])('rejects %p', (raw, message) => {
20
+ expect(() => parseCorsOrigins(raw)).toThrow(message);
21
+ });
22
+ });
23
+
24
+ describe('parseCorsMaxAge', () => {
25
+ it('defaults to 600 and validates the range', () => {
26
+ expect(parseCorsMaxAge(undefined)).toBe(600);
27
+ expect(parseCorsMaxAge('120')).toBe(120);
28
+ expect(() => parseCorsMaxAge('-1')).toThrow();
29
+ expect(() => parseCorsMaxAge('abc')).toThrow();
30
+ });
31
+ });
32
+
33
+ describe('buildCorsOptions', () => {
34
+ const options = buildCorsOptions(['https://app.example.com'], 600);
35
+ const decide = (origin: string | undefined): boolean | undefined => {
36
+ let result: boolean | undefined;
37
+ (
38
+ options.origin as (
39
+ o: string | undefined,
40
+ cb: (e: Error | null, allow?: boolean) => void,
41
+ ) => void
42
+ )(origin, (_err, allow) => (result = allow));
43
+ return result;
44
+ };
45
+
46
+ it('allows only origins in the allowlist and never uses credentials', () => {
47
+ expect(decide('https://app.example.com')).toBe(true);
48
+ expect(decide('https://evil.example.com')).toBe(false);
49
+ expect(decide(undefined)).toBe(false);
50
+ expect(options.credentials).toBe(false);
51
+ expect(options.methods).toEqual(['GET', 'POST', 'OPTIONS']);
52
+ });
53
+ });
@@ -0,0 +1,75 @@
1
+ // Plantilla del harness → src/config/cors.ts (AGENTS.md §9, harness/reference/contrato-http.md "CORS").
2
+ import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
3
+
4
+ export const CORS_METHODS = ['GET', 'POST', 'OPTIONS'];
5
+ export const CORS_ALLOWED_HEADERS = ['Authorization', 'Content-Type', 'X-Request-Id'];
6
+ export const CORS_EXPOSED_HEADERS = ['X-Cache', 'X-Request-Id'];
7
+ export const CORS_DEFAULT_MAX_AGE_SECONDS = 600;
8
+
9
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
10
+
11
+ /**
12
+ * Parses CORS_ORIGINS (comma separated) into exact origins.
13
+ * Throws on an empty list, wildcards, values that are not an origin, or http:// outside localhost.
14
+ */
15
+ export function parseCorsOrigins(raw: string | undefined): string[] {
16
+ const items = (raw ?? '')
17
+ .split(',')
18
+ .map((item) => item.trim())
19
+ .filter(Boolean);
20
+ if (items.length === 0) {
21
+ throw new Error('CORS_ORIGINS is required: list the allowed origins separated by commas');
22
+ }
23
+ return [
24
+ ...new Set(
25
+ items.map((item) => {
26
+ if (item.includes('*')) {
27
+ throw new Error(`CORS_ORIGINS does not accept wildcards: ${item}`);
28
+ }
29
+ let url: URL;
30
+ try {
31
+ url = new URL(item);
32
+ } catch {
33
+ throw new Error(`CORS_ORIGINS has an invalid origin: ${item}`);
34
+ }
35
+ if (url.origin === 'null' || url.origin !== item.replace(/\/$/, '')) {
36
+ throw new Error(`CORS_ORIGINS must contain origins only (scheme://host[:port]): ${item}`);
37
+ }
38
+ if (
39
+ url.protocol !== 'https:' &&
40
+ !(url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))
41
+ ) {
42
+ throw new Error(`CORS_ORIGINS requires https:// except for localhost: ${item}`);
43
+ }
44
+ return url.origin;
45
+ }),
46
+ ),
47
+ ];
48
+ }
49
+
50
+ /** Parses CORS_MAX_AGE_SECONDS; defaults to 600 seconds. */
51
+ export function parseCorsMaxAge(raw: string | undefined): number {
52
+ if (raw === undefined || raw.trim() === '') return CORS_DEFAULT_MAX_AGE_SECONDS;
53
+ const value = Number(raw);
54
+ if (!Number.isInteger(value) || value < 0 || value > 86400) {
55
+ throw new Error('CORS_MAX_AGE_SECONDS must be an integer between 0 and 86400');
56
+ }
57
+ return value;
58
+ }
59
+
60
+ /** Options for app.enableCors(): only origins in the allowlist get Access-Control-Allow-Origin. */
61
+ export function buildCorsOptions(origins: readonly string[], maxAgeSeconds: number): CorsOptions {
62
+ const allowed = new Set(origins);
63
+ return {
64
+ origin: (
65
+ origin: string | undefined,
66
+ callback: (err: Error | null, allow?: boolean) => void,
67
+ ): void => callback(null, origin !== undefined && allowed.has(origin)),
68
+ methods: CORS_METHODS,
69
+ allowedHeaders: CORS_ALLOWED_HEADERS,
70
+ exposedHeaders: CORS_EXPOSED_HEADERS,
71
+ credentials: false,
72
+ maxAge: maxAgeSeconds,
73
+ optionsSuccessStatus: 204,
74
+ };
75
+ }