@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,22 @@
1
+ <!-- Plantilla del harness → docs/EVOLUTIVO.md (AGENTS.md §12). Quita este comentario al copiarla. -->
2
+ # Evolutivo
3
+
4
+ Trazabilidad de lo que se ha entregado en el proyecto, en orden cronológico. Cada entrega agrega una entrada al final; las anteriores no se reescriben ni se borran.
5
+
6
+ **Solo avance.** Aquí no van pendientes, bloqueos, stoppers ni riesgos: esos van en `docs/PENDIENTES.md`.
7
+
8
+ Formato de cada entrada (lenguaje entendible para negocio):
9
+
10
+ ```markdown
11
+ ## AAAA-MM-DD · <cliente>/<módulo>
12
+
13
+ - **Entregado:** <qué se puede hacer ahora, en una o dos frases>.
14
+ - **Detalle técnico:** <endpoint, conexión, widget o cambio de contrato>.
15
+ - **Referencia:** <commit, PR o tag>.
16
+ ```
17
+
18
+ ## AAAA-MM-DD · Base del proyecto
19
+
20
+ - **Entregado:** base del backend lista para agregar reportes: autenticación, salud del servicio y caché.
21
+ - **Detalle técnico:** proyecto NestJS creado con la skill `nest-base` del harness.
22
+ - **Referencia:** <commit inicial>.
@@ -0,0 +1,20 @@
1
+ <!-- Plantilla del harness → docs/PENDIENTES.md (AGENTS.md §12). Quita este comentario al copiarla. -->
2
+ # Pendientes
3
+
4
+ Pendientes, bloqueos, stoppers, recomendaciones, deuda técnica y advertencias de `./harness/init.sh` que no se corrigieron. Lo entregado va en `docs/EVOLUTIVO.md`.
5
+
6
+ Cada ítem lleva fecha, contexto y tipo. Al resolverse, se mueve a **Resueltos** con la fecha de cierre (y, si fue una entrega, se registra en `docs/EVOLUTIVO.md`).
7
+
8
+ Tipos: `pendiente` · `bloqueo` · `recomendación` · `deuda técnica` · `advertencia init.sh`.
9
+
10
+ ## Abiertos
11
+
12
+ | Fecha | Contexto (cliente/módulo) | Tipo | Descripción | Responsable |
13
+ |---|---|---|---|---|
14
+ | | | | | |
15
+
16
+ ## Resueltos
17
+
18
+ | Fecha | Cierre | Contexto | Tipo | Descripción |
19
+ |---|---|---|---|---|
20
+ | | | | | |
@@ -0,0 +1,43 @@
1
+ <!-- Plantilla del harness → docs/TECNICO.md (AGENTS.md §12). Quita este comentario al copiarla. -->
2
+ # Documentación técnica
3
+
4
+ Backend de analítica de **<cliente o producto>**: reportes de solo lectura sobre PostgreSQL. Las reglas de trabajo están en `AGENTS.md`; el contrato con frontend, en `FRONT.md`.
5
+
6
+ ## Arquitectura
7
+
8
+ - NestJS por capas hexagonales por endpoint: controller → service (Cache-Aside) → repository (`pg`) → `*.sql.ts`.
9
+ - Organización `src/modules/<cliente>/<modulo>/<endpoint>/`; lo transversal vive en `src/common/`.
10
+ - Autenticación JWT (access + refresh) en todos los endpoints salvo `login`, `refresh` y `health`.
11
+ - Caché Redis opcional: la API responde igual si Redis está caído o deshabilitado.
12
+
13
+ ## Módulos
14
+
15
+ | Cliente | Módulo | Propósito | Conexión | README |
16
+ |---|---|---|---|---|
17
+ | | | | | |
18
+
19
+ ## Conexiones de base de datos
20
+
21
+ Solo nombres lógicos y origen; **nunca** hosts, usuarios ni contraseñas.
22
+
23
+ | Nombre (`DATABASES`) | Propósito | Esquemas o vistas principales |
24
+ |---|---|---|
25
+ | MAIN | | |
26
+
27
+ ## Configuración
28
+
29
+ Variables por tema en `src/config/configuration.ts` y referencia completa en `harness/reference/configuracion.md`. Variables propias de este proyecto:
30
+
31
+ | Variable | Para qué | Obligatoria |
32
+ |---|---|---|
33
+ | | | |
34
+
35
+ ## Despliegue
36
+
37
+ - Imagen Docker (`Dockerfile`) y pipeline `azure-pipelines.yml`.
38
+ - Entornos: <desarrollo, QA, producción> y sus URLs base.
39
+ - `GET /health` reporta cada BD y la caché; `GET /monitor` solo donde `MONITOR_ENABLED=true`.
40
+
41
+ ## Decisiones
42
+
43
+ Las decisiones del proyecto están en `harness/decisiones/` (índice en su `README.md`).
@@ -0,0 +1,60 @@
1
+ // Plantilla del harness → test/cors.e2e-spec.ts
2
+ import { Controller, Get, INestApplication } from '@nestjs/common';
3
+ import { Test } from '@nestjs/testing';
4
+ import request from 'supertest';
5
+ import { App } from 'supertest/types';
6
+ import { buildCorsOptions } from '../src/config/cors';
7
+
8
+ @Controller('probe')
9
+ class ProbeController {
10
+ @Get()
11
+ get(): { data: string } {
12
+ return { data: 'ok' };
13
+ }
14
+ }
15
+
16
+ describe('CORS (e2e)', () => {
17
+ let app: INestApplication<App>;
18
+ const allowed = 'https://app.example.com';
19
+
20
+ beforeAll(async () => {
21
+ const moduleRef = await Test.createTestingModule({ controllers: [ProbeController] }).compile();
22
+ app = moduleRef.createNestApplication();
23
+ app.enableCors(buildCorsOptions([allowed], 600));
24
+ await app.init();
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await app.close();
29
+ });
30
+
31
+ it('answers an allowed preflight with the CORS headers', async () => {
32
+ const res = await request(app.getHttpServer())
33
+ .options('/probe')
34
+ .set('Origin', allowed)
35
+ .set('Access-Control-Request-Method', 'GET')
36
+ .set('Access-Control-Request-Headers', 'Authorization');
37
+ expect(res.status).toBe(204);
38
+ expect(res.headers['access-control-allow-origin']).toBe(allowed);
39
+ expect(res.headers['access-control-allow-methods']).toBe('GET,POST,OPTIONS');
40
+ expect(res.headers['access-control-allow-headers']).toBe(
41
+ 'Authorization,Content-Type,X-Request-Id',
42
+ );
43
+ expect(res.headers['access-control-max-age']).toBe('600');
44
+ expect(res.headers['access-control-allow-credentials']).toBeUndefined();
45
+ });
46
+
47
+ it('exposes X-Cache and X-Request-Id to allowed origins', async () => {
48
+ const res = await request(app.getHttpServer()).get('/probe').set('Origin', allowed);
49
+ expect(res.headers['access-control-allow-origin']).toBe(allowed);
50
+ expect(res.headers['access-control-expose-headers']).toBe('X-Cache,X-Request-Id');
51
+ });
52
+
53
+ it('does not allow an origin outside the allowlist', async () => {
54
+ const res = await request(app.getHttpServer())
55
+ .options('/probe')
56
+ .set('Origin', 'https://evil.example.com')
57
+ .set('Access-Control-Request-Method', 'GET');
58
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
59
+ });
60
+ });
@@ -0,0 +1,134 @@
1
+ // Plantilla del harness → test/monitor.e2e-spec.ts
2
+ import { Controller, Get, INestApplication, ValidationPipe } from '@nestjs/common';
3
+ import { ConfigModule } from '@nestjs/config';
4
+ import { Test } from '@nestjs/testing';
5
+ import request from 'supertest';
6
+ import { App } from 'supertest/types';
7
+ import { MonitorModule } from '../src/common/monitor/monitor.module';
8
+ import { MONITOR_WIDGETS, MonitorWidget } from '../src/common/monitor/monitor-widget.interface';
9
+ import { EndpointsTotalWidget } from '../src/common/monitor/widgets/endpoints-total.widget';
10
+
11
+ @Controller('cartera')
12
+ class CarteraController {
13
+ @Get('resumen')
14
+ summary(): void {}
15
+
16
+ @Get('detalle')
17
+ detail(): void {}
18
+ }
19
+
20
+ @Controller('health')
21
+ class HealthController {
22
+ @Get()
23
+ check(): void {}
24
+ }
25
+
26
+ const brokenWidget: MonitorWidget = {
27
+ id: 'broken',
28
+ title: 'Widget roto',
29
+ order: 99,
30
+ collect: () => Promise.reject(new Error('connect ECONNREFUSED 10.0.0.5:5432')),
31
+ };
32
+
33
+ async function createApp(enabled: boolean): Promise<INestApplication<App>> {
34
+ const moduleRef = await Test.createTestingModule({
35
+ imports: [
36
+ ConfigModule.forRoot({
37
+ isGlobal: true,
38
+ ignoreEnvFile: true,
39
+ load: [
40
+ () => ({
41
+ MONITOR_ENABLED: String(enabled),
42
+ MONITOR_SESSION_EXPIRES_IN: '15m',
43
+ ACCESS_TOKEN: 'example123',
44
+ JWT_ACCESS_SECRET: 'e2e-access-secret-with-enough-length',
45
+ }),
46
+ ],
47
+ }),
48
+ MonitorModule,
49
+ ],
50
+ controllers: [CarteraController, HealthController],
51
+ })
52
+ .overrideProvider(MONITOR_WIDGETS)
53
+ .useFactory({
54
+ factory: (total: EndpointsTotalWidget) => [total, brokenWidget],
55
+ inject: [EndpointsTotalWidget],
56
+ })
57
+ .compile();
58
+ const app = moduleRef.createNestApplication<INestApplication<App>>();
59
+ app.useGlobalPipes(
60
+ new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
61
+ );
62
+ await app.init();
63
+ return app;
64
+ }
65
+
66
+ describe('Monitor (e2e)', () => {
67
+ let app: INestApplication<App>;
68
+
69
+ beforeAll(async () => {
70
+ app = await createApp(true);
71
+ });
72
+
73
+ afterAll(async () => {
74
+ await app.close();
75
+ });
76
+
77
+ const login = (password: string): request.Test =>
78
+ request(app.getHttpServer()).post('/monitor/login').type('form').send({ password });
79
+
80
+ it('redirects to the login form without a session', async () => {
81
+ const res = await request(app.getHttpServer()).get('/monitor');
82
+ expect(res.status).toBe(303);
83
+ expect(res.headers.location).toBe('/monitor/login');
84
+ });
85
+
86
+ it('serves the login form with the security headers and no scripts', async () => {
87
+ const res = await request(app.getHttpServer()).get('/monitor/login');
88
+ expect(res.status).toBe(200);
89
+ expect(res.headers['content-type']).toMatch(/text\/html/);
90
+ expect(res.headers['content-security-policy']).toContain("default-src 'none'");
91
+ expect(res.headers['cache-control']).toBe('no-store');
92
+ expect(res.headers['x-frame-options']).toBe('DENY');
93
+ expect(res.text).not.toMatch(/<script/i);
94
+ });
95
+
96
+ it('rejects a wrong password with 401 and no cookie', async () => {
97
+ const res = await login('wrong');
98
+ expect(res.status).toBe(401);
99
+ expect(res.headers['set-cookie']).toBeUndefined();
100
+ expect(res.text).toContain('Contraseña incorrecta');
101
+ });
102
+
103
+ it('logs in, shows the endpoint count and isolates a failing widget', async () => {
104
+ const res = await login('example123');
105
+ expect(res.status).toBe(303);
106
+ expect(res.headers.location).toBe('/monitor');
107
+ const cookie = String(res.headers['set-cookie']);
108
+ expect(cookie).toMatch(
109
+ /arness_monitor=.+; Max-Age=900; Path=\/monitor; HttpOnly; Secure; SameSite=Strict/,
110
+ );
111
+
112
+ const page = await request(app.getHttpServer())
113
+ .get('/monitor')
114
+ .set('Cookie', cookie.split(';')[0]);
115
+ expect(page.status).toBe(200);
116
+ expect(page.text).toMatch(/data-widget="endpoints-total".*?>2</);
117
+ expect(page.text).toContain('No disponible');
118
+ expect(page.text).not.toContain('ECONNREFUSED');
119
+ expect(page.text).not.toMatch(/<script/i);
120
+ });
121
+
122
+ it('logout clears the cookie', async () => {
123
+ const res = await request(app.getHttpServer()).post('/monitor/logout');
124
+ expect(res.status).toBe(303);
125
+ expect(String(res.headers['set-cookie'])).toContain('arness_monitor=; Max-Age=0');
126
+ });
127
+
128
+ it('answers 404 when MONITOR_ENABLED=false', async () => {
129
+ const disabled = await createApp(false);
130
+ await request(disabled.getHttpServer()).get('/monitor').expect(404);
131
+ await request(disabled.getHttpServer()).get('/monitor/login').expect(404);
132
+ await disabled.close();
133
+ });
134
+ });
@@ -0,0 +1,12 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_style = space
7
+ indent_size = 2
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.md]
12
+ trim_trailing_whitespace = false
@@ -0,0 +1,65 @@
1
+ // @ts-check
2
+ // Fronteras de arquitectura del harness (AGENTS.md §4). Sin dependencias: solo
3
+ // @typescript-eslint/no-restricted-imports con patrones. Se copia a la raíz del proyecto y la
4
+ // importa eslint.config.mjs. Usa una regla distinta de no-restricted-imports (la de `pg`) para que
5
+ // los overrides de una no apaguen la otra. También exporta configBoundaries (process.env solo en
6
+ // src/config/, AGENTS.md §14).
7
+
8
+ const RULE = '@typescript-eslint/no-restricted-imports';
9
+
10
+ export const COMMON_IMPORTS_MODULES =
11
+ 'src/common no importa de src/modules: lo compartido vive en common (AGENTS.md §4).';
12
+ export const MODULE_IMPORTS_OTHER_MODULE =
13
+ 'Un módulo no importa de otro módulo ni de otro cliente: lo compartido va a src/common (AGENTS.md §4).';
14
+ export const MODULE_ABSOLUTE_IMPORT =
15
+ 'Dentro de un módulo usa imports relativos; importar otro módulo está prohibido (AGENTS.md §4).';
16
+ export const PROCESS_ENV_OUTSIDE_CONFIG =
17
+ 'process.env solo se lee en src/config/configuration.ts; usa ConfigService (AGENTS.md §13 y §14).';
18
+
19
+ /** Niveles de carpeta bajo src/modules/<cliente>/<modulo>/ que se verifican. */
20
+ const MAX_DEPTH = 5;
21
+
22
+ /**
23
+ * Un archivo a `depth` carpetas bajo la raíz de su módulo sale del módulo con depth + 1 `../`.
24
+ * Desde ahí solo puede llegar a src/common o src/config (dos niveles más arriba: cliente y modules).
25
+ */
26
+ const leavesModule = (depth) => `^(\\.\\./){${depth + 1}}(?!\\.\\./\\.\\./(common|config)(/|$))`;
27
+
28
+ /** @type {import('eslint').Linter.Config[]} */
29
+ export const architectureBoundaries = [
30
+ {
31
+ files: ['src/common/**/*.ts'],
32
+ rules: {
33
+ [RULE]: ['error', { patterns: [{ regex: '(^|/)modules(/|$)', message: COMMON_IMPORTS_MODULES }] }],
34
+ },
35
+ },
36
+ ...Array.from({ length: MAX_DEPTH }, (_, depth) => ({
37
+ files: [`src/modules/*/*/${'*/'.repeat(depth)}*.ts`],
38
+ rules: {
39
+ [RULE]: [
40
+ 'error',
41
+ {
42
+ patterns: [
43
+ { regex: leavesModule(depth), message: MODULE_IMPORTS_OTHER_MODULE },
44
+ { regex: '^src/modules/', message: MODULE_ABSOLUTE_IMPORT },
45
+ ],
46
+ },
47
+ ],
48
+ },
49
+ })),
50
+ ];
51
+
52
+ /**
53
+ * Configuración centralizada: process.env solo en src/config/. Los tests (specs y test/) pueden
54
+ * preparar variables. Usa no-restricted-properties, que ningún otro override del harness apaga.
55
+ * @type {import('eslint').Linter.Config[]}
56
+ */
57
+ export const configBoundaries = [
58
+ {
59
+ files: ['src/**/*.ts'],
60
+ ignores: ['src/config/**', 'src/**/*.spec.ts'],
61
+ rules: {
62
+ 'no-restricted-properties': ['error', { object: 'process', property: 'env', message: PROCESS_ENV_OUTSIDE_CONFIG }],
63
+ },
64
+ },
65
+ ];
@@ -0,0 +1,82 @@
1
+ // @ts-check
2
+ // ESLint del harness (AGENTS.md §4, §5, §6, §13 y §14).
3
+ import eslint from '@eslint/js';
4
+ import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
5
+ import globals from 'globals';
6
+ import tseslint from 'typescript-eslint';
7
+ import { architectureBoundaries, configBoundaries } from './eslint.boundaries.mjs';
8
+
9
+ // Las palabras clave SQL se escriben en MAYÚSCULAS por convención. Detecta el SQL al inicio del
10
+ // literal, también tras saltos de línea o paréntesis (subconsultas), incluidas escrituras.
11
+ const SQL_TEXT = '^[\\s(]*(SELECT|WITH|INSERT|UPDATE|DELETE|MERGE)\\s';
12
+ const SQL_WRITE =
13
+ '\\b(INSERT\\s+INTO|UPDATE\\s+\\S+\\s+SET|DELETE\\s+FROM|MERGE\\s+INTO|TRUNCATE|DROP\\s|ALTER\\s|CREATE\\s|GRANT\\s|REVOKE\\s)';
14
+
15
+ const SQL_OUTSIDE_SQL_FILE = 'El texto SQL solo puede vivir en *.sql.ts (AGENTS.md §5).';
16
+ const SQL_WRITE_FORBIDDEN = 'La BD es de solo lectura: prohibido escribir o ejecutar DDL (AGENTS.md §6).';
17
+
18
+ export default tseslint.config(
19
+ { ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'eslint.config.mjs', 'eslint.boundaries.mjs'] },
20
+ eslint.configs.recommended,
21
+ ...tseslint.configs.recommendedTypeChecked,
22
+ eslintPluginPrettierRecommended,
23
+ {
24
+ languageOptions: {
25
+ globals: { ...globals.node, ...globals.jest },
26
+ sourceType: 'commonjs',
27
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
28
+ },
29
+ },
30
+ {
31
+ rules: {
32
+ '@typescript-eslint/no-explicit-any': 'error',
33
+ '@typescript-eslint/no-floating-promises': 'error',
34
+ '@typescript-eslint/explicit-module-boundary-types': 'error',
35
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
36
+ 'no-console': 'error',
37
+ eqeqeq: ['error', 'always'],
38
+ 'no-restricted-imports': [
39
+ 'error',
40
+ {
41
+ paths: [
42
+ {
43
+ name: 'pg',
44
+ message: 'pg solo se usa en src/common/database y en *.repository.ts (AGENTS.md §5).',
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ 'no-restricted-syntax': [
50
+ 'error',
51
+ { selector: `Literal[value=/${SQL_TEXT}/]`, message: SQL_OUTSIDE_SQL_FILE },
52
+ { selector: `TemplateElement[value.raw=/${SQL_TEXT}/]`, message: SQL_OUTSIDE_SQL_FILE },
53
+ ],
54
+ },
55
+ },
56
+ // Fronteras: common no importa de modules y los módulos no se importan entre sí.
57
+ ...architectureBoundaries,
58
+ // Configuración: process.env solo en src/config/ (configuration.ts).
59
+ ...configBoundaries,
60
+ {
61
+ files: ['src/common/database/**/*.ts', 'src/**/*.repository.ts', 'src/**/*.repository.spec.ts'],
62
+ rules: { 'no-restricted-imports': 'off' },
63
+ },
64
+ {
65
+ files: ['src/**/*.sql.ts'],
66
+ rules: {
67
+ 'no-restricted-syntax': [
68
+ 'error',
69
+ { selector: `Literal[value=/${SQL_WRITE}/]`, message: SQL_WRITE_FORBIDDEN },
70
+ { selector: `TemplateElement[value.raw=/${SQL_WRITE}/]`, message: SQL_WRITE_FORBIDDEN },
71
+ ],
72
+ },
73
+ },
74
+ {
75
+ files: ['src/**/*.sql.spec.ts'],
76
+ rules: { 'no-restricted-syntax': 'off' },
77
+ },
78
+ {
79
+ files: ['**/*.spec.ts', 'test/**/*.ts'],
80
+ rules: { '@typescript-eslint/unbound-method': 'off' },
81
+ },
82
+ );
@@ -0,0 +1,57 @@
1
+ // Plantilla del harness → src/common/logging/logger-options.spec.ts
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { buildLoggerParams, parseLogFormat, REDACT_PATHS, requestId } from './logger-options';
4
+
5
+ type PinoHttpOptions = Exclude<NonNullable<ReturnType<typeof buildLoggerParams>['pinoHttp']>, unknown[]>;
6
+ const options = (params: ReturnType<typeof buildLoggerParams>): PinoHttpOptions =>
7
+ params.pinoHttp as PinoHttpOptions;
8
+
9
+ describe('parseLogFormat', () => {
10
+ it('defaults to json and accepts pretty outside production', () => {
11
+ expect(parseLogFormat(undefined, 'production')).toBe('json');
12
+ expect(parseLogFormat(' Pretty ', 'development')).toBe('pretty');
13
+ });
14
+
15
+ it('rejects unknown formats and pretty in production', () => {
16
+ expect(() => parseLogFormat('xml', 'development')).toThrow('json or pretty');
17
+ expect(() => parseLogFormat('pretty', 'production')).toThrow('not allowed');
18
+ });
19
+ });
20
+
21
+ describe('buildLoggerParams', () => {
22
+ it('json: no transport and the same redaction as pretty', () => {
23
+ const json = options(buildLoggerParams({ level: 'info', format: 'json' }, true));
24
+ const pretty = options(buildLoggerParams({ level: 'debug', format: 'pretty' }, true));
25
+ expect(json.transport).toBeUndefined();
26
+ expect(pretty.transport).toEqual(expect.objectContaining({ target: 'pino-pretty' }));
27
+ expect(json.redact).toEqual(pretty.redact);
28
+ expect(REDACT_PATHS).toEqual(
29
+ expect.arrayContaining(['req.headers.authorization', 'req.headers.cookie', '*.password']),
30
+ );
31
+ });
32
+
33
+ it('falls back to json when pino-pretty is not installed', () => {
34
+ expect(options(buildLoggerParams({ level: 'info', format: 'pretty' }, false)).transport).toBeUndefined();
35
+ });
36
+ });
37
+
38
+ describe('requestId', () => {
39
+ const call = (header?: string): { id: string; sent: string } => {
40
+ const res = { setHeader: jest.fn() };
41
+ const id = requestId(
42
+ { headers: header === undefined ? {} : { 'x-request-id': header } } as IncomingMessage,
43
+ res as unknown as ServerResponse,
44
+ );
45
+ return { id, sent: res.setHeader.mock.calls[0][1] as string };
46
+ };
47
+
48
+ it('reuses a safe X-Request-Id and returns it in the response', () => {
49
+ expect(call('abc-123')).toEqual({ id: 'abc-123', sent: 'abc-123' });
50
+ });
51
+
52
+ it.each([undefined, '', 'bad id <script>', 'x'.repeat(129)])('generates a UUID for %p', (header) => {
53
+ const { id, sent } = call(header);
54
+ expect(id).toMatch(/^[0-9a-f-]{36}$/);
55
+ expect(sent).toBe(id);
56
+ });
57
+ });
@@ -0,0 +1,85 @@
1
+ // Plantilla del harness → src/common/logging/logger-options.ts (AGENTS.md §10, harness/reference/contrato-http.md "Logging").
2
+ // JSON in production; one readable line per event in local development (LOG_FORMAT=pretty).
3
+ // Both formats carry the same fields and the same redaction.
4
+ import { randomUUID } from 'node:crypto';
5
+ import type { IncomingMessage, ServerResponse } from 'node:http';
6
+ import type { Params } from 'nestjs-pino';
7
+
8
+ export const LOG_FORMATS = ['json', 'pretty'] as const;
9
+ export type LogFormat = (typeof LOG_FORMATS)[number];
10
+
11
+ export interface LoggingConfig {
12
+ level: string;
13
+ format: LogFormat;
14
+ }
15
+
16
+ /** Never logged in any format: credentials, tokens, session cookies and secrets. */
17
+ export const REDACT_PATHS = [
18
+ 'req.headers.authorization',
19
+ 'req.headers.cookie',
20
+ 'res.headers["set-cookie"]',
21
+ '*.password',
22
+ '*.accessToken',
23
+ '*.refreshToken',
24
+ '*.ACCESS_TOKEN',
25
+ '*.JWT_ACCESS_SECRET',
26
+ '*.JWT_REFRESH_SECRET',
27
+ ];
28
+
29
+ /** Incoming X-Request-Id is reused only if it is short and safe; otherwise a UUID is generated. */
30
+ const REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/;
31
+
32
+ export const PRETTY_OPTIONS = {
33
+ colorize: true,
34
+ singleLine: true,
35
+ translateTime: 'SYS:HH:MM:ss',
36
+ ignore: 'pid,hostname,req,res,requestId,responseTime',
37
+ messageFormat: '[{requestId}] {msg}',
38
+ };
39
+
40
+ /** Parses LOG_FORMAT (default json). pretty is rejected with NODE_ENV=production. */
41
+ export function parseLogFormat(raw: string | undefined, nodeEnv: string): LogFormat {
42
+ const value = (raw ?? '').trim().toLowerCase() || 'json';
43
+ if (!(LOG_FORMATS as readonly string[]).includes(value)) {
44
+ throw new Error('LOG_FORMAT must be json or pretty');
45
+ }
46
+ if (value === 'pretty' && nodeEnv === 'production') {
47
+ throw new Error('LOG_FORMAT=pretty is not allowed with NODE_ENV=production');
48
+ }
49
+ return value as LogFormat;
50
+ }
51
+
52
+ /** pino-pretty is a devDependency: images built for production do not include it. */
53
+ export function isPrettyInstalled(): boolean {
54
+ try {
55
+ require.resolve('pino-pretty');
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export function requestId(req: IncomingMessage, res: ServerResponse): string {
63
+ const header = req.headers['x-request-id'];
64
+ const incoming = typeof header === 'string' ? header.trim() : '';
65
+ const id = REQUEST_ID.test(incoming) ? incoming : randomUUID();
66
+ res.setHeader('X-Request-Id', id);
67
+ return id;
68
+ }
69
+
70
+ /** Options for LoggerModule.forRootAsync({ useFactory }). Falls back to JSON if pino-pretty is missing. */
71
+ export function buildLoggerParams(config: LoggingConfig, prettyInstalled = isPrettyInstalled()): Params {
72
+ const pretty = config.format === 'pretty' && prettyInstalled;
73
+ return {
74
+ pinoHttp: {
75
+ level: config.level,
76
+ genReqId: requestId,
77
+ customProps: (req) => ({ requestId: String(req.id) }),
78
+ customSuccessMessage: (req, res, responseTime) =>
79
+ `${req.method} ${req.url} ${res.statusCode} ${Math.round(responseTime)}ms`,
80
+ customErrorMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode} failed`,
81
+ redact: { paths: REDACT_PATHS, censor: '[REDACTED]' },
82
+ ...(pretty ? { transport: { target: 'pino-pretty', options: PRETTY_OPTIONS } } : {}),
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,16 @@
1
+ // Plantilla del harness → src/common/monitor/monitor-login-redirect.filter.ts
2
+ import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
3
+ import type { Response } from 'express';
4
+ import { MONITOR_LOGIN_URL, MONITOR_SECURITY_HEADERS } from './monitor.constants';
5
+ import { MonitorLoginRequiredException } from './monitor-session.guard';
6
+
7
+ @Catch(MonitorLoginRequiredException)
8
+ export class MonitorLoginRedirectFilter implements ExceptionFilter {
9
+ catch(_exception: MonitorLoginRequiredException, host: ArgumentsHost): void {
10
+ host
11
+ .switchToHttp()
12
+ .getResponse<Response>()
13
+ .set(MONITOR_SECURITY_HEADERS)
14
+ .redirect(303, MONITOR_LOGIN_URL);
15
+ }
16
+ }
@@ -0,0 +1,8 @@
1
+ // Plantilla del harness → src/common/monitor/monitor-login.dto.ts
2
+ import { IsString, MaxLength } from 'class-validator';
3
+
4
+ export class MonitorLoginDto {
5
+ @IsString()
6
+ @MaxLength(512)
7
+ password!: string;
8
+ }
@@ -0,0 +1,41 @@
1
+ // Plantilla del harness → src/common/monitor/monitor-page.spec.ts
2
+ import { renderLoginPage, renderMonitorPage } from './monitor-page';
3
+
4
+ describe('monitor pages', () => {
5
+ const html = renderMonitorPage(
6
+ [
7
+ {
8
+ id: 'endpoints-total',
9
+ title: 'Endpoints de la API',
10
+ status: 'ok',
11
+ value: { value: 3, detail: [{ label: 'cartera', value: 3 }] },
12
+ },
13
+ { id: 'broken', title: 'Roto', status: 'unavailable' },
14
+ ],
15
+ new Date('2026-10-20T12:00:00Z'),
16
+ );
17
+
18
+ it('renders the widgets, "No disponible" and a logout form', () => {
19
+ expect(html.startsWith('<!doctype html>')).toBe(true);
20
+ expect(html).toContain('Endpoints de la API');
21
+ expect(html).toContain('cartera');
22
+ expect(html).toContain('No disponible');
23
+ expect(html).toContain('action="/monitor/logout"');
24
+ });
25
+
26
+ it('never ships scripts or inline event handlers', () => {
27
+ for (const page of [html, renderLoginPage('Contraseña incorrecta')]) {
28
+ expect(page).not.toMatch(/<script/i);
29
+ expect(page).not.toMatch(/\son[a-z]+=/i);
30
+ }
31
+ });
32
+
33
+ it('escapes widget values', () => {
34
+ const page = renderMonitorPage(
35
+ [{ id: 'x', title: '<b>', status: 'ok', value: { value: '<script>' } }],
36
+ new Date(),
37
+ );
38
+ expect(page).not.toContain('<script>');
39
+ expect(page).toContain('&lt;script&gt;');
40
+ });
41
+ });