@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.
- package/CHANGELOG.md +64 -0
- package/README.md +156 -2
- package/bin/arness.mjs +710 -0
- package/harness/AGENTS.md +234 -0
- package/harness/agents/implementador.md +42 -0
- package/harness/agents/lider.md +71 -0
- package/harness/agents/revisor.md +62 -0
- package/harness/decisiones/README.md +17 -0
- package/harness/decisiones/_plantilla.md +24 -0
- package/harness/init.sh +696 -0
- package/harness/playbooks/add-database.md +16 -0
- package/harness/playbooks/new-endpoint.md +88 -0
- package/harness/playbooks/new-module.md +21 -0
- package/harness/playbooks/new-monitor-widget.md +31 -0
- package/harness/playbooks/review.md +40 -0
- package/harness/reference/configuracion.md +104 -0
- package/harness/reference/contrato-http.md +138 -0
- package/harness/reference/novedades.json +107 -0
- package/harness/reference/progress-plantilla.md +50 -0
- package/harness/skills/commit-hygiene/SKILL.md +137 -0
- package/harness/skills/nest-base/SKILL.md +167 -0
- package/harness/skills/nest-base/templates/config/configuration.spec.ts +84 -0
- package/harness/skills/nest-base/templates/config/configuration.ts +143 -0
- package/harness/skills/nest-base/templates/cors/cors.spec.ts +53 -0
- package/harness/skills/nest-base/templates/cors/cors.ts +75 -0
- package/harness/skills/nest-base/templates/docs/EVOLUTIVO.md +22 -0
- package/harness/skills/nest-base/templates/docs/PENDIENTES.md +20 -0
- package/harness/skills/nest-base/templates/docs/TECNICO.md +43 -0
- package/harness/skills/nest-base/templates/e2e/cors.e2e-spec.ts +60 -0
- package/harness/skills/nest-base/templates/e2e/monitor.e2e-spec.ts +134 -0
- package/harness/skills/nest-base/templates/editorconfig +12 -0
- package/harness/skills/nest-base/templates/eslint.boundaries.mjs +65 -0
- package/harness/skills/nest-base/templates/eslint.config.mjs +82 -0
- package/harness/skills/nest-base/templates/logging/logger-options.spec.ts +57 -0
- package/harness/skills/nest-base/templates/logging/logger-options.ts +85 -0
- package/harness/skills/nest-base/templates/monitor/monitor-login-redirect.filter.ts +16 -0
- package/harness/skills/nest-base/templates/monitor/monitor-login.dto.ts +8 -0
- package/harness/skills/nest-base/templates/monitor/monitor-page.spec.ts +41 -0
- package/harness/skills/nest-base/templates/monitor/monitor-page.ts +190 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.guard.spec.ts +46 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.guard.ts +36 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.service.spec.ts +57 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.service.ts +77 -0
- package/harness/skills/nest-base/templates/monitor/monitor-widget.interface.ts +18 -0
- package/harness/skills/nest-base/templates/monitor/monitor.constants.ts +25 -0
- package/harness/skills/nest-base/templates/monitor/monitor.controller.ts +63 -0
- package/harness/skills/nest-base/templates/monitor/monitor.decorators.ts +11 -0
- package/harness/skills/nest-base/templates/monitor/monitor.module.ts +30 -0
- package/harness/skills/nest-base/templates/monitor/monitor.service.spec.ts +55 -0
- package/harness/skills/nest-base/templates/monitor/monitor.service.ts +49 -0
- package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.spec.ts +51 -0
- package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.ts +53 -0
- package/harness/skills/nest-base/templates/prettierignore +6 -0
- package/harness/skills/nest-base/templates/prettierrc.json +8 -0
- 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
|
+
}
|