@growconnect/growtickets-nest 0.0.0-stage → 0.1.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/.env.example +2 -0
- package/README.md +413 -2
- package/bin/growtickets-nest.mjs +5 -0
- package/dist/cli/args.d.ts +14 -0
- package/dist/cli/args.js +23 -0
- package/dist/cli/check.d.ts +20 -0
- package/dist/cli/check.js +121 -0
- package/dist/cli/deps.d.ts +38 -0
- package/dist/cli/deps.js +237 -0
- package/dist/cli/env-example.d.ts +15 -0
- package/dist/cli/env-example.js +60 -0
- package/dist/cli/gitignore.d.ts +11 -0
- package/dist/cli/gitignore.js +28 -0
- package/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.js +53 -0
- package/dist/cli/init.d.ts +14 -0
- package/dist/cli/init.js +73 -0
- package/dist/cli/run.d.ts +1 -0
- package/dist/cli/run.js +14 -0
- package/dist/client/create-growtickets-client.d.ts +29 -0
- package/dist/client/create-growtickets-client.js +70 -0
- package/dist/client/ingest-client.d.ts +22 -0
- package/dist/client/ingest-client.js +99 -0
- package/dist/client/query-string.d.ts +7 -0
- package/dist/client/query-string.js +26 -0
- package/dist/client/reporter-payload.d.ts +7 -0
- package/dist/client/reporter-payload.js +18 -0
- package/dist/config/env.schema.d.ts +32 -0
- package/dist/config/env.schema.js +42 -0
- package/dist/config/get-server-config.d.ts +12 -0
- package/dist/config/get-server-config.js +46 -0
- package/dist/contracts/index.d.ts +52 -0
- package/dist/contracts/index.js +17 -0
- package/dist/errors/authentication-error.d.ts +7 -0
- package/dist/errors/authentication-error.js +14 -0
- package/dist/errors/authorization-error.d.ts +7 -0
- package/dist/errors/authorization-error.js +14 -0
- package/dist/errors/configuration-error.d.ts +11 -0
- package/dist/errors/configuration-error.js +18 -0
- package/dist/errors/external-service-error.d.ts +14 -0
- package/dist/errors/external-service-error.js +20 -0
- package/dist/errors/growtickets-error.d.ts +12 -0
- package/dist/errors/growtickets-error.js +16 -0
- package/dist/errors/index.d.ts +8 -0
- package/dist/errors/index.js +20 -0
- package/dist/errors/not-found-error.d.ts +7 -0
- package/dist/errors/not-found-error.js +14 -0
- package/dist/errors/rate-limit-error.d.ts +7 -0
- package/dist/errors/rate-limit-error.js +14 -0
- package/dist/errors/to-public-error.d.ts +9 -0
- package/dist/errors/to-public-error.js +25 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +60 -0
- package/dist/logging/logger.d.ts +19 -0
- package/dist/logging/logger.js +27 -0
- package/dist/logging/redact.d.ts +12 -0
- package/dist/logging/redact.js +34 -0
- package/dist/logging/safe-text.d.ts +6 -0
- package/dist/logging/safe-text.js +16 -0
- package/dist/nest/decorators.d.ts +24 -0
- package/dist/nest/decorators.js +25 -0
- package/dist/nest/front-deps.d.ts +19 -0
- package/dist/nest/front-deps.js +108 -0
- package/dist/nest/growtickets.module.d.ts +14 -0
- package/dist/nest/growtickets.module.js +52 -0
- package/dist/nest/options.d.ts +57 -0
- package/dist/nest/options.js +7 -0
- package/dist/nest/portal.controller.d.ts +19 -0
- package/dist/nest/portal.controller.js +210 -0
- package/dist/nest/request.d.ts +63 -0
- package/dist/nest/request.js +101 -0
- package/dist/nest/route-tag.d.ts +16 -0
- package/dist/nest/route-tag.guard.d.ts +16 -0
- package/dist/nest/route-tag.guard.js +76 -0
- package/dist/nest/route-tag.js +15 -0
- package/dist/nest/security.middleware.d.ts +42 -0
- package/dist/nest/security.middleware.js +205 -0
- package/dist/nest/services.d.ts +43 -0
- package/dist/nest/services.js +89 -0
- package/dist/nest/throttle.d.ts +20 -0
- package/dist/nest/throttle.js +40 -0
- package/dist/proxy/handle-portal-request.d.ts +46 -0
- package/dist/proxy/handle-portal-request.js +272 -0
- package/dist/proxy/php-variable-name.d.ts +9 -0
- package/dist/proxy/php-variable-name.js +21 -0
- package/dist/proxy/portal-routes.d.ts +33 -0
- package/dist/proxy/portal-routes.js +71 -0
- package/dist/proxy/types.d.ts +45 -0
- package/dist/proxy/types.js +2 -0
- package/dist/proxy/unread.d.ts +11 -0
- package/dist/proxy/unread.js +55 -0
- package/dist/secret-value.d.ts +24 -0
- package/dist/secret-value.js +47 -0
- package/dist/security/aggregator.d.ts +117 -0
- package/dist/security/aggregator.js +197 -0
- package/dist/security/blocklist.d.ts +50 -0
- package/dist/security/blocklist.js +119 -0
- package/dist/security/central-api.d.ts +16 -0
- package/dist/security/central-api.js +28 -0
- package/dist/security/classifier.d.ts +63 -0
- package/dist/security/classifier.js +110 -0
- package/dist/security/client-ip.d.ts +37 -0
- package/dist/security/client-ip.js +176 -0
- package/dist/security/event-type.d.ts +24 -0
- package/dist/security/event-type.js +52 -0
- package/dist/security/flusher.d.ts +83 -0
- package/dist/security/flusher.js +205 -0
- package/dist/security/health.d.ts +34 -0
- package/dist/security/health.js +47 -0
- package/dist/security/hmac.d.ts +24 -0
- package/dist/security/hmac.js +158 -0
- package/dist/security/http.d.ts +12 -0
- package/dist/security/http.js +29 -0
- package/dist/security/identity.d.ts +23 -0
- package/dist/security/identity.js +62 -0
- package/dist/security/origin.d.ts +13 -0
- package/dist/security/origin.js +35 -0
- package/dist/security/probe-paths.d.ts +7 -0
- package/dist/security/probe-paths.js +34 -0
- package/dist/security/ranges.d.ts +36 -0
- package/dist/security/ranges.js +197 -0
- package/dist/security/remote-config.d.ts +40 -0
- package/dist/security/remote-config.js +80 -0
- package/dist/security/request-ip.d.ts +16 -0
- package/dist/security/request-ip.js +14 -0
- package/dist/security/route-options.d.ts +30 -0
- package/dist/security/route-options.js +17 -0
- package/dist/security/route-template.d.ts +17 -0
- package/dist/security/route-template.js +57 -0
- package/dist/security/runtime.d.ts +94 -0
- package/dist/security/runtime.js +203 -0
- package/dist/security/sdk.d.ts +15 -0
- package/dist/security/sdk.js +21 -0
- package/dist/security/settings.d.ts +32 -0
- package/dist/security/settings.js +60 -0
- package/package.json +62 -4
package/.env.example
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,414 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @growconnect/growtickets-nest
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
GrowTickets para **NestJS** (10, 11 y 12, con **Express o Fastify**), en dos mitades que no se necesitan entre sí:
|
|
4
|
+
|
|
5
|
+
- **El proxy del portal**: guarda `GROWTICKETS_API_KEY`, firma quién reporta con la sesión del producto y reenvía a
|
|
6
|
+
`api/ingest` por una lista blanca de 9 rutas. Es el mismo contrato que `growtickets-laravel` y
|
|
7
|
+
`@growconnect/growtickets-next-server`: el front que ya tienes (`@growconnect/growtickets-ng`, `-next-react`,
|
|
8
|
+
`growtickets_flutter` o los componentes web) funciona encima sin cambiar nada.
|
|
9
|
+
- **La seguridad del producto** (opcional, apagada por defecto): manda a la mesa de ayuda lo que ve pasar, **corta a
|
|
10
|
+
las IP que ella bloquee**, contesta la ruta de salud con la que sabe si el producto se cayó y reenvía la lista de
|
|
11
|
+
paquetes de tu front. Ver [Seguridad y disponibilidad](#seguridad-y-disponibilidad).
|
|
12
|
+
|
|
13
|
+
No trae pantallas: Nest es tu backend y el portal lo pinta tu front.
|
|
14
|
+
|
|
15
|
+
## Instalación
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @growconnect/growtickets-nest
|
|
19
|
+
npx growtickets-nest init # .env.example y .gitignore, e imprime los dos fragmentos de abajo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Necesita Node 18.18+ y, como `peerDependencies`, los `@nestjs/common` y `@nestjs/core` que ya tienes (10, 11 o 12),
|
|
23
|
+
`reflect-metadata` y `rxjs`. Nest 12 sólo se publica como ESM y este paquete es CommonJS: tu app lo importa igual
|
|
24
|
+
(`import { GrowTicketsModule } from '@growconnect/growtickets-nest'`), con **Node 20.19+ o 22.12+**, que son los que cargan
|
|
25
|
+
ESM desde CommonJS. Pon en tu `.env` la dirección de tu GrowTickets y la clave de la pestaña «Claves de API» del proyecto:
|
|
26
|
+
|
|
27
|
+
```dotenv
|
|
28
|
+
GROWTICKETS_URL=https://… # obligatoria: la dirección de la mesa de ayuda
|
|
29
|
+
GROWTICKETS_API_KEY=gt_live_…
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`GROWTICKETS_URL` **no tiene valor por defecto**: uno que no fuera la mesa de ayuda se llevaría la clave de quien olvide
|
|
33
|
+
configurarla. Tiene que ser `https`, salvo un nombre que no sale de tu máquina o de pruebas (`localhost`, `*.localhost`,
|
|
34
|
+
`*.test`, `127.0.0.1`), sin importar `NODE_ENV`.
|
|
35
|
+
|
|
36
|
+
## Montarlo
|
|
37
|
+
|
|
38
|
+
Son dos piezas. El módulo, en tu `AppModule`:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { AuthGuard } from '@nestjs/passport';
|
|
42
|
+
import { GrowTicketsModule } from '@growconnect/growtickets-nest';
|
|
43
|
+
|
|
44
|
+
@Module({
|
|
45
|
+
imports: [
|
|
46
|
+
GrowTicketsModule.forRoot({
|
|
47
|
+
guards: [AuthGuard('jwt')],
|
|
48
|
+
resolveReporter: (req: Request & { user?: Usuario }) =>
|
|
49
|
+
req.user ? { id: String(req.user.id), name: req.user.nombre, email: req.user.email } : null,
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
})
|
|
53
|
+
export class AppModule {}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Y una línea en `main.ts`, antes de `listen()`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { growTicketsMiddleware } from '@growconnect/growtickets-nest';
|
|
60
|
+
|
|
61
|
+
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
|
62
|
+
app.use(growTicketsMiddleware(app));
|
|
63
|
+
app.useBodyParser('json', { limit: '256kb' }); // sólo con Express: su tope de serie es 100 KB
|
|
64
|
+
await app.listen(3000);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
El portal queda en **`/api/soporte`**, el `baseUrl` por defecto de todos los fronts, con sus 9 rutas y además
|
|
68
|
+
`GET /api/soporte/unread` para el [lanzador del componente web](#el-componente-web-growtickets-launcher).
|
|
69
|
+
`npx growtickets-nest check` avisa si falta alguna de las dos piezas.
|
|
70
|
+
|
|
71
|
+
- **El middleware va en `main.ts` y no dentro del módulo** a propósito: un middleware de módulo (`consumer.apply()`)
|
|
72
|
+
sólo ve las rutas bajo el prefijo global, y se perderían la ruta de salud, el relevo del front y los escáneres que
|
|
73
|
+
piden `/.env`. Sin él el portal funciona igual; lo que falta es la seguridad.
|
|
74
|
+
- **El tope de Express**: su parser de JSON corta en 100 KB y un mensaje largo del portal (el contrato admite 256 KB)
|
|
75
|
+
recibiría un 413 antes de llegar al proxy. Fastify trae 1 MB y no hace falta tocar nada.
|
|
76
|
+
- **Con un prefijo global** (`app.setGlobalPrefix('api')`) el prefijo se suma: pasa `path: 'soporte'` para que siga en
|
|
77
|
+
`/api/soporte`. `path` acepta cualquier ruta (`'ayuda/portal'`), y el front se configura con esa.
|
|
78
|
+
|
|
79
|
+
### `resolveReporter`: la única pieza que tienes que escribir
|
|
80
|
+
|
|
81
|
+
Es obligatorio y sin valor por defecto — Nest no tiene un `auth()->user()` universal, así que la librería no puede
|
|
82
|
+
adivinar cómo autentica tu producto. `null` responde 401 **sin llamar** a la ingesta. La identidad tiene que salir de
|
|
83
|
+
la SESIÓN (`req.user`, que llenan los `guards`), nunca de los parámetros, la query o el cuerpo: el proxy borra
|
|
84
|
+
`reporter`, `reporter_id`, `internal` y `external_user_id` de todo lo que llega del navegador y los repone desde aquí.
|
|
85
|
+
|
|
86
|
+
- `guards` son los del producto, y corren antes de `resolveReporter` (`AuthGuard('jwt')`, el tuyo de sesión…). Si el
|
|
87
|
+
guard necesita providers de otro módulo, pásalo en `imports`.
|
|
88
|
+
- Recibe la petición de Nest: la de Express o la de Fastify, la que ya usas en tus controladores.
|
|
89
|
+
- El `id` tiene que ser ESTABLE: es lo único que ata a una persona con su historial.
|
|
90
|
+
|
|
91
|
+
### Con `ConfigService`, o cualquier dependencia: `forRootAsync`
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
GrowTicketsModule.forRootAsync({
|
|
95
|
+
path: 'soporte', // la ruta, los guards y los imports van FUERA de la fábrica:
|
|
96
|
+
guards: [JwtAuthGuard], // Nest los necesita al declarar el controlador
|
|
97
|
+
imports: [UsuariosModule],
|
|
98
|
+
inject: [UsuariosService, DataSource],
|
|
99
|
+
useFactory: (usuarios: UsuariosService, db: DataSource) => ({
|
|
100
|
+
resolveReporter: (req) => usuarios.comoReportante(req.user),
|
|
101
|
+
security: { checks: { database: () => db.query('select 1') } },
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### El front en otro dominio: CORS y `allowedOrigins`
|
|
107
|
+
|
|
108
|
+
Lo normal con Nest es que el front viva aparte (`app.tuproducto.co` contra `api.tuproducto.co`). El proxy tiene una
|
|
109
|
+
valla anti-CSRF: todo `POST` exige `Content-Type: application/json` y, en **cualquier método**, si llega `Origin` o
|
|
110
|
+
`Sec-Fetch-Site`, que sea del propio origen **o esté en `allowedOrigins`**. Pon ahí los mismos orígenes que en
|
|
111
|
+
`app.enableCors()`; si no, el portal da 403. Vale también para `GET`: con la sesión en una cookie, un CORS que refleja
|
|
112
|
+
cualquier origen (`origin: true` con `credentials: true`) dejaría a otro sitio leer los tickets de quien lo visite. El
|
|
113
|
+
origen propio sale del `Host`, nunca de `X-Forwarded-Host`, que puede poner el cliente.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
GrowTicketsModule.forRoot({ allowedOrigins: ['https://app.tuproducto.co'], resolveReporter });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Límite por persona
|
|
120
|
+
|
|
121
|
+
`throttle` (por defecto **120** peticiones por minuto y por persona, como en Laravel; `0` lo apaga). Va detrás de tu
|
|
122
|
+
autenticación, así que cuenta por usuario y no por IP. Pasado el límite, 429 con `Retry-After: 60`. Vive en la memoria
|
|
123
|
+
del proceso: con varios servidores, cada uno cuenta lo suyo.
|
|
124
|
+
|
|
125
|
+
### El componente web: `<growtickets-launcher>`
|
|
126
|
+
|
|
127
|
+
`growtickets-ng` también se compila como componentes web (`<growtickets-portal>`, `<growtickets-launcher>`) para páginas que no son
|
|
128
|
+
Angular. Funcionan sobre este proxy con `base-url="/api/soporte"`. El lanzador, al cerrar el panel, pregunta a **`GET <base-url>/unread`**
|
|
129
|
+
cuántas conversaciones siguen sin leer y corrige su insignia:
|
|
130
|
+
|
|
131
|
+
- Contesta `{ "success": true, "data": { "count": 3 } }`, con los mismos `guards`, el mismo `resolveReporter` (sin sesión, 401) y el
|
|
132
|
+
mismo límite por persona que el portal. Pregunta siempre a la mesa de ayuda —justo acaba de leer— y nunca se guarda en caché.
|
|
133
|
+
- Si la mesa de ayuda falla, es un 503 y no un «0»: la insignia se queda como estaba en vez de decir que no hay nada que leer.
|
|
134
|
+
- El componente pide con `credentials: 'same-origin'`, sin `Authorization`: sirve cuando la página y la API comparten origen y la
|
|
135
|
+
sesión viaja en una cookie. Con un JWT y el front en otro dominio, usa `@growconnect/growtickets-ng` o `-next-react`, que van por
|
|
136
|
+
tu propio cliente HTTP.
|
|
137
|
+
- La cifra inicial (`unread="2"`) la pinta tu servidor al servir la página: `GrowTicketsClient.sinLeer(reporter)`. Pregunta cada vez
|
|
138
|
+
y corta a los 2 segundos; si lo llamas en cada página, guárdalo un minuto como hace `growtickets-laravel`.
|
|
139
|
+
|
|
140
|
+
### Lo que el proxy no deja pasar de la query
|
|
141
|
+
|
|
142
|
+
La mesa de ayuda es PHP, y dos cosas de PHP harían que la identidad dejara de ser la de la sesión si el proxy reenviara
|
|
143
|
+
la query a ciegas:
|
|
144
|
+
|
|
145
|
+
- **El recorte de `max_input_vars`**: PHP lee hasta 1000 variables y descarta el resto en silencio. Por eso `reporter_id`
|
|
146
|
+
va **primero** en lo que se reenvía, y una query de más de 100 valores —el portal manda una decena— se rechaza con 403
|
|
147
|
+
sin llamar a la ingesta. La mesa de ayuda, además, rechaza una query que llega al tope.
|
|
148
|
+
- **Las claves que PHP reescribe**: quita los espacios del principio, corta en un byte nulo y cambia `.` y ` ` por `_`,
|
|
149
|
+
así que `+internal`, `reporter.id` o `external.user.id` llegarían como `internal`, `reporter_id` y `external_user_id`.
|
|
150
|
+
Cada clave se compara con la identidad tal como la leerá PHP, y se borra.
|
|
151
|
+
|
|
152
|
+
Las respuestas del proxy salen con `Cache-Control: private, no-store` y `X-Content-Type-Options: nosniff`.
|
|
153
|
+
|
|
154
|
+
## Con qué versión escribe cada persona
|
|
155
|
+
|
|
156
|
+
En cada llamada, el proxy le dice a la mesa de ayuda **qué versión es** y reenvía lo que dice el portal de sí mismo.
|
|
157
|
+
Con eso, quien atiende ve en la ficha del ticket con qué librería y desde qué aparato escribe la persona, y en el
|
|
158
|
+
catálogo de proyectos se avisa de «Librería desactualizada».
|
|
159
|
+
|
|
160
|
+
| Cabecera | La pone | Contenido |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `X-GrowTickets-Proxy` | Este paquete, **siempre** | `growtickets-nest/0.1.0` |
|
|
163
|
+
| `X-GrowTickets-Client` | La librería del portal; el proxy la **reenvía** | base64url de un JSON corto: librería, versión, canal, sistema, modelo |
|
|
164
|
+
|
|
165
|
+
`X-GrowTickets-Client` se reenvía sólo si parece lo que es (hasta 2 KB, sólo caracteres base64url) y es **la única
|
|
166
|
+
cabecera del navegador que pasa**. Es información, nunca autorización. `X-GrowTickets-Proxy` también sale del cliente
|
|
167
|
+
servidor a servidor y de la telemetría de seguridad.
|
|
168
|
+
|
|
169
|
+
## Seguridad y disponibilidad
|
|
170
|
+
|
|
171
|
+
Lo mismo que `growtickets-laravel` y `growtickets-next-server`, con el mismo protocolo: el producto **manda telemetría
|
|
172
|
+
de seguridad** (intentos de login, 401/403/429, errores 5xx, escáneres que piden `/.env`), **corta con un 403 a las IP
|
|
173
|
+
que la mesa de ayuda bloquee** y contesta una **ruta de salud firmada**. Está **apagada por defecto**: apagada no
|
|
174
|
+
registra, no corta y no arranca ningún temporizador. La seguridad también hay que encenderla en GrowTickets, en la
|
|
175
|
+
ficha del proyecto.
|
|
176
|
+
|
|
177
|
+
### Variables de entorno
|
|
178
|
+
|
|
179
|
+
| Variable | Por defecto | Qué hace |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `GROWTICKETS_SECURITY` | apagada | `true` la enciende. Hace falta además `GROWTICKETS_API_KEY`. |
|
|
182
|
+
| `GROWTICKETS_SECURITY_BLOCKING` | `true` | `false` sigue capturando pero nunca corta una petición. |
|
|
183
|
+
| `GROWTICKETS_SECURITY_HEALTH_PATH` | `growtickets/health` | Dónde contesta la ruta de salud (sin barras en los extremos, sin el prefijo global). `off` la apaga. |
|
|
184
|
+
| `GROWTICKETS_TRUSTED_PROXY_HOPS` | `1` | Cuántos proxies de confianza hay delante (1 = Railway; con Cloudflare delante de Railway, 2). `0` = sin proxy. |
|
|
185
|
+
| `GROWTICKETS_FRONT_DEPS_TOKEN` | vacío | El token de build del front (ver [Dependencias de un front](#dependencias-de-un-front-que-vive-en-otro-repo)). Vacío = el relevo no existe. |
|
|
186
|
+
| `GROWTICKETS_FRONT_DEPS_PATH` | `growtickets/front-deps` | Dónde recibe el relevo. `off` lo quita. |
|
|
187
|
+
| `GROWTICKETS_SECURITY_DEPENDENCIES` | `true` | `false` apaga el relevo aunque tenga token. |
|
|
188
|
+
|
|
189
|
+
Una variable mal escrita cae a su valor por defecto: la seguridad nunca lanza ni tumba una petición. Se leen al
|
|
190
|
+
arrancar el proceso y en cada petición, no al compilar.
|
|
191
|
+
|
|
192
|
+
### Qué hace el middleware de `main.ts`
|
|
193
|
+
|
|
194
|
+
Corre **antes** que el router, los guards y el parser de cuerpo, en Express y en Fastify. Por petición:
|
|
195
|
+
|
|
196
|
+
1. **La ruta de salud** se contesta ahí mismo: sólo a una petición firmada por la mesa de ayuda (HMAC-SHA256 de la
|
|
197
|
+
hora con tu clave de API, válida 5 minutos) y a todo lo demás un 404 corriente. Nunca se corta por IP ni se cuenta.
|
|
198
|
+
No revela versiones ni tablas.
|
|
199
|
+
2. **Una IP bloqueada** recibe 403 (`{ "message": "Acceso bloqueado." }`, o texto si pide HTML) y no llega a tu app.
|
|
200
|
+
3. **El relevo del front** (`POST /growtickets/front-deps`) se contesta ahí con el token de build.
|
|
201
|
+
4. **Todo lo demás sigue**, y al terminar la respuesta se cuenta con su código real.
|
|
202
|
+
|
|
203
|
+
Lo que se cuenta: el **tráfico** por minuto y por PLANTILLA de ruta (`GET /api/orders/[id]`, nunca la URL ni la query),
|
|
204
|
+
con su reparto de códigos y su latencia; **`access.unauthorized` / `forbidden` / `rate_limited`** para 401, 403 y 429,
|
|
205
|
+
también los que pone un `AuthGuard` tuyo; **`server.error`** para los 5xx; y **`request.probe`** para un 404 sin ruta
|
|
206
|
+
contra una de las que sólo pide un escáner (`/.env`, `/wp-login.php`, `/actuator`…). La plantilla la anota un guard
|
|
207
|
+
global que registra el módulo (`GrowTicketsRouteGuard`, que siempre deja pasar): un 404 que no casó con ninguna ruta
|
|
208
|
+
va como `(sin ruta)`. Las rutas de salud de tu propio producto (`/health`, `/healthz`, `/up`, `/livez`, `/readyz`) no
|
|
209
|
+
se cuentan, pero una IP bloqueada tampoco entra por ellas.
|
|
210
|
+
|
|
211
|
+
### Las rutas de login
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { GrowTicketsLogin, GrowTicketsOnSuccess, GrowTicketsSecondFactor } from '@growconnect/growtickets-nest';
|
|
215
|
+
|
|
216
|
+
@Post('auth/login')
|
|
217
|
+
@GrowTicketsLogin({ identifier: 'email' }) // 401/422 = intento fallido, 2xx = entró
|
|
218
|
+
login(@Body() dto: LoginDto) { … }
|
|
219
|
+
|
|
220
|
+
@Post('auth/2fa')
|
|
221
|
+
@GrowTicketsSecondFactor() // sus fallos son auth.second_factor_failed
|
|
222
|
+
segundoFactor(@Body() dto: CodigoDto) { … }
|
|
223
|
+
|
|
224
|
+
@Post('cuenta/clave')
|
|
225
|
+
@GrowTicketsOnSuccess('account.password_changed') // el evento que cuenta un 2xx de esta ruta
|
|
226
|
+
cambiarClave(@Body() dto: ClaveDto) { … }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
El login se detecta por la RESPUESTA de la ruta, porque es lo único que ven todos los productos —también cuando la
|
|
230
|
+
cuenta no existe, que es justo la señal del *credential stuffing*—. **Del cuerpo sólo se lee el campo `identifier`**, y
|
|
231
|
+
sale hasheado (HMAC con una sal que sólo conocen tu servidor y la mesa de ayuda) y enmascarado (`a***@olivos.co`). Nunca
|
|
232
|
+
se lee la contraseña. Un cuerpo de más de 16 KB no se toca. Si tu login falla con otro código (un `ValidationPipe` da
|
|
233
|
+
400), pásalo: `@GrowTicketsLogin({ identifier: 'email', failureStatuses: [400, 401] })`.
|
|
234
|
+
|
|
235
|
+
### Quién es el usuario
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
GrowTicketsModule.forRoot({ resolveReporter, security: { actor: (req) => req.user?.id } });
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Se lee al terminar la respuesta, así que ve el `req.user` que dejó tu guard. Sin él, las peticiones se cuentan sin
|
|
242
|
+
cuenta. Un id numérico viaja como `u:12`.
|
|
243
|
+
|
|
244
|
+
### Lo que sólo tu producto sabe
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
constructor(private readonly security: GrowTicketsSecurity) {}
|
|
248
|
+
|
|
249
|
+
this.security.track('auth.login_failed', { identifier: dto.email, request });
|
|
250
|
+
this.security.track('custom.pago_rechazado', { actor: usuario.id, meta: { pasarela: 'x' } });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
El tipo tiene que ser del catálogo del protocolo o `custom.<nombre>`; uno inventado se descarta. `meta` sólo lleva
|
|
254
|
+
escalares. Apagada la seguridad, no hace nada; nunca lanza.
|
|
255
|
+
|
|
256
|
+
### La ruta de salud
|
|
257
|
+
|
|
258
|
+
No hay que montar nada: la contesta el middleware en `/growtickets/health` (o en `GROWTICKETS_SECURITY_HEALTH_PATH`).
|
|
259
|
+
Agrega esa misma ruta al dominio en la pestaña «Disponibilidad». Dice «caído» si el servidor no responde y, si le das
|
|
260
|
+
una comprobación de tu base, también cuando ésta no contesta:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
security: { checks: { database: () => dataSource.query('select 1') } } // con forRootAsync + inject
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`false` o una excepción son un 503 que la mesa de ayuda cuenta como caída. Sin `checks` no se mira ninguna base.
|
|
267
|
+
|
|
268
|
+
### Bloques de red
|
|
269
|
+
|
|
270
|
+
Además de IP sueltas, GrowTickets puede cerrar un **país, una red (ASN) o las salidas de Tor**. Esos bloques son miles de
|
|
271
|
+
rangos: se guardan ordenados en memoria y se consultan con una búsqueda binaria, sin coste apreciable por petición.
|
|
272
|
+
Fallan abiertos igual que las IP sueltas.
|
|
273
|
+
|
|
274
|
+
### Dependencias de un front que vive en otro repo
|
|
275
|
+
|
|
276
|
+
Tu front (Angular…) tiene paquetes con fallos conocidos, pero tu servidor no los ve: están en otro repo. **El front no
|
|
277
|
+
necesita ninguna clave de GrowTickets.** Su build manda la lista (sólo nombres y versiones) a tu servidor con un *token
|
|
278
|
+
de build*, y la librería la reenvía a GrowTickets con la clave que ya tienes. Aparece en «Postura» como la lista de ese
|
|
279
|
+
front, con su dominio.
|
|
280
|
+
|
|
281
|
+
```dotenv
|
|
282
|
+
# .env del servidor Nest
|
|
283
|
+
GROWTICKETS_FRONT_DEPS_TOKEN=un-secreto-largo-y-solo-para-esto
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
En las variables del hosting que construye el front: el mismo `GROWTICKETS_FRONT_DEPS_TOKEN`,
|
|
287
|
+
`GROWTICKETS_FRONT_DEPS_ENDPOINT=https://api.tuproducto.co/growtickets/front-deps` (y, opcional,
|
|
288
|
+
`GROWTICKETS_ORIGIN=https://app.tuproducto.co`), y en su `package.json` `"postbuild": "growtickets-ng deps --soft"`.
|
|
289
|
+
|
|
290
|
+
- **El token sólo sirve para esta ruta.** Si se filtra, lo peor que puede hacer quien lo tenga es falsear la lista de
|
|
291
|
+
paquetes de ese front: no puede leer nada, ni abrir tickets, ni tocar el monitor. Se rota cambiando el `.env`.
|
|
292
|
+
- **Sin token, la ruta no existe.** Con otro token responde el 404 de siempre de tu app. La comparación es en tiempo
|
|
293
|
+
constante y hay un límite de 30 peticiones por minuto y por IP.
|
|
294
|
+
- Funciona aunque `GROWTICKETS_SECURITY` esté apagada, como en Laravel: sólo necesita el token y la clave.
|
|
295
|
+
- Respuestas que ve el build del front: `200` (`sent`, o `disabled` si la seguridad está apagada en el proyecto), `422`
|
|
296
|
+
(lista mal formada o rechazada), `501` (una central anterior), `502` (no se pudo reenviar; reintenta en el siguiente build).
|
|
297
|
+
|
|
298
|
+
Las dependencias del **propio servidor Nest** se mandan desde CI con [`npx growtickets-nest deps`](#deps).
|
|
299
|
+
|
|
300
|
+
### Límites (léelos antes de fiarte)
|
|
301
|
+
|
|
302
|
+
- **Necesita un proceso Node que viva** (Railway, Docker, una máquina). Lo capturado y la lista de bloqueos están en
|
|
303
|
+
memoria y se envían cada minuto. En serverless cada invocación puede empezar de cero: la ruta de salud funciona, el
|
|
304
|
+
resto no es fiable.
|
|
305
|
+
- **No es un cortafuegos.** Falla ABIERTO: sin lista descargada, con la mesa de ayuda caída o ante cualquier error de la
|
|
306
|
+
librería, la petición pasa. Un bloqueo se aplica en ~1 minuto.
|
|
307
|
+
- **Varios servidores** detrás de un balanceador son varias instancias del mismo proyecto: cada uno descarga su lista y
|
|
308
|
+
manda sus lotes, y la mesa de ayuda los suma.
|
|
309
|
+
- **La IP** sale de `X-Forwarded-For` contando `GROWTICKETS_TRUSTED_PROXY_HOPS` desde la DERECHA: lo que escribe el cliente
|
|
310
|
+
va a la izquierda y no cuenta. No depende del `trust proxy` de Express. Con un número equivocado todas las peticiones
|
|
311
|
+
parecen de la misma IP privada; la librería lo detecta y avisa a la mesa de ayuda (`no_trusted_proxies`).
|
|
312
|
+
- **La dirección pública del producto** se aprende de las respuestas 2xx (con `X-Forwarded-Host`/`-Proto` detrás de un
|
|
313
|
+
proxy) y viaja con cada lote: de ahí sale la sugerencia de dominio en «Disponibilidad».
|
|
314
|
+
- **Fastify** contesta 415 a un `POST` sin `Content-Type` antes de que llegue al proxy (el contrato pide 403). Sigue
|
|
315
|
+
rechazado; sólo cambia el código.
|
|
316
|
+
- **Sin política de contenido (CSP)** en esta versión: Nest sirve JSON y el HTML lo sirve tu front. **Sin integridad de
|
|
317
|
+
archivos**: los despliegues de un producto Node son inmutables, y comparar archivos no aportaría nada.
|
|
318
|
+
|
|
319
|
+
## CLI: `npx growtickets-nest`
|
|
320
|
+
|
|
321
|
+
Sin dependencias propias.
|
|
322
|
+
|
|
323
|
+
### `init [--dry-run]`
|
|
324
|
+
|
|
325
|
+
Aborta si `package.json` no trae `@nestjs/core`. Crea o completa `.env.example` (la URL con su valor público, la clave
|
|
326
|
+
vacía y `GROWTICKETS_SECURITY=` vacío) y `.gitignore` (un bloque marcado, idempotente). **Nunca toca `.env` ni ningún
|
|
327
|
+
otro `.env*`**, no pide secretos y no usa la red. El módulo y `main.ts` son tus archivos: imprime los fragmentos y los
|
|
328
|
+
deja ahí.
|
|
329
|
+
|
|
330
|
+
### `check`
|
|
331
|
+
|
|
332
|
+
De sólo lectura, para el CI del producto. Sale con 1 si `.gitignore` no cubre `.env` o si `.env.example` lleva en una
|
|
333
|
+
`GROWTICKETS_*` un valor que parece un secreto (la clave o el token de build). Además **avisa** —sin cambiar el código de
|
|
334
|
+
salida— de un montaje que no hará lo que se espera: el módulo sin `growTicketsMiddleware` en `main.ts`, Express sin
|
|
335
|
+
subir el tope de JSON, o un prefijo global con la ruta de serie del portal.
|
|
336
|
+
|
|
337
|
+
### `deps`
|
|
338
|
+
|
|
339
|
+
`deps [--lockfile <ruta>] [--app <nombre>] [--origin <url>] [--dry-run]` manda a GrowTickets los **nombres y versiones**
|
|
340
|
+
de tus paquetes npm, para que te avise en «Postura». Lee `package-lock.json`, `pnpm-lock.yaml` y `yarn.lock`. **Nunca
|
|
341
|
+
viaja el archivo.** Pensado para CI, después de instalar; la clave necesita el alcance **Seguridad**. `--origin` (o
|
|
342
|
+
`GROWTICKETS_ORIGIN`) distingue dos servidores con el mismo nombre. Sale con 1 si no puede enviar y con 0 si la mesa de
|
|
343
|
+
ayuda aún no recibe dependencias o tiene la seguridad apagada: eso no debe frenar un despliegue.
|
|
344
|
+
|
|
345
|
+
## Cliente servidor a servidor
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
constructor(private readonly growtickets: GrowTicketsClient) {}
|
|
349
|
+
|
|
350
|
+
async avisarFalloDePago(usuario: Usuario) {
|
|
351
|
+
return this.growtickets.abrirTicket({ id: String(usuario.id), name: usuario.nombre }, { subject: 'El cobro falló' });
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Para un job, un cron o un webhook que abre un ticket sin nadie mirando una pantalla. **Lanza** si la mesa de ayuda
|
|
356
|
+
responde `>= 400`, al revés que el proxy, que devuelve el código tal cual. También `tickets()`, `ticket()`,
|
|
357
|
+
`responder()`, `configuracion()` y `sinLeer()`, que dice cuántas conversaciones tiene alguien sin leer (y lanza si no se
|
|
358
|
+
pudo saber: nunca devuelve un cero que no es).
|
|
359
|
+
|
|
360
|
+
## Dónde puede acabar un secreto, y qué lo impide
|
|
361
|
+
|
|
362
|
+
| # | Vía de fuga | Barrera |
|
|
363
|
+
|---|---|---|
|
|
364
|
+
| 1 | La clave en el navegador | Nunca sale del servidor: el front habla con el proxy, que la pone en `X-Api-Key` |
|
|
365
|
+
| 2 | Respuesta del proxy (cuerpo o cabeceras de la ingesta) | JSON re-serializado, `>= 500` sustituido por 503, sólo `content-type`/`cache-control` propios, la clave tapada por valor |
|
|
366
|
+
| 3 | Mensajes de error | `toPublicError()` es la única salida hacia el navegador |
|
|
367
|
+
| 4 | Registros | `redactByKey`/`redactByValue`, también con un logger inyectado |
|
|
368
|
+
| 5 | Un `console.log` del objeto de configuración | `SecretValue` se imprime como `[redacted]` |
|
|
369
|
+
| 6 | Una cabecera libre hacia la mesa de ayuda | Sólo `X-GrowTickets-Client`, validada |
|
|
370
|
+
| 7 | La identidad falsificada desde el navegador | `reporter`/`reporter_id` se borran de cuerpo y query y salen de la sesión |
|
|
371
|
+
| 8 | CSRF con la cookie de la víctima | `Origin`/`Sec-Fetch-Site` + `Content-Type: application/json` obligatorio en todo `POST` |
|
|
372
|
+
| 9 | Tarball de npm | `files` en lista blanca + `scripts/audit-tarball.mjs` en `prepublishOnly` |
|
|
373
|
+
| 10 | Commit del `.env` | `.gitignore` (lo escribe `init`, lo vigila `check`) |
|
|
374
|
+
| 11 | Un valor por defecto de `GROWTICKETS_URL` que no es GrowTickets | No hay valor por defecto; `https` obligatorio salvo nombres locales |
|
|
375
|
+
| 12 | La clave hacia un proxy de la red en claro | `https` sin depender de `NODE_ENV`, que en Nest casi nunca está |
|
|
376
|
+
| 13 | El ticket de otra persona por el recorte de `max_input_vars` | `reporter_id` primero, tope de 100 valores, y la mesa de ayuda rechaza la query recortada |
|
|
377
|
+
| 14 | Una línea falsa en tus registros (un `%0A` en la URL) | Los mensajes salen con los caracteres de control escapados y cortados a 500 |
|
|
378
|
+
|
|
379
|
+
## Cadena de suministro: prácticas recomendadas
|
|
380
|
+
|
|
381
|
+
- **Instala desde el lockfile**: `npm ci --ignore-scripts` en CI, y ejecuta a mano los scripts de los pocos paquetes que
|
|
382
|
+
de verdad los necesitan.
|
|
383
|
+
- **Audita**: `npm audit --omit=dev` y `npx growtickets-nest deps` en el pipeline.
|
|
384
|
+
- **Cuarentena para lo recién publicado**: una versión nueva no se adopta el día que sale (`npm config set min-release-age 7`
|
|
385
|
+
donde tu npm lo admita).
|
|
386
|
+
- **2FA en tu cuenta de npm** y tokens de publicación de sólo ese paquete, con caducidad.
|
|
387
|
+
- **Un SBOM por versión desplegada** (`npm sbom --sbom-format cyclonedx`) guardado con el artefacto.
|
|
388
|
+
|
|
389
|
+
## Desarrollo
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
npm run verify # lint, typecheck, pruebas, build, auditoría del tarball y publint
|
|
393
|
+
npm run test:fixtures # empaqueta el .tgz, lo instala en Nest 10 + Express, Nest 11 + Fastify y Nest 12 (ESM) con las dos, y los prueba de punta a punta (pide red)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Las pruebas de `test/nest` levantan una app de Nest de verdad con **cada plataforma**; las de `test/proxy` y
|
|
397
|
+
`test/security` son las del núcleo.
|
|
398
|
+
|
|
399
|
+
**El núcleo es una COPIA** del de `@growconnect/growtickets-next-server` (`packages/server/src` de `growtickets-next`,
|
|
400
|
+
commit `8abdcaa`): `proxy/`, `security/`, `client/`, `errors/`, `logging/`, `config/` y `cli/deps.ts`, sin la CSP, sin el
|
|
401
|
+
runtime edge y sin la comprobación de `NEXT_PUBLIC_*`. Lo de Nest vive en `src/nest/`. Tras la revisión de seguridad del
|
|
402
|
+
2026-10-19 el núcleo además lleva aquí: `reporter_id` primero con `limitQuery` (100 valores) y `phpVariableName` en
|
|
403
|
+
`handle-portal-request.ts`, la valla de origen en todos los métodos (`assertTrustedOrigin`), `safeLogText` en los
|
|
404
|
+
registros y `GROWTICKETS_URL` obligatoria y `https` sin `NODE_ENV` en `config/env.schema.ts`; la dirección aprendida y el
|
|
405
|
+
origen propio salen del `Host`. **Un arreglo del núcleo hay que
|
|
406
|
+
hacerlo también allá** (y al revés): es el mismo contrato con la mesa de ayuda. La validación del entorno sin zod de
|
|
407
|
+
`-next-server` 0.6.1 no se copió a propósito: aquí zod sigue haciendo falta para validar lo que devuelve
|
|
408
|
+
`resolveReporter`, y no hay un bundle de edge que adelgazar.
|
|
409
|
+
|
|
410
|
+
## Versiones
|
|
411
|
+
|
|
412
|
+
- **0.1.0** — Primera versión: el proxy del portal (con `throttle`) y `GET unread` para el lanzador del componente web, la
|
|
413
|
+
seguridad (bloqueo de IP y de redes, sondeos, logins por decorador, ruta de salud firmada), el relevo de dependencias del
|
|
414
|
+
front, `GrowTicketsClient` (con `sinLeer()`) y el CLI (`init`, `check`, `deps`). Nest 10, 11 y 12, Express y Fastify.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shim delgado: TODA la lógica vive en dist/cli/run.js (compilado de src/cli/run.ts), que se auto-
|
|
3
|
+
// ejecuta al cargarse. .mjs y no .cjs a propósito: Node lo trata como ESM por la extensión sola,
|
|
4
|
+
// sin depender del campo "type" del package.json — que aquí es "commonjs" para el resto del paquete.
|
|
5
|
+
import '../dist/cli/run.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ParsedArgs {
|
|
2
|
+
command: string | null;
|
|
3
|
+
dryRun: boolean;
|
|
4
|
+
/** `--lockfile <ruta>`: el archivo de bloqueo a leer en `deps`, en vez de buscarlo. */
|
|
5
|
+
lockfile: string | null;
|
|
6
|
+
/** `--app <nombre>`: cómo aparece el proyecto en el informe de `deps`. */
|
|
7
|
+
app: string | null;
|
|
8
|
+
/** `--origin <url>`: la dirección pública del producto, para que `deps` distinga este servidor de otro con el mismo nombre. */
|
|
9
|
+
origin: string | null;
|
|
10
|
+
/** Banderas que no se reconocen — `runCli` decide qué hacer con ellas, este archivo sólo las señala. */
|
|
11
|
+
unknown: string[];
|
|
12
|
+
}
|
|
13
|
+
/** Analizador de argumentos propio: son cuatro banderas, y así el CLI no arrastra ninguna dependencia. */
|
|
14
|
+
export declare function parseArgs(argv: readonly string[]): ParsedArgs;
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseArgs = parseArgs;
|
|
4
|
+
/** Analizador de argumentos propio: son cuatro banderas, y así el CLI no arrastra ninguna dependencia. */
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const [command = null, ...rest] = argv;
|
|
7
|
+
const result = { command, dryRun: false, lockfile: null, app: null, origin: null, unknown: [] };
|
|
8
|
+
for (let i = 0; i < rest.length; i++) {
|
|
9
|
+
const arg = rest[i];
|
|
10
|
+
if (arg === '--dry-run')
|
|
11
|
+
result.dryRun = true;
|
|
12
|
+
else if (arg === '--lockfile' || arg === '--app' || arg === '--origin') {
|
|
13
|
+
const value = rest[++i];
|
|
14
|
+
if (value && !value.startsWith('--'))
|
|
15
|
+
result[arg === '--lockfile' ? 'lockfile' : arg === '--app' ? 'app' : 'origin'] = value;
|
|
16
|
+
else
|
|
17
|
+
result.unknown.push(`${arg} ${value ?? ''}`.trim());
|
|
18
|
+
}
|
|
19
|
+
else if (arg !== undefined)
|
|
20
|
+
result.unknown.push(arg);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface CheckResult {
|
|
2
|
+
exitCode: number;
|
|
3
|
+
/** Lo que puede acabar con la clave en un repositorio: hace fallar el CI. */
|
|
4
|
+
issues: string[];
|
|
5
|
+
/** Cosas que no funcionarán como se espera pero no son una fuga: se avisan, no fallan. */
|
|
6
|
+
warnings: string[];
|
|
7
|
+
messages: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* De sólo lectura: nunca escribe nada, y por eso sirve para el CI del propio producto. Sale con 1 si
|
|
11
|
+
* encuentra una vía por la que la clave acabe versionada (un `.gitignore` que no cubre `.env`, un
|
|
12
|
+
* `.env.example` con un valor que parece un secreto). Avisa —sin cambiar el código de salida— de un
|
|
13
|
+
* montaje que no hará lo que se espera:
|
|
14
|
+
*
|
|
15
|
+
* - el módulo sin `app.use(growTicketsMiddleware(app))` en `main.ts`: sin él no se corta ninguna IP, no hay
|
|
16
|
+
* ruta de salud y no se cuenta lo que no casó con ninguna ruta;
|
|
17
|
+
* - Express sin subir el tope de JSON: su parser corta en 100 KB y el contrato del portal es de 256 KB;
|
|
18
|
+
* - un prefijo global con la ruta de serie: el portal quedaría en `/api/api/soporte`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runCheck(cwd: string): CheckResult;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runCheck = runCheck;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const env_example_1 = require("./env-example");
|
|
7
|
+
const SOURCE_ROOTS = ['src', 'apps', 'libs'];
|
|
8
|
+
const SCAN_EXTENSIONS = new Set(['.ts', '.js', '.mts', '.cts', '.mjs', '.cjs']);
|
|
9
|
+
/** Un escaneo SUPERFICIAL: hasta 8 niveles bajo cada raíz, sin entrar en node_modules/dist/.git. */
|
|
10
|
+
const MAX_DEPTH = 8;
|
|
11
|
+
const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage']);
|
|
12
|
+
const USES_MODULE = /\bGrowTicketsModule\s*\.\s*forRoot(?:Async)?\s*\(/;
|
|
13
|
+
const USES_MIDDLEWARE = /\bgrowTicketsMiddleware\s*\(/;
|
|
14
|
+
const USES_FASTIFY = /\bFastifyAdapter\b/;
|
|
15
|
+
const RAISES_JSON_LIMIT = /useBodyParser\s*\(\s*['"]json['"][^)]*limit/;
|
|
16
|
+
const GLOBAL_PREFIX = /\bsetGlobalPrefix\s*\(\s*['"`]\/?([^'"`]+)['"`]/;
|
|
17
|
+
/**
|
|
18
|
+
* De sólo lectura: nunca escribe nada, y por eso sirve para el CI del propio producto. Sale con 1 si
|
|
19
|
+
* encuentra una vía por la que la clave acabe versionada (un `.gitignore` que no cubre `.env`, un
|
|
20
|
+
* `.env.example` con un valor que parece un secreto). Avisa —sin cambiar el código de salida— de un
|
|
21
|
+
* montaje que no hará lo que se espera:
|
|
22
|
+
*
|
|
23
|
+
* - el módulo sin `app.use(growTicketsMiddleware(app))` en `main.ts`: sin él no se corta ninguna IP, no hay
|
|
24
|
+
* ruta de salud y no se cuenta lo que no casó con ninguna ruta;
|
|
25
|
+
* - Express sin subir el tope de JSON: su parser corta en 100 KB y el contrato del portal es de 256 KB;
|
|
26
|
+
* - un prefijo global con la ruta de serie: el portal quedaría en `/api/api/soporte`.
|
|
27
|
+
*/
|
|
28
|
+
function runCheck(cwd) {
|
|
29
|
+
const issues = [...checkGitignore(cwd), ...checkEnvExample(cwd)];
|
|
30
|
+
const warnings = checkWiring(cwd);
|
|
31
|
+
return {
|
|
32
|
+
exitCode: issues.length > 0 ? 1 : 0,
|
|
33
|
+
issues,
|
|
34
|
+
warnings,
|
|
35
|
+
messages: [
|
|
36
|
+
...(issues.length > 0
|
|
37
|
+
? ['✗ growtickets-nest check encontró problemas:', ...issues.map((issue) => ` - ${issue}`)]
|
|
38
|
+
: ['✓ growtickets-nest check: nada que reportar.']),
|
|
39
|
+
...(warnings.length > 0 ? ['', '⚠ Avisos (no hacen fallar el check):', ...warnings.map((warning) => ` - ${warning}`)] : []),
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function checkWiring(cwd) {
|
|
44
|
+
let usaModulo = false;
|
|
45
|
+
let usaMiddleware = false;
|
|
46
|
+
let usaFastify = false;
|
|
47
|
+
let subeTope = false;
|
|
48
|
+
let prefijo = null;
|
|
49
|
+
let rutaPropia = false;
|
|
50
|
+
for (const root of SOURCE_ROOTS) {
|
|
51
|
+
walk((0, node_path_1.join)(cwd, root), 0, (_path, content) => {
|
|
52
|
+
usaModulo ||= USES_MODULE.test(content);
|
|
53
|
+
usaMiddleware ||= USES_MIDDLEWARE.test(content);
|
|
54
|
+
usaFastify ||= USES_FASTIFY.test(content);
|
|
55
|
+
subeTope ||= RAISES_JSON_LIMIT.test(content);
|
|
56
|
+
prefijo ??= GLOBAL_PREFIX.exec(content)?.[1] ?? null;
|
|
57
|
+
rutaPropia ||= passesPath(content);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (!usaModulo)
|
|
61
|
+
return [];
|
|
62
|
+
const warnings = [];
|
|
63
|
+
if (!usaMiddleware) {
|
|
64
|
+
warnings.push('GrowTicketsModule está montado pero no hay app.use(growTicketsMiddleware(app)) en main.ts: sin él la seguridad no corta IP bloqueadas, no contesta la ruta de salud ni cuenta los sondeos.');
|
|
65
|
+
}
|
|
66
|
+
if (!usaFastify && !subeTope) {
|
|
67
|
+
warnings.push("Con Express, el parser de JSON corta en 100 KB y un mensaje largo del portal (hasta 256 KB) recibiría un 413. Añade en main.ts: app.useBodyParser('json', { limit: '256kb' }).");
|
|
68
|
+
}
|
|
69
|
+
if (prefijo !== null && !rutaPropia) {
|
|
70
|
+
warnings.push(`Hay un prefijo global (setGlobalPrefix('${prefijo}')) y el portal usa la ruta de serie: quedará en /${prefijo}/api/soporte. Pasa path: 'soporte' a GrowTicketsModule.forRoot() si tu front lo espera en /${prefijo}/soporte.`);
|
|
71
|
+
}
|
|
72
|
+
return warnings;
|
|
73
|
+
}
|
|
74
|
+
/** ¿La llamada a `forRoot`/`forRootAsync` le da su propia `path`? Se mira el trozo de texto justo después de la llamada. */
|
|
75
|
+
function passesPath(content) {
|
|
76
|
+
const inicio = content.search(USES_MODULE);
|
|
77
|
+
return inicio >= 0 && /\bpath\s*:/.test(content.slice(inicio, inicio + 800));
|
|
78
|
+
}
|
|
79
|
+
function walk(dir, depth, onFile) {
|
|
80
|
+
if (depth > MAX_DEPTH || !(0, node_fs_1.existsSync)(dir))
|
|
81
|
+
return;
|
|
82
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
|
|
83
|
+
if (SKIPPED_DIRS.has(entry.name))
|
|
84
|
+
continue;
|
|
85
|
+
const full = (0, node_path_1.join)(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory())
|
|
87
|
+
walk(full, depth + 1, onFile);
|
|
88
|
+
else if (SCAN_EXTENSIONS.has((0, node_path_1.extname)(entry.name)) && !entry.name.endsWith('.d.ts'))
|
|
89
|
+
onFile(full, (0, node_fs_1.readFileSync)(full, 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function checkGitignore(cwd) {
|
|
93
|
+
const path = (0, node_path_1.join)(cwd, '.gitignore');
|
|
94
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
95
|
+
return ['.gitignore no existe: nada impide que .env se termine versionando.'];
|
|
96
|
+
const lines = (0, node_fs_1.readFileSync)(path, 'utf8').split('\n').map((line) => line.trim());
|
|
97
|
+
const cubre = lines.some((line) => line === '.env' || line === '.env*' || line === '.env.*' || line === '/.env');
|
|
98
|
+
return cubre ? [] : ['.gitignore no cubre .env.'];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Sólo mira las claves `GROWTICKETS_*`: un `.env.example` de un producto real trae más variables
|
|
102
|
+
* suyas con valores de ejemplo legítimos, y esta comprobación es sobre los SECRETOS de GrowTickets (la
|
|
103
|
+
* clave de API y el token de build del front), no una auditoría genérica de higiene de variables de entorno.
|
|
104
|
+
*/
|
|
105
|
+
function checkEnvExample(cwd) {
|
|
106
|
+
const path = (0, node_path_1.join)(cwd, '.env.example');
|
|
107
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
108
|
+
return [];
|
|
109
|
+
const issues = [];
|
|
110
|
+
for (const line of (0, node_fs_1.readFileSync)(path, 'utf8').split('\n')) {
|
|
111
|
+
const match = /^(GROWTICKETS_[A-Z0-9_]*)=(.*)$/.exec(line.trim());
|
|
112
|
+
if (!match || !match[1])
|
|
113
|
+
continue;
|
|
114
|
+
const key = match[1];
|
|
115
|
+
const value = match[2] ?? '';
|
|
116
|
+
const allowed = value === '' || env_example_1.PUBLIC_KEYS.includes(key);
|
|
117
|
+
if (!allowed)
|
|
118
|
+
issues.push(`.env.example: ${key} lleva un valor y no es una variable pública — ¿se coló un secreto?`);
|
|
119
|
+
}
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface DepPackage {
|
|
2
|
+
name: string;
|
|
3
|
+
version: string;
|
|
4
|
+
dev: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface DepsOptions {
|
|
7
|
+
lockfile: string | null;
|
|
8
|
+
app: string | null;
|
|
9
|
+
/** La dirección pública del producto (`--origin`); sin ella se mira `GROWTICKETS_ORIGIN`, y sin ninguna no se manda. */
|
|
10
|
+
origin?: string | null;
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface DepsOutcome {
|
|
14
|
+
exitCode: number;
|
|
15
|
+
output: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `growtickets-nest deps [--lockfile <ruta>] [--app <nombre>] [--origin <url>] [--dry-run]`
|
|
19
|
+
*
|
|
20
|
+
* Manda a la mesa de ayuda los NOMBRES y VERSIONES de los paquetes npm del proyecto, para que ella —que sí consulta
|
|
21
|
+
* las bases de vulnerabilidades— avise en la pestaña «Postura» cuando uno tiene un fallo conocido. Nunca viaja el
|
|
22
|
+
* archivo: de cada paquete sólo `name`, `version` y si es de desarrollo. Está pensado para CI, después de instalar,
|
|
23
|
+
* y sirve a cualquier proyecto de Node —un front de Angular o React incluido—, no sólo a los de Nest.
|
|
24
|
+
*
|
|
25
|
+
* Lee `package-lock.json` (v1, v2 y v3), `pnpm-lock.yaml` y `yarn.lock` clásico. Yarn y pnpm no dicen qué paquetes
|
|
26
|
+
* son de desarrollo, así que van todos como de producción: se prefiere un aviso de más a uno de menos.
|
|
27
|
+
*
|
|
28
|
+
* Con `--origin` (o `GROWTICKETS_ORIGIN`) viaja también la dirección pública del producto: es lo que distingue dos servidores
|
|
29
|
+
* con el mismo nombre de aplicación, que sin ella la mesa de ayuda vería como una sola lista. Un comando de CI no sirve
|
|
30
|
+
* peticiones y no la aprende solo, así que se la das tú; si no la das, no se manda y la lista queda donde estaba.
|
|
31
|
+
*
|
|
32
|
+
* Necesita `GROWTICKETS_API_KEY` y, si no es la de serie, `GROWTICKETS_URL`. Sale con 1 si no puede enviar y con 0
|
|
33
|
+
* si la mesa de ayuda no recibe dependencias todavía (una central anterior) o tiene la seguridad apagada: eso no es
|
|
34
|
+
* un fallo del proyecto y no debe frenar un despliegue.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runDeps(cwd: string, options: DepsOptions, env: Record<string, string | undefined>, fetchImpl?: typeof fetch): Promise<DepsOutcome>;
|
|
37
|
+
/** Los paquetes de un archivo de bloqueo, con los de producción primero. Un archivo ilegible da una lista vacía. */
|
|
38
|
+
export declare function readLockfile(archivo: string): DepPackage[];
|