@bi-analytics/arness-back 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +156 -2
  3. package/bin/arness.mjs +710 -0
  4. package/harness/AGENTS.md +238 -0
  5. package/harness/agents/implementador.md +42 -0
  6. package/harness/agents/lider.md +72 -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 +743 -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 +41 -0
  16. package/harness/playbooks/update-node-lts.md +46 -0
  17. package/harness/reference/configuracion.md +111 -0
  18. package/harness/reference/contrato-http.md +169 -0
  19. package/harness/reference/novedades.json +158 -0
  20. package/harness/reference/progress-plantilla.md +50 -0
  21. package/harness/skills/commit-hygiene/SKILL.md +137 -0
  22. package/harness/skills/nest-base/SKILL.md +168 -0
  23. package/harness/skills/nest-base/templates/config/auth.spec.ts +32 -0
  24. package/harness/skills/nest-base/templates/config/auth.ts +80 -0
  25. package/harness/skills/nest-base/templates/config/configuration.spec.ts +85 -0
  26. package/harness/skills/nest-base/templates/config/configuration.ts +137 -0
  27. package/harness/skills/nest-base/templates/cors/cors.spec.ts +53 -0
  28. package/harness/skills/nest-base/templates/cors/cors.ts +75 -0
  29. package/harness/skills/nest-base/templates/docs/EVOLUTIVO.md +27 -0
  30. package/harness/skills/nest-base/templates/docs/PENDIENTES.md +20 -0
  31. package/harness/skills/nest-base/templates/docs/TECNICO.md +43 -0
  32. package/harness/skills/nest-base/templates/e2e/cors.e2e-spec.ts +60 -0
  33. package/harness/skills/nest-base/templates/e2e/monitor.e2e-spec.ts +151 -0
  34. package/harness/skills/nest-base/templates/editorconfig +12 -0
  35. package/harness/skills/nest-base/templates/eslint.boundaries.mjs +65 -0
  36. package/harness/skills/nest-base/templates/eslint.config.mjs +82 -0
  37. package/harness/skills/nest-base/templates/logging/logger-options.spec.ts +57 -0
  38. package/harness/skills/nest-base/templates/logging/logger-options.ts +85 -0
  39. package/harness/skills/nest-base/templates/monitor/monitor-login-redirect.filter.ts +16 -0
  40. package/harness/skills/nest-base/templates/monitor/monitor-login.dto.ts +8 -0
  41. package/harness/skills/nest-base/templates/monitor/monitor-page.spec.ts +41 -0
  42. package/harness/skills/nest-base/templates/monitor/monitor-page.ts +190 -0
  43. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.spec.ts +46 -0
  44. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.ts +36 -0
  45. package/harness/skills/nest-base/templates/monitor/monitor-session.service.spec.ts +57 -0
  46. package/harness/skills/nest-base/templates/monitor/monitor-session.service.ts +77 -0
  47. package/harness/skills/nest-base/templates/monitor/monitor-widget.interface.ts +18 -0
  48. package/harness/skills/nest-base/templates/monitor/monitor.constants.ts +25 -0
  49. package/harness/skills/nest-base/templates/monitor/monitor.controller.ts +84 -0
  50. package/harness/skills/nest-base/templates/monitor/monitor.decorators.ts +11 -0
  51. package/harness/skills/nest-base/templates/monitor/monitor.module.ts +30 -0
  52. package/harness/skills/nest-base/templates/monitor/monitor.service.spec.ts +55 -0
  53. package/harness/skills/nest-base/templates/monitor/monitor.service.ts +49 -0
  54. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.spec.ts +51 -0
  55. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.ts +53 -0
  56. package/harness/skills/nest-base/templates/prettierignore +6 -0
  57. package/harness/skills/nest-base/templates/prettierrc.json +8 -0
  58. package/harness/skills/nest-base/templates/security/login-attempts.spec.ts +48 -0
  59. package/harness/skills/nest-base/templates/security/login-attempts.ts +90 -0
  60. package/harness/skills/nest-base/templates/security/security-headers.spec.ts +27 -0
  61. package/harness/skills/nest-base/templates/security/security-headers.ts +21 -0
  62. package/harness/skills/nest-base/templates/security/security.module.ts +20 -0
  63. package/harness/skills/nest-base/templates/security/session-lifetime.spec.ts +21 -0
  64. package/harness/skills/nest-base/templates/security/session-lifetime.ts +28 -0
  65. package/package.json +44 -3
