@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,190 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-page.ts
|
|
2
|
+
// HTML renderizado en el servidor con React, sin JSX, sin hidratación y sin JavaScript en el cliente.
|
|
3
|
+
import { createElement as h, type CSSProperties, type ReactNode } from 'react';
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
5
|
+
import { MONITOR_HOME_URL, MONITOR_LOGIN_URL } from './monitor.constants';
|
|
6
|
+
import type { MonitorCard } from './monitor.service';
|
|
7
|
+
|
|
8
|
+
const color = {
|
|
9
|
+
bg: '#f5f6f8',
|
|
10
|
+
card: '#ffffff',
|
|
11
|
+
text: '#1f2933',
|
|
12
|
+
muted: '#616e7c',
|
|
13
|
+
line: '#e4e7eb',
|
|
14
|
+
accent: '#2457c5',
|
|
15
|
+
warn: '#9a5b00',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const styles: Record<string, CSSProperties> = {
|
|
19
|
+
body: {
|
|
20
|
+
margin: 0,
|
|
21
|
+
background: color.bg,
|
|
22
|
+
color: color.text,
|
|
23
|
+
font: '15px/1.5 system-ui, -apple-system, Segoe UI, sans-serif',
|
|
24
|
+
},
|
|
25
|
+
header: {
|
|
26
|
+
display: 'flex',
|
|
27
|
+
alignItems: 'center',
|
|
28
|
+
gap: 16,
|
|
29
|
+
padding: '16px 24px',
|
|
30
|
+
background: color.card,
|
|
31
|
+
borderBottom: `1px solid ${color.line}`,
|
|
32
|
+
},
|
|
33
|
+
title: { margin: 0, fontSize: 18, fontWeight: 600, flex: 1 },
|
|
34
|
+
muted: { color: color.muted, fontSize: 13 },
|
|
35
|
+
main: {
|
|
36
|
+
padding: 24,
|
|
37
|
+
display: 'grid',
|
|
38
|
+
gap: 16,
|
|
39
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
|
40
|
+
},
|
|
41
|
+
card: {
|
|
42
|
+
background: color.card,
|
|
43
|
+
border: `1px solid ${color.line}`,
|
|
44
|
+
borderRadius: 10,
|
|
45
|
+
padding: 16,
|
|
46
|
+
},
|
|
47
|
+
cardTitle: {
|
|
48
|
+
margin: '0 0 8px',
|
|
49
|
+
fontSize: 13,
|
|
50
|
+
fontWeight: 600,
|
|
51
|
+
color: color.muted,
|
|
52
|
+
textTransform: 'uppercase',
|
|
53
|
+
letterSpacing: 0.4,
|
|
54
|
+
},
|
|
55
|
+
value: { fontSize: 32, fontWeight: 600, margin: 0 },
|
|
56
|
+
unavailable: { fontSize: 15, color: color.warn, margin: 0 },
|
|
57
|
+
list: { listStyle: 'none', padding: 0, margin: '12px 0 0', borderTop: `1px solid ${color.line}` },
|
|
58
|
+
row: {
|
|
59
|
+
display: 'flex',
|
|
60
|
+
justifyContent: 'space-between',
|
|
61
|
+
padding: '6px 0',
|
|
62
|
+
borderBottom: `1px solid ${color.line}`,
|
|
63
|
+
fontSize: 14,
|
|
64
|
+
},
|
|
65
|
+
button: {
|
|
66
|
+
font: 'inherit',
|
|
67
|
+
padding: '8px 14px',
|
|
68
|
+
borderRadius: 8,
|
|
69
|
+
border: `1px solid ${color.line}`,
|
|
70
|
+
background: color.card,
|
|
71
|
+
color: color.text,
|
|
72
|
+
cursor: 'pointer',
|
|
73
|
+
},
|
|
74
|
+
primary: {
|
|
75
|
+
font: 'inherit',
|
|
76
|
+
padding: '10px 14px',
|
|
77
|
+
borderRadius: 8,
|
|
78
|
+
border: 0,
|
|
79
|
+
background: color.accent,
|
|
80
|
+
color: '#fff',
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
width: '100%',
|
|
83
|
+
},
|
|
84
|
+
loginBox: {
|
|
85
|
+
maxWidth: 360,
|
|
86
|
+
margin: '12vh auto',
|
|
87
|
+
background: color.card,
|
|
88
|
+
border: `1px solid ${color.line}`,
|
|
89
|
+
borderRadius: 12,
|
|
90
|
+
padding: 24,
|
|
91
|
+
},
|
|
92
|
+
input: {
|
|
93
|
+
font: 'inherit',
|
|
94
|
+
width: '100%',
|
|
95
|
+
boxSizing: 'border-box',
|
|
96
|
+
padding: '10px 12px',
|
|
97
|
+
margin: '6px 0 16px',
|
|
98
|
+
borderRadius: 8,
|
|
99
|
+
border: `1px solid ${color.line}`,
|
|
100
|
+
},
|
|
101
|
+
error: { color: '#b42318', margin: '0 0 12px' },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function document(title: string, body: ReactNode): string {
|
|
105
|
+
const html = h(
|
|
106
|
+
'html',
|
|
107
|
+
{ lang: 'es' },
|
|
108
|
+
h(
|
|
109
|
+
'head',
|
|
110
|
+
null,
|
|
111
|
+
h('meta', { charSet: 'utf-8' }),
|
|
112
|
+
h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }),
|
|
113
|
+
h('meta', { name: 'robots', content: 'noindex, nofollow' }),
|
|
114
|
+
h('title', null, title),
|
|
115
|
+
),
|
|
116
|
+
h('body', { style: styles.body }, body),
|
|
117
|
+
);
|
|
118
|
+
return `<!doctype html>${renderToStaticMarkup(html)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function card({ id, title, status, value }: MonitorCard): ReactNode {
|
|
122
|
+
return h(
|
|
123
|
+
'section',
|
|
124
|
+
{ key: id, style: styles.card, 'data-widget': id },
|
|
125
|
+
h('h2', { style: styles.cardTitle }, title),
|
|
126
|
+
status === 'ok' && value
|
|
127
|
+
? [
|
|
128
|
+
h('p', { key: 'v', style: styles.value }, String(value.value)),
|
|
129
|
+
value.detail?.length
|
|
130
|
+
? h(
|
|
131
|
+
'ul',
|
|
132
|
+
{ key: 'd', style: styles.list },
|
|
133
|
+
value.detail.map((item) =>
|
|
134
|
+
h(
|
|
135
|
+
'li',
|
|
136
|
+
{ key: item.label, style: styles.row },
|
|
137
|
+
h('span', null, item.label),
|
|
138
|
+
h('strong', null, String(item.value)),
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
: null,
|
|
143
|
+
]
|
|
144
|
+
: h('p', { style: styles.unavailable }, 'No disponible'),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderMonitorPage(cards: readonly MonitorCard[], generatedAt: Date): string {
|
|
149
|
+
return document('Monitor', [
|
|
150
|
+
h(
|
|
151
|
+
'header',
|
|
152
|
+
{ key: 'h', style: styles.header },
|
|
153
|
+
h('h1', { style: styles.title }, 'Monitor'),
|
|
154
|
+
h('span', { style: styles.muted }, `Actualizado ${generatedAt.toISOString()}`),
|
|
155
|
+
h(
|
|
156
|
+
'form',
|
|
157
|
+
{ method: 'post', action: `${MONITOR_HOME_URL}/logout` },
|
|
158
|
+
h('button', { type: 'submit', style: styles.button }, 'Cerrar sesión'),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
h('main', { key: 'm', style: styles.main }, cards.map(card)),
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderLoginPage(error?: string): string {
|
|
166
|
+
return document(
|
|
167
|
+
'Monitor · Ingresar',
|
|
168
|
+
h(
|
|
169
|
+
'main',
|
|
170
|
+
{ style: styles.loginBox },
|
|
171
|
+
h('h1', { style: { ...styles.title, marginBottom: 16 } }, 'Monitor'),
|
|
172
|
+
error ? h('p', { style: styles.error, role: 'alert' }, error) : null,
|
|
173
|
+
h(
|
|
174
|
+
'form',
|
|
175
|
+
{ method: 'post', action: MONITOR_LOGIN_URL },
|
|
176
|
+
h('label', { htmlFor: 'password' }, 'Contraseña'),
|
|
177
|
+
h('input', {
|
|
178
|
+
id: 'password',
|
|
179
|
+
name: 'password',
|
|
180
|
+
type: 'password',
|
|
181
|
+
required: true,
|
|
182
|
+
autoComplete: 'current-password',
|
|
183
|
+
autoFocus: true,
|
|
184
|
+
style: styles.input,
|
|
185
|
+
}),
|
|
186
|
+
h('button', { type: 'submit', style: styles.primary }, 'Ingresar'),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-session.guard.spec.ts
|
|
2
|
+
import { ExecutionContext, NotFoundException } from '@nestjs/common';
|
|
3
|
+
import { Reflector } from '@nestjs/core';
|
|
4
|
+
import { MonitorLoginRequiredException, MonitorSessionGuard } from './monitor-session.guard';
|
|
5
|
+
import { MonitorSessionService } from './monitor-session.service';
|
|
6
|
+
|
|
7
|
+
const context = (cookie?: string): ExecutionContext =>
|
|
8
|
+
({
|
|
9
|
+
getHandler: () => undefined,
|
|
10
|
+
getClass: () => undefined,
|
|
11
|
+
switchToHttp: () => ({ getRequest: () => ({ headers: { cookie } }) }),
|
|
12
|
+
}) as unknown as ExecutionContext;
|
|
13
|
+
|
|
14
|
+
describe('MonitorSessionGuard', () => {
|
|
15
|
+
const session = { isEnabled: jest.fn(), hasValidSession: jest.fn() };
|
|
16
|
+
const reflector = { getAllAndOverride: jest.fn() };
|
|
17
|
+
const guard = new MonitorSessionGuard(
|
|
18
|
+
reflector as unknown as Reflector,
|
|
19
|
+
session as unknown as MonitorSessionService,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
session.isEnabled.mockReturnValue(true);
|
|
24
|
+
session.hasValidSession.mockResolvedValue(false);
|
|
25
|
+
reflector.getAllAndOverride.mockReturnValue(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('answers 404 when the monitor is disabled', async () => {
|
|
29
|
+
session.isEnabled.mockReturnValue(false);
|
|
30
|
+
await expect(guard.canActivate(context())).rejects.toBeInstanceOf(NotFoundException);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('lets login routes through without a session', async () => {
|
|
34
|
+
reflector.getAllAndOverride.mockReturnValue(true);
|
|
35
|
+
await expect(guard.canActivate(context())).resolves.toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('requires a valid session cookie', async () => {
|
|
39
|
+
await expect(guard.canActivate(context())).rejects.toBeInstanceOf(
|
|
40
|
+
MonitorLoginRequiredException,
|
|
41
|
+
);
|
|
42
|
+
session.hasValidSession.mockResolvedValue(true);
|
|
43
|
+
await expect(guard.canActivate(context('arness_monitor=t'))).resolves.toBe(true);
|
|
44
|
+
expect(session.hasValidSession).toHaveBeenCalledWith('arness_monitor=t');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-session.guard.ts
|
|
2
|
+
import {
|
|
3
|
+
CanActivate,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
Injectable,
|
|
6
|
+
NotFoundException,
|
|
7
|
+
UnauthorizedException,
|
|
8
|
+
} from '@nestjs/common';
|
|
9
|
+
import { Reflector } from '@nestjs/core';
|
|
10
|
+
import type { Request } from 'express';
|
|
11
|
+
import { IS_MONITOR_LOGIN_KEY } from './monitor.decorators';
|
|
12
|
+
import { MonitorSessionService } from './monitor-session.service';
|
|
13
|
+
|
|
14
|
+
/** Caught by MonitorLoginRedirectFilter, which redirects to the login form. */
|
|
15
|
+
export class MonitorLoginRequiredException extends UnauthorizedException {}
|
|
16
|
+
|
|
17
|
+
/** Cookie session for /monitor. 404 when MONITOR_ENABLED is not true. */
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class MonitorSessionGuard implements CanActivate {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly reflector: Reflector,
|
|
22
|
+
private readonly session: MonitorSessionService,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
26
|
+
if (!this.session.isEnabled()) throw new NotFoundException();
|
|
27
|
+
const isLogin = this.reflector.getAllAndOverride<boolean>(IS_MONITOR_LOGIN_KEY, [
|
|
28
|
+
context.getHandler(),
|
|
29
|
+
context.getClass(),
|
|
30
|
+
]);
|
|
31
|
+
if (isLogin) return true;
|
|
32
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
33
|
+
if (await this.session.hasValidSession(request.headers.cookie)) return true;
|
|
34
|
+
throw new MonitorLoginRequiredException();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -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,63 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor.controller.ts
|
|
2
|
+
import { Body, Controller, Get, Post, Res, UseFilters, UseGuards } from '@nestjs/common';
|
|
3
|
+
import type { Response } from 'express';
|
|
4
|
+
import {
|
|
5
|
+
MONITOR_HOME_URL,
|
|
6
|
+
MONITOR_LOGIN_URL,
|
|
7
|
+
MONITOR_PATH,
|
|
8
|
+
MONITOR_SECURITY_HEADERS,
|
|
9
|
+
} from './monitor.constants';
|
|
10
|
+
import { MonitorLogin, MonitorRoute } from './monitor.decorators';
|
|
11
|
+
import { MonitorLoginRedirectFilter } from './monitor-login-redirect.filter';
|
|
12
|
+
import { MonitorLoginDto } from './monitor-login.dto';
|
|
13
|
+
import { renderLoginPage, renderMonitorPage } from './monitor-page';
|
|
14
|
+
import { MonitorSessionGuard } from './monitor-session.guard';
|
|
15
|
+
import { MonitorSessionService } from './monitor-session.service';
|
|
16
|
+
import { MonitorService } from './monitor.service';
|
|
17
|
+
|
|
18
|
+
function sendHtml(res: Response, status: number, html: string): void {
|
|
19
|
+
res.status(status).set(MONITOR_SECURITY_HEADERS).type('html').send(html);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Fuera de /api/v1. No es @Public(): lo protege MonitorSessionGuard con cookie, no el Bearer. */
|
|
23
|
+
@MonitorRoute()
|
|
24
|
+
@UseGuards(MonitorSessionGuard)
|
|
25
|
+
@UseFilters(MonitorLoginRedirectFilter)
|
|
26
|
+
@Controller(MONITOR_PATH)
|
|
27
|
+
export class MonitorController {
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly monitor: MonitorService,
|
|
30
|
+
private readonly session: MonitorSessionService,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
@Get()
|
|
34
|
+
async page(@Res() res: Response): Promise<void> {
|
|
35
|
+
sendHtml(res, 200, renderMonitorPage(await this.monitor.collect(), new Date()));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@MonitorLogin()
|
|
39
|
+
@Get('login')
|
|
40
|
+
loginForm(@Res() res: Response): void {
|
|
41
|
+
sendHtml(res, 200, renderLoginPage());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@MonitorLogin()
|
|
45
|
+
@Post('login')
|
|
46
|
+
async login(@Body() body: MonitorLoginDto, @Res() res: Response): Promise<void> {
|
|
47
|
+
if (!this.session.verifyPassword(body.password)) {
|
|
48
|
+
sendHtml(res, 401, renderLoginPage('Contraseña incorrecta'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
res
|
|
52
|
+
.set(MONITOR_SECURITY_HEADERS)
|
|
53
|
+
.setHeader('Set-Cookie', await this.session.createSessionCookie());
|
|
54
|
+
res.redirect(303, MONITOR_HOME_URL);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@MonitorLogin()
|
|
58
|
+
@Post('logout')
|
|
59
|
+
logout(@Res() res: Response): void {
|
|
60
|
+
res.set(MONITOR_SECURITY_HEADERS).setHeader('Set-Cookie', this.session.clearSessionCookie());
|
|
61
|
+
res.redirect(303, MONITOR_LOGIN_URL);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|