ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Entregar un fichero que se genera al pedirlo

template-api
último cambio 2026-09-25

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:

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:

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 select es 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();

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.