@@ -0,0 +1,57 @@
1
+ // Plantilla del harness → src/common/monitor/monitor-session.service.spec.ts
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { JwtService } from '@nestjs/jwt';
4
+ import { MonitorSessionService, readCookie } from './monitor-session.service';
5
+
6
+ const env: Record<string, string> = {
7
+ MONITOR_ENABLED: 'true',
8
+ ACCESS_TOKEN: 'example123',
9
+ JWT_ACCESS_SECRET: 'test-access-secret-with-enough-length',
10
+ MONITOR_SESSION_EXPIRES_IN: '10m',
11
+ };
12
+ const config = (overrides: Record<string, string> = {}): ConfigService =>
13
+ new ConfigService({ ...env, ...overrides });
14
+
15
+ describe('MonitorSessionService', () => {
16
+ const jwt = new JwtService({});
17
+ const service = new MonitorSessionService(config(), jwt);
18
+
19
+ it('is disabled unless MONITOR_ENABLED is true', () => {
20
+ expect(service.isEnabled()).toBe(true);
21
+ expect(new MonitorSessionService(config({ MONITOR_ENABLED: 'false' }), jwt).isEnabled()).toBe(
22
+ false,
23
+ );
24
+ });
25
+
26
+ it('compares the password with ACCESS_TOKEN', () => {
27
+ expect(service.verifyPassword('example123')).toBe(true);
28
+ expect(service.verifyPassword('example12')).toBe(false);
29
+ expect(new MonitorSessionService(config({ ACCESS_TOKEN: '' }), jwt).verifyPassword('')).toBe(
30
+ false,
31
+ );
32
+ });
33
+
34
+ it('issues an HttpOnly, Secure, SameSite=Strict cookie scoped to /monitor', async () => {
35
+ const cookie = await service.createSessionCookie();
36
+ expect(cookie).toMatch(
37
+ /^arness_monitor=[\w-]+\.[\w-]+\.[\w-]+; Max-Age=600; Path=\/monitor; HttpOnly; Secure; SameSite=Strict$/,
38
+ );
39
+ expect(await service.hasValidSession(cookie.split(';')[0])).toBe(true);
40
+ });
41
+
42
+ it('rejects missing, tampered and non-monitor tokens', async () => {
43
+ const accessToken = await jwt.signAsync({ type: 'access' }, { secret: env.JWT_ACCESS_SECRET });
44
+ expect(await service.hasValidSession(undefined)).toBe(false);
45
+ expect(await service.hasValidSession('arness_monitor=abc.def.ghi')).toBe(false);
46
+ expect(await service.hasValidSession(`arness_monitor=${accessToken}`)).toBe(false);
47
+ });
48
+
49
+ it('clears the cookie on logout', () => {
50
+ expect(service.clearSessionCookie()).toContain('arness_monitor=; Max-Age=0; Path=/monitor');
51
+ });
52
+
53
+ it('reads a cookie from the header', () => {
54
+ expect(readCookie('a=1; arness_monitor=xyz; b=2', 'arness_monitor')).toBe('xyz');
55
+ expect(readCookie(undefined, 'arness_monitor')).toBeUndefined();
56
+ });
57
+ });
@@ -0,0 +1,77 @@
1
+ // Plantilla del harness → src/common/monitor/monitor-session.service.ts
2
+ import { Injectable } from '@nestjs/common';
3
+ import { ConfigService } from '@nestjs/config';
4
+ import { JwtService, JwtSignOptions } from '@nestjs/jwt';
5
+ import { createHash, timingSafeEqual } from 'node:crypto';
6
+ import {
7
+ MONITOR_COOKIE,
8
+ MONITOR_DEFAULT_SESSION_EXPIRES_IN,
9
+ MONITOR_HOME_URL,
10
+ MONITOR_TOKEN_TYPE,
11
+ } from './monitor.constants';
12
+
13
+ const sha256 = (value: string): Buffer => createHash('sha256').update(value).digest();
14
+
15
+ /** Reads one cookie from a raw Cookie header without extra dependencies. */
16
+ export function readCookie(header: string | undefined, name: string): string | undefined {
17
+ for (const part of (header ?? '').split(';')) {
18
+ const index = part.indexOf('=');
19
+ if (index > 0 && part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ @Injectable()
25
+ export class MonitorSessionService {
26
+ constructor(
27
+ private readonly config: ConfigService,
28
+ private readonly jwt: JwtService,
29
+ ) {}
30
+
31
+ isEnabled(): boolean {
32
+ const value = this.config.get<unknown>('MONITOR_ENABLED');
33
+ return value === true || value === 'true';
34
+ }
35
+
36
+ /** Same comparison as /auth/login: sha256 of both values and timingSafeEqual. */
37
+ verifyPassword(candidate: string): boolean {
38
+ const expected = this.config.get<string>('ACCESS_TOKEN') ?? '';
39
+ return expected.length > 0 && timingSafeEqual(sha256(candidate), sha256(expected));
40
+ }
41
+
42
+ async createSessionCookie(): Promise<string> {
43
+ const expiresIn = (this.config.get<string>('MONITOR_SESSION_EXPIRES_IN') ||
44
+ MONITOR_DEFAULT_SESSION_EXPIRES_IN) as JwtSignOptions['expiresIn'];
45
+ const token = await this.jwt.signAsync(
46
+ { type: MONITOR_TOKEN_TYPE },
47
+ { secret: this.secret(), expiresIn },
48
+ );
49
+ const { exp, iat } = this.jwt.decode<{ exp: number; iat: number }>(token);
50
+ return this.cookie(token, exp - iat);
51
+ }
52
+
53
+ clearSessionCookie(): string {
54
+ return this.cookie('', 0);
55
+ }
56
+
57
+ async hasValidSession(cookieHeader: string | undefined): Promise<boolean> {
58
+ const token = readCookie(cookieHeader, MONITOR_COOKIE);
59
+ if (!token) return false;
60
+ try {
61
+ const payload = await this.jwt.verifyAsync<{ type?: string }>(token, {
62
+ secret: this.secret(),
63
+ });
64
+ return payload.type === MONITOR_TOKEN_TYPE;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ private secret(): string {
71
+ return this.config.getOrThrow<string>('JWT_ACCESS_SECRET');
72
+ }
73
+
74
+ private cookie(value: string, maxAgeSeconds: number): string {
75
+ return `${MONITOR_COOKIE}=${value}; Max-Age=${maxAgeSeconds}; Path=${MONITOR_HOME_URL}; HttpOnly; Secure; SameSite=Strict`;
76
+ }
77
+ }
@@ -0,0 +1,18 @@
1
+ // Plantilla del harness → src/common/monitor/monitor-widget.interface.ts
2
+ export interface MonitorValue {
3
+ /** Main figure shown on the card. Never secrets, SQL, driver errors or business data. */
4
+ value: string | number;
5
+ /** Optional breakdown (label → value). */
6
+ detail?: ReadonlyArray<{ label: string; value: string | number }>;
7
+ }
8
+
9
+ export interface MonitorWidget {
10
+ readonly id: string;
11
+ readonly title: string;
12
+ /** Ascending position on the page. */
13
+ readonly order: number;
14
+ collect(): Promise<MonitorValue>;
15
+ }
16
+
17
+ /** Multi-provider token: every widget registered in MonitorModule. */
18
+ export const MONITOR_WIDGETS = Symbol('MONITOR_WIDGETS');
@@ -0,0 +1,25 @@
1
+ // Plantilla del harness → src/common/monitor/monitor.constants.ts (AGENTS.md §10).
2
+ export const MONITOR_PATH = 'monitor';
3
+ export const MONITOR_LOGIN_URL = `/${MONITOR_PATH}/login`;
4
+ export const MONITOR_HOME_URL = `/${MONITOR_PATH}`;
5
+ export const MONITOR_COOKIE = 'arness_monitor';
6
+ export const MONITOR_TOKEN_TYPE = 'monitor';
7
+ export const MONITOR_DEFAULT_SESSION_EXPIRES_IN = '15m';
8
+ export const MONITOR_WIDGET_TIMEOUT_MS = 2000;
9
+
10
+ /** Route segments that never count as business endpoints. */
11
+ export const MONITOR_EXCLUDED_SEGMENTS: ReadonlySet<string> = new Set([
12
+ MONITOR_PATH,
13
+ 'health',
14
+ 'auth',
15
+ ]);
16
+
17
+ /** Sent on every monitor response: no scripts, no framing, no caching. */
18
+ export const MONITOR_SECURITY_HEADERS: Readonly<Record<string, string>> = Object.freeze({
19
+ 'Content-Security-Policy':
20
+ "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
21
+ 'Cache-Control': 'no-store',
22
+ 'X-Frame-Options': 'DENY',
23
+ 'X-Content-Type-Options': 'nosniff',
24
+ 'Referrer-Policy': 'no-referrer',
25
+ });
@@ -0,0 +1,84 @@
1
+ // Plantilla del harness → src/common/monitor/monitor.controller.ts
2
+ import { Body, Controller, Get, Post, Req, Res, UseFilters, UseGuards } from '@nestjs/common';
3
+ import type { Request, Response } from 'express';
4
+ import { LoginAttemptLimiter } from '../security/login-attempts';
5
+ import {
6
+ MONITOR_HOME_URL,
7
+ MONITOR_LOGIN_URL,
8
+ MONITOR_PATH,
9
+ MONITOR_SECURITY_HEADERS,
10
+ } from './monitor.constants';
11
+ import { MonitorLogin, MonitorRoute } from './monitor.decorators';
12
+ import { MonitorLoginRedirectFilter } from './monitor-login-redirect.filter';
13
+ import { MonitorLoginDto } from './monitor-login.dto';
14
+ import { renderLoginPage, renderMonitorPage } from './monitor-page';
15
+ import { MonitorSessionGuard } from './monitor-session.guard';
16
+ import { MonitorSessionService } from './monitor-session.service';
17
+ import { MonitorService } from './monitor.service';
18
+
19
+ function sendHtml(res: Response, status: number, html: string): void {
20
+ res.status(status).set(MONITOR_SECURITY_HEADERS).type('html').send(html);
21
+ }
22
+
23
+ /** Fuera de /api/v1. No es @Public(): lo protege MonitorSessionGuard con cookie, no el Bearer. */
24
+ @MonitorRoute()
25
+ @UseGuards(MonitorSessionGuard)
26
+ @UseFilters(MonitorLoginRedirectFilter)
27
+ @Controller(MONITOR_PATH)
28
+ export class MonitorController {
29
+ constructor(
30
+ private readonly monitor: MonitorService,
31
+ private readonly session: MonitorSessionService,
32
+ private readonly attempts: LoginAttemptLimiter,
33
+ ) {}
34
+
35
+ @Get()
36
+ async page(@Res() res: Response): Promise<void> {
37
+ sendHtml(res, 200, renderMonitorPage(await this.monitor.collect(), new Date()));
38
+ }
39
+
40
+ @MonitorLogin()
41
+ @Get('login')
42
+ loginForm(@Res() res: Response): void {
43
+ sendHtml(res, 200, renderLoginPage());
44
+ }
45
+
46
+ @MonitorLogin()
47
+ @Post('login')
48
+ async login(
49
+ @Body() body: MonitorLoginDto,
50
+ @Req() req: Request,
51
+ @Res() res: Response,
52
+ ): Promise<void> {
53
+ // Same limiter as /auth/login (contrato-http.md, Límite de intentos).
54
+ const client = req.ip ?? 'unknown';
55
+ const status = this.attempts.check(client);
56
+ if (!status.allowed) {
57
+ const minutes = Math.ceil(status.retryAfterSeconds / 60);
58
+ res.setHeader('Retry-After', String(status.retryAfterSeconds));
59
+ sendHtml(
60
+ res,
61
+ 429,
62
+ renderLoginPage(`Demasiados intentos. Intenta de nuevo en ${minutes} min.`),
63
+ );
64
+ return;
65
+ }
66
+ if (!this.session.verifyPassword(body.password)) {
67
+ this.attempts.registerFailure(client);
68
+ sendHtml(res, 401, renderLoginPage('Contraseña incorrecta'));
69
+ return;
70
+ }
71
+ this.attempts.reset(client);
72
+ res
73
+ .set(MONITOR_SECURITY_HEADERS)
74
+ .setHeader('Set-Cookie', await this.session.createSessionCookie());
75
+ res.redirect(303, MONITOR_HOME_URL);
76
+ }
77
+
78
+ @MonitorLogin()
79
+ @Post('logout')
80
+ logout(@Res() res: Response): void {
81
+ res.set(MONITOR_SECURITY_HEADERS).setHeader('Set-Cookie', this.session.clearSessionCookie());
82
+ res.redirect(303, MONITOR_LOGIN_URL);
83
+ }
84
+ }
@@ -0,0 +1,11 @@
1
+ // Plantilla del harness → src/common/monitor/monitor.decorators.ts
2
+ import { CustomDecorator, SetMetadata } from '@nestjs/common';
3
+
4
+ export const IS_MONITOR_ROUTE_KEY = 'arness:monitor-route';
5
+ export const IS_MONITOR_LOGIN_KEY = 'arness:monitor-login';
6
+
7
+ /** Marks the monitor controller: JwtAuthGuard skips it and MonitorSessionGuard protects it instead. */
8
+ export const MonitorRoute = (): CustomDecorator<string> => SetMetadata(IS_MONITOR_ROUTE_KEY, true);
9
+
10
+ /** Monitor handlers reachable without a session (login form, login and logout). */
11
+ export const MonitorLogin = (): CustomDecorator<string> => SetMetadata(IS_MONITOR_LOGIN_KEY, true);
@@ -0,0 +1,30 @@
1
+ // Plantilla del harness → src/common/monitor/monitor.module.ts
2
+ import { Module } from '@nestjs/common';
3
+ import { DiscoveryModule } from '@nestjs/core';
4
+ import { JwtModule } from '@nestjs/jwt';
5
+ import { MonitorController } from './monitor.controller';
6
+ import { MonitorSessionGuard } from './monitor-session.guard';
7
+ import { MonitorSessionService } from './monitor-session.service';
8
+ import { MonitorService } from './monitor.service';
9
+ import { MONITOR_WIDGETS, MonitorWidget } from './monitor-widget.interface';
10
+ import { EndpointsTotalWidget } from './widgets/endpoints-total.widget';
11
+
12
+ /** Widgets del tablero: agrega la clase a WIDGETS (playbook new-monitor-widget). */
13
+ const WIDGETS = [EndpointsTotalWidget];
14
+
15
+ @Module({
16
+ imports: [DiscoveryModule, JwtModule.register({})],
17
+ controllers: [MonitorController],
18
+ providers: [
19
+ MonitorService,
20
+ MonitorSessionService,
21
+ MonitorSessionGuard,
22
+ ...WIDGETS,
23
+ {
24
+ provide: MONITOR_WIDGETS,
25
+ useFactory: (...widgets: MonitorWidget[]): MonitorWidget[] => widgets,
26
+ inject: WIDGETS,
27
+ },
28
+ ],
29
+ })
30
+ export class MonitorModule {}
@@ -0,0 +1,55 @@
1
+ // Plantilla del harness → src/common/monitor/monitor.service.spec.ts
2
+ import { Test } from '@nestjs/testing';
3
+ import { MONITOR_WIDGETS, MonitorWidget } from './monitor-widget.interface';
4
+ import { MonitorService } from './monitor.service';
5
+
6
+ const widget = (id: string, order: number, collect: MonitorWidget['collect']): MonitorWidget => ({
7
+ id,
8
+ title: id,
9
+ order,
10
+ collect,
11
+ });
12
+
13
+ describe('MonitorService', () => {
14
+ const build = async (widgets: MonitorWidget[]): Promise<MonitorService> => {
15
+ const moduleRef = await Test.createTestingModule({
16
+ providers: [MonitorService, { provide: MONITOR_WIDGETS, useValue: widgets }],
17
+ }).compile();
18
+ return moduleRef.get(MonitorService);
19
+ };
20
+
21
+ it('returns the widgets ordered by order', async () => {
22
+ const service = await build([
23
+ widget('b', 20, () => Promise.resolve({ value: 2 })),
24
+ widget('a', 10, () => Promise.resolve({ value: 1 })),
25
+ ]);
26
+ expect((await service.collect()).map((c) => c.id)).toEqual(['a', 'b']);
27
+ });
28
+
29
+ it('marks a failing widget as unavailable without exposing the error', async () => {
30
+ const service = await build([
31
+ widget('ok', 1, () => Promise.resolve({ value: 7 })),
32
+ widget('broken', 2, () =>
33
+ Promise.reject(new Error('password authentication failed for user "x"')),
34
+ ),
35
+ widget('throws', 3, () => {
36
+ throw new Error('sync');
37
+ }),
38
+ ]);
39
+ const cards = await service.collect();
40
+ expect(cards).toEqual([
41
+ { id: 'ok', title: 'ok', status: 'ok', value: { value: 7 } },
42
+ { id: 'broken', title: 'broken', status: 'unavailable' },
43
+ { id: 'throws', title: 'throws', status: 'unavailable' },
44
+ ]);
45
+ });
46
+
47
+ it('marks a slow widget as unavailable', async () => {
48
+ jest.useFakeTimers();
49
+ const service = await build([widget('slow', 1, () => new Promise(() => undefined))]);
50
+ const pending = service.collect();
51
+ await jest.advanceTimersByTimeAsync(2000);
52
+ expect((await pending)[0].status).toBe('unavailable');
53
+ jest.useRealTimers();
54
+ });
55
+ });
@@ -0,0 +1,49 @@
1
+ // Plantilla del harness → src/common/monitor/monitor.service.ts
2
+ import { Inject, Injectable, Logger } from '@nestjs/common';
3
+ import { MONITOR_WIDGET_TIMEOUT_MS } from './monitor.constants';
4
+ import { MONITOR_WIDGETS, MonitorValue, MonitorWidget } from './monitor-widget.interface';
5
+
6
+ export interface MonitorCard {
7
+ id: string;
8
+ title: string;
9
+ status: 'ok' | 'unavailable';
10
+ value?: MonitorValue;
11
+ }
12
+
13
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
14
+ let timer: NodeJS.Timeout | undefined;
15
+ const timeout = new Promise<never>((_resolve, reject) => {
16
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
17
+ });
18
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
19
+ }
20
+
21
+ @Injectable()
22
+ export class MonitorService {
23
+ private readonly logger = new Logger(MonitorService.name);
24
+
25
+ constructor(@Inject(MONITOR_WIDGETS) private readonly widgets: MonitorWidget[]) {}
26
+
27
+ /** A failing or slow widget becomes "unavailable"; the page always renders. */
28
+ async collect(): Promise<MonitorCard[]> {
29
+ const ordered = [...this.widgets].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
30
+ return Promise.all(
31
+ ordered.map(async (widget): Promise<MonitorCard> => {
32
+ const { id, title } = widget;
33
+ try {
34
+ const value = await withTimeout(
35
+ Promise.resolve().then(() => widget.collect()),
36
+ MONITOR_WIDGET_TIMEOUT_MS,
37
+ );
38
+ return { id, title, status: 'ok', value };
39
+ } catch (error) {
40
+ // Only the error type: messages may carry SQL, hosts or data.
41
+ this.logger.warn(
42
+ `Monitor widget "${id}" unavailable (${error instanceof Error ? error.name : 'unknown'})`,
43
+ );
44
+ return { id, title, status: 'unavailable' };
45
+ }
46
+ }),
47
+ );
48
+ }
49
+ }
@@ -0,0 +1,51 @@
1
+ // Plantilla del harness → src/common/monitor/widgets/endpoints-total.widget.spec.ts
2
+ import { Controller, Get, Post } from '@nestjs/common';
3
+ import { DiscoveryModule } from '@nestjs/core';
4
+ import { Test } from '@nestjs/testing';
5
+ import { EndpointsTotalWidget } from './endpoints-total.widget';
6
+
7
+ @Controller('cartera/resumen')
8
+ class CarteraController {
9
+ @Get()
10
+ list(): void {}
11
+
12
+ @Get(['detalle', 'detalle-v2'])
13
+ detail(): void {}
14
+
15
+ helper(): void {}
16
+ }
17
+
18
+ @Controller()
19
+ class RecaudoController {
20
+ @Get('recaudo/diario')
21
+ daily(): void {}
22
+ }
23
+
24
+ @Controller('auth')
25
+ class AuthController {
26
+ @Post('login')
27
+ login(): void {}
28
+ }
29
+
30
+ @Controller('health')
31
+ class HealthController {
32
+ @Get()
33
+ check(): void {}
34
+ }
35
+
36
+ describe('EndpointsTotalWidget', () => {
37
+ it('counts routes by module segment and skips auth, health and monitor', async () => {
38
+ const moduleRef = await Test.createTestingModule({
39
+ imports: [DiscoveryModule],
40
+ controllers: [CarteraController, RecaudoController, AuthController, HealthController],
41
+ providers: [EndpointsTotalWidget],
42
+ }).compile();
43
+ await expect(moduleRef.get(EndpointsTotalWidget).collect()).resolves.toEqual({
44
+ value: 4,
45
+ detail: [
46
+ { label: 'cartera', value: 3 },
47
+ { label: 'recaudo', value: 1 },
48
+ ],
49
+ });
50
+ });
51
+ });
@@ -0,0 +1,53 @@
1
+ // Plantilla del harness → src/common/monitor/widgets/endpoints-total.widget.ts
2
+ import { Injectable } from '@nestjs/common';
3
+ import { PATH_METADATA, METHOD_METADATA } from '@nestjs/common/constants';
4
+ import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
5
+ import { MONITOR_EXCLUDED_SEGMENTS } from '../monitor.constants';
6
+ import { MonitorValue, MonitorWidget } from '../monitor-widget.interface';
7
+
8
+ const toPaths = (value: string | string[] | undefined): string[] =>
9
+ (Array.isArray(value) ? value : [value ?? '']).map((path) => path.replace(/^\/+|\/+$/g, ''));
10
+
11
+ /** Counts the HTTP routes of the API (excluding monitor, health and auth), grouped by module segment. */
12
+ @Injectable()
13
+ export class EndpointsTotalWidget implements MonitorWidget {
14
+ readonly id = 'endpoints-total';
15
+ readonly title = 'Endpoints de la API';
16
+ readonly order = 10;
17
+
18
+ constructor(
19
+ private readonly discovery: DiscoveryService,
20
+ private readonly scanner: MetadataScanner,
21
+ private readonly reflector: Reflector,
22
+ ) {}
23
+
24
+ collect(): Promise<MonitorValue> {
25
+ const byModule = new Map<string, number>();
26
+ for (const { metatype, instance } of this.discovery.getControllers()) {
27
+ if (!metatype || !instance) continue;
28
+ const prefixes = toPaths(
29
+ this.reflector.get<string | string[] | undefined>(PATH_METADATA, metatype),
30
+ );
31
+ const prototype = Object.getPrototypeOf(instance) as Record<string, unknown>;
32
+ for (const name of this.scanner.getAllMethodNames(prototype)) {
33
+ const handler = prototype[name];
34
+ if (typeof handler !== 'function') continue;
35
+ if (this.reflector.get<number | undefined>(METHOD_METADATA, handler) === undefined)
36
+ continue;
37
+ for (const prefix of prefixes) {
38
+ for (const path of toPaths(
39
+ this.reflector.get<string | string[] | undefined>(PATH_METADATA, handler),
40
+ )) {
41
+ const segment = [prefix, path].filter(Boolean).join('/').split('/')[0] || '/';
42
+ if (MONITOR_EXCLUDED_SEGMENTS.has(segment)) continue;
43
+ byModule.set(segment, (byModule.get(segment) ?? 0) + 1);
44
+ }
45
+ }
46
+ }
47
+ }
48
+ const detail = [...byModule.entries()]
49
+ .sort(([a], [b]) => a.localeCompare(b))
50
+ .map(([label, value]) => ({ label, value }));
51
+ return Promise.resolve({ value: detail.reduce((sum, item) => sum + item.value, 0), detail });
52
+ }
53
+ }
@@ -0,0 +1,6 @@
1
+ dist
2
+ coverage
3
+ node_modules
4
+ pnpm-lock.yaml
5
+ postman
6
+ harness
@@ -0,0 +1,8 @@
1
+ {
2
+ "singleQuote": true,
3
+ "trailingComma": "all",
4
+ "printWidth": 100,
5
+ "semi": true,
6
+ "arrowParens": "always",
7
+ "endOfLine": "lf"
8
+ }
@@ -0,0 +1,48 @@
1
+ // Plantilla del harness → src/common/security/login-attempts.spec.ts
2
+ import { LoginAttemptLimiter } from './login-attempts';
3
+
4
+ describe('LoginAttemptLimiter', () => {
5
+ let now: number;
6
+ const limiter = (maxTrackedClients?: number): LoginAttemptLimiter =>
7
+ new LoginAttemptLimiter({ maxAttempts: 3, windowSeconds: 60, maxTrackedClients }, () => now);
8
+
9
+ beforeEach(() => {
10
+ now = 1_000_000;
11
+ });
12
+
13
+ it('blocks a client after maxAttempts failures until the window ends', () => {
14
+ const attempts = limiter();
15
+ expect(attempts.registerFailure('10.0.0.1')).toEqual({ allowed: true, retryAfterSeconds: 0 });
16
+ attempts.registerFailure('10.0.0.1');
17
+ expect(attempts.registerFailure('10.0.0.1')).toEqual({ allowed: false, retryAfterSeconds: 60 });
18
+
19
+ now += 59_500;
20
+ expect(attempts.check('10.0.0.1')).toEqual({ allowed: false, retryAfterSeconds: 1 });
21
+
22
+ now += 500;
23
+ expect(attempts.check('10.0.0.1')).toEqual({ allowed: true, retryAfterSeconds: 0 });
24
+ });
25
+
26
+ it('counts each client separately', () => {
27
+ const attempts = limiter();
28
+ for (let i = 0; i < 3; i += 1) attempts.registerFailure('10.0.0.1');
29
+ expect(attempts.check('10.0.0.1').allowed).toBe(false);
30
+ expect(attempts.check('10.0.0.2').allowed).toBe(true);
31
+ });
32
+
33
+ it('a successful login resets the counter', () => {
34
+ const attempts = limiter();
35
+ attempts.registerFailure('10.0.0.1');
36
+ attempts.registerFailure('10.0.0.1');
37
+ attempts.reset('10.0.0.1');
38
+ expect(attempts.registerFailure('10.0.0.1').allowed).toBe(true);
39
+ });
40
+
41
+ it('never tracks more than maxTrackedClients', () => {
42
+ const attempts = limiter(2);
43
+ for (let i = 0; i < 3; i += 1) attempts.registerFailure('10.0.0.1');
44
+ attempts.registerFailure('10.0.0.2');
45
+ attempts.registerFailure('10.0.0.3');
46
+ expect(attempts.check('10.0.0.1').allowed).toBe(true);
47
+ });
48
+ });
@@ -0,0 +1,90 @@
1
+ // Plantilla del harness → src/common/security/login-attempts.ts (AGENTS.md §8, contrato-http.md "Límite de intentos").
2
+ // Counts failed logins per client in memory: one counter per instance, never depends on Redis.
3
+ // /auth/login and /monitor/login share it.
4
+
5
+ export interface LoginAttemptOptions {
6
+ maxAttempts: number;
7
+ windowSeconds: number;
8
+ /** Upper bound of tracked clients, so an attacker cannot grow the map without limit. */
9
+ maxTrackedClients?: number;
10
+ }
11
+
12
+ export interface LoginAttemptStatus {
13
+ allowed: boolean;
14
+ /** Seconds until the client can try again (0 when allowed). Goes to the Retry-After header. */
15
+ retryAfterSeconds: number;
16
+ }
17
+
18
+ interface Failures {
19
+ count: number;
20
+ resetAt: number;
21
+ }
22
+
23
+ const DEFAULT_MAX_TRACKED_CLIENTS = 10000;
24
+
25
+ export class LoginAttemptLimiter {
26
+ private readonly failures = new Map<string, Failures>();
27
+ private readonly maxTrackedClients: number;
28
+
29
+ constructor(
30
+ private readonly options: LoginAttemptOptions,
31
+ private readonly now: () => number = Date.now,
32
+ ) {
33
+ this.maxTrackedClients = options.maxTrackedClients ?? DEFAULT_MAX_TRACKED_CLIENTS;
34
+ }
35
+
36
+ /** Call before comparing the password: a blocked client gets 429 without checking it. */
37
+ check(client: string): LoginAttemptStatus {
38
+ const entry = this.current(client);
39
+ if (!entry || entry.count < this.options.maxAttempts) {
40
+ return { allowed: true, retryAfterSeconds: 0 };
41
+ }
42
+ return { allowed: false, retryAfterSeconds: this.secondsUntil(entry.resetAt) };
43
+ }
44
+
45
+ /** Call after a wrong password. The window starts at the first failure. */
46
+ registerFailure(client: string): LoginAttemptStatus {
47
+ const entry = this.current(client);
48
+ if (entry) {
49
+ entry.count += 1;
50
+ } else {
51
+ this.makeRoom();
52
+ this.failures.set(client, {
53
+ count: 1,
54
+ resetAt: this.now() + this.options.windowSeconds * 1000,
55
+ });
56
+ }
57
+ return this.check(client);
58
+ }
59
+
60
+ /** Call after a successful login. */
61
+ reset(client: string): void {
62
+ this.failures.delete(client);
63
+ }
64
+
65
+ private current(client: string): Failures | undefined {
66
+ const entry = this.failures.get(client);
67
+ if (entry && entry.resetAt <= this.now()) {
68
+ this.failures.delete(client);
69
+ return undefined;
70
+ }
71
+ return entry;
72
+ }
73
+
74
+ private makeRoom(): void {
75
+ if (this.failures.size < this.maxTrackedClients) return;
76
+ const now = this.now();
77
+ for (const [client, entry] of this.failures) {
78
+ if (entry.resetAt <= now) this.failures.delete(client);
79
+ }
80
+ // Still full: drop the oldest entry (Map keeps insertion order).
81
+ if (this.failures.size >= this.maxTrackedClients) {
82
+ const oldest = this.failures.keys().next().value;
83
+ if (oldest !== undefined) this.failures.delete(oldest);
84
+ }
85
+ }
86
+
87
+ private secondsUntil(timestamp: number): number {
88
+ return Math.max(1, Math.ceil((timestamp - this.now()) / 1000));
89
+ }
90
+ }