Arquitectura
Backend compartido multi-tenant, N frontends y flujo de servicios.
En esta página
BloomX se compone de N frontends (una app Next.js por empresa o dominio) y un backend compartido multi-tenant. Cada frontend es dueño de sus usuarios, correos y base de datos; el backend compartido aporta el registro de dominios, la configuración de marca, el catálogo y la ejecución de extensiones, y los pagos.
Visión general#
- Frontend (
bloomx): interfaz, API propia (/api/*), base de datos PostgreSQL propia (DATABASE_URL), almacenamiento de adjuntos y el envío/recepción por Resend. - Backend compartido (
bloomx-backend):GET /api/config(marca y extensiones del dominio), registro (/api/auth/register,register-domain,verify-domain), instalación de extensiones, ejecución sandboxed de extensiones (/api/extension/execute,/api/extension/hooks), pagos y el almacén público de logos. - Servicios externos: PostgreSQL (por ejemplo Neon), S3/B2/R2, Resend, Upstash Redis (opcional para rate limit distribuido) y, si los activas, Google/Zoom/Slack/Notion/HubSpot/Trello por OAuth.
Backend compartido y multi-tenant#
- Alta abierta: cualquiera puede registrar un manager (
POST /api/auth/register) o un dominio (POST /api/auth/register-domain+POST /api/auth/verify-domaincon un OTP de 6 dígitos enviado por correo; con límites de intentos). Para un despliegue privado desactiva el alta conALLOW_OPEN_REGISTRATION=falseen el backend. - **CORS
*intencional**: el backend responde siempreAccess-Control-Allow-Origin: *y sin credenciales. No es un agujero: la autenticación no usa cookies del navegador sino firmas por dominio (las cookies de manager de Lucia solo las usa el panel de gestión de dominio). No existe variable para restringirlo. - Un dominio = un tenant: el tenant se identifica por la cabecera
X-BloomX-Domain. Cada tenant tiene su marca (Domain.theme), sus extensiones instaladas, sus credenciales de extensión cifradas y, opcionalmente, su clave pública Ed25519.
Confianza sin secretos globales#
Frontend y backend no comparten ningún secreto. Esto permite que cualquier empresa levante su frontend sin coordinar claves con el operador del backend:
| Necesidad | Mecanismo | Detalle |
|---|---|---|
| Sesión de usuario | NEXTAUTH_SECRET propio de cada frontend | No se comparte con el backend; el backend ya no valida JWT del frontend. |
| Autenticación interna servidor-a-servidor del frontend (webhook → process-attachments, cron) | Clave derivada por HKDF-SHA256 de NEXTAUTH_SECRET | info bloomx-internal-v1, cabecera x-internal-secret. Solo vale dentro de esa instancia. INTERNAL_SECRET solo se acepta si lo defines (compat). |
| Frontend → backend | Firma Ed25519 por dominio | BLOOMX_DOMAIN_PRIVATE_KEY en el frontend; la clave pública se registra en el backend (Domain.signingPublicKey). Ver protocolo de firma. |
Backend → frontend (puente services.mail, Organizer) | executionGrant firmada por la instancia | El backend no firma nada: reenvía la concesión (120 s, extensión + versión + usuario + permisos) que la instancia firmó con su BLOOMX_DOMAIN_PRIVATE_KEY y esta la verifica con su propia clave. Legado deprecado: firma del backend (BACKEND_SIGNING_PRIVATE_KEY), apagable con BLOOMX_ACCEPT_BACKEND_SIGNATURE=false. |
| Dominio que aún no tiene clave | Modo legado | Cabeceras x-bloomx-domain, x-user-id, x-user-email sin firma, con rate limit y sin services privilegiados (services.mail, EMAIL_RECEIVED, CRON); las extensiones siguen recibiendo sus credenciales (dominio, heredadas o del servidor) y cada ejecución con credenciales se audita; el backend responde X-BloomX-Auth: legacy. |
Qué ya no hace falta configurar
INTERNAL_SECRET, EXTENSION_HOOKS_SECRET y FRONTEND_INTERNAL_URL ya no se usan entre frontend y backend. Tampoco hay NEXTAUTH_SECRET en el backend. Si los ves en instrucciones antiguas, ignóralos.
Modo firmado frente a modo legado#
| Firmado (dominio con clave) | Legado (sin clave) | |
|---|---|---|
| Autenticación | Firma Ed25519 obligatoria (401 si falta o es inválida) | Cabeceras sin firma |
execute de extensiones instaladas | Sí | Sí (con las mismas credenciales; auditado) |
EMAIL_PRE_SEND | Sí | Sí |
EMAIL_RECEIVED y CRON | Sí (los de su dominio) | No (403) |
| Credenciales de extensiones | Sí | Sí (mismo orden: dominio, heredadas, servidor) |
services.mail (puente firmado) | Sí | No |
| Rate limit | Por endpoint | 60/min por IP y dominio; 240/min por dominio |
Flujo del correo#
- Entrante: Resend recibe (registro MX de tu dominio) y llama a
POST /api/webhooks/resend. Si definesWEBHOOK_SECRETse exige firma Svix válida; sin él se acepta sin firma (cada organizador decide). El frontend guarda el correo en PostgreSQL y los adjuntos en el almacenamiento, procesa adjuntos en segundo plano y notifica a la interfaz por SSE. - Saliente: la interfaz llama a
POST /api/emails, que valida propiedad del remitente, límites y adjuntos, ejecuta el hookEMAIL_PRE_SEND(DLP) contra el backend y envía por Resend conIdempotency-Key. - Eventos: rebotes y quejas llegan a
POST /api/webhooks/resend-events(conRESEND_WEBHOOK_SECRETopcional) y alimentan la lista de supresión. - Gmail/Outlook/IMAP no están soportados como buzones: el correo entra por Resend. Sí existe sincronización de calendario y contactos de Google.
Datos y estado#
- Frontend: PostgreSQL vía Prisma más SQL crudo. El esquema se garantiza con
npm run db:ensure(aditivo, con advisory locks). Ver Operación. - Backend: PostgreSQL propio (dominios, managers, extensiones, instalaciones, transacciones). Su esquema se aplica con
npm run prisma:push. - Adjuntos y activos: S3-compatible; en el frontend
S3_*, en el backendB2_*. Se sirven mediante URLs firmadas. Ver Almacenamiento. - Estado efímero: nonces anti-replay y rate limit en memoria por instancia (el rate limit del frontend puede ser global con Upstash).
Fronteras de seguridad#
- El navegador solo habla con su frontend; el frontend firma cuando habla con el backend.
- Las extensiones corren en el backend, dentro de un sandbox
worker_threads, con red filtrada (safe-fetch). No es una frontera fuerte: ver Extensiones. - Los secretos de terceros (claves de Resend por dominio, credenciales de extensiones) se cifran en el backend con
DATA_ENCRYPTION_KEYy nunca vuelven al navegador. - Detalle completo en Seguridad.