@growconnect/growtickets-next-server
v0.9.1
Published
GrowTickets para Next.js: el proxy que guarda la clave de API y reenvía a api/ingest por una lista blanca de 9 rutas, y la seguridad del producto (bloqueo de IP, sondeos, ruta de salud).
Downloads
917
Readme
@growconnect/growtickets-next-server
GrowTickets para Next.js, en cuatro partes que no se necesitan entre sí:
- El proxy del portal: guarda
GROWTICKETS_API_KEY, firma quién reporta con la sesión del producto y reenvía aapi/ingestpor una lista blanca de 9 rutas. - La seguridad del producto (0.3.0, opcional): manda a la mesa de ayuda lo que ve pasar, corta a las IP que ella bloquee y expone la ruta con la que sabe si el producto se cayó. Ver Seguridad y disponibilidad.
- Los logs del servidor (0.7.0, opcional): lo que el servidor de Next escribe con
consoley cada error de una ruta o de un render llegan a GrowTickets, con los secretos tapados, para buscarlos y agrupar lo repetido. Ver Logs. - Las variables de entorno desde GrowTickets (0.8.0, opcional): el
.envdel servidor se queda en dos líneas y «Vincular» en la mesa de ayuda aplica las variables en segundos, sin desplegar. Ver Variables de entorno desde GrowTickets.
Ninguna entrada corre en el navegador: todas (menos ./middleware, ver abajo) resuelven a una guardia
que lanza si un bundler las incluye en un bundle de cliente.
| Subruta | Para |
|---|---|
| ./app | Route Handlers, Server Components y Server Actions (Next 13.4+). Lleva server-only. |
| ./pages | pages/api/* (Next 10+). Nunca importa server-only: ahí revienta. |
| ./middleware | El middleware.ts de Next 12.2+, en el runtime edge: corta IP bloqueadas en todo el sitio y cuenta sondeos. |
| ./instrumentation | instrumentation.ts de Next 15+: las variables de entorno desde GrowTickets, los logs del servidor y los errores de cada ruta. Corre sólo en nodejs. |
| ./env | Las variables de entorno desde GrowTickets (código de Node). No se importa a mano: lo cargan ./instrumentation, ./app y ./pages con import(). |
| ./config | getServerConfig() a secas. |
| . | El núcleo: createGrowTicketsClient, errores y tipos. |
Instalación
npm install @growconnect/growtickets-next-serverVersiones de Next (0.5.0). El paquete declara como compatibles Next 15.5.27+ y 16.3.8+, las líneas con parche para los avisos de seguridad conocidos (el bypass del middleware y la RCE de
next/og). Con una anterior sigue funcionando —el código no cambió su alcance—, pero npm avisa delpeerDependenciesynpx growtickets-next checklo repite: quedarse ahí es quedarse con esos avisos.
La forma más rápida de empezar es el CLI incluido:
npx growtickets-next initCopia .env.example a .env.local y completa las dos variables, que son obligatorias (0.6.2):
GROWTICKETS_URL, la dirección de tu GrowTickets, y GROWTICKETS_API_KEY, la clave de la pestaña
«Claves de API» del proyecto. La URL no tiene valor por defecto a propósito: el que hubo hasta la 0.6.0
(https://tickets.growconnect.co) no era GrowTickets, y un producto que olvidara la variable le habría
mandado su clave a quien llegara a controlar ese nombre.
App Router (Next 13.4+)
// lib/growtickets.server.ts
import { createGrowTicketsRouteHandlers } from '@growconnect/growtickets-next-server/app';
export const { GET, POST } = createGrowTicketsRouteHandlers({
resolveReporter: async (request) => resolveReporterDeTuSesion(request),
});// app/api/soporte/[...growtickets]/route.ts
export { GET, POST } from '@/lib/growtickets.server';
export const dynamic = 'force-dynamic';Pages Router (Next 10+)
// lib/growtickets.server.ts
import { createGrowTicketsApiHandler } from '@growconnect/growtickets-next-server/pages';
export default createGrowTicketsApiHandler({
resolveReporter: async (req) => resolveReporterDeTuSesion(req),
});// pages/api/soporte/[...growtickets].ts
export { default } from '../../../lib/growtickets.server';resolveReporter: la única pieza que tienes que escribir
Es obligatorio y sin valor por defecto — Next no tiene un auth()->user() universal, así que
-server no puede adivinar cómo autentica tu producto. null responde 401 sin llamar a la
ingesta. La identidad tiene que salir de la SESIÓN, nunca de params, la query o el cuerpo.
Con NextAuth v4
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { createGrowTicketsRouteHandlers } from '@growconnect/growtickets-next-server/app';
export const { GET, POST } = createGrowTicketsRouteHandlers({
resolveReporter: async () => {
const session = await getServerSession(authOptions);
if (!session?.user) return null;
return { id: session.user.id, name: session.user.name ?? 'Sin nombre', email: session.user.email ?? undefined };
},
});Con Auth.js v5
import { auth } from '@/auth';
import { createGrowTicketsRouteHandlers } from '@growconnect/growtickets-next-server/app';
export const { GET, POST } = createGrowTicketsRouteHandlers({
resolveReporter: async () => {
const session = await auth();
if (!session?.user) return null;
return { id: session.user.id, name: session.user.name ?? 'Sin nombre', email: session.user.email ?? undefined };
},
});Ninguno de los dos es una dependencia de este paquete: son sólo la FORMA que resolveReporter
tiene que tomar contra cada librería.
Límites del proxy (0.6.2 y 0.6.3)
120 peticiones por minuto y por persona (
throttle, como engrowtickets-laravel). La mesa de ayuda limita la ingesta por PROYECTO, así que sin este tope una sola sesión podía dejar sin portal a todos los usuarios de tu producto. Pasado el tope responde 429 sin llamar a la ingesta. Se cuenta en la memoria de cada proceso: con varias instancias, cada una lleva su cuenta.throttle: 0lo apaga:createGrowTicketsRouteHandlers({ resolveReporter, throttle: 300 });Hasta 100 valores en la query de una petición del portal; con más responde 403. La identidad de quien reporta va SIEMPRE primero en lo que se reenvía, y una clave que PHP leería como identidad (
reporter.id,reporter_id…) se borra igual que la de verdad: la mesa de ayuda es PHP, que descarta sin avisar lo que pasa de 1000 variables y reescribe los nombres.El cuerpo se lee al final y con tope: con App Router, sólo si la ruta existe, hay sesión y pasa la valla de origen, y se corta en 256 KB aunque no venga
Content-Length.La valla de origen también en
GET(0.6.3): con la cookie de sesión y un CORS permisivo en tu producto (Access-Control-Allow-Originreflejado másAllow-Credentials), otro sitio podía LEER el historial de tickets. Una petición conOriginoSec-Fetch-Sitede otro sitio responde 403, salvo que su origen esté enallowedOrigins. El portal no lo nota: unGETdel propio origen no mandaOrigin, y las llamadas de servidor a servidor no mandan ninguna de las dos cabeceras.El registro no se puede falsificar (0.6.3): la ruta y el
Originque acaban en un mensaje los escribe quien llama, y un%0Aen una ruta abría una línea falsa en un registro de texto. Los saltos de línea y los demás caracteres de control salen escritos (\n,\u001b) y el mensaje se corta en 1000 caracteres; vale también para unloggerque inyectes.
Con qué versión escribe cada persona
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. Con eso, quien atiende ve en la ficha del ticket con qué librería y desde qué aparato escribe la persona (web o app, navegador o modelo, versión de tu app) y si va al día, y en el catálogo de proyectos se avisa de «Librería desactualizada».
| Cabecera | La pone | Contenido |
|---|---|---|
| X-GrowTickets-Proxy | Este paquete, siempre | growtickets-next/0.7.0 |
| X-GrowTickets-Runtime | Este paquete, donde corre Node (0.7.0) | node/22.11.0: la versión COMPLETA, para los avisos de Node. En el middleware edge no va (allí no hay Node) |
| X-GrowTickets-Client | La librería del portal (-react, Angular, Flutter); el proxy la reenvía | base64url de un JSON corto: librería y versión, canal, sistema, modelo, versión de tu app |
- Es información, nunca autorización. La mesa de ayuda no decide nada con ella: sirve para orientar a quien atiende. Quien controla el navegador controla lo que dice de sí mismo.
X-GrowTickets-Clientse reenvía sólo si parece lo que es: hasta 2 KB y sólo caracteres base64url. Cualquier otra cosa se descarta sin rechazar la petición. Es la única cabecera del navegador que pasa: reenviar cabeceras a voluntad sería abrir un canal libre hacia la mesa de ayuda con tu clave puesta.- No hay nada que configurar. Un portal que todavía no manda su cabecera sigue funcionando igual; en la ficha se ve como «su portal todavía no informa la versión».
- La cabecera
X-GrowTickets-Proxytambién sale del cliente servidor-a-servidor (createGrowTicketsClient) y de la telemetría de seguridad, que comparten la misma llamada.
Seguridad y disponibilidad
Lo mismo que growtickets-laravel 0.3, con el mismo protocolo: el producto manda telemetría de
seguridad (intentos de login, 401/403/429, errores 5xx, escáneres que piden /.env), corta con un
403 a las IP que la mesa de ayuda bloquee y contesta una ruta de salud firmada con la que la
mesa de ayuda sabe si el producto se cayó y desde cuándo. Está apagada por defecto: apagada no
registra, no corta y no arranca ningún temporizador.
Qué da cada versión de Next
| Next | Ruta de salud | Captura (tráfico, login, 401/403/429/5xx) | Cortar IP bloqueadas |
|---|---|---|---|
| 10 – 12.1 | Sí (pages/api) | Sólo las rutas que envuelves | Sólo las rutas que envuelves |
| 12.2+ | Sí | Rutas envueltas + sondeos por middleware.ts | Todo el sitio (middleware.ts) |
Las páginas —lo que renderiza Next, no una ruta de API— no se cuentan: no hay dónde engancharse sin un middleware que no ve la respuesta. Lo que cubre tráfico y errores es envolver TUS rutas de API.
Variables de entorno
| Variable | Por defecto | Qué hace |
|---|---|---|
| GROWTICKETS_SECURITY | apagada | true la enciende. Hace falta además GROWTICKETS_API_KEY. |
| GROWTICKETS_SECURITY_BLOCKING | true | false sigue capturando pero nunca corta una petición. |
| GROWTICKETS_SECURITY_HEALTH_PATH | growtickets/health | Dónde quedó la ruta de salud (sin barras en los extremos). Con Pages Router: api/growtickets/health. off la apaga. |
| GROWTICKETS_SECURITY_CSP | encendida | Añade a tus páginas la política de contenido que GrowTickets define para el proyecto, sólo informando, y sólo cuando él la enciende (ver más abajo). false no la añade nunca. |
| GROWTICKETS_SECURITY_URGENT_FLUSH | encendida | Un login fallido (una fuerza bruta) o dar privilegios salen a los 2 segundos en vez de esperar al minuto, para que GrowTickets avise mientras pasa (0.9.0). En el middleware los saca la siguiente petición. false vuelve al envío de cada minuto. |
| GROWTICKETS_SECURITY_DEPENDENCIES | encendida | Desde la 0.9.0, cuando alguien pulsa Sincronizar en la ficha del proyecto, el servidor Node manda los paquetes de su package-lock.json (o pnpm-lock.yaml, yarn.lock) en su siguiente consulta a GrowTickets (cada minuto). Lo instala el register() de @growconnect/growtickets-next-server/instrumentation (en Node); el middleware no lo hace. false lo apaga. |
| GROWTICKETS_TRUSTED_PROXY_HOPS | 1 | Cuántos proxies de confianza hay delante (1 = Railway; con Cloudflare delante de Railway, 2). 0 = sin proxy. Tiene que ser exacto: ver «La IP» más abajo. |
| GROWTICKETS_TRUST_REAL_IP | apagada | true lee X-Real-IP cuando X-Forwarded-For no da una IP. Sólo si tu proxy escribe ésa y no la otra. |
Una variable mal escrita cae a su valor por defecto: la seguridad nunca lanza ni tumba una petición.
1 · La ruta de salud
npx growtickets-next init la deja creada. A mano:
// app/growtickets/health/route.ts (App Router)
import { createGrowTicketsHealthRoute } from '@growconnect/growtickets-next-server/app';
export const dynamic = 'force-dynamic'; // escrito AQUÍ: Next no lo lee de un re-export
export const { GET } = createGrowTicketsHealthRoute();// pages/api/growtickets/health.ts (Pages Router; pon GROWTICKETS_SECURITY_HEALTH_PATH=api/growtickets/health)
import { createGrowTicketsHealthApiHandler } from '@growconnect/growtickets-next-server/pages';
export default createGrowTicketsHealthApiHandler();Sólo contesta a la mesa de ayuda: exige una firma (HMAC-SHA256 de la hora con la clave de API) y a cualquier otra petición —o con la seguridad apagada— le responde un 404 corriente. No revela versiones ni tablas. Dice «caído» a la mesa de ayuda si el servidor no responde y, si le das una comprobación de tu base, también cuando ésta no contesta:
createGrowTicketsHealthRoute({ checks: { database: () => prisma.$queryRaw`select 1` } });false o una excepción son un 503 que la mesa de ayuda cuenta como caída. Sin checks no se mira ninguna
base (Next no tiene una «por defecto»).
2 · Envolver las rutas
// app/api/login/route.ts
import { withGrowTicketsSecurity } from '@growconnect/growtickets-next-server/app';
export const POST = withGrowTicketsSecurity(handler, {
login: { identifier: 'email' }, // 401/422 = intento fallido, 2xx = entró
actor: async (request) => (await getSession(request))?.user.id,
});// pages/api/orders/[id].ts
import { withGrowTicketsSecurityApi } from '@growconnect/growtickets-next-server/pages';
export default withGrowTicketsSecurityApi(handler, { route: '/api/orders/[id]' });Antes del handler, si la IP está bloqueada contesta 403 y no lo llama. Después, cronometra, clasifica y aprende la dirección pública del producto de las 2xx. Una excepción de la captura se traga; una del handler se relanza (Next responde el 500 y se cuenta como tal). Con la seguridad apagada es el handler tal cual.
| Opción | Para |
|---|---|
| route | La plantilla (/api/orders/[id]). En App Router sale de los params; en Pages Router req.query mezcla parámetros y query, así que si la ruta es dinámica pásala — si no, los ids numéricos, UUID y hexadecimales largos se cuentan como [id]. |
| login | Este handler es de login. { identifier: 'email' } es el campo con la cuenta; true si no quieres leer ninguno. |
| secondFactor | Igual, para el segundo factor: sus fallos son auth.second_factor_failed. |
| onSuccess | El evento que cuenta un 2xx de una escritura: 'account.password_changed', 'custom.exportacion'… |
| actor | Quién es el usuario (un id, o u:12). Sin él, la petición se cuenta sin cuenta. |
| failureStatuses | Qué códigos de un login son fallo. Por defecto [401, 422]. |
De lo tecleado sólo se lee UN campo —el de identifier— y sale hasheado (HMAC con una sal que sólo
conocen tu servidor y la mesa de ayuda) y enmascarado (j***@gmail.com). Nunca se lee la contraseña ni
ningún otro campo. En App Router se lee de una copia de la petición y sólo en un POST de login; un cuerpo
de más de 16 KB no se toca.
Lo que sólo tu producto sabe se cuenta a mano:
import { growTicketsSecurity } from '@growconnect/growtickets-next-server/app'; // o /pages
growTicketsSecurity.track('auth.login_failed', { identifier: email, request });
growTicketsSecurity.track('custom.pago_rechazado', { actor: user.id, meta: { pasarela: 'x' } });El tipo tiene que ser del catálogo del protocolo o custom.<nombre>; uno inventado se descarta.
3 · El middleware (Next 12.2+; proxy.ts en Next 16)
// middleware.ts
import { createGrowTicketsMiddleware } from '@growconnect/growtickets-next-server/middleware';
export const middleware = createGrowTicketsMiddleware();
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };Corta a las IP bloqueadas en todo el sitio y cuenta los sondeos (quien pide /wp-login.php o /.env).
Si ya tienes un middleware, pásalo: createGrowTicketsMiddleware({ next: tuMiddleware }) lo llama cuando el
de GrowTickets deja pasar. probePaths amplía la lista de rutas de escáner.
En Next 16 el archivo se llama proxy.ts y corre en Node (export const proxy = createGrowTicketsMiddleware();):
la función es la misma, y ahí comparte memoria con tus rutas envueltas en lugar de tener la suya.
Política de contenido (CSP), sólo informando (0.5.0)
Sin configurar nada —GrowTickets enciende la política solo cuando ve esta versión—, el
middleware añade a las páginas (navegaciones GET, no datos ni imágenes) Content-Security-Policy-Report-Only
y Reporting-Endpoints. Nunca la versión que bloquea. Sirve para ver qué scripts externos carga tu página:
un skimmer aparece como un host nuevo.
Devuelve la señal «continuar» de Next (x-middleware-next) con las cabeceras puestas, o se las añade a la
respuesta de tu propio middleware si lo compones con next. Una redirección o un error se dejan como están.
Si tu app ya tiene su propia política «sólo informar» (0.5.1), pásasela al middleware. Next escribe las
cabeceras del middleware después de las de next.config → headers(), así que si la dejas sólo ahí la de
GrowTickets la reemplaza y los informes de tu app dejan de llegar a su endpoint, sin ningún aviso. Con csp,
el middleware emite una cabecera con las dos políticas (varias separadas por coma son válidas: el navegador
evalúa e informa cada una por su lado) y fusiona Reporting-Endpoints:
// csp.ts — una sola fuente de verdad, para next.config y para el middleware
export const APP_CSP = "default-src 'self'; report-uri /mi-endpoint";
// middleware.ts
export const middleware = createGrowTicketsMiddleware({
csp: {
reportOnly: APP_CSP, // un texto, o un arreglo de políticas
reportingEndpoints: { csp: '/mi-endpoint' }, // sólo si tu política usa `report-to`
},
});- La tuya va primero; la de GrowTickets usa su propio grupo,
growtickets-csp, para no chocar con el tuyo. - Si tu propio
nextya devuelve esa cabecera, también se suman. Una política idéntica no se repite. - Tu política se aplica aunque la seguridad esté apagada: moverla aquí no te deja sin ella.
GROWTICKETS_SECURITY_CSP=falseapaga sólo la de GrowTickets; la tuya se sigue emitiendo.- Una política con saltos de línea o caracteres no imprimibles lanza
ConfigurationErroral crear el middleware (se ve ennext dev, no se pierde en silencio). El mensaje no repite el valor. npx growtickets-next checkavisa si encuentra una política «sólo informar» ennext.configy el middleware no la recibe. También leeproxy.ts(Next 16).
Bloques de red (0.5.0)
Además de IP sueltas, GrowTickets puede cerrar un país, una red (ASN) o las salidas de Tor. Esos bloques son miles de rangos: se guardan ordenados en memoria y se consultan con una búsqueda binaria, sin coste apreciable por petición. Fallan abiertos igual que las IP sueltas. Con una versión anterior la regla aparece en GrowTickets como «no aplica todavía».
Límites (léelos antes de fiarte)
- Necesita un proceso Node que viva (Railway, Docker, una máquina). El estado —lo capturado y la lista de bloqueos— está en memoria y se envía cada minuto. En serverless (Vercel) cada invocación puede empezar de cero: la ruta de salud funciona, el resto no es fiable.
- No es un cortafuegos. Falla ABIERTO: sin lista descargada, con la mesa de ayuda caída o ante cualquier error de la librería, la petición pasa. Un bloqueo se aplica en ~1 minuto (cada proceso pregunta la configuración de la mesa de ayuda una vez por minuto, y con ella la versión de su lista).
- El middleware no ve la respuesta: no cuenta tráfico ni 401/403/5xx (eso lo hacen los handlers envueltos),
y un «sondeo» es «se pidió una ruta de escáner», no «dio 404» — por eso
/console,/actuatory/config.json, que una app puede tener de verdad, no cuentan ahí. - Las variables se leen al ARRANCAR el servidor, no al construir, también en el middleware: con
next starten Node no hace falta queGROWTICKETS_API_KEYniGROWTICKETS_SECURITYexistan en el build (así se probó, con Next 16). Si tu plataforma incrusta las variables al compilar el middleware, tienen que estar puestas también ahí. - El middleware y las rutas tienen memoria separada (el runtime edge y el de Node no comparten nada): cada
uno descarga su lista y manda sus lotes, con
instancedistinta (…-edge). - La IP sale de
X-Forwarded-ForcontandoGROWTICKETS_TRUSTED_PROXY_HOPSdesde la DERECHA: lo que escribe el cliente va a la izquierda y no cuenta. Sin proxy (0) sólo hay IP en Pages Router (req.socket). ⚠️ El número tiene que ser EXACTO. Con menos de los que hay, todas las peticiones parecen de la misma IP privada: la librería lo detecta y avisa a la mesa de ayuda (no_trusted_proxies), que se niega a bloquear direcciones privadas. Con MÁS de los que hay, la entrada que se toma la escribió el cliente: cualquiera puede pasar por una IP ajena, saltarse un bloqueo o hacer que se bloquee a un inocente. Desde la 0.6.4 la librería también lo detecta cuando casi todo el tráfico (más del 90 %, con al menos 50 peticiones) llega con menos entradas enX-Forwarded-Forque saltos declarados, y lo avisa a la mesa de ayuda (too_many_trusted_proxies). No lo ve si no hay ningún proxy y el valor es1(Next rellena la cabecera con la IP de la conexión), ni si tu proxy reescribeX-Forwarded-Foren vez de ampliarla: para eso sirve comprobarlo a mano concurl -H 'X-Forwarded-For: 203.0.113.7' https://tu-producto/…a una ruta envuelta y mirar en la mesa de ayuda que esa IP NO aparezca entre los orígenes.X-Real-IPno se lee salvo conGROWTICKETS_TRUST_REAL_IP=true(0.6.3): casi ningún proxy la reescribe, y cuando faltaX-Forwarded-Fores justo la que mandaría el cliente. - La dirección pública del producto se aprende de las peticiones 2xx de las rutas envueltas y se manda con
cada lote: de ahí sale la sugerencia de dominio en la pestaña «Disponibilidad». Sin ninguna ruta envuelta
hay que añadir el dominio a mano en la mesa de ayuda. Sale del
Hosty de nada más (0.6.3):X-Forwarded-Hostla escribe quien llama, y con ella se podía hacer que la mesa de ayuda vigilara —con su firma puesta— un dominio ajeno. Si tu proxy reescribe elHosta uno interno (app:3000), no se aprende nada y el dominio se añade a mano. El protocolo eshttpssalvo queX-Forwarded-Protodigahttp(la entrada de la derecha, y sólo conGROWTICKETS_TRUSTED_PROXY_HOPSmayor que 0).
Logs (0.7.0)
Lo que el servidor de Next escribe y cada error que Next cuenta llegan a GrowTickets, para buscarlos, agrupar los repetidos y ver un
archivo por servidor. No cambia lo que ya sale por la consola: Railway, Docker o journald guardan lo mismo de siempre.
Un archivo, instrumentation.ts en la raíz del proyecto (o en src/), con una línea:
export { register, onRequestError } from '@growconnect/growtickets-next-server/instrumentation';Y la clave de la pestaña «Claves de API» del proyecto (la Principal ya lleva el alcance «Logs»; si usas otra, márcalo allí), con el módulo
«Logs» encendido en «General». Necesita Next 15+ (el gancho onRequestError nace ahí) y Node 18.18+.
registerhace queconsole.log/info/debug/warn/error—el de Next y el de tu código— se mande además a GrowTickets.console.logeinfollegan comoinfo;warncomowarning. UnErrorentre los argumentos es la excepción de la línea.onRequestErrormanda cada error de una ruta, de un render o de una Server Action con la ruta que falló (/orders/[id], el patrón y no la dirección con el id), el método y el tipo de render. No lee las cabeceras ni la query. Si el mismo error sale porconsoley poronRequestError, llega UNA vez, con la ruta.- Sólo en el runtime
nodejs. En el edge (middleware, rutasruntime = 'edge') no se captura nada, y el archivo compila también para el edge sin avisos.
Si tu producto usa pino en sus rutas, growTicketsLogStream() (de la misma entrada) es la salida que lo manda:
pino({ level: 'info' }, growTicketsLogStream()).
Lo que se tapa antes de salir
- Por nombre de campo:
password,token,secret,api_key,authorization,cookie… y las palabras que añadas enGROWTICKETS_LOGS_REDACT_KEYS(cpf,documento). - Por forma:
Bearer …, un JWT, una clave de GrowTickets o de AWS, credenciales en una URL, una clave privada y los paresclave=valor. - Por valor: los secretos que tu entorno tiene de verdad (
DATABASE_URL,STRIPE_SECRET_KEY, cualquier*_TOKEN,*_KEY,*_DSN) se tapan dondequiera que aparezcan. - Nunca se ejecuta código tuyo: un objeto con
toJSON()o con getters en el contexto sale como[object Clase].
GrowTickets lo vuelve a tapar al recibirlo y puede además tachar los datos personales (correos, cédulas, celulares) con un interruptor del proyecto («Tachar datos personales», en «General»).
Variables
| Variable | Qué hace |
|---|---|
| GROWTICKETS_LOGS | false apaga los logs sin tocar el código. |
| GROWTICKETS_LOGS_LEVEL | El nivel más bajo que se manda (debug de serie). |
| GROWTICKETS_LOGS_ORIGIN | La dirección pública del producto, si quieres fijarla. |
| GROWTICKETS_LOGS_REDACT_KEYS | Palabras que hacen que se tape el valor de un campo, separadas por comas. |
Cómo funciona
Capturar es síncrono, barato y nunca lanza: una línea que no se entiende, un error al tapar o la cola llena se pierden en silencio y
jamás rompen el log ni la petición. Cada minuto se manda lo acumulado, comprimido, en lotes de hasta 2.000 líneas; una línea warning o
peor se manda a los cinco segundos, para que un SIGTERM o un fallo no se lleven el último minuto. Un lote se reintenta con el mismo
identificador, tras tres fallos seguidos hay un corte de cinco minutos y lo que lleva más de una hora esperando se descarta y se cuenta
como perdido (la central lo sabe). Por encima de 5.000 líneas por minuto se muestrea lo leve con peso; lo grave se manda siempre.
Los archivos de log de un servidor Node no se pueden gestionar desde GrowTickets (escriben a la consola): se ven en el explorador, como
project.log y como un archivo por servidor («Next.js»).
npx growtickets-next doctor # entorno, clave, alcance «Logs» y módulo encendido
npx growtickets-next doctor --send # además manda una línea de prueba y comprueba que llegaVariables de entorno desde GrowTickets (0.8.0)
Las variables de cada servidor viven cifradas en GrowTickets (pestaña «Entornos» del proyecto) y el .env del servidor se queda en dos
líneas. «Vincular» en GrowTickets las aplica en segundos, sin desplegar. Es opcional: sin GROWTICKETS_ENV_KEY esta parte no hace
nada —ni red, ni archivos, ni temporizador—. Necesita Next 15+ (el gancho register es de instrumentation.ts) y Node 18.18+.
Es el mismo protocolo (GT-ENV2) que growtickets-laravel 0.11.0.
- En la pestaña «Entornos», crea el entorno (Producción, Pruebas…), carga sus variables (o pega tu
.env) y pulsa Vincular. - Conectar un servidor: eliges el dominio (pestaña «Dominios») al que GrowTickets avisará y escribes tu código de dos pasos. La llave del servidor
nace en tu navegador y a GrowTickets sólo va la pública; te enseña la credencial una sola vez. Si prefieres que la privada no salga nunca del
servidor (uno propio, con su
.env):npx growtickets-next env keygen, pega la pública que te da en «Ya tengo una llave pública», y termina connpx growtickets-next env keygen --central=<la llave de la central> --write. - En el servidor:
# .env, o las variables de la plataforma: lo único que se queda ahí
GROWTICKETS_URL=https://tu-growtickets
GROWTICKETS_ENV_KEY=gtk2....// instrumentation.ts: la MISMA línea de los logs
export { register, onRequestError } from '@growconnect/growtickets-next-server/instrumentation';// app/growtickets/env/[operation]/route.ts (App Router: sirve el aviso `apply` y el reto `identity`)
import { createGrowTicketsEnvRoute } from '@growconnect/growtickets-next-server/app';
export const runtime = 'nodejs'; // escrito AQUÍ: Next no lo lee de un re-export
export const dynamic = 'force-dynamic';
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = createGrowTicketsEnvRoute();// pages/api/growtickets/env/[operation].ts (Pages Router; pon GROWTICKETS_ENV_PATH=api/growtickets/env)
import { createGrowTicketsEnvApiHandler } from '@growconnect/growtickets-next-server/pages';
export const config = { api: { bodyParser: false } }; // la firma se comprueba sobre el cuerpo tal como llegó
export default createGrowTicketsEnvApiHandler();Y .growtickets/ en el .gitignore (ahí vive la copia cifrada, ver más abajo). Si ya tienes un register propio, llama al de GrowTickets
primero:
import { register as registerGrowTickets } from '@growconnect/growtickets-next-server/instrumentation';
export async function register() {
await registerGrowTickets();
// ...lo tuyo, que ya ve las variables de la central
}Compruébalo con npx growtickets-next env status (no usa la red) y npx growtickets-next check, que avisa si falta alguna de las tres piezas.
Cómo llegan sin tocar el .env
register() pregunta a GrowTickets (con su credencial, por https; ver «Seguridad» más abajo) y escribe las variables en process.env antes de lo demás que hace la
librería (los logs) y de que tu código lea su configuración: Next espera a register() antes de atender la primera petición.
- Precedencia: plataforma <
.env< GrowTickets. La central manda sobre lo que trajo la plataforma.GROWTICKETS_URLyGROWTICKETS_ENV_KEYson siempre los del arranque: la central no las pone, porque son con las que el servidor vuelve a preguntar. - Las
NEXT_PUBLIC_*se fijan en el BUILD. La central puede ponerlas enprocess.envdel servidor, pero el navegador conserva el valor con el que se construyó el bundle: cambiarlas desde la central no cambia lo que ya está en el cliente. La librería lo avisa por su nombre. - En el runtime edge no hace nada (no hay
fs): el middleware y las rutasruntime = 'edge'no reciben variables de la central. - Si
GROWTICKETS_ENV_ON_FAILURE=faily no se lograron,register()lanza y Next no arranca. Conkeep(de serie) escribe el motivo en el error estándar y sigue con el entorno de la plataforma. - Pensado para servidores que viven (Railway, Docker, una máquina): en una plataforma sin proceso persistente (funciones sin estado) lo que se aplica después de responder no tiene dónde ocurrir, y ahí las variables siguen siendo las de la plataforma.
Cuando hay una versión nueva en un proceso que ya corre
Lo decide GROWTICKETS_ENV_ON_CHANGE:
restart(de serie). Tras confirmar a GrowTickets, el proceso espera lo pendiente (lo último de los logs, hasta 5 s), da un segundo de margen a las peticiones en vuelo y sale conprocess.exit(75)(GROWTICKETS_ENV_EXIT_CODE). Tu gestor de procesos tiene que levantarlo (la política de reinicio de Railway o Docker, systemd, pm2…): sin uno, el servidor se queda parado. Es 75 y no 0 a propósito: Railway (su política de serie es «On Failure»), Docker conrestart: on-failurey systemd conRestart=on-failureno reinician una salida con 0, y el producto se quedaría caído tras cada «Vincular». Conrestart: always,unless-stopped, pm2 o Kubernetes da igual el código. Si tu gestor trata un código distinto de 0 como un fallo que alerta, ponGROWTICKETS_ENV_EXIT_CODE=0y que reinicie también ante una salida limpia (en Railway, la política «Always»). El proceso nuevo arranca con la versión desde su copia local, sin depender de que GrowTickets conteste en ese instante. Ennext devno sale: sólo lo dice.reload. Escribeprocess.enven caliente y avisa a quien se haya suscrito; lo que ya leíste y guardaste (un cliente de base de datos, una tarifa) lo tienes que volver a leer tú. Lo que la central quita vuelve a lo que valía antes de ponerlo.
import { onGrowTicketsEnvChange } from '@growconnect/growtickets-next-server/instrumentation';
onGrowTicketsEnvChange(({ version, keys }) => {
// `keys` son los NOMBRES que cambiaron; los valores están en process.env, nunca en el evento
if (keys.includes('DATABASE_URL')) reconnect();
});Seguridad: la credencial nunca viaja
GROWTICKETS_ENV_KEY (gtk2.<semilla>.<pública de la central>) no se manda nunca: con ella la librería firma cada petición (Ed25519, con la hora, un
nonce de un solo uso y las cabeceras que la identifican) y fija la llave de GrowTickets. Al bajar manda además una llave efímera X25519, y GrowTickets le
contesta cifrado para esa petición (ChaCha20-Poly1305): ni quien capture el tráfico, ni quien robe la credencial después, puede leer lo que viajó. Todo lo que
contesta GrowTickets va firmado por ella; un 304, un «revocada» o unas variables sin esa firma no cambian nada (unsigned_response: se sigue con la copia), y
una entrega en claro no se acepta aunque venga firmada. El mensaje de unsigned_response dice con qué audiencia se firmó (el host de GROWTICKETS_URL) y la causa más probable: en local, localhost y 127.0.0.1 son audiencias distintas y la central sólo acepta las de su ENV_AUDIENCES (o, sin ellas, el host de su APP_URL); una página web o un 404 son una dirección que apunta al front de GrowTickets o que acaba en /api. GrowTickets, además, puede limitar desde qué red se pide (una lista de IPs). El reloj tiene que ir a ±2
minutos del de GrowTickets. Lo que esto no cubre: quien controle tu servidor o las variables de tu plataforma ve la credencial; si se compromete, rota en
GrowTickets la credencial del servidor por filtración. El protocolo para otras librerías está en el README de growtickets-laravel, con un vector común que
esta librería pasa byte a byte.
«Vincular» al instante
GrowTickets avisa con un POST /growtickets/env/apply (cambia la carpeta con GROWTICKETS_ENV_PATH) firmado con su llave —la fijada en tu credencial—, para ESTE
servidor, de un solo uso y con hora (±60 s). El aviso no trae ningún valor, sólo {"version": N}: el servidor contesta 202 al instante y,
después de responder, pregunta a GrowTickets por su cuenta y aplica. Cualquier otra petición —sin firma, con otra, para otro servidor, repetida, vieja, con
query, con otro método o con esta parte apagada— recibe un 404 corriente: no revela que la ruta existe ni deja medir la firma. Nada se
escribe antes de verificar.
GrowTickets también reta a tu dominio a demostrar que tiene esta credencial (GET /growtickets/env/identity?challenge=…, una vez al día y con «Confirmar el
dominio»): sólo contesta a un reto firmado por GrowTickets, y si el dominio responde con otra credencial, te avisa. Lo que contesta no lleva ningún secreto: el
key_id, la pública y la firma del reto. Por eso la carpeta del aviso es dinámica ([operation]): sirve las dos rutas.
- Los nonces se recuerdan 120 s en memoria, por proceso. Con varias réplicas, una petición capturada podría repetirse una vez por réplica dentro del minuto de tolerancia; es inocuo (lo único que hace es preguntar) y cada réplica pregunta sola, ver abajo.
- Con
createGrowTicketsMiddleware, las rutas del aviso y del reto quedan fuera del corte por IP bloqueada y del mantenimiento (como la ruta de salud), sea cual seaGROWTICKETS_ENV_PATH. Si elmatcherde tu middleware la cubre, no hace falta tocarlo. - En Pages Router la ruta queda bajo
/api: ponGROWTICKETS_ENV_PATH=api/growtickets/env, porque es lo que se le cuenta a GrowTickets.
Varias réplicas
El aviso entra por el dominio y detrás de un balanceador lo atiende una réplica. Por eso cada proceso pregunta por su cuenta cada 60 s
(más o menos 10 s de jitter, para que no pregunten a la vez) con If-None-Match: si nada cambió, GrowTickets contesta 304 y no se toca nada. El
temporizador no retiene el proceso (unref). Un proceso que no logró las variables al arrancar las recoge así en cuanto GrowTickets vuelve a
contestar. Dos procesos sobre la misma carpeta no aplican a la vez (un candado de archivo).
Si GrowTickets no contesta
Cada versión bajada queda en .growtickets/env.snapshot (o donde diga GROWTICKETS_ENV_DIR; nunca dentro de .next), cifrada con
AES-256-GCM y una llave derivada de la semilla de tu credencial (HKDF), con permisos 0600 y escrita de forma atómica. Sin la credencial no
dice nada; con otra (se rotó) no se lee y se baja de nuevo.
GROWTICKETS_ENV_ON_FAILURE=keep(de serie): el arranque sigue con esa copia si no tiene más de una semana (GROWTICKETS_ENV_MAX_STALE_HOURS).fail: no arranca sin GrowTickets.- Una credencial revocada, vencida o inválida (dicho y firmado por GrowTickets) nunca cae a la copia: la borra. Una respuesta sin la firma de GrowTickets, o desde una red no permitida, no: es un fallo pasajero y se sigue con la copia.
- Nunca se retrocede de versión dentro del mismo entorno (
rollback, con aviso a GrowTickets); cambiar de entorno sí puede bajar el número. - Sin la carpeta escribible no hay copia: se sigue funcionando, sólo que sin con qué arrancar si GrowTickets está caído.
Lo que GrowTickets nunca puede poner
Lo que llega acaba en process.env, así que se vuelve a validar aquí aunque GrowTickets ya lo haya hecho al guardar. Se descartan (se dice su
nombre, nunca su valor) y el resto de la versión sigue llegando:
- Los nombres que no son
^[A-Z_][A-Z0-9_]{0,127}$(sin la banderam:"PATH\n"no pasa). - Todo el prefijo
GROWTICKETS_: gobierna lo que la librería le permite a GrowTickets, y quien edita la bóveda no se da más poder sobre el servidor. - Lo que gobierna el proceso o carga código y certificados por otra puerta:
PATH,HOME,NODE_OPTIONS,NODE_PATH,NODE_EXTRA_CA_CERTS,NODE_TLS_REJECT_UNAUTHORIZED,NEXT_RUNTIME,OPENSSL_CONF,SSL_CERT_FILE,SSLKEYLOGFILE,PYTHONWARNINGS,BROWSER,PS4,JAVA_TOOL_OPTIONS… y los prefijosLD_,DYLD_yGIT_: la lista entera de GrowTickets y degrowtickets-laravel. - Hasta 1.000 variables y 32 KB por valor; un valor con NUL o que no es texto invalida la versión entera.
Variables
| Variable | Por defecto | Qué hace |
|---|---|---|
| GROWTICKETS_ENV_KEY | — | La credencial de servidor (gtk2. y 92 caracteres), de «Conectar un servidor». Sin ella, nada. Es un secreto: nunca en .env.example ni con NEXT_PUBLIC_. |
| GROWTICKETS_URL | — | La de siempre. Tiene que ser https (salvo localhost, 127.0.0.1 y nombres .test): por ahí viajan los secretos. |
| GROWTICKETS_ENV_PATH | growtickets/env | La carpeta del aviso y del reto, sin barras en los extremos (las rutas son …/apply y …/identity). |
| GROWTICKETS_ENV_ON_CHANGE | restart | restart o reload, ver arriba. |
| GROWTICKETS_ENV_EXIT_CODE | 75 | Con restart: el código con que sale el proceso (0 a 255). Ver arriba por qué no es 0. |
| GROWTICKETS_ENV_ON_FAILURE | keep | keep o fail. |
| GROWTICKETS_ENV_MAX_STALE_HOURS | 168 | Cuántas horas puede tener la copia local para arrancar con ella. |
| GROWTICKETS_ENV_DIR | .growtickets | Dónde viven la copia cifrada, el estado y el candado. |
| GROWTICKETS_ORIGIN | — | La dirección pública del producto: GrowTickets la cruza con el dominio elegido en «Dónde se usa». |
Cada llamada dice quién pregunta (la instancia, la versión de la librería y la de Node), va firmada con esas cabeceras dentro, y nunca lleva un valor ni la credencial en ningún registro.
Mantenimiento: ventanas programadas desde GrowTickets (0.7.0)
Una ventana se programa en la pestaña «Mantenimiento» del proyecto y el middleware la cumple (el mismo createGrowTicketsMiddleware, en
middleware.ts o en el proxy.ts de Next 16). Viene encendido, aunque la seguridad esté apagada: sin ventanas no se nota.
| Variable | Por defecto | Qué hace |
|---|---|---|
| GROWTICKETS_MAINTENANCE | true | false lo apaga: no pregunta, no cierra y la ruta del front no contesta. |
| GROWTICKETS_MAINTENANCE_HOST | la aprendida | La dirección de este sitio (https://app.empresa.co), para decirle a GrowTickets por dónde se llega. |
| GROWTICKETS_MAINTENANCE_PATH | api/growtickets/maintenance | Por dónde pregunta el front. |
/api/*es el BACKEND de tu producto y las páginas el FRONT. Una ventana del backend cierra las rutas de la API (503 en JSON) y deja la página abierta: congrowtickets-next-react≥ 0.5 el front sabe que el backend está cerrado y puede cerrar la sesión y decirlo en el login. Una ventana del front cierra las páginas con la del proyecto (su marca, el motivo, cuándo vuelve y una cuenta atrás) y deja la API: los webhooks siguen llegando. Una de alcance total cierra todo; una «por dominios», lo que llegue por esa dirección.- Pregunta a GrowTickets cada minuto y guarda la respuesta en memoria (la del middleware edge es suya, aparte de la de las rutas). Sólo las primeras peticiones al arrancar esperan esa pregunta, como mucho 2 s. Activa y apaga POR RELOJ, aunque GrowTickets no conteste en ese momento; si no contesta sigue con lo último que supo y, sin nada, deja pasar. Nunca tumba tu producto.
- El pase del equipo entra sin que se note: cabecera
X-GrowTickets-Bypass, cookie, o?growtickets_bypass=…una vez (deja una cookie de 12 h). - Nunca se cierran la ruta de salud ni
growtickets/*, ni la ruta del front: el middleware la contesta él mismo conwindows(las que tocan al front) ybackend_windows(las que cierran/api/*), sin el pase, con su propio CORS. ⚠️ Elmatchertiene que dejar pasar/api/growtickets/maintenance(el de serie,/((?!_next/static|_next/image|favicon.ico).*), ya lo hace). - Una navegación del router (RSC) durante una ventana recibe el 503 sin HTML y Next hace la navegación completa, que ya trae la página del proyecto.
- El middleware edge pesa más (≈55 KB con todo dentro): la página y la decisión tienen que vivir ahí, porque durante una ventana del front es lo único que contesta.
A quién le afecta: zonas y público
Una ventana de alcance «zonas» no cierra el proyecto entero sino lo que nombra: el panel administrativo, el portal de clientes… Cada zona se
crea en la mesa de ayuda con un slug (panel, empresas), las rutas de sus pantallas (/admin) y las de su API (/api/admin).
- Por ruta, sin tocar tu código. El middleware cierra lo que cae en las rutas de una zona en curso: por SEGMENTO (
/admincierra/admin/usuariosy no/administrador), sin distinguir mayúsculas. Lo que no está en ninguna zona —el login, la autenticación, el soporte— nunca se cierra por zona. Una zona sin rutas no cierra nada por ruta. - Por público, cuando la API es la misma para todos. Si empresas y equipo comparten
/api/afiliados, la ruta no dice a quién cerrar: lo dice la sesión. Das un resolutoraudienceque devuelve elslugde la zona de quien hace la petición, onull:
// middleware.ts: lo que el middleware sabe leer de la sesión (una cookie firmada, un JWT)
export const middleware = createGrowTicketsMiddleware<NextRequest>({
audience: (request) => (request.cookies.get('rol')?.value === 'empresa' ? 'empresas' : null),
});
// app/api/afiliados/route.ts: donde ya cargas la sesión
export const GET = withGrowTicketsSecurity(handler, { audience: async (request) => (await sessionOf(request))?.audience });
// pages/api/afiliados.ts
export default withGrowTicketsSecurityApi(handler, { audience: (req) => req.session?.audience });El resolutor solo se llama mientras hay una ventana de zonas en curso, nunca en la ruta del front ni en la salud, y puede devolver uno o varios
slug. Si lanza, o devuelve algo que no sea un slug (mayúsculas, espacios, números), la petición pasa: un público inventado no cierra a nadie.
En una ruta envuelta, audience activa además el mantenimiento de esa ruta aunque no tengas el middleware (se cierra por todos los alcances), y
la respuesta 503 no se anota como error del producto. Con audience el servidor le avisa a GrowTickets de que sabe resolver el público; sin él, la
mesa de ayuda advierte de que una zona por público no cerraría su API.
- La ruta del front (
api/growtickets/maintenance) añadezone_windows: todas las ventanas de zonas vigentes o por venir con elslug, el nombre y las pantallas (front_paths) de cada zona —nunca las rutas de la API—. Es el front quien decide si le toca: porque su ruta actual cae en una zona o porque el usuario es de su público. Un front anterior ignora la lista. El pase del equipo vale también para las zonas.
CLI: npx growtickets-next
Sin dependencias propias, arg-parsing hecho a mano (no node:util's parseArgs, que exige Node
18.3) para poder correr sobre Node 14 o 16.
init [--dry-run] [--router app|pages] [--force]
- Aborta si
package.jsonno traenext. - Detecta
app/,src/app/,pages/,src/pages/y si el proyecto usa TypeScript o JavaScript. Conapp/y Next 13.4+ elige App Router; si no, Pages Router.--routerfuerza la elección. - Crea o completa
.env.examplecon las claves: la URL con su valor público, la clave de API vacía yGROWTICKETS_SECURITY=(vacío = apagada; con Pages Router también la ruta de salud). Nunca toca.env,.env.localni ninguna otra.env*. - Completa
.gitignorecon un bloque marcado (idempotente: correrlo dos veces no lo duplica). - Crea, sin sobrescribir salvo
--force,lib/growtickets.server.(ts|js), el archivo de ruta del router elegido y la ruta de salud. ElresolveReportergenerado devuelvenull: falla CERRADO hasta que lo implementes. - Imprime los próximos pasos, incluido el fragmento de
middleware.ts(con Next 12.2+): ése nunca lo escribe, es tu archivo y quizá ya tienes uno.
No pide secretos, no escribe ningún secreto y no usa la red.
npx growtickets-next init --dry-run # enseña el plan sin escribir nada
npx growtickets-next init # lo escribe de verdadcheck
De sólo lectura — sirve para el CI del propio producto. Sale con 1 si encuentra:
GROWTICKETS_*dentro deenv:opublicRuntimeConfigen cualquiernext.config.*de la raíz;- cualquier
NEXT_PUBLIC_GROWTICKETS_*en el código fuente (app/,pages/,src/, hasta 8 niveles de profundidad, sin entrar ennode_modules/.next/.git); - un
.gitignoreque no cubre.env/.env.local; - un
.env.examplecuyas clavesGROWTICKETS_*llevan un valor que no es ni vacío ni la URL pública — la señal de que se coló un secreto real en un archivo que sí se versiona. Las variables de la seguridad (GROWTICKETS_SECURITY*,GROWTICKETS_TRUSTED_PROXY_HOPS) no son un secreto y pueden llevar valor.
Además avisa (sin cambiar el código de salida) si usas la seguridad sin ruta de salud, o si la de
Pages Router no tiene GROWTICKETS_SECURITY_HEALTH_PATH: sin ella la mesa de ayuda no puede decir que el
producto se cayó, y no da ningún error. Y avisa si el Next.js instalado es anterior a las versiones con
parche (15.5.27 y 16.3.8): hay avisos de seguridad conocidos, entre ellos el bypass del middleware, que
afecta justo a quien usa createGrowTicketsMiddleware para cortar IP.
Y, si algún .env* nombra GROWTICKETS_ENV_KEY, avisa de las tres piezas de las variables desde la central que se olvidan por separado:
el instrumentation.ts que exporta register, la ruta del aviso y .growtickets/ en el .gitignore.
npx growtickets-next checkdoctor [--send] (0.7.0)
Comprueba los logs de punta a punta: que el entorno traiga la dirección y la clave, que la mesa de ayuda las reconozca, que la clave lleve el
alcance «Logs» y que el módulo esté encendido en el proyecto. Los fallos de esta parte no dan error, sólo un explorador vacío. Con
--send manda además una línea de prueba y comprueba que llega. Sale con 1 si algo impide que las líneas lleguen. También comprueba la dirección: falla si GROWTICKETS_URL acaba en /api (la librería ya añade /api/ingest/…) o si contesta una página web —el front de GrowTickets responde 200 a cualquier ruta—, que antes se daba por buena.
env pull, env status y env keygen (0.8.0)
npx growtickets-next env pull # baja las variables y las deja en la copia local cifrada
npx growtickets-next env status # qué tiene aplicado este servidor y si todo está en su sitio (no usa la red)
npx growtickets-next env keygen # genera aquí el par de este servidor: la privada no sale de élpullbaja lo que GrowTickets tiene para este servidor y lo deja en.growtickets/env.snapshot, sin aplicarlo a ningún proceso ni confirmar nada. Sirve en un paso de despliegue (unRUNde Docker, un release command) para que el primer arranque tenga con qué seguir si GrowTickets no contesta. Dice la versión y cuántas variables son, nunca un valor. Sale con 1 si no pudo.statusdice la versión aplicada y cuándo, la edad de la copia local, elkey_idde la credencial (nunca ella), qué rutas escuchan el aviso y el reto, y qué pasa con una versión nueva (con qué código sale) o con GrowTickets caído.✓está bien,!funciona pero conviene mirarlo,✗impide aplicar y sale con 1.keygengenera el par de este servidor aquí mismo: sin opciones guarda la semilla en.growtickets/env.pending(0600) y enseña sólo la pública y sukey_id(lo que va a «Ya tengo una llave pública»); con--central=<la llave de la central>arma la credencial y, con--write, la escribe en el.envcomoGROWTICKETS_ENV_KEYsi todavía no hay una (nunca la pisa). Sin--writela enseña, y entonces queda en el historial de la terminal.--forcegenera una semilla nueva aunque haya una pendiente.
deps [--lockfile <ruta>] [--app <nombre>] [--origin <url>] [--dry-run] (0.5.0)
Manda a GrowTickets los nombres y versiones de tus paquetes npm, para que él —que sí consulta las
bases públicas de vulnerabilidades— te avise en la pestaña «Postura». Nunca viaja el archivo: de cada
paquete sólo name, version y si es de desarrollo. Pensado para CI, después de instalar; sirve a cualquier
proyecto de Node (un front de Angular o React incluido), no sólo a los de Next.
GROWTICKETS_API_KEY=… npx growtickets-next deps # busca package-lock.json, pnpm-lock.yaml o yarn.lock
npx growtickets-next deps --dry-run # cuenta lo que se enviaría, sin enviar ni pedir la clave
npx growtickets-next deps --lockfile web/package-lock.json --app "Mi producto"
npx growtickets-next deps --origin https://app.miproducto.co # su dirección pública (o GROWTICKETS_ORIGIN)- La clave necesita el alcance Seguridad (pestaña «Claves de API»). Yarn y pnpm no dicen qué paquetes son de desarrollo: van todos como de producción.
GROWTICKETS_URLtiene que serhttps(0.6.3), salvolocalhosty127.0.0.1, y sin mirarNODE_ENV: un CI no lo trae puesto, y unhttp://…mal escrito mandaría la clave sin cifrar. Es lo contrario de lo que pasa en el servidor, donde sólo se exige conNODE_ENV=productionpara no estorbar en desarrollo.--origin(0.6.0): la dirección pública del producto, oGROWTICKETS_ORIGIN. Es lo que distingue dos servidores con el mismo nombre de aplicación, que sin ella GrowTickets vería como una sola lista, y lo que hace que la tarjeta de «Postura» diga de qué dominio es. Un comando de CI no sirve 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.- Sale con 1 si no puede enviar y con 0 si la mesa de ayuda aún no recibe dependencias o tiene la seguridad apagada: eso no es un fallo de tu proyecto y no debe frenar un despliegue.
- La integridad de archivos que tiene
growtickets-laravelno existe aquí: los despliegues de un producto Next son inmutables, y comparar archivos no aportaría nada.
Cadena de suministro: prácticas recomendadas
- Instala desde el lockfile:
npm ci --ignore-scriptsen CI y en el servidor de build, y ejecuta a mano los scripts de los pocos paquetes que de verdad los necesitan. - Audita:
npm audit --omit=devfalla el pipeline si hay avisos conocidos, ynpx growtickets-next depslos deja a la vista del equipo en GrowTickets. - Cuarentena para lo recién publicado: una versión nueva de un paquete no se adopta el día que sale. Espera
unos días a que otros la prueben (y a que se retire si estaba envenenada).
npm config set min-release-age 7donde tu npm lo admita. - 2FA en tu cuenta de npm y tokens de publicación de sólo ese paquete, con caducidad.
- Un SBOM por versión desplegada (
npm sbom --sbom-format cyclonedx) guardado con el artefacto: cuando salga un aviso nuevo sabrás en minutos qué versión lo tiene. - Next.js al día: el bypass del middleware (CVE-2025-29927 y sus repeticiones) y la RCE de
next/ogestán corregidos en 15.5.27 y 16.3.8; por eso esas son las versiones que admite elpeerDependencies.
El fragmento de ESLint
Para que ningún componente cliente pueda importar este paquete por accidente:
{
files: ['**/*.tsx', '**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@growconnect/growtickets-next-server', '@growconnect/growtickets-next-server/*'],
message: 'Este paquete es del servidor: úsalo sólo desde un Route Handler, Server Component o Server Action.',
},
],
},
],
},
}server-only (en la entrada ./app) ya hace fallar el build si esto se salta; el fragmento de
arriba lo detecta ANTES, en el editor.
Dónde puede acabar un secreto en el navegador, y qué lo impide
| # | Vía de fuga | Barrera |
|---|---|---|
| 1 | next.config.js → env: { GROWTICKETS_API_KEY }: DefinePlugin lo incrusta en TODOS los bundles | growtickets-next check lo detecta |
| 2 | publicRuntimeConfig (Pages): viaja en __NEXT_DATA__ | growtickets-next check |
| 3 | Prefijo NEXT_PUBLIC_: se incrusta en el build y no hay vuelta atrás | getServerConfig() lanza; check; hay que rotar la clave |
| 4 | Un Client Component importa el paquete server | server-only rompe el build (./app); guardia browser (./pages y el núcleo); ESLint |
| 5 | Props de Server Component → Client Component (payload RSC) | SecretValue no es serializable; nunca se exporta la clave cruda |
| 6 | Props de getServerSideProps (HTML) | ídem |
| 7 | Closure de una Server Action | el cliente (createGrowTicketsClient) se crea DENTRO de la acción, nunca capturado fuera |
| 8 | Respuesta del proxy (cuerpo o cabeceras de la ingesta) | JSON re-serializado, >= 500 sustituido por 503, sólo content-type/cache-control propios |
| 9 | Mensajes de error | toPublicError() es la única salida hacia el navegador |
| 10 | Registros | redactByKey/redactByValue; los controles de la petición salen escritos (escapeControlChars); logger inyectable |
| 11 | Data Cache de Next 13/14 (historial de un usuario servido a otro) | cache: 'no-store', dynamic = 'force-dynamic', Cache-Control: private, no-store |
| 12 | Tarball de npm | files en lista blanca + scripts/audit-tarball.mjs en prepublishOnly |
| 13 | Commit de .env.local | .gitignore (lo escribe init) + gitleaks (pre-commit y CI) |
| 14 | headers() del provider de -react confundido con la clave | tipado y documentado como sesión del USUARIO, nunca la clave de API |
| 15 | CSRF: la clave usada por un tercero con la cookie de la víctima | Origin/Sec-Fetch-Site (también en GET) + Content-Type: application/json obligatorio en todo POST |
Cliente servidor-a-servidor
import { createGrowTicketsClient } from '@growconnect/growtickets-next-server';
export async function abrirTicketDesdeUnJob(reporter: { id: string; name: string }) {
const client = createGrowTicketsClient();
return client.abrirTicket(reporter, { subject: 'Generado desde un job' });
}Lanza en caso de fallo (al revés que el proxy, que devuelve el código HTTP tal cual). Sirve para un
Job o una Server Action que abre un ticket sin que haya nadie mirando una pantalla. La clave viaja
sólo en X-Api-Key, y el cliente se crea DENTRO de la función que lo usa — nunca en un módulo
capturado por un closure que pueda viajar al navegador.
