@growconnect/growtickets-nest 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.
Files changed (146) hide show
  1. package/.env.example +8 -0
  2. package/README.md +319 -6
  3. package/dist/cli/args.d.ts +9 -1
  4. package/dist/cli/args.js +13 -4
  5. package/dist/cli/check.d.ts +4 -1
  6. package/dist/cli/check.js +47 -2
  7. package/dist/cli/env-example.js +17 -0
  8. package/dist/cli/env.d.ts +34 -0
  9. package/dist/cli/env.js +125 -0
  10. package/dist/cli/index.js +35 -1
  11. package/dist/cli/init.js +5 -1
  12. package/dist/client/ingest-client.js +6 -1
  13. package/dist/env/applier.d.ts +83 -0
  14. package/dist/env/applier.js +162 -0
  15. package/dist/env/errors.d.ts +12 -0
  16. package/dist/env/errors.js +18 -0
  17. package/dist/env/load.d.ts +35 -0
  18. package/dist/env/load.js +43 -0
  19. package/dist/env/overlay.d.ts +23 -0
  20. package/dist/env/overlay.js +48 -0
  21. package/dist/env/policy.d.ts +30 -0
  22. package/dist/env/policy.js +101 -0
  23. package/dist/env/poller.d.ts +27 -0
  24. package/dist/env/poller.js +54 -0
  25. package/dist/env/runtime.d.ts +122 -0
  26. package/dist/env/runtime.js +238 -0
  27. package/dist/env/settings.d.ts +26 -0
  28. package/dist/env/settings.js +41 -0
  29. package/dist/env/snapshot.d.ts +32 -0
  30. package/dist/env/snapshot.js +85 -0
  31. package/dist/env/state.d.ts +27 -0
  32. package/dist/env/state.js +51 -0
  33. package/dist/env/storage.d.ts +33 -0
  34. package/dist/env/storage.js +133 -0
  35. package/dist/env/transport/client.d.ts +58 -0
  36. package/dist/env/transport/client.js +249 -0
  37. package/dist/env/transport/config.d.ts +20 -0
  38. package/dist/env/transport/config.js +38 -0
  39. package/dist/env/transport/credential.d.ts +33 -0
  40. package/dist/env/transport/credential.js +76 -0
  41. package/dist/env/transport/gt2-transport.d.ts +53 -0
  42. package/dist/env/transport/gt2-transport.js +162 -0
  43. package/dist/env/transport/index.d.ts +10 -0
  44. package/dist/env/transport/index.js +17 -0
  45. package/dist/env/transport/keygen.d.ts +32 -0
  46. package/dist/env/transport/keygen.js +77 -0
  47. package/dist/env/transport/nonce-cache.d.ts +18 -0
  48. package/dist/env/transport/nonce-cache.js +37 -0
  49. package/dist/env/transport/protocol.d.ts +100 -0
  50. package/dist/env/transport/protocol.js +292 -0
  51. package/dist/env/transport/types.d.ts +78 -0
  52. package/dist/env/transport/types.js +2 -0
  53. package/dist/index.d.ts +14 -1
  54. package/dist/index.js +18 -1
  55. package/dist/logs/buffer.d.ts +25 -0
  56. package/dist/logs/buffer.js +58 -0
  57. package/dist/logs/doctor.d.ts +17 -0
  58. package/dist/logs/doctor.js +66 -0
  59. package/dist/logs/entry-builder.d.ts +20 -0
  60. package/dist/logs/entry-builder.js +127 -0
  61. package/dist/logs/flusher.d.ts +72 -0
  62. package/dist/logs/flusher.js +189 -0
  63. package/dist/logs/format.d.ts +4 -0
  64. package/dist/logs/format.js +76 -0
  65. package/dist/logs/levels.d.ts +12 -0
  66. package/dist/logs/levels.js +55 -0
  67. package/dist/logs/monitor.d.ts +92 -0
  68. package/dist/logs/monitor.js +224 -0
  69. package/dist/logs/normalize.d.ts +14 -0
  70. package/dist/logs/normalize.js +114 -0
  71. package/dist/logs/pino-line.d.ts +13 -0
  72. package/dist/logs/pino-line.js +52 -0
  73. package/dist/logs/redactor.d.ts +38 -0
  74. package/dist/logs/redactor.js +146 -0
  75. package/dist/logs/remote-config.d.ts +37 -0
  76. package/dist/logs/remote-config.js +89 -0
  77. package/dist/logs/runtime.d.ts +24 -0
  78. package/dist/logs/runtime.js +59 -0
  79. package/dist/logs/sampler.d.ts +15 -0
  80. package/dist/logs/sampler.js +36 -0
  81. package/dist/logs/sender.d.ts +20 -0
  82. package/dist/logs/sender.js +64 -0
  83. package/dist/logs/settings.d.ts +22 -0
  84. package/dist/logs/settings.js +30 -0
  85. package/dist/logs/types.d.ts +74 -0
  86. package/dist/logs/types.js +9 -0
  87. package/dist/logs-nest/api.d.ts +5 -0
  88. package/dist/logs-nest/api.js +13 -0
  89. package/dist/logs-nest/host.d.ts +10 -0
  90. package/dist/logs-nest/host.js +45 -0
  91. package/dist/logs-nest/lifecycle.d.ts +20 -0
  92. package/dist/logs-nest/lifecycle.js +40 -0
  93. package/dist/logs-nest/nest-args.d.ts +7 -0
  94. package/dist/logs-nest/nest-args.js +66 -0
  95. package/dist/logs-nest/service.d.ts +25 -0
  96. package/dist/logs-nest/service.js +61 -0
  97. package/dist/logs-nest/stream.d.ts +21 -0
  98. package/dist/logs-nest/stream.js +32 -0
  99. package/dist/maintenance/feed.d.ts +68 -0
  100. package/dist/maintenance/feed.js +160 -0
  101. package/dist/maintenance/http.d.ts +67 -0
  102. package/dist/maintenance/http.js +177 -0
  103. package/dist/maintenance/page.d.ts +11 -0
  104. package/dist/maintenance/page.js +208 -0
  105. package/dist/maintenance/settings.d.ts +24 -0
  106. package/dist/maintenance/settings.js +42 -0
  107. package/dist/maintenance/windows.d.ts +78 -0
  108. package/dist/maintenance/windows.js +167 -0
  109. package/dist/nest/csp-response.d.ts +19 -0
  110. package/dist/nest/csp-response.js +84 -0
  111. package/dist/nest/env-push.d.ts +35 -0
  112. package/dist/nest/env-push.js +110 -0
  113. package/dist/nest/growtickets.module.js +11 -3
  114. package/dist/nest/maintenance-request.d.ts +12 -0
  115. package/dist/nest/maintenance-request.js +30 -0
  116. package/dist/nest/maintenance.guard.d.ts +28 -0
  117. package/dist/nest/maintenance.guard.js +90 -0
  118. package/dist/nest/options.d.ts +15 -0
  119. package/dist/nest/request.d.ts +1 -1
  120. package/dist/nest/request.js +2 -2
  121. package/dist/nest/route-tag.d.ts +6 -0
  122. package/dist/nest/route-tag.js +13 -0
  123. package/dist/nest/security.middleware.d.ts +10 -4
  124. package/dist/nest/security.middleware.js +77 -18
  125. package/dist/nest/services.js +1 -1
  126. package/dist/proxy/handle-portal-request.js +2 -1
  127. package/dist/proxy/types.d.ts +1 -0
  128. package/dist/proxy/unread.js +1 -1
  129. package/dist/security/client-ip.d.ts +15 -1
  130. package/dist/security/client-ip.js +20 -2
  131. package/dist/security/csp.d.ts +58 -0
  132. package/dist/security/csp.js +109 -0
  133. package/dist/security/flusher.js +4 -2
  134. package/dist/security/proxy-chain.d.ts +35 -0
  135. package/dist/security/proxy-chain.js +58 -0
  136. package/dist/security/remote-config.d.ts +4 -0
  137. package/dist/security/remote-config.js +7 -0
  138. package/dist/security/request-ip.d.ts +3 -2
  139. package/dist/security/request-ip.js +6 -3
  140. package/dist/security/runtime.d.ts +8 -0
  141. package/dist/security/runtime.js +17 -0
  142. package/dist/security/sdk.d.ts +7 -1
  143. package/dist/security/sdk.js +27 -1
  144. package/dist/security/settings.d.ts +3 -1
  145. package/dist/security/settings.js +1 -0
  146. package/package.json +1 -1
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EnvClient = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const sdk_1 = require("../../security/sdk");
6
+ const errors_1 = require("../errors");
7
+ const policy_1 = require("../policy");
8
+ const config_1 = require("./config");
9
+ const protocol_1 = require("./protocol");
10
+ const MAX_BYTES = 8 * 1024 * 1024;
11
+ const MAX_ERROR_BYTES = 64 * 1024;
12
+ const CONNECT_TIMEOUT_MS = 3000;
13
+ const TOTAL_TIMEOUT_MS = 10_000;
14
+ const ACK_TIMEOUT_MS = 5000;
15
+ const ATTEMPTS = 3;
16
+ const RETRY_DELAY_MS = 300;
17
+ /**
18
+ * Lo que habla con la central (protocolo GT-ENV2): bajar las variables de ESTE servidor (`GET api/ingest/env`) y confirmar que se aplicaron
19
+ * (`POST api/ingest/env/ack`). Ninguna clave viaja: cada petición va FIRMADA con la llave del servidor (`EnvCredential`) y cada respuesta se
20
+ * cree sólo si la firmó la central FIJADA en la credencial.
21
+ *
22
+ * - **Cifrado de punta a punta**: al bajar se manda una efímera X25519 nueva (firmada dentro de la petición) y la central cifra para ella. Sólo
23
+ * este proceso puede abrir la respuesta, y la efímera se borra al salir: capturar el tráfico —o robar luego la credencial— no descifra nada.
24
+ * - **Sólo lo firmado cambia el estado**: un 304, un «revocada» o un error sin la firma de la central son `unsigned_response` (un fallo
25
+ * pasajero: se sigue con la copia), y un 200 sin cifrar no se acepta aunque venga firmado.
26
+ * - **Sólo https** (salvo `localhost`, `127.0.0.1` y nombres `.test`/`.localhost`), **sin redirecciones** (`redirect: 'error'`), **tiempos y
27
+ * tamaño con tope** (3 s hasta la respuesta, 10 s en total, 8 MB) y hasta tres intentos si la central no contestó o dio 5xx: cada uno con su
28
+ * hora, su nonce y su efímera, porque repetir la misma petición sería un `replayed`.
29
+ * - **Errores con CÓDIGO** (`invalid_key`, `revoked_key`, `expired_key`, `no_environment`, `not_published`, `clock_skew`, `replayed`,
30
+ * `network_not_allowed`, `unsigned_response`, `unreachable`, `timeout`, `malformed`, `incompatible_schema`), nunca con un valor ni con la
31
+ * credencial.
32
+ *
33
+ * Cada petición dice quién pregunta —la réplica, la dirección del producto, la ruta por la que escucha avisos, la librería y el runtime— y
34
+ * esas cabeceras van DENTRO de la firma: la central las guarda y las usa.
35
+ */
36
+ class EnvClient {
37
+ constructor(deps) {
38
+ this.deps = deps;
39
+ }
40
+ /** La versión vigente, o `null` si la central dice (firmado) que no cambió: 304 con el `etag` de la copia. */
41
+ async fetch(etag) {
42
+ const limpio = etag && (0, protocol_1.validEtag)(etag) ? etag : '';
43
+ const respuesta = await this.call('pull', '', limpio, TOTAL_TIMEOUT_MS, MAX_BYTES);
44
+ try {
45
+ return this.delivered(respuesta);
46
+ }
47
+ finally {
48
+ respuesta.ephemeral?.secret.fill(0);
49
+ }
50
+ }
51
+ /** Confirma a la central que esta réplica aplicó (o por qué no). Nunca lanza: un acuse perdido sólo deja el panel atrás. */
52
+ async ack(version, ok, errorCode) {
53
+ const cuerpo = ok ? { version, status: 'ok' } : { version, status: 'error', error_code: cleanCode(errorCode) };
54
+ try {
55
+ const respuesta = await this.call('ack', JSON.stringify(cuerpo), '', ACK_TIMEOUT_MS, MAX_ERROR_BYTES, 1);
56
+ return respuesta.status >= 200 && respuesta.status < 300 && this.signed(respuesta);
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * Lo que entregó la central, si lo FIRMÓ ella para esta petición.
64
+ *
65
+ * @throws EnvError
66
+ */
67
+ delivered(respuesta) {
68
+ if (!this.signed(respuesta)) {
69
+ throw new errors_1.EnvError('unsigned_response', `La central contestó ${respuesta.status} sin su firma (o la de otra central): revisa GROWTICKETS_URL. Se sigue con la copia local.`);
70
+ }
71
+ if (respuesta.status === 304)
72
+ return null;
73
+ const texto = respuesta.body.toString('utf8');
74
+ if (respuesta.status === 200) {
75
+ const claro = (0, protocol_1.open)(texto, this.deps.credential().keyId(), respuesta.hash, respuesta.ephemeral?.secret ?? Buffer.alloc(0));
76
+ if (claro === null)
77
+ throw new errors_1.EnvError('malformed', 'La respuesta de la central no se pudo descifrar.');
78
+ const datos = parseJson(claro);
79
+ return (0, policy_1.parseEnvPayload)(typeof datos === 'object' && datos !== null && !Array.isArray(datos) ? { ...datos, schema: policy_1.ENV_SCHEMA } : null, cleanEtag(respuesta.headers.get('etag')));
80
+ }
81
+ throw failure(respuesta.status, texto);
82
+ }
83
+ /** ¿La firmó la central fijada, para ESTA petición (su hash), con este estado, este cuerpo y este ETag? */
84
+ signed(respuesta) {
85
+ const credencial = this.deps.credential();
86
+ const cadena = (0, protocol_1.responseString)(respuesta.status, credencial.keyId(), respuesta.hash, respuesta.body, respuesta.headers.get('etag') ?? '');
87
+ return credencial.signedByCentral(cadena, respuesta.headers.get(protocol_1.SIGNATURE_HEADER) ?? '');
88
+ }
89
+ async call(operation, body, etag, totalMs, maxBytes, attempts = ATTEMPTS) {
90
+ const ajustes = this.deps.config();
91
+ if (ajustes.baseUrl === '')
92
+ throw new errors_1.EnvError('invalid_url', 'Falta GROWTICKETS_URL: la dirección de tu GrowTickets.');
93
+ if (!(0, config_1.isSecureUrl)(ajustes.baseUrl))
94
+ throw new errors_1.EnvError('insecure_url', 'GROWTICKETS_URL tiene que ser https: por ahí viajan los secretos.');
95
+ const credencial = this.deps.credential();
96
+ const url = `${ajustes.baseUrl}/api/ingest/${operation === 'pull' ? 'env' : 'env/ack'}`;
97
+ let ultimo = null;
98
+ for (let intento = 1; intento <= attempts; intento++) {
99
+ const efimera = operation === 'pull' ? (0, protocol_1.ephemeralFrom)(this.random(32)) : null;
100
+ const firmada = this.sign(credencial, ajustes, operation, body, etag, efimera);
101
+ try {
102
+ const respuesta = await this.once(url, { method: operation === 'pull' ? 'GET' : 'POST', headers: firmada.headers, body: operation === 'ack' ? body : undefined }, totalMs, maxBytes);
103
+ const entera = { ...respuesta, hash: firmada.hash, ephemeral: efimera };
104
+ if (respuesta.status < 500 || intento === attempts)
105
+ return entera;
106
+ efimera?.secret.fill(0);
107
+ ultimo = new errors_1.EnvError('unreachable', `La central contestó ${respuesta.status}.`);
108
+ }
109
+ catch (error) {
110
+ efimera?.secret.fill(0);
111
+ if (!(error instanceof errors_1.EnvError) || error.reason === 'malformed')
112
+ throw error;
113
+ ultimo = error;
114
+ }
115
+ await (this.deps.sleep ?? delay)(RETRY_DELAY_MS * intento);
116
+ }
117
+ throw ultimo ?? new errors_1.EnvError('unreachable', 'No se pudo llegar a la central.');
118
+ }
119
+ /** Las cabeceras de UNA petición firmada y el hash de su cadena. Cada intento llama otra vez: hora, nonce y efímera nuevos. */
120
+ sign(credencial, ajustes, operation, body, etag, efimera) {
121
+ const audiencia = (0, protocol_1.audience)(ajustes.baseUrl) ?? '';
122
+ const origen = ajustes.origin ?? this.deps.learnedOrigin?.() ?? '';
123
+ const identidad = [(0, sdk_1.instanceId)(this.deps.env()), origen, ajustes.path, `${sdk_1.SDK_NAME}/${sdk_1.SDK_VERSION}`, (0, sdk_1.runtimeHeader)()];
124
+ const hora = String(this.deps.now ? this.deps.now() : Math.floor(Date.now() / 1000));
125
+ const nonce = (0, protocol_1.b64u)(this.random(protocol_1.NONCE_BYTES));
126
+ const publicaEfimera = efimera ? (0, protocol_1.b64u)(efimera.public) : '';
127
+ const cadena = (0, protocol_1.requestString)(audiencia, operation, credencial.keyId(), hora, nonce, publicaEfimera, etag, body, (0, protocol_1.meta)(identidad));
128
+ const cabeceras = {
129
+ [protocol_1.KEY_HEADER]: credencial.keyId(),
130
+ [protocol_1.TIMESTAMP_HEADER]: hora,
131
+ [protocol_1.NONCE_HEADER]: nonce,
132
+ [protocol_1.EPHEMERAL_HEADER]: publicaEfimera,
133
+ 'If-None-Match': etag,
134
+ [protocol_1.SIGNATURE_HEADER]: credencial.sign(cadena),
135
+ ...Object.fromEntries(protocol_1.META_HEADERS.map((nombre, i) => [nombre, identidad[i] ?? ''])),
136
+ ...(operation === 'ack' ? { 'Content-Type': 'application/json' } : {}),
137
+ Accept: 'application/json',
138
+ };
139
+ return { headers: Object.fromEntries(Object.entries(cabeceras).filter(([, valor]) => valor !== '')), hash: (0, protocol_1.requestHash)(cadena) };
140
+ }
141
+ async once(url, init, totalMs, maxBytes) {
142
+ const controller = new AbortController();
143
+ let motivo = null;
144
+ const total = setTimeout(() => {
145
+ motivo ??= 'total';
146
+ controller.abort();
147
+ }, totalMs);
148
+ const conexion = setTimeout(() => {
149
+ motivo ??= 'connect';
150
+ controller.abort();
151
+ }, CONNECT_TIMEOUT_MS);
152
+ try {
153
+ const respuesta = await (this.deps.fetchImpl ?? globalThis.fetch)(url, { ...init, signal: controller.signal, redirect: 'error' });
154
+ clearTimeout(conexion);
155
+ return { status: respuesta.status, headers: respuesta.headers, body: await readCapped(respuesta, maxBytes) };
156
+ }
157
+ catch (error) {
158
+ if (error instanceof errors_1.EnvError)
159
+ throw error;
160
+ throw motivo ? new errors_1.EnvError('timeout', 'La central tardó demasiado en contestar.') : new errors_1.EnvError('unreachable', 'No se pudo llegar a la central.');
161
+ }
162
+ finally {
163
+ clearTimeout(conexion);
164
+ clearTimeout(total);
165
+ }
166
+ }
167
+ random(bytes) {
168
+ return this.deps.random ? this.deps.random(bytes) : (0, node_crypto_1.randomBytes)(bytes);
169
+ }
170
+ }
171
+ exports.EnvClient = EnvClient;
172
+ function delay(ms) {
173
+ return new Promise((resolve) => setTimeout(resolve, ms));
174
+ }
175
+ /**
176
+ * Lee el cuerpo hasta `max` bytes y, pasado el tope, DEJA de leer y lo da por malformado: una central comprometida (o un intermediario) no
177
+ * consigue que se siga consumiendo memoria. Con `content-length` a la vista se rechaza sin leer nada. Devuelve los BYTES: la firma de la
178
+ * respuesta es sobre ellos, y pasar por texto cambiaría los que no sean UTF-8.
179
+ */
180
+ async function readCapped(respuesta, max) {
181
+ const declarado = Number(respuesta.headers.get('content-length') ?? '0');
182
+ if (Number.isFinite(declarado) && declarado > max)
183
+ throw new errors_1.EnvError('malformed', 'La respuesta de la central es demasiado grande.');
184
+ const lector = respuesta.body?.getReader();
185
+ if (!lector)
186
+ return Buffer.alloc(0);
187
+ const partes = [];
188
+ let total = 0;
189
+ for (;;) {
190
+ const { done, value } = await lector.read();
191
+ if (done)
192
+ break;
193
+ total += value.byteLength;
194
+ if (total > max) {
195
+ void lector.cancel().catch(() => undefined);
196
+ throw new errors_1.EnvError('malformed', 'La respuesta de la central es demasiado grande.');
197
+ }
198
+ partes.push(value);
199
+ }
200
+ return Buffer.concat(partes);
201
+ }
202
+ function parseJson(text) {
203
+ try {
204
+ return JSON.parse(text);
205
+ }
206
+ catch {
207
+ throw new errors_1.EnvError('malformed', 'La central contestó algo que no son variables de entorno.');
208
+ }
209
+ }
210
+ /** El error de una respuesta FIRMADA que no es ni 200 ni 304, con el código que la central dio cuando es uno de los conocidos. */
211
+ function failure(status, text) {
212
+ const cuerpo = safeJson(text);
213
+ const codigo = typeof cuerpo.code === 'string' ? cuerpo.code : '';
214
+ const mensaje = typeof cuerpo.message === 'string' ? printable(cuerpo.message) : '';
215
+ if (status === 401 && ['revoked_key', 'expired_key', 'clock_skew', 'replayed'].includes(codigo))
216
+ return new errors_1.EnvError(codigo, mensaje || 'La central ya no acepta esta credencial.');
217
+ if (status === 401)
218
+ return new errors_1.EnvError('invalid_key', 'La central no reconoce GROWTICKETS_ENV_KEY: cópiala de nuevo desde la pestaña «Entornos» o conecta el servidor otra vez.');
219
+ if (status === 403 && codigo === 'network_not_allowed')
220
+ return new errors_1.EnvError(codigo, 'La central no deja pedir las variables desde la red de este servidor: revisa la red permitida en la pestaña «Entornos».');
221
+ if (status === 409 && (codigo === 'no_environment' || codigo === 'not_published'))
222
+ return new errors_1.EnvError(codigo, mensaje || 'El entorno no tiene versión vinculada.');
223
+ if (status === 429)
224
+ return new errors_1.EnvError('rate_limited', 'La central pidió esperar: demasiadas preguntas seguidas.');
225
+ return new errors_1.EnvError('unreachable', `La central contestó ${status}.`);
226
+ }
227
+ /** Un texto de la central listo para un mensaje: sin caracteres de control (una línea falsa en el registro) y acotado. */
228
+ function printable(text) {
229
+ return [...text]
230
+ .map((c) => (c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127 ? ' ' : c))
231
+ .join('')
232
+ .slice(0, 200);
233
+ }
234
+ function safeJson(text) {
235
+ try {
236
+ const valor = JSON.parse(text);
237
+ return typeof valor === 'object' && valor !== null ? valor : {};
238
+ }
239
+ catch {
240
+ return {};
241
+ }
242
+ }
243
+ function cleanEtag(value) {
244
+ const limpio = (value ?? '').trim();
245
+ return limpio !== '' && (0, protocol_1.validEtag)(limpio) ? limpio : null;
246
+ }
247
+ function cleanCode(code) {
248
+ return /^[a-z_]{1,40}$/.test(code ?? '') ? code : 'apply_failed';
249
+ }
@@ -0,0 +1,20 @@
1
+ type Env = Record<string, string | undefined>;
2
+ export interface TransportConfig {
3
+ /** `GROWTICKETS_ENV_KEY`: la credencial de SERVIDOR (`gtk2.<semilla>.<pública de la central>`, 92 caracteres). Vacía = la pieza no hace nada. */
4
+ key: string;
5
+ /** `GROWTICKETS_URL` sin barra final, o `''` si falta. */
6
+ baseUrl: string;
7
+ /** `GROWTICKETS_ENV_PATH`, que la central guarda para saber por dónde avisar. */
8
+ path: string;
9
+ /** La dirección pública del producto (`GROWTICKETS_ORIGIN`), que la central cruza con el dominio elegido al conectar el servidor. */
10
+ origin: string | null;
11
+ }
12
+ /** Las variables de entorno donde el producto declara su credencial para la central: `check` las busca en el código y en `.env.example`. */
13
+ export declare const ENV_CREDENTIAL_VARIABLES: readonly string[];
14
+ /** Lo que el entorno dice de con qué y hacia dónde se habla con la central. NUNCA lanza: una dirección mal escrita se queda como está y el cliente se niega a usarla. */
15
+ export declare function readTransportConfig(env: Env): TransportConfig;
16
+ /** ¿Hay credencial en este entorno? Es lo único que el middleware y la CLI preguntan antes de crear nada. */
17
+ export declare function envConfigured(env: Env): boolean;
18
+ /** ¿Se puede hablar con esta dirección? https siempre; http sólo con un nombre de desarrollo que no sale de la máquina. */
19
+ export declare function isSecureUrl(url: string): boolean;
20
+ export {};
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ENV_CREDENTIAL_VARIABLES = void 0;
4
+ exports.readTransportConfig = readTransportConfig;
5
+ exports.envConfigured = envConfigured;
6
+ exports.isSecureUrl = isSecureUrl;
7
+ const settings_1 = require("../settings");
8
+ /** Las variables de entorno donde el producto declara su credencial para la central: `check` las busca en el código y en `.env.example`. */
9
+ exports.ENV_CREDENTIAL_VARIABLES = ['GROWTICKETS_ENV_KEY'];
10
+ const ORIGIN_SHAPE = /^https?:\/\/[^\s"'<>/][^\s"'<>]{0,253}$/i;
11
+ /** Lo que el entorno dice de con qué y hacia dónde se habla con la central. NUNCA lanza: una dirección mal escrita se queda como está y el cliente se niega a usarla. */
12
+ function readTransportConfig(env) {
13
+ const origen = (env.GROWTICKETS_ORIGIN ?? env.GROWTICKETS_LOGS_ORIGIN ?? '').trim();
14
+ return {
15
+ key: (env.GROWTICKETS_ENV_KEY ?? '').trim(),
16
+ baseUrl: (env.GROWTICKETS_URL ?? '').trim().replace(/\/+$/, ''),
17
+ path: (0, settings_1.readEnvSettings)(env).path,
18
+ origin: ORIGIN_SHAPE.test(origen) ? origen.replace(/\/+$/, '') : null,
19
+ };
20
+ }
21
+ /** ¿Hay credencial en este entorno? Es lo único que el middleware y la CLI preguntan antes de crear nada. */
22
+ function envConfigured(env) {
23
+ return readTransportConfig(env).key !== '';
24
+ }
25
+ /** ¿Se puede hablar con esta dirección? https siempre; http sólo con un nombre de desarrollo que no sale de la máquina. */
26
+ function isSecureUrl(url) {
27
+ let parsed;
28
+ try {
29
+ parsed = new URL(url);
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
35
+ if (parsed.protocol === 'https:')
36
+ return host !== '';
37
+ return parsed.protocol === 'http:' && (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.test') || host.endsWith('.localhost'));
38
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * La credencial de SERVIDOR (`GROWTICKETS_ENV_KEY=gtk2.<semilla>.<pública de la central>`, 92 caracteres) que da «Conectar un servidor» en la
3
+ * pestaña «Entornos», o que arma `growtickets-nest env keygen` en el propio servidor.
4
+ *
5
+ * De ella sale todo lo de este servidor en el protocolo GT-ENV2:
6
+ *
7
+ * - su par Ed25519, con el que FIRMA cada petición (la privada nunca viaja; su `key_id` sí, es público);
8
+ * - la pública de la central, que queda FIJADA: sólo se cree una respuesta, un aviso o un reto firmados con ella, aunque alguien se ponga en
9
+ * medio o cambie `GROWTICKETS_URL`;
10
+ * - por HKDF de la semilla, las llaves locales (la copia cifrada): cada una con su `info`, y ninguna sirve para firmar.
11
+ *
12
+ * El formato anterior (`gte_…`) ya no vale, y el error dice qué hacer en vez de «clave inválida». Nunca repite la credencial en un mensaje.
13
+ */
14
+ export declare class EnvCredential {
15
+ private readonly seed;
16
+ private readonly secretKey;
17
+ readonly publicKey: Buffer;
18
+ /** La pública de la central FIJADA en la credencial. */
19
+ readonly centralKey: Buffer;
20
+ private constructor();
21
+ /** @throws EnvError `invalid_key`, con un mensaje que dice qué hacer y nunca la credencial. */
22
+ static parse(text: string): EnvCredential;
23
+ /** ¿Tiene la forma de una credencial válida? Para el doctor: nunca lanza. */
24
+ static looksRight(text: string): boolean;
25
+ keyId(): string;
26
+ centralKid(): string;
27
+ /** Firma una cadena canónica con la llave de este servidor. */
28
+ sign(message: string): string;
29
+ /** ¿Lo firmó la central? Sólo cuenta la firma del kid FIJADO: otra llave, aunque la cabecera la traiga, no se prueba. */
30
+ signedByCentral(message: string, signatureHeader: string): boolean;
31
+ /** Una llave local de 32 bytes derivada de la semilla: cada `info` da otra distinta. */
32
+ derive(info: string): Buffer;
33
+ }
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EnvCredential = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const errors_1 = require("../errors");
6
+ const protocol_1 = require("./protocol");
7
+ const SHAPE = /^gtk2\.([A-Za-z0-9_-]{43})\.([A-Za-z0-9_-]{43})$/;
8
+ /**
9
+ * La credencial de SERVIDOR (`GROWTICKETS_ENV_KEY=gtk2.<semilla>.<pública de la central>`, 92 caracteres) que da «Conectar un servidor» en la
10
+ * pestaña «Entornos», o que arma `growtickets-nest env keygen` en el propio servidor.
11
+ *
12
+ * De ella sale todo lo de este servidor en el protocolo GT-ENV2:
13
+ *
14
+ * - su par Ed25519, con el que FIRMA cada petición (la privada nunca viaja; su `key_id` sí, es público);
15
+ * - la pública de la central, que queda FIJADA: sólo se cree una respuesta, un aviso o un reto firmados con ella, aunque alguien se ponga en
16
+ * medio o cambie `GROWTICKETS_URL`;
17
+ * - por HKDF de la semilla, las llaves locales (la copia cifrada): cada una con su `info`, y ninguna sirve para firmar.
18
+ *
19
+ * El formato anterior (`gte_…`) ya no vale, y el error dice qué hacer en vez de «clave inválida». Nunca repite la credencial en un mensaje.
20
+ */
21
+ class EnvCredential {
22
+ constructor(seed, secretKey, publicKey,
23
+ /** La pública de la central FIJADA en la credencial. */
24
+ centralKey) {
25
+ this.seed = seed;
26
+ this.secretKey = secretKey;
27
+ this.publicKey = publicKey;
28
+ this.centralKey = centralKey;
29
+ }
30
+ /** @throws EnvError `invalid_key`, con un mensaje que dice qué hacer y nunca la credencial. */
31
+ static parse(text) {
32
+ if (text.startsWith('gte_')) {
33
+ throw new errors_1.EnvError('invalid_key', 'GROWTICKETS_ENV_KEY tiene el formato anterior (gte_): conecta el servidor de nuevo en la pestaña «Entornos» y pon la credencial nueva (gtk2.…).');
34
+ }
35
+ const forma = SHAPE.exec(text);
36
+ if (forma === null) {
37
+ throw new errors_1.EnvError('invalid_key', 'GROWTICKETS_ENV_KEY no tiene la forma de una credencial de servidor (gtk2.…, 92 caracteres): cópiala entera de la pestaña «Entornos».');
38
+ }
39
+ const semilla = (0, protocol_1.fromB64u)(forma[1], protocol_1.KEY_BYTES);
40
+ const central = (0, protocol_1.fromB64u)(forma[2], protocol_1.KEY_BYTES);
41
+ if (semilla === null || central === null || !(0, protocol_1.validPublicKey)(central)) {
42
+ throw new errors_1.EnvError('invalid_key', 'GROWTICKETS_ENV_KEY está dañada (algún carácter cambió al copiarla): cópiala de nuevo o rota la del servidor.');
43
+ }
44
+ const { secretKey, publicKey } = (0, protocol_1.edKeysFromSeed)(semilla);
45
+ return new EnvCredential(semilla, secretKey, publicKey, central);
46
+ }
47
+ /** ¿Tiene la forma de una credencial válida? Para el doctor: nunca lanza. */
48
+ static looksRight(text) {
49
+ try {
50
+ EnvCredential.parse(text);
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ keyId() {
58
+ return (0, protocol_1.keyId)(this.publicKey);
59
+ }
60
+ centralKid() {
61
+ return (0, protocol_1.keyId)(this.centralKey);
62
+ }
63
+ /** Firma una cadena canónica con la llave de este servidor. */
64
+ sign(message) {
65
+ return (0, protocol_1.sign)(message, this.secretKey);
66
+ }
67
+ /** ¿Lo firmó la central? Sólo cuenta la firma del kid FIJADO: otra llave, aunque la cabecera la traiga, no se prueba. */
68
+ signedByCentral(message, signatureHeader) {
69
+ return (0, protocol_1.verifyHeader)(message, signatureHeader, this.centralKey);
70
+ }
71
+ /** Una llave local de 32 bytes derivada de la semilla: cada `info` da otra distinta. */
72
+ derive(info) {
73
+ return Buffer.from((0, node_crypto_1.hkdfSync)('sha256', this.seed, Buffer.alloc(0), Buffer.from(info, 'utf8'), protocol_1.KEY_BYTES));
74
+ }
75
+ }
76
+ exports.EnvCredential = EnvCredential;
@@ -0,0 +1,53 @@
1
+ import type { EnvPayload } from '../policy';
2
+ import { EnvCredential } from './credential';
3
+ import { NonceCache } from './nonce-cache';
4
+ import type { EnvTransport, IdentityRequest, IdentityVerdict, PushRequest, PushVerdict, TransportCheck } from './types';
5
+ type Env = Record<string, string | undefined>;
6
+ export interface Gt2TransportDeps {
7
+ env: () => Env;
8
+ fetchImpl?: typeof fetch;
9
+ /** La última dirección pública que vio la seguridad, para cuando `GROWTICKETS_ORIGIN` no la fija. */
10
+ learnedOrigin?: () => string | null;
11
+ sleep?: (ms: number) => Promise<void>;
12
+ /** Segundos, época Unix; las pruebas lo mueven. */
13
+ now?: () => number;
14
+ /** Las pruebas fijan nonces y efímeras para casar con el vector común. */
15
+ random?: (bytes: number) => Buffer;
16
+ }
17
+ /**
18
+ * El transporte GT-ENV2: se autentica FIRMANDO cada petición con la llave del servidor (la semilla de `GROWTICKETS_ENV_KEY`), cree sólo lo que
19
+ * firma la central FIJADA en esa credencial y recibe las variables CIFRADAS para cada petición. Ninguna clave viaja.
20
+ *
21
+ * ## El aviso y el reto
22
+ *
23
+ * Los dos los firma la central y sólo se atienden si la firma es suya, para ESTE `key_id`, con hora reciente y un nonce de un solo uso. La firma
24
+ * se comprueba ANTES de anotar el nonce, para que quien no la tenga no pueda llenar la memoria. El aviso trae `{"version": N}` y ningún valor; el
25
+ * reto se contesta firmando con la llave del servidor lo que demuestra que detrás de este dominio está esta credencial. Cualquier otra cosa es
26
+ * un 404 mudo. Los nonce viven en la memoria de este proceso (ver `NonceCache`).
27
+ */
28
+ export declare class Gt2EnvTransport implements EnvTransport {
29
+ private readonly deps;
30
+ readonly pushOperation = "apply";
31
+ readonly identityOperation = "identity";
32
+ readonly maxPushBodyBytes = 256;
33
+ readonly nonces: NonceCache;
34
+ private readonly client;
35
+ private parsed;
36
+ constructor(deps: Gt2TransportDeps);
37
+ configured(): boolean;
38
+ diagnose(): TransportCheck[];
39
+ fetch(etag?: string | null): Promise<EnvPayload | null>;
40
+ ack(version: number, ok: boolean, errorCode?: string | null): Promise<boolean>;
41
+ verifyPush(request: PushRequest): PushVerdict;
42
+ verifyIdentity(request: IdentityRequest): IdentityVerdict;
43
+ snapshotKey(): Buffer | null;
44
+ /** La credencial de este entorno; la parsea una vez por valor. @throws EnvError `invalid_key` */
45
+ credential(): EnvCredential;
46
+ private credentialOrNull;
47
+ /** Lo común de un aviso y un reto: es para ESTE servidor, la hora es reciente y el nonce tiene su forma. */
48
+ private fresh;
49
+ /** El host de la petición o el de la dirección pública del producto: detrás de un proxy uno de los dos es el que la central pidió. */
50
+ private hostCandidates;
51
+ private config;
52
+ }
53
+ export {};
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Gt2EnvTransport = void 0;
4
+ const errors_1 = require("../errors");
5
+ const client_1 = require("./client");
6
+ const config_1 = require("./config");
7
+ const credential_1 = require("./credential");
8
+ const nonce_cache_1 = require("./nonce-cache");
9
+ const protocol_1 = require("./protocol");
10
+ const PUSH_OPERATION = 'apply';
11
+ const IDENTITY_OPERATION = 'identity';
12
+ const MAX_PUSH_BODY_BYTES = 256;
13
+ const SNAPSHOT_INFO = 'growtickets-env-snapshot';
14
+ const OFF_HINT = 'Está apagada: falta GROWTICKETS_ENV_KEY (la da «Conectar un servidor» en la pestaña «Entornos» del proyecto).';
15
+ /**
16
+ * El transporte GT-ENV2: se autentica FIRMANDO cada petición con la llave del servidor (la semilla de `GROWTICKETS_ENV_KEY`), cree sólo lo que
17
+ * firma la central FIJADA en esa credencial y recibe las variables CIFRADAS para cada petición. Ninguna clave viaja.
18
+ *
19
+ * ## El aviso y el reto
20
+ *
21
+ * Los dos los firma la central y sólo se atienden si la firma es suya, para ESTE `key_id`, con hora reciente y un nonce de un solo uso. La firma
22
+ * se comprueba ANTES de anotar el nonce, para que quien no la tenga no pueda llenar la memoria. El aviso trae `{"version": N}` y ningún valor; el
23
+ * reto se contesta firmando con la llave del servidor lo que demuestra que detrás de este dominio está esta credencial. Cualquier otra cosa es
24
+ * un 404 mudo. Los nonce viven en la memoria de este proceso (ver `NonceCache`).
25
+ */
26
+ class Gt2EnvTransport {
27
+ constructor(deps) {
28
+ this.deps = deps;
29
+ this.pushOperation = PUSH_OPERATION;
30
+ this.identityOperation = IDENTITY_OPERATION;
31
+ this.maxPushBodyBytes = MAX_PUSH_BODY_BYTES;
32
+ this.nonces = new nonce_cache_1.NonceCache();
33
+ this.parsed = null;
34
+ this.client = new client_1.EnvClient({
35
+ credential: () => this.credential(),
36
+ config: () => this.config(),
37
+ env: deps.env,
38
+ fetchImpl: deps.fetchImpl,
39
+ learnedOrigin: deps.learnedOrigin,
40
+ sleep: deps.sleep,
41
+ now: deps.now,
42
+ random: deps.random,
43
+ });
44
+ }
45
+ configured() {
46
+ return this.config().key !== '';
47
+ }
48
+ diagnose() {
49
+ const config = this.config();
50
+ if (config.key === '')
51
+ return [{ level: 'warn', text: OFF_HINT }];
52
+ let clave;
53
+ try {
54
+ clave = { level: 'ok', text: `La credencial tiene forma de credencial de servidor (key_id ${this.credential().keyId()}, el mismo que enseña el panel)` };
55
+ }
56
+ catch (error) {
57
+ clave = { level: 'fail', text: error instanceof errors_1.EnvError ? error.message : 'GROWTICKETS_ENV_KEY no es válida.' };
58
+ }
59
+ if (config.baseUrl === '')
60
+ return [clave, { level: 'fail', text: 'Falta GROWTICKETS_URL: la dirección de tu GrowTickets.' }];
61
+ return [
62
+ clave,
63
+ (0, config_1.isSecureUrl)(config.baseUrl)
64
+ ? { level: 'ok', text: 'GROWTICKETS_URL usa https' }
65
+ : { level: 'fail', text: 'GROWTICKETS_URL tiene que ser https (o localhost en desarrollo): por ahí viajan los secretos.' },
66
+ ];
67
+ }
68
+ fetch(etag) {
69
+ return this.client.fetch(etag);
70
+ }
71
+ ack(version, ok, errorCode) {
72
+ return this.client.ack(version, ok, errorCode);
73
+ }
74
+ verifyPush(request) {
75
+ const rechazado = { status: 'rejected' };
76
+ if (request.method !== 'POST' || request.query !== '' || request.body === null || Buffer.byteLength(request.body, 'utf8') > MAX_PUSH_BODY_BYTES)
77
+ return rechazado;
78
+ const credencial = this.credentialOrNull();
79
+ if (credencial === null)
80
+ return rechazado;
81
+ const kid = request.header(protocol_1.KEY_HEADER.toLowerCase()) ?? '';
82
+ const hora = request.header(protocol_1.TIMESTAMP_HEADER.toLowerCase()) ?? '';
83
+ const nonce = request.header(protocol_1.NONCE_HEADER.toLowerCase()) ?? '';
84
+ if (!this.fresh(credencial, kid, hora, nonce, request.nowSeconds))
85
+ return rechazado;
86
+ if (!credencial.signedByCentral((0, protocol_1.pushString)(kid, hora, nonce, request.body), request.header(protocol_1.SIGNATURE_HEADER.toLowerCase()) ?? ''))
87
+ return rechazado;
88
+ if (!this.nonces.consume(`apply:${nonce}`, request.nowSeconds * 1000))
89
+ return rechazado;
90
+ const version = versionOf(request.body);
91
+ return version === null ? { status: 'invalid_body' } : { status: 'accepted', version };
92
+ }
93
+ verifyIdentity(request) {
94
+ const rechazado = { status: 'rejected' };
95
+ const credencial = this.credentialOrNull();
96
+ const reto = /^challenge=([A-Za-z0-9_-]{43})$/.exec(request.query)?.[1] ?? '';
97
+ if (credencial === null || request.method !== 'GET' || (0, protocol_1.fromB64u)(reto, protocol_1.KEY_BYTES) === null)
98
+ return rechazado;
99
+ const kid = request.header(protocol_1.KEY_HEADER.toLowerCase()) ?? '';
100
+ const hora = request.header(protocol_1.TIMESTAMP_HEADER.toLowerCase()) ?? '';
101
+ const nonce = request.header(protocol_1.NONCE_HEADER.toLowerCase()) ?? '';
102
+ if (!this.fresh(credencial, kid, hora, nonce, request.nowSeconds))
103
+ return rechazado;
104
+ const firma = request.header(protocol_1.SIGNATURE_HEADER.toLowerCase()) ?? '';
105
+ const host = this.hostCandidates(request.host).find((candidato) => credencial.signedByCentral((0, protocol_1.challengeString)(kid, candidato, reto, hora, nonce), firma));
106
+ if (host === undefined || !this.nonces.consume(`identity:${nonce}`, request.nowSeconds * 1000))
107
+ return rechazado;
108
+ const ahora = String(request.nowSeconds);
109
+ return { status: 'answer', body: { kid, public_key: (0, protocol_1.b64u)(credencial.publicKey), host, challenge: reto, ts: ahora, sig: credencial.sign((0, protocol_1.identityString)(kid, host, reto, ahora)) } };
110
+ }
111
+ snapshotKey() {
112
+ return this.credentialOrNull()?.derive(SNAPSHOT_INFO) ?? null;
113
+ }
114
+ /** La credencial de este entorno; la parsea una vez por valor. @throws EnvError `invalid_key` */
115
+ credential() {
116
+ const texto = this.config().key;
117
+ if (texto === '')
118
+ throw new errors_1.EnvError('invalid_key', 'Falta GROWTICKETS_ENV_KEY.');
119
+ if (this.parsed?.text !== texto)
120
+ this.parsed = { text: texto, credential: credential_1.EnvCredential.parse(texto) };
121
+ return this.parsed.credential;
122
+ }
123
+ credentialOrNull() {
124
+ try {
125
+ return this.credential();
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ /** Lo común de un aviso y un reto: es para ESTE servidor, la hora es reciente y el nonce tiene su forma. */
132
+ fresh(credencial, kid, hora, nonce, ahora) {
133
+ return kid === credencial.keyId() && (0, protocol_1.validTimestamp)(hora) && Math.abs(ahora - Number(hora)) <= nonce_cache_1.TOLERANCE_SECONDS && (0, protocol_1.fromB64u)(nonce, protocol_1.NONCE_BYTES) !== null;
134
+ }
135
+ /** El host de la petición o el de la dirección pública del producto: detrás de un proxy uno de los dos es el que la central pidió. */
136
+ hostCandidates(host) {
137
+ const origen = this.config().origin ?? this.deps.learnedOrigin?.() ?? null;
138
+ let delOrigen = null;
139
+ try {
140
+ delOrigen = origen === null ? null : new URL(origen).hostname.toLowerCase();
141
+ }
142
+ catch {
143
+ delOrigen = null;
144
+ }
145
+ return [...new Set([host, delOrigen].filter((h) => h !== null && /^[a-z0-9.:[\]-]{1,253}$/.test(h)))];
146
+ }
147
+ config() {
148
+ return (0, config_1.readTransportConfig)(this.deps.env());
149
+ }
150
+ }
151
+ exports.Gt2EnvTransport = Gt2EnvTransport;
152
+ function versionOf(cuerpo) {
153
+ try {
154
+ const datos = JSON.parse(cuerpo);
155
+ const version = typeof datos === 'object' && datos !== null ? datos.version : null;
156
+ const numero = typeof version === 'string' && /^\d{1,15}$/.test(version) ? Number(version) : version;
157
+ return typeof numero === 'number' && Number.isSafeInteger(numero) && numero >= 1 ? numero : null;
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
@@ -0,0 +1,10 @@
1
+ import { type Gt2TransportDeps } from './gt2-transport';
2
+ import type { EnvTransport } from './types';
3
+ export { ENV_CREDENTIAL_VARIABLES, envConfigured } from './config';
4
+ export { Gt2EnvTransport } from './gt2-transport';
5
+ export type { EnvTransport, IdentityRequest, IdentityVerdict, PushRequest, PushVerdict, TransportCheck } from './types';
6
+ /**
7
+ * El transporte de ESTE protocolo con la central (GT-ENV2). Es el único punto de la pieza que sabe cómo se autentica, cómo se cifra y cómo se
8
+ * verifica un aviso o un reto: un protocolo nuevo se enchufa cambiando lo que devuelve esta función (o pasando otro `transport` a `EnvRuntime`).
9
+ */
10
+ export declare function createEnvTransport(deps: Gt2TransportDeps): EnvTransport;