ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todo el contenido

Credenciales por tenant

notification-worker
último cambio 2026-09-25

Las credenciales de proveedor —la clave de Resend, la cuenta de servicio de FCM— se guardan por tenant, cifradas, en TenantProviderConfig, con clave única (tenantId, channel).

Siempre por tenantId

El worker las busca con el tenantId del trabajo, el tenant de entrega, y nunca con sourceTenantId. La clínica de sourceTenantId puede aportar su propia plantilla (la elige la API al encolar, con respaldo a la del tenant de entrega; ver tenantId contra sourceTenantId), pero no credenciales ni remitente. Con un único tenant de entrega por producto, unas credenciales guardadas para una clínica nunca se usan.

Consecuencia práctica: una plantilla EMAIL de clínica se envía con la clave de Resend del tenant de entrega, así que su templateId tiene que existir en esa cuenta de Resend.

No viajan por la cola

Es la regla que explica el diseño, y está escrita en el contrato: todo lo demás —remitente, id de plantilla— se resuelve en el productor para que el worker no relea filas, pero la credencial es la excepción y se queda detrás de la base de datos y del descifrado.

El motivo es que una carga de BullMQ es una entrada de Redis: se queda ahí mientras el trabajo vive, aparece en Bull-Board y sobrevive a los reintentos. Un secreto no debe estar en ninguno de esos sitios.

La api cifra, el worker descifra

secret-cipher.ts existe en los dos repos y no es el mismo fichero, a diferencia del contrato de cola. Cada uno exporta solo la mitad que le corresponde:

Repo Exporta Puede
notification-api encryptSecret() Guardar credenciales, no leerlas
notification-worker decryptSecret() Leerlas, no escribirlas

Es aes-256-gcm con el mismo formato a ambos lados —iv.tag.ciphertext, cada parte en base64— y la misma clave, PROVIDER_ENCRYPTION_KEY (32 bytes en base64). Si las claves no coinciden entre servicios, guardar funciona y enviar falla al descifrar.

No unifiques esos dos ficheros “para no duplicar”: la asimetría es la que impide que el worker escriba credenciales.

Email y push no se comportan igual

// Email: clave del tenant si la hay, si no la global
async resolveResendKey(tenantId) {
  const config = await this.prisma.tenantProviderConfig.findUnique(...);
  if (config) return decryptSecret(config.credentials);
  const fallback = this.env.RESEND_API_KEY;
  if (!fallback) throw new NotFoundError(...);
  return fallback;
}

// Push: cuenta de servicio del tenant, obligatoria
async resolveFcmCredentials(tenantId) {
  const config = await this.prisma.tenantProviderConfig.findUnique(...);
  if (!config) throw new NotFoundError(...);
  return JSON.parse(decryptSecret(config.credentials));
}
Respaldo global Si falta
Email (Resend) RESEND_API_KEY Solo falla si tampoco hay global
Push (FCM) ninguno Falla siempre

La diferencia es razonable —una cuenta de servicio de FCM es por proyecto de Firebase, no hay una “global” que tenga sentido— pero explica un síntoma habitual: un tenant recién creado manda emails y no manda push. No es un fallo del alta, es que le falta la configuración de FCM.

La de FCM además se guarda como JSON dentro del texto cifrado, así que se descifra y se parsea; la de Resend es una cadena suelta.

Por qué molestarse, si el global funciona

En push no hay elección: sin la cuenta de servicio del tenant no se envía. En email sí la hay —el RESEND_API_KEY global cubre a todos— así que la pregunta es por qué querrías darle una key propia a un tenant.

Por la analítica. Resend segmenta las métricas de envío (deliverability, aperturas, bounces) por API key, así que una key por tenant da sus números aislados sin montar nada encima. Y la key se puede crear desde la propia API de Resend, así que el alta de un tenant puede auto-provisionarla.

Importante: es por-key dentro de una sola cuenta, no una cuenta por tenant. Los dominios se verifican una vez y el billing sigue centralizado.

La salvedad: los rate-limits de Resend son por cuenta, no por key. Las keys aíslan la analítica, no el throughput — un tenant que envíe mucho sigue consumiendo el límite de todos.

Quién resuelve, y dónde

Otra asimetría, esta interna del worker:

Por eso MailerService no es el homólogo de FcmProvider pese a compartir carpeta: el homólogo es ResendService, que es quien implementa ChannelProvider.