Entregar un fichero que se genera al pedirlo
Hay un tipo de endpoint que no encaja en las cuatro capas sin pensarlo un rato: el que no devuelve JSON sino un fichero, y encima un fichero que no existe hasta que alguien lo pide. El caso real es la exportación de datos del RGPD —todo lo que el sistema guarda sobre una persona, en un ZIP— pero el patrón vale igual para un informe en PDF o un volcado CSV.
Dos problemas, y ninguno es el empaquetado.
Problema 1: la app descarga con GET
El endpoint tiene que pedir la contraseña. Entregar este paquete a quien no es sería una brecha por sí misma, y una sesión robada no debería bastar.
Pero en la app la descarga de un fichero es un GET: expo-file-system no hace POST, y una
contraseña no viaja en la query. Y aunque se pudiera, el Authorization tampoco va en el GET que
el sistema operativo dispara al abrir una descarga.
La salida es partirlo en dos:
POST /users/me/export → valida contraseña, devuelve un token corto
GET /users/me/export/download → @Public(), ?token=…, streamea el fichero
El GET es @Public() porque su autorización es el token, no la cabecera. Es la misma forma que
la descarga de adjuntos, que ya resuelve esto mismo para los ficheros de un informe.
Por qué el token no es un JWT
El repo firma JWTs para media docena de cosas —verificación de correo, reset de contraseña, descarga de adjuntos— así que lo natural sería firmar otro. Aquí no.
Un JWT es válido hasta que caduca, y no hay forma de invalidarlo antes sin guardar algo. Quien intercepte la URL tiene tantas descargas como quepan en la ventana, y este fichero es todo sobre una persona. Lo que hace falta es un token de un solo uso, y para eso hay que guardarlo:
async issue(userId: string, ttlSeconds: number): Promise<string> {
const token = randomBytes(32).toString("hex");
await this.redis.set(this.key(token), userId, "EX", ttlSeconds);
return token;
}
async consume(token: string): Promise<string | null> {
// GETDEL es atómico: de dos descargas simultáneas con el mismo token, sólo
// una recibe el id.
return this.redis.getdel(this.key(token));
}
Detrás de un ExportTokenStore en application/ports, como cualquier otra pieza de
infraestructura — ver ¿repositorio o port?. El almacén ya está
ahí: es el Redis de las sesiones.
Dos detalles que no son obvios:
- El token se consume antes de recopilar nada. Si se recopilase primero, dos peticiones simultáneas con el mismo token construirían dos paquetes. El precio es que un fallo a mitad obliga a volver a pedir la exportación, que es el lado correcto en el que equivocarse.
- Este store sí propaga los errores. Si Redis no responde, no hay forma de comprobar el token, y el modo de fallo correcto es no entregar el fichero. Es lo contrario de una caché, donde un Redis caído tiene que degradar en silencio.
Problema 2: el fichero no cabe dos veces en memoria
La primera versión del diseño era la de siempre: una task construye el fichero, lo sube al almacenamiento, y se manda un enlace. Se descartó al mirar el código, y los tres motivos se repiten en cualquier repo con esta forma:
FileStorage.uploadrecibe unBuffer, no un stream. El fichero entero pasaría por memoria del contenedor, y dos veces: al construirlo y al subirlo.- No hay cola. Las tasks son
@nestjs/schedulea secas, así que “asíncrono” significa un modelo nuevo con su estado, un cron que lo sondee y una purga. Piezas móviles para algo que se usa unas pocas veces al año. - Un paquete así en reposo es el peor objeto del sistema: todo sobre una persona en un fichero, en el mismo bucket que el material real, esperando a que alguien recuerde purgarlo.
Así que se construye y se sirve en la misma petición. El puerto recibe el destino abierto en vez de devolver el fichero montado:
export interface ExportPackager {
write(bundle: DataExportBundle, out: Writable): Promise<void>;
}
Y la implementación escribe sobre él, leyendo cada adjunto del almacenamiento como stream:
const archive = archiver("zip", { zlib: { level: 6 } });
archive.pipe(out);
archive.append(renderIndex(bundle), { name: "index.html" });
archive.append(JSON.stringify(bundle.user, null, 2), { name: "data/user.json" });
for (const { name, key } of attachmentsOf(bundle)) {
archive.append(await this.storage.download(key), { name });
}
await archive.finalize();
Nivel 6, el de por defecto: los adjuntos son PDFs e imágenes, que ya vienen comprimidos, y subir a 9 gasta CPU mientras alguien espera.
Lo que cambia cuando ya has enviado la primera cabecera
Esta es la parte que se olvida. En cuanto el primer byte sale, un fallo ya no puede ser un error
HTTP: el cliente tiene un 200 y un ZIP a medias. No hay catch que arregle eso.
Lo que sí se puede hacer es que la ausencia no sea silenciosa:
try {
archive.append(await this.storage.download(key), { name });
} catch (err) {
this.logger.error(`Attachment "${key}" could not be read (${String(err)})`);
archive.append(`No se pudo leer este adjunto: ${name}\n`, {
name: `${name}.no-disponible.txt`,
});
}
Un adjunto que falta —borrado del bucket, clave antigua— degrada a una línea dentro del paquete en vez de tumbar la entrega. Y por eso el controlador pone las cabeceras antes de empezar:
@Public()
@Get("me/export/download")
async download(@Query("token") token: string, @Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "application/zip");
res.setHeader("Cache-Control", "no-store"); // nada de caché intermedia
const filename = await this.streamExportUC.execute(token ?? "", res);
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
}
Recopilar: un select, no seis repositorios
El contenido del paquete cruza ocho entidades. Recorrer sus repositorios serían decenas de viajes
para armar un árbol que Prisma trae de una vez, así que esto es un read model, como las
estadísticas: un puerto en domain/ que devuelve exactamente la forma que se serializa, y una
implementación con un select anidado.
La razón de fondo no es el rendimiento:
El
selectes el contrato. Lo que no se nombra ahí no puede salir del paquete el día que alguien añada una columna al schema.
En un endpoint que entrega todo sobre una persona, eso es la diferencia entre una promesa y un
comentario. password, el secreto de 2FA y el token de push no están en el select, así que no hay
forma de que se filtren por descuido. Un mapper daría la misma salida hoy y no esa garantía mañana.
Problema 3: construir el fichero es el ataque
Nadie tiene motivo para pedir su paquete cien veces, y da igual: el endpoint lee ocho entidades y
streamea todos los adjuntos, así que pulsar sin parar es la única forma de hacer daño con él. El
ThrottlerGuard del repo no lo cubre: cuenta por IP en ventanas de un minuto, y en una red
compartida pena a quien no ha pedido nada.
Un contador por usuario en Redis, en el mismo sitio donde ya vive el token:
const results = await this.redis
.multi()
.incr(key)
.expire(key, windowSeconds, "NX") // ← el NX es la línea importante
.ttl(key)
.exec();
expire ... NX, sólo en el primer intento. Renovando el TTL en cada uno, alguien que pulse cada 23 horas nunca vería caducar su clave: el cupo pasaría de ventana a condena perpetua.- Ventana deslizante, no día natural. Quien pide a las 23:59 tendría cupo otra vez un minuto después.
INCRcuenta y decide en una operación. Con unCOUNTsobre una tabla, dos peticiones simultáneas pueden ver las dos el cupo libre y pasar las dos; cerrarlo pediría aislamiento serializable o un lock.- Devuelve el TTL restante, para que la respuesta diga cuándo volver en vez de sólo “no”.
Tres decisiones que no se ven en el código
El cupo no es 1 aunque la política sea “una al día”. El token es de un solo uso, así que una descarga que se corta a medias —se pierde la cobertura, alguien cierra la pestaña— gasta el intento sin haber entregado nada. Con el límite en uno, ese usuario se queda fuera veinticuatro horas por un fallo de red. Tres deja margen para reintentar y sigue cerrando lo que importa.
Se cuenta después de comprobar la contraseña, y en el paso 1. Si contase los intentos fallidos, teclear mal tres veces dejaría a alguien sin sus datos. Y se cuenta al emitir el token, no al descargar: sin token no hay descarga, así que limitar la puerta limita el pasillo.
La puerta del personal no lleva cupo. La abre alguien de dentro atendiendo una solicitud, y bloquearla porque el interesado ya pidió su copia por su cuenta sería lo contrario de lo que hace falta. Dos puertas al mismo caso de uso pueden tener políticas distintas — eso es precisamente por qué son dos.
No lo cuentes con el registro de auditoría, aunque vayas a escribir una fila ahí de todos modos. Además del problema de atomicidad, ataría un límite operativo a una tabla de evidencia con su propia retención: podarla resetearía los cupos, y reclasificar qué se audita cambiaría el límite sin que nadie lo tocase. Redis decide, la auditoría deja constancia.
Elegir la librería mirando el module del tsconfig
archiver@8 es sólo ESM. El repo compila a CommonJS, así que ni jest la carga ni require() de
un paquete ESM es estable en Node 22 —depende de la versión menor—. La versión 7 es CommonJS y tiene
la misma API de siempre.
Merece la pena generalizarlo, porque pasa con cualquier dependencia nueva de esta familia: antes de
instalar la última, comprueba si el paquete es ESM puro. Un npm ls no lo dice; el exports de su
package.json y un require() en la consola, sí.
Qué probar
El paquete se genera en vivo y no hay forma de que el compilador diga si sale bien, así que los tests cubren lo que rompería sin avisar:
| Qué | Por qué |
|---|---|
| El token se consume antes de recopilar | Si alguien invierte el orden, dos descargas simultáneas dan dos paquetes |
| Un token gastado, caducado o inventado no recopila nada | Es el control de acceso entero |
| Sin contraseña no se emite token | Igual |
Un adjunto ilegible acaba en un .no-disponible.txt |
Que el degradado exista de verdad |
| Los nombres de fichero se sanean | Un nombre con / crea carpetas fantasma dentro del ZIP |
El ZIP empieza por PK y contiene las rutas esperadas |
Los nombres de entrada viajan en claro, así que se leen del buffer sin descomprimir |
| El cupo no se gasta con una contraseña incorrecta | Lo contrario deja a alguien sin sus datos por teclear mal |
El TTL del contador se pone con NX |
Sin eso el cupo no caduca nunca, y no se nota hasta que alguien se queja |
Y una comprobación que no es un test: generar un ZIP de verdad y abrirlo. Un unzip -l y un
grep por password sobre los data/*.json tardan diez segundos y confirman lo único que importa
de este endpoint.