@hostwebhook/platform-node 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,10 @@
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';
7
+ export * from './presets-de-oauth/github-oauth-presets';
8
+ export * from './presets-de-oauth/linkedin-oauth-presets';
9
+ export * from './presets-de-oauth/mailchimp-oauth-presets';
10
+ export * from './presets-de-oauth/shopify-oauth-presets';
package/dist/index.js CHANGED
@@ -1,6 +1,32 @@
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);
26
+ /* Los presets de OAuth: datos puros, cero dependencias externas (shopify usa el
27
+ `crypto` de Node, que es incorporado). Los comparten la api, `nodes/` y los
28
+ ayudantes de `common/` que hablan con cada plataforma. */
29
+ __exportStar(require("./presets-de-oauth/github-oauth-presets"), exports);
30
+ __exportStar(require("./presets-de-oauth/linkedin-oauth-presets"), exports);
31
+ __exportStar(require("./presets-de-oauth/mailchimp-oauth-presets"), exports);
32
+ __exportStar(require("./presets-de-oauth/shopify-oauth-presets"), 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
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Constantes y formas del OAuth de HostWebhook contra GitHub.
3
+ *
4
+ * ── Qué se guarda, y por qué es distinto a los demás proveedores ──
5
+ *
6
+ * Casi todos los proveedores de esta carpeta guardan un `access_token` del
7
+ * usuario y lo refrescan. Aquí NO. Lo que se guarda es un `installationId`, y
8
+ * **ningún secreto**: cada llamada mina un token de instalación firmando un
9
+ * JWT con la private key de la app, que vive en el entorno.
10
+ *
11
+ * La razón no es estética. Un token de usuario muere cuando esa persona deja
12
+ * la organización, y una automatización que lleva meses corriendo se cae con
13
+ * ella. El token de instalación pertenece a la instalación, no a quien la hizo.
14
+ *
15
+ * Consecuencia práctica: aquí no hay nada que caduque en la base de datos, y
16
+ * el flujo de OAuth existe para dos cosas —comprobar que el usuario autorizó y
17
+ * quedarse con el `installation_id` que vuelve en el callback—, no para
18
+ * quedarse con su token.
19
+ *
20
+ * ── Contra qué docs está escrito (verificado 2026-08-25) ──
21
+ *
22
+ * - JWT: RS256; `iss` es el App ID (los docs admiten también el client id);
23
+ * `iat` **60 segundos en el pasado** por deriva de reloj; `exp` como mucho
24
+ * 10 minutos en el futuro.
25
+ * - Token de instalación: `POST /app/installations/{id}/access_tokens` con
26
+ * el JWT en `Authorization: Bearer`. Dura **1 hora**.
27
+ * - Token de usuario: 8 h (28800 s), refresh de 6 meses. No se usa más allá
28
+ * del intercambio inicial — ver arriba.
29
+ *
30
+ * ── ⚠️ Las variables de entorno ──
31
+ *
32
+ * Son `GITHUB_APP_*`. `GITHUB_OAUTH_CLIENT_ID` y `GITHUB_OAUTH_CLIENT_SECRET`
33
+ * ya existían y son de OTRA app: la OAuth App que usa `MCP_OAUTH_PRESETS.github`
34
+ * para llegar al servidor MCP de GitHub. Reusar ese par rompería las dos cosas.
35
+ */
36
+ export declare const GITHUB_APP_OAUTH_CONFIG: {
37
+ readonly authorizationEndpoint: "https://github.com/login/oauth/authorize";
38
+ readonly tokenEndpoint: "https://github.com/login/oauth/access_token";
39
+ readonly apiBase: "https://api.github.com";
40
+ /** La versión de la API REST que fijan todas las llamadas. Fijarla es lo que
41
+ * evita que un cambio de GitHub llegue sin avisar. */
42
+ readonly apiVersion: "2026-03-10";
43
+ /** Nombres de las variables con las credenciales registradas de HW. */
44
+ readonly clientIdEnv: "GITHUB_APP_CLIENT_ID";
45
+ readonly clientSecretEnv: "GITHUB_APP_CLIENT_SECRET";
46
+ readonly appIdEnv: "GITHUB_APP_ID";
47
+ /** La private key va en base64: un PEM en crudo dentro de una variable de
48
+ * entorno se parte por los saltos de línea y sólo se descubre en runtime. */
49
+ readonly privateKeyBase64Env: "GITHUB_APP_PRIVATE_KEY_BASE64";
50
+ /**
51
+ * El secreto con el que GitHub firma cada entrega del webhook.
52
+ *
53
+ * ⚠️ Es el mismo valor que hay escrito en **Webhook → Secret** del registro
54
+ * de la app en github.com. Si los dos no dicen exactamente lo mismo, TODAS
55
+ * las entregas se rechazan con 401 y en GitHub se ven en rojo — el síntoma
56
+ * es un trigger activo que no dispara nunca.
57
+ *
58
+ * Sin la variable no se deja pasar nada: un despliegue al que le falte no
59
+ * debe convertirse en un endpoint abierto por el que cualquiera pueda
60
+ * inyectar eventos. Ver `verificarFirmaGithub`.
61
+ */
62
+ readonly webhookSecretEnv: "GITHUB_APP_WEBHOOK_SECRET";
63
+ /** El JWT no puede pedir más de 10 minutos. Se usan 9 para dejar margen. */
64
+ readonly jwtLifetimeSeconds: number;
65
+ /** Los docs piden retrasar `iat` para absorber la deriva de reloj. */
66
+ readonly jwtClockSkewSeconds: 60;
67
+ /** Un token de instalación dura 1 h; se re-mina un minuto antes. */
68
+ readonly installationTokenTtlSeconds: number;
69
+ readonly installationTokenSafetySeconds: 60;
70
+ };
71
+ /** El `type` de la credencial que crea este flujo. */
72
+ export declare const GITHUB_APP_CREDENTIAL_TYPE = "github_app";
73
+ /** El `type` de la credencial de token pegado. Mismas operaciones, otra vía. */
74
+ export declare const GITHUB_PAT_CREDENTIAL_TYPE = "github_pat";
75
+ /**
76
+ * Lo que devuelve `POST /login/oauth/access_token`.
77
+ *
78
+ * Todo opcional a propósito: GitHub responde `200` con un cuerpo de error
79
+ * cuando el `code` ya se usó, así que tratar `access_token` como obligatorio
80
+ * convierte un error explicable en un fallo de parseo.
81
+ */
82
+ export interface GithubTokenResponse {
83
+ access_token?: string;
84
+ token_type?: string;
85
+ scope?: string;
86
+ expires_in?: number;
87
+ refresh_token?: string;
88
+ refresh_token_expires_in?: number;
89
+ error?: string;
90
+ error_description?: string;
91
+ }
92
+ /** Lo que devuelve `POST /app/installations/{id}/access_tokens`. */
93
+ export interface GithubInstallationTokenResponse {
94
+ token?: string;
95
+ /** ISO-8601. Una hora después de emitirlo. */
96
+ expires_at?: string;
97
+ permissions?: Record<string, string>;
98
+ repository_selection?: 'all' | 'selected';
99
+ message?: string;
100
+ }
101
+ /** La instalación, tal como la devuelve `GET /app/installations/{id}`. */
102
+ export interface GithubInstallation {
103
+ id?: number;
104
+ account?: {
105
+ login?: string;
106
+ type?: string;
107
+ avatar_url?: string;
108
+ };
109
+ repository_selection?: 'all' | 'selected';
110
+ app_id?: number;
111
+ target_type?: string;
112
+ }
113
+ /**
114
+ * El blob cifrado de una credencial `github_app`.
115
+ *
116
+ * No lleva token. Es deliberado — ver la cabecera de este fichero.
117
+ */
118
+ export interface GithubAppStoredPayload {
119
+ /** La instalación. Es lo único que hace falta para minar tokens. */
120
+ installationId: string;
121
+ /** La cuenta donde se instaló, para poder nombrar la credencial y para que
122
+ * el usuario distinga dos instalaciones en la lista. */
123
+ accountLogin?: string;
124
+ accountType?: string;
125
+ /** `all` o `selected`. Cambia el texto que se le enseña al usuario cuando el
126
+ * selector de repos sale corto. */
127
+ repositorySelection?: 'all' | 'selected';
128
+ authMode: 'app';
129
+ }
130
+ /**
131
+ * El blob cifrado de una credencial `github_pat`.
132
+ *
133
+ * Aquí SÍ hay un secreto del usuario, y además caduca: GitHub obliga a poner
134
+ * fecha a los fine-grained. Guardar la fecha permite avisar antes de que una
135
+ * corrida se caiga por una credencial muerta.
136
+ */
137
+ export interface GithubPatStoredPayload {
138
+ token: string;
139
+ /** ISO-8601, cuando GitHub la ha dicho. */
140
+ expiresAt?: string | null;
141
+ authMode: 'pat';
142
+ }
143
+ export interface PendingGithubOAuthState {
144
+ orgId: string;
145
+ userId: string;
146
+ credentialName: string;
147
+ /** En reconexión, para actualizar la credencial en vez de crear otra. */
148
+ credentialId?: string;
149
+ tags?: string[];
150
+ /** La carpeta elegida en el formulario. Viaja por Redis entre `start` y el
151
+ * callback; sin esto la elección se perdía por el camino. */
152
+ folderId?: string;
153
+ clientId: string;
154
+ clientSecret: string;
155
+ /** Marca absoluta en ms de cuándo caduca este estado pendiente. */
156
+ expiresAt: number;
157
+ }
158
+ /**
159
+ * A dónde se manda al usuario: a **INSTALAR**, no a autorizar.
160
+ *
161
+ * En GitHub son dos gestos distintos y sólo uno sirve para lo que hace este
162
+ * nodo. `/login/oauth/authorize` le da a HostWebhook permiso para actuar en su
163
+ * nombre; **no instala nada**, así que después no hay ninguna instalación de la
164
+ * que sacar un token y la conexión muere con "no lo has instalado en ninguna
165
+ * cuenta". Pasó en producción, con el mensaje correcto y la puerta equivocada.
166
+ *
167
+ * `/apps/{slug}/installations/new` es la buena: lleva por elegir cuenta y
168
+ * elegir repositorios, que es justo la promesa que le hacemos al usuario en el
169
+ * texto de al lado. Y con "Request user authorization (OAuth) during
170
+ * installation" encendido en el registro de la app, al terminar vuelve al
171
+ * Callback URL con el `code` — así que el resto del flujo no cambia.
172
+ *
173
+ * `state` viaja igual y vuelve igual: los docs lo contemplan explícitamente
174
+ * para esta URL, que es lo que permite seguir atando la vuelta con lo que
175
+ * guardamos en Redis.
176
+ *
177
+ * Sigue sin llevar `scope`, por lo de siempre: los permisos de una GitHub App
178
+ * se declaran al registrarla, y lo que el usuario elige son repos.
179
+ */
180
+ export declare function buildGithubInstallUrl(params: {
181
+ appSlug: string;
182
+ state: string;
183
+ }): string;
184
+ /**
185
+ * El nombre que se ve en la lista de credenciales.
186
+ *
187
+ * Prefiere la cuenta donde se instaló, que es lo que de verdad alcanza el
188
+ * token y lo que necesita alguien que tenga dos instalaciones. El id suelto es
189
+ * el último recurso: es feo, pero es cierto y único, y gana a una fila vacía.
190
+ */
191
+ export declare function githubDisplayName(inst: GithubInstallation): string;
192
+ /**
193
+ * Decodifica la private key desde la variable en base64.
194
+ *
195
+ * Devuelve `null` en vez de reventar cuando falta o viene mal, para que quien
196
+ * llama pueda dar un mensaje que diga QUÉ variable falta. Un stack trace de
197
+ * `crypto` no le sirve a nadie a las tres de la mañana.
198
+ */
199
+ export declare function decodeGithubPrivateKey(raw: string | undefined): string | null;