@hostwebhook/platform-node 0.1.0 → 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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Lo que se teclea en un buscador es TEXTO, no una expresión regular.
3
+ *
4
+ * ## El fallo que cierra
5
+ *
6
+ * `GET /api/credentials?search=` y `GET /api/signing-secrets?search=` metían la
7
+ * cadena de la query directamente en un `$regex` de MongoDB. O sea que quien
8
+ * llamaba elegía la expresión regular que ejecuta el motor de la base.
9
+ *
10
+ * Y el motor de Mongo resuelve por retroceso, igual que el de JavaScript:
11
+ *
12
+ * 1. se crea una credencial con `name` = 100 000 letras 'a'
13
+ * (ni el DTO ni el esquema ponían tope a la longitud);
14
+ * 2. `GET /api/credentials?search=(a%2B)%2Bb`
15
+ *
16
+ * y `(a+)+b` contra esa cadena es exponencial. Una petición barata deja un
17
+ * worker de mongod girando, y la base es la MISMA para todos los inquilinos del
18
+ * clúster. Repitiendo hasta el tope de peticiones —que además se esquiva
19
+ * variando `X-Forwarded-For`, cosa que el propio `client-ip.ts` avisa de que es
20
+ * falsificable— la CPU no baja.
21
+ *
22
+ * Lo más llamativo es que el escape correcto ya estaba escrito noventa líneas
23
+ * más abajo en el mismo fichero, para el buscador de etiquetas. Estaba resuelto
24
+ * y copiado a medias.
25
+ *
26
+ * ## Lo que hace
27
+ *
28
+ * Escapa los metacaracteres —para que el patrón case LITERALMENTE lo que se
29
+ * escribió— y acota la longitud. Lo segundo importa aparte: el coste de una
30
+ * búsqueda crece con el patrón además de con el texto, y un buscador no
31
+ * necesita mil caracteres.
32
+ *
33
+ * ⚠️ El escape en sí NO vive aquí: es `escaparRegex`, en el módulo de al
34
+ * lado. Esta función es «escapar Y acotar»; hay un puñado de sitios que
35
+ * incrustan un valor interno en un patrón y no pueden recortarlo sin cambiar
36
+ * lo que significa, y ésos usan el primitivo pelado. Una sola expresión de
37
+ * escape en todo el repositorio, y dos maneras de pedirla.
38
+ *
39
+ * No se usa `$text`: exigiría un índice de texto por colección y cambiaría la
40
+ * semántica de «contiene» a «palabras completas», que no es lo que el panel
41
+ * ofrece hoy.
42
+ */
43
+ /** Un buscador no necesita más. Por encima, se recorta. */
44
+ export declare const MAX_BUSQUEDA = 200;
45
+ /**
46
+ * Devuelve la cadena lista para meter en un `$regex`, casando literalmente.
47
+ *
48
+ * Se exporta también `MAX_BUSQUEDA` para que quien quiera pueda rechazar en vez
49
+ * de recortar; aquí se recorta porque un buscador que devuelve un error por
50
+ * escribir de más es peor que uno que busca por los primeros 200 caracteres.
51
+ */
52
+ export declare function busquedaLiteral(bruto: unknown): string;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ /**
3
+ * Lo que se teclea en un buscador es TEXTO, no una expresión regular.
4
+ *
5
+ * ## El fallo que cierra
6
+ *
7
+ * `GET /api/credentials?search=` y `GET /api/signing-secrets?search=` metían la
8
+ * cadena de la query directamente en un `$regex` de MongoDB. O sea que quien
9
+ * llamaba elegía la expresión regular que ejecuta el motor de la base.
10
+ *
11
+ * Y el motor de Mongo resuelve por retroceso, igual que el de JavaScript:
12
+ *
13
+ * 1. se crea una credencial con `name` = 100 000 letras 'a'
14
+ * (ni el DTO ni el esquema ponían tope a la longitud);
15
+ * 2. `GET /api/credentials?search=(a%2B)%2Bb`
16
+ *
17
+ * y `(a+)+b` contra esa cadena es exponencial. Una petición barata deja un
18
+ * worker de mongod girando, y la base es la MISMA para todos los inquilinos del
19
+ * clúster. Repitiendo hasta el tope de peticiones —que además se esquiva
20
+ * variando `X-Forwarded-For`, cosa que el propio `client-ip.ts` avisa de que es
21
+ * falsificable— la CPU no baja.
22
+ *
23
+ * Lo más llamativo es que el escape correcto ya estaba escrito noventa líneas
24
+ * más abajo en el mismo fichero, para el buscador de etiquetas. Estaba resuelto
25
+ * y copiado a medias.
26
+ *
27
+ * ## Lo que hace
28
+ *
29
+ * Escapa los metacaracteres —para que el patrón case LITERALMENTE lo que se
30
+ * escribió— y acota la longitud. Lo segundo importa aparte: el coste de una
31
+ * búsqueda crece con el patrón además de con el texto, y un buscador no
32
+ * necesita mil caracteres.
33
+ *
34
+ * ⚠️ El escape en sí NO vive aquí: es `escaparRegex`, en el módulo de al
35
+ * lado. Esta función es «escapar Y acotar»; hay un puñado de sitios que
36
+ * incrustan un valor interno en un patrón y no pueden recortarlo sin cambiar
37
+ * lo que significa, y ésos usan el primitivo pelado. Una sola expresión de
38
+ * escape en todo el repositorio, y dos maneras de pedirla.
39
+ *
40
+ * No se usa `$text`: exigiría un índice de texto por colección y cambiaría la
41
+ * semántica de «contiene» a «palabras completas», que no es lo que el panel
42
+ * ofrece hoy.
43
+ */
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.MAX_BUSQUEDA = void 0;
46
+ exports.busquedaLiteral = busquedaLiteral;
47
+ const escapar_regex_1 = require("./escapar-regex");
48
+ /** Un buscador no necesita más. Por encima, se recorta. */
49
+ exports.MAX_BUSQUEDA = 200;
50
+ /**
51
+ * Devuelve la cadena lista para meter en un `$regex`, casando literalmente.
52
+ *
53
+ * Se exporta también `MAX_BUSQUEDA` para que quien quiera pueda rechazar en vez
54
+ * de recortar; aquí se recorta porque un buscador que devuelve un error por
55
+ * escribir de más es peor que uno que busca por los primeros 200 caracteres.
56
+ */
57
+ function busquedaLiteral(bruto) {
58
+ if (typeof bruto !== 'string' || bruto.length === 0)
59
+ return '';
60
+ return (0, escapar_regex_1.escaparRegex)(bruto.slice(0, exports.MAX_BUSQUEDA));
61
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Lo que escribe el usuario es TEXTO, no un patrón.
3
+ *
4
+ * Sin escapar pasan dos cosas, y ninguna es «no encuentra nada»: un
5
+ * paréntesis suelto hace que Mongo rechace la consulta entera con un error
6
+ * de sintaxis, y un patrón anidado del tipo «grupo repetido dentro de grupo
7
+ * repetido» se convierte en un ReDoS — el regex lo evalúa el servidor de
8
+ * base de datos, así que quien se queda colgado es él, no el proceso que
9
+ * recibió la petición.
10
+ *
11
+ * ⚠️ **Ésta es la única expresión regular de escape del repositorio.** No
12
+ * escribas otra: `busquedaLiteral` la usa, y todo lo demás usa una de las
13
+ * dos. Cuál de las dos:
14
+ *
15
+ * - `busquedaLiteral` — texto que teclea alguien en un buscador. Escapa
16
+ * **y acota** la longitud, porque el coste de la búsqueda crece con el
17
+ * patrón además de con el texto. Es la que quieres casi siempre.
18
+ * - `escaparRegex` (ésta) — un valor interno que se incrusta en un patrón
19
+ * y que NO puede recortarse sin cambiar lo que significa.
20
+ *
21
+ * El 2026-08-27 este módulo decía que quedaban «tres copias literales» por
22
+ * migrar. Eran DOCE: se habían contado buscando los nombres `escapeRegex` y
23
+ * `escaparRegex`, y la mitad estaban escritas a pelo dentro de la consulta,
24
+ * sin función que buscar. Contar por el nombre no es inventariar — hay que
25
+ * contar por la clase de caracteres. `una-sola-copia-del-escape.spec.ts` lo
26
+ * cuenta ahora por ti y no deja aparecer la trece.
27
+ */
28
+ export declare function escaparRegex(s: string): string;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.escaparRegex = escaparRegex;
4
+ /**
5
+ * Lo que escribe el usuario es TEXTO, no un patrón.
6
+ *
7
+ * Sin escapar pasan dos cosas, y ninguna es «no encuentra nada»: un
8
+ * paréntesis suelto hace que Mongo rechace la consulta entera con un error
9
+ * de sintaxis, y un patrón anidado del tipo «grupo repetido dentro de grupo
10
+ * repetido» se convierte en un ReDoS — el regex lo evalúa el servidor de
11
+ * base de datos, así que quien se queda colgado es él, no el proceso que
12
+ * recibió la petición.
13
+ *
14
+ * ⚠️ **Ésta es la única expresión regular de escape del repositorio.** No
15
+ * escribas otra: `busquedaLiteral` la usa, y todo lo demás usa una de las
16
+ * dos. Cuál de las dos:
17
+ *
18
+ * - `busquedaLiteral` — texto que teclea alguien en un buscador. Escapa
19
+ * **y acota** la longitud, porque el coste de la búsqueda crece con el
20
+ * patrón además de con el texto. Es la que quieres casi siempre.
21
+ * - `escaparRegex` (ésta) — un valor interno que se incrusta en un patrón
22
+ * y que NO puede recortarse sin cambiar lo que significa.
23
+ *
24
+ * El 2026-08-27 este módulo decía que quedaban «tres copias literales» por
25
+ * migrar. Eran DOCE: se habían contado buscando los nombres `escapeRegex` y
26
+ * `escaparRegex`, y la mitad estaban escritas a pelo dentro de la consulta,
27
+ * sin función que buscar. Contar por el nombre no es inventariar — hay que
28
+ * contar por la clase de caracteres. `una-sola-copia-del-escape.spec.ts` lo
29
+ * cuenta ahora por ti y no deja aparecer la trece.
30
+ */
31
+ function escaparRegex(s) {
32
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Escapar HTML, una sola vez y en un solo sitio.
3
+ *
4
+ * Había TRES copias de esta función en el repo —en `credentials.controller`,
5
+ * en `approval-nodes.controller` y en `send-and-wait.controller`— idénticas
6
+ * carácter por carácter. Que hoy coincidan no es garantía de nada: son tres
7
+ * sitios donde alguien puede añadir un caso, olvidarse de los otros dos, y
8
+ * dejar dos escapes peores que el tercero sin que nada lo señale.
9
+ *
10
+ * Y estaba a punto de haber una cuarta, para el email de invitación.
11
+ *
12
+ * Los cinco caracteres son los de siempre, y el orden importa: `&` va primero
13
+ * o acabaría escapando los `&` que introducen los demás reemplazos.
14
+ */
15
+ export declare function escapeHtml(valor: unknown): string;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.escapeHtml = escapeHtml;
4
+ /**
5
+ * Escapar HTML, una sola vez y en un solo sitio.
6
+ *
7
+ * Había TRES copias de esta función en el repo —en `credentials.controller`,
8
+ * en `approval-nodes.controller` y en `send-and-wait.controller`— idénticas
9
+ * carácter por carácter. Que hoy coincidan no es garantía de nada: son tres
10
+ * sitios donde alguien puede añadir un caso, olvidarse de los otros dos, y
11
+ * dejar dos escapes peores que el tercero sin que nada lo señale.
12
+ *
13
+ * Y estaba a punto de haber una cuarta, para el email de invitación.
14
+ *
15
+ * Los cinco caracteres son los de siempre, y el orden importa: `&` va primero
16
+ * o acabaría escapando los `&` que introducen los demás reemplazos.
17
+ */
18
+ function escapeHtml(valor) {
19
+ return String(valor)
20
+ .replace(/&/g, '&')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;');
25
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,6 @@
1
1
  export { encrypt, decrypt, type Llaves } from './crypto';
2
+ export * from './escape-html';
3
+ export * from './mongo-databases';
4
+ export * from './postgres-databases';
5
+ export * from './busqueda-literal';
6
+ export * from './escapar-regex';
package/dist/index.js CHANGED
@@ -1,6 +1,25 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
2
16
  Object.defineProperty(exports, "__esModule", { value: true });
3
17
  exports.decrypt = exports.encrypt = void 0;
4
18
  var crypto_1 = require("./crypto");
5
19
  Object.defineProperty(exports, "encrypt", { enumerable: true, get: function () { return crypto_1.encrypt; } });
6
20
  Object.defineProperty(exports, "decrypt", { enumerable: true, get: function () { return crypto_1.decrypt; } });
21
+ __exportStar(require("./escape-html"), exports);
22
+ __exportStar(require("./mongo-databases"), exports);
23
+ __exportStar(require("./postgres-databases"), exports);
24
+ __exportStar(require("./busqueda-literal"), exports);
25
+ __exportStar(require("./escapar-regex"), exports);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Las bases que MongoDB trae siempre y que nadie elige como destino.
3
+ *
4
+ * Vive en `common` y no junto al nodo porque la usan los dos lados y en
5
+ * direcciones opuestas: el nodo de Mongo al listar las bases del servidor, y
6
+ * `CredentialsService` al comprobar que la base tecleada en el formulario
7
+ * existe. Importarla desde `nodes/` en `credentials/` invertiría la
8
+ * dependencia (los nodos importan credenciales, no al revés).
9
+ */
10
+ export declare const BASES_INTERNAS: ReadonlySet<string>;
11
+ /**
12
+ * Por qué rechazar la base que declara una credencial de Mongo, o `null` si
13
+ * no hay motivo.
14
+ *
15
+ * ⚠️ La distinción que lo es todo: `disponibles === undefined` significa **no
16
+ * se pudo mirar** (credencial acotada a una base, servidor caído), y eso
17
+ * **nunca** bloquea. Una lista vacía sí es una respuesta. Tratar los dos
18
+ * casos igual impediría guardar credenciales perfectamente buenas.
19
+ *
20
+ * Existe porque `client.db("no_existe")` no falla en Mongo —las bases se
21
+ * crean al escribir— así que un nombre mal tecleado se guarda tan campante y
22
+ * después toda lectura devuelve cero, sin error. Medido el 2026-08-12: una
23
+ * credencial decía `my_hostwebhook_testing_database` y el servidor sólo tenía
24
+ * `test`.
25
+ */
26
+ export declare function motivoDeBaseInvalida(pedida: string | null | undefined, disponibles: string[] | undefined): string | null;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BASES_INTERNAS = void 0;
4
+ exports.motivoDeBaseInvalida = motivoDeBaseInvalida;
5
+ /**
6
+ * Las bases que MongoDB trae siempre y que nadie elige como destino.
7
+ *
8
+ * Vive en `common` y no junto al nodo porque la usan los dos lados y en
9
+ * direcciones opuestas: el nodo de Mongo al listar las bases del servidor, y
10
+ * `CredentialsService` al comprobar que la base tecleada en el formulario
11
+ * existe. Importarla desde `nodes/` en `credentials/` invertiría la
12
+ * dependencia (los nodos importan credenciales, no al revés).
13
+ */
14
+ exports.BASES_INTERNAS = new Set([
15
+ 'admin',
16
+ 'local',
17
+ 'config',
18
+ ]);
19
+ /**
20
+ * Por qué rechazar la base que declara una credencial de Mongo, o `null` si
21
+ * no hay motivo.
22
+ *
23
+ * ⚠️ La distinción que lo es todo: `disponibles === undefined` significa **no
24
+ * se pudo mirar** (credencial acotada a una base, servidor caído), y eso
25
+ * **nunca** bloquea. Una lista vacía sí es una respuesta. Tratar los dos
26
+ * casos igual impediría guardar credenciales perfectamente buenas.
27
+ *
28
+ * Existe porque `client.db("no_existe")` no falla en Mongo —las bases se
29
+ * crean al escribir— así que un nombre mal tecleado se guarda tan campante y
30
+ * después toda lectura devuelve cero, sin error. Medido el 2026-08-12: una
31
+ * credencial decía `my_hostwebhook_testing_database` y el servidor sólo tenía
32
+ * `test`.
33
+ */
34
+ function motivoDeBaseInvalida(pedida, disponibles) {
35
+ const nombre = (pedida ?? '').trim();
36
+ if (!nombre)
37
+ return null;
38
+ if (!disponibles)
39
+ return null;
40
+ if (disponibles.includes(nombre))
41
+ return null;
42
+ return disponibles.length > 0
43
+ ? `Database "${nombre}" doesn't exist on this server. Available: ${disponibles.join(', ')}`
44
+ : `Database "${nombre}" doesn't exist on this server, and the server has no databases yet`;
45
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Qué bases ofrece el selector del formulario de una credencial de Postgres.
3
+ *
4
+ * Aparte del servicio, como `mongo-databases.ts`, para que la consulta y el
5
+ * filtrado se puedan probar sin levantar un Postgres.
6
+ */
7
+ /**
8
+ * Las bases a las que un cliente se puede conectar de verdad.
9
+ *
10
+ * Los dos filtros están por un motivo concreto, y quitarlos no rompe nada
11
+ * hasta que alguien elige la opción equivocada:
12
+ *
13
+ * - `datistemplate = false` deja fuera `template0` y `template1`, que son
14
+ * plantillas y no destinos.
15
+ * - `datallowconn = true` es el que de verdad importa: **`template0` rechaza
16
+ * conexiones por diseño**. Ofrecerla es ofrecer una opción que sólo puede
17
+ * fallar, y el error (`FATAL: database "template0" is not currently
18
+ * accepting connections`) no se parece en nada a «esa no la elijas».
19
+ */
20
+ export declare const SQL_BASES_DE_POSTGRES = "SELECT datname FROM pg_database\n WHERE datistemplate = false AND datallowconn = true\n ORDER BY datname";
21
+ /**
22
+ * Los nombres, de lo que devuelva el driver.
23
+ *
24
+ * Se valida el tipo en vez de castear: `pg` tipa las filas por lo que le
25
+ * digas, no por lo que llegue, así que una fila rara entraría como `undefined`
26
+ * y pintaría una opción vacía en el desplegable.
27
+ */
28
+ export declare function nombresDeBases(filas: ReadonlyArray<{
29
+ datname?: unknown;
30
+ }>): string[];
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * Qué bases ofrece el selector del formulario de una credencial de Postgres.
4
+ *
5
+ * Aparte del servicio, como `mongo-databases.ts`, para que la consulta y el
6
+ * filtrado se puedan probar sin levantar un Postgres.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.SQL_BASES_DE_POSTGRES = void 0;
10
+ exports.nombresDeBases = nombresDeBases;
11
+ /**
12
+ * Las bases a las que un cliente se puede conectar de verdad.
13
+ *
14
+ * Los dos filtros están por un motivo concreto, y quitarlos no rompe nada
15
+ * hasta que alguien elige la opción equivocada:
16
+ *
17
+ * - `datistemplate = false` deja fuera `template0` y `template1`, que son
18
+ * plantillas y no destinos.
19
+ * - `datallowconn = true` es el que de verdad importa: **`template0` rechaza
20
+ * conexiones por diseño**. Ofrecerla es ofrecer una opción que sólo puede
21
+ * fallar, y el error (`FATAL: database "template0" is not currently
22
+ * accepting connections`) no se parece en nada a «esa no la elijas».
23
+ */
24
+ exports.SQL_BASES_DE_POSTGRES = `SELECT datname FROM pg_database
25
+ WHERE datistemplate = false AND datallowconn = true
26
+ ORDER BY datname`;
27
+ /**
28
+ * Los nombres, de lo que devuelva el driver.
29
+ *
30
+ * Se valida el tipo en vez de castear: `pg` tipa las filas por lo que le
31
+ * digas, no por lo que llegue, así que una fila rara entraría como `undefined`
32
+ * y pintaría una opción vacía en el desplegable.
33
+ */
34
+ function nombresDeBases(filas) {
35
+ return filas
36
+ .map((f) => f.datname)
37
+ .filter((n) => typeof n === 'string' && n.trim() !== '');
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostwebhook/platform-node",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Lo que los servicios de HostWebhook comparten del lado de NODE: cifrado y utilidades que no pueden estar escritas dos veces",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",