Credenciales por tenant
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:
- Push: la resuelve
push.processordirectamente. - Email: la resuelve
MailerService, una capa intermedia que solo existe en este canal.
Por eso MailerService no es el homólogo de FcmProvider pese a compartir carpeta: el homólogo es
ResendService, que es quien implementa ChannelProvider.