API del backend y firma
Backend compartido y protocolo de firma v1 con código Node.
En esta página
API del backend compartido (bloomx-backend). Para la API de cada frontend ve a API del frontend. Las respuestas de error son JSON {"error": "..."}; CORS es siempre * sin credenciales y se exponen las cabeceras X-BloomX-Auth y Retry-After.
Formas de autenticación#
- Cookie de manager (Lucia,
auth_session): panel de gestión de dominios. Registro y login de managers. - Firma Ed25519 por dominio (
X-BloomX-*): extensiones, hooks y su listado. Es el mecanismo principal entre un frontend y el backend. - Modo legado: dominios sin clave registrada, con privilegios reducidos (Arquitectura).
- Operador:
Authorization: Bearer <BACKEND_CRON_SECRET>(opcional) para ejecutar hooksCRONde todos los dominios; y Basic Auth de plataforma (ADMIN_EMAIL/ADMIN_PASSWORD) para publicar extensiones en/api/admin/extensions(60/15 min por IP).
Endpoints#
| Ruta | Autenticación | Detalle y errores |
|---|---|---|
GET /api/config?domain= | Pública | Marca (theme, logo, displayName) y extensiones habilitadas del dominio. Dominio desconocido: 200 con la config por defecto. TOP_DOMAIN, si existe, prevalece sobre ?domain. |
POST /api/auth/register | Pública (403 si ALLOW_OPEN_REGISTRATION=false) | {email, password} (8–256). 5/h por IP. 400 / 429 / 403. |
POST /api/auth/register-domain | Pública | {email, domain, password, resendApiKey}. 5/h por IP. 409 si email o dominio ya existen. |
POST /api/auth/verify-domain | Pública | {email, otp, signingPublicKey?}. 30/15 min por IP y 5 intentos por email. 400 / 404 / 409 / 429. |
POST /api/auth/login · GET /api/auth/me · POST /api/auth/logout | Cookie de manager (Lucia) | Login: 200/15 min por IP y 10/15 min por cuenta; 401 Invalid credentials. me siempre devuelve 200 ({user:null} sin sesión). |
GET/POST /api/manager/domain-key | Manager dueño del dominio | Consulta ({registered, fingerprint}) o registra/rota la clave. 30/min. 401 / 403 / 400. |
POST /api/manager/extensions/install · uninstall | Manager dueño del dominio | Instalar (402 PAYMENT_REQUIRED si es de pago sin transacción aprobada) y desinstalar (borra credenciales y revoca tokens OAuth, mejor esfuerzo). |
GET/PUT /api/admin/domain | Manager | Lee o guarda displayName (≤120), logo y theme (saneado; landing incluido). 400 si el tema es demasiado grande. |
GET/PUT /api/extension/settings | Manager dueño | Credenciales por dominio: GET devuelve {keys:[{name, configured, set, updatedAt?, last4?}]} (write-only: nunca valores; last4 solo con revealLast4); PUT {domainId, extensionId, credentials} ("" = sin cambio, null = borrar, auditado; pattern/format del campo validados: 422). 60/min. 503 sin DATA_ENCRYPTION_KEY en producción. |
GET /api/extensions | Pública (sin trigger) / firmada (con trigger) | Catálogo gratuito, o lista de handlers de un hook (?trigger=EMAIL_PRE_SEND). |
POST /api/extension/execute | Firmada (o legado) | {extensionId, action, params?, context?}. Cuerpo ≤1 MiB (413), 60/min. 400 / 401 AUTH_REQUIRED / 404 / 408 timeout / 500. |
POST /api/extension/hooks | Firmada (EMAIL_PRE_SEND también en legado) | {event: EMAIL_PRE_SEND|EMAIL_RECEIVED|CRON, context?, schedule?}. 120/min. 403 para EMAIL_RECEIVED/CRON sin firma. |
GET /api/storage/<clave> | Pública | Sirve objetos del bucket (con STORAGE_PUBLIC_PREFIXES solo esos prefijos). Solo imágenes inline; lo demás como descarga. 300/min. |
POST /api/payments/orders · orders/capture · GET orders/[id] · /api/payments/subscriptions/* · GET /api/payments/billing/* | Firmada (solo dominios con clave) | Compra única, suscripciones y facturación del dominio. Modo legado: 403 signature_required. Sin PayPal: 503 payments_not_configured; sin tablas: 503 payments_unavailable. Ver Guía de facturación. |
/api/payments/paypal/link/* · /api/payments/paypal/account · POST /api/payments/webhook · /api/cron/payments | Firmada / PayPal (firma verificada) / Bearer de cron | Vínculo de la cuenta PayPal (Log in with PayPal), webhook verificado con verify-webhook-signature y cron de conciliación y pagos (PAYMENTS_CRON_SECRET). Ver Configuración de PayPal. |
/api/developer/* (overview, validate, submissions, terms, pricing, extensions/[id]/test-install|yank) | Firmada (solo dominios con clave) | Portal de desarrollador: validar, enviar a revisión, precios, términos y retirar. Los proxies del frontend son /api/admin/billing y /api/admin/developer (nivel 4 y step-up). Ver Guía de desarrollador. |
GET /.well-known/bloomx-backend-key.json | Pública | Clave pública Ed25519 del backend; 404 {available:false} si no tiene BACKEND_SIGNING_PRIVATE_KEY. |
Ejemplos con curl#
# 1) Alta del dominio: el backend envía un OTP de 6 dígitos (válido 15 min) al correo
curl -sS -X POST "$BACKEND/api/auth/register-domain" \
-H 'content-type: application/json' \
-d '{"email":"admin@mi-empresa.com","domain":"mail.mi-empresa.com","password":"<contraseña>","resendApiKey":"<tu-clave-de-resend>"}'
# => {"success":true,"message":"OTP sent to email"}
# 2) Verificar y, opcionalmente, registrar la clave pública Ed25519 de esta instancia
curl -sS -X POST "$BACKEND/api/auth/verify-domain" \
-H 'content-type: application/json' \
-d '{"email":"admin@mi-empresa.com","otp":"123456","signingPublicKey":"<clave-publica-base64url>"}'
# => {"success":true,"manager":{"id":"...","email":"...","role":"OWNER"},"domain":{"id":"...","name":"mail.mi-empresa.com"}}# Público, sin autenticación
curl -sS "$BACKEND/api/config?domain=mail.mi-empresa.com"
# => {"config":{"id":"...","name":"...","displayName":"...","theme":{...},"logo":"..."},
# "extensions":[{"id":"core-dlp","name":"...","template":{...},"settings":{}}]}
# Clave pública del backend (solo si tiene BACKEND_SIGNING_PRIVATE_KEY; si no, 404 {"available":false})
curl -sS "$BACKEND/.well-known/bloomx-backend-key.json"
# => {"version":1,"alg":"Ed25519","kid":"...","publicKey":"...","jwk":{"kty":"OKP","crv":"Ed25519","x":"...","kid":"..."}}# Con la sesión de manager (cookie auth_session) dueño del dominio: registrar o ROTAR la clave pública
curl -sS -X POST "$BACKEND/api/manager/domain-key" \
-H 'content-type: application/json' -b 'auth_session=<cookie>' \
-d '{"domainId":"<id>","signingPublicKey":"<clave-publica-base64url>"}'
# => {"success":true,"registered":true,"fingerprint":"<16 hex>"}Protocolo de firma v1#
Cada petición de un dominio con clave registrada se firma con Ed25519 sobre esta cadena, con las líneas unidas por \n (los \r y \n internos de cada valor se sustituyen por un espacio):
BLOOMX-SIG-V1- Método HTTP en mayúsculas
- Ruta + query tal como la ve el receptor (
pathname+search) sha256del cuerpo crudo, en hexadecimal (cadena vacía si no hay cuerpo)- Dominio (audiencia) en minúsculas
- Timestamp en segundos Unix
- Nonce
x-user-id(o vacío)x-user-email(o vacío)x-bloomx-callback(o vacío)
| Cabecera | Contenido |
|---|---|
X-BloomX-Domain | Dominio del tenant. Obligatoria en modo firmado (nunca se deduce del Host). |
X-BloomX-Signature | Firma Ed25519 de 64 bytes en base64url |
X-BloomX-Timestamp | Segundos Unix (^\d{9,12}$); ventana ±120 s |
X-BloomX-Nonce | ^[A-Za-z0-9_-]{16,64}$; se consume una vez por dominio (anti-replay) |
X-User-Id, X-User-Email | Identidad del usuario, incluida en la firma |
X-BloomX-Callback | Opcional: origen https de la instancia (mismo dominio o subdominio) para el puente backend→frontend |
// sign-request.mjs (Node >= 18, solo node:crypto)
import crypto from 'node:crypto';
const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL; // https://backend.example.com
const DOMAIN = 'mail.mi-empresa.com'; // el dominio (tenant) registrado
const PRIVATE_KEY = crypto.createPrivateKey(
(process.env.BLOOMX_DOMAIN_PRIVATE_KEY ?? '').replace(/\\n/g, '\n'), // PEM PKCS8 en una linea
);
export async function signedFetch(path, { method = 'POST', body, userId = '', userEmail = '', callback = '' } = {}) {
const raw = body === undefined ? '' : JSON.stringify(body); // los MISMOS bytes que se envian
const url = new URL(path, BACKEND);
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(18).toString('base64url'); // 24 caracteres: cumple ^[A-Za-z0-9_-]{16,64}$
const canonical = [
'BLOOMX-SIG-V1',
method.toUpperCase(),
url.pathname + url.search, // ruta + query tal como la ve el backend
crypto.createHash('sha256').update(raw).digest('hex'), // sha256 del cuerpo crudo (vacio en GET)
DOMAIN.toLowerCase(),
timestamp,
nonce,
userId,
userEmail,
callback, // https://<tu-dominio> o ''
].join('\n');
const signature = crypto.sign(null, Buffer.from(canonical, 'utf8'), PRIVATE_KEY).toString('base64url');
return fetch(url, {
method,
headers: {
'content-type': 'application/json',
'X-BloomX-Domain': DOMAIN,
'X-BloomX-Signature': signature,
'X-BloomX-Timestamp': timestamp,
'X-BloomX-Nonce': nonce,
'X-User-Id': userId,
'X-User-Email': userEmail,
...(callback ? { 'X-BloomX-Callback': callback } : {}),
},
body: raw || undefined,
});
}
// Ejemplo: ejecutar una acción de una extensión instalada
const res = await signedFetch('/api/extension/execute', {
userId: 'user_123',
userEmail: 'ana@mi-empresa.com',
body: { extensionId: 'core-hello', action: 'hello', params: { name: 'Ana' } },
});
console.log(res.status, await res.json());// Lo que hace el backend (resumen de src/lib/domain-auth.ts)
// 1. domain = X-BloomX-Domain (obligatorio si el dominio tiene clave)
// 2. |now - timestamp| <= 120 s y nonce ^[A-Za-z0-9_-]{16,64}$
// 3. canonical = mismo string de arriba, con el cuerpo crudo recibido
// 4. crypto.verify(null, canonical, publicKeyDelDominio, base64url(signature)) // firma de 64 bytes
// 5. el nonce (domain:nonce) se consume UNA vez, solo si la firma es validaRegistrar la clave pública#
- Genera el par en el frontend:
node scripts/gen-domain-keypair.mjs(o--json). La privada va enBLOOMX_DOMAIN_PRIVATE_KEYdel frontend; nunca la subas a git. - Registra la pública: campo opcional
signingPublicKeyenPOST /api/auth/verify-domainal dar de alta el dominio, oPOST /api/manager/domain-keycon la sesión del manager dueño (sirve para rotar). Se acepta PEM SPKI o base64/base64url de 32 bytes. - La columna se crea de forma perezosa la primera vez, o a mano (aditiva):
ALTER TABLE "Domain" ADD COLUMN IF NOT EXISTS "signingPublicKey" TEXT;Al registrar la clave, el modo legado termina
Desde ese momento el backend exige firma para ese dominio: un 401 significa firma ausente, inválida, caducada (±120 s de desfase de reloj) o nonce repetido. Comprueba que el reloj de tu servidor esté sincronizado (NTP).
Anti-replay por instancia
El almacén de nonces está en la memoria de cada instancia del backend (TTL 245 s, hasta 50 000 entradas). Con varias instancias, un replay dentro de la ventana contra otra instancia distinta podría aceptarse; la firma incluye método, ruta y cuerpo, así que solo se puede repetir la misma petición exacta.
Sentido inverso: backend → frontend#
El backend NO firma hacia el frontend. Cuando la instancia pide ejecutar una extensión adjunta una executionGrant (cuerpo executionGrant / executionGrants, o cabecera X-BloomX-Grant en /api/ext/*) firmada con su propia clave de dominio; el backend comprueba que corresponde a la ejecución (dominio autenticado, extensión, versión, usuario) y la reenvía tal cual a /api/internal/** (services.mail, storage, notify, ai, oauth...). La instancia la verifica sin red. Capacidad ext.grants.v1. Legado deprecado: firma del backend con BACKEND_SIGNING_PRIVATE_KEY.
Códigos de error y límites#
| Código | Significado |
|---|---|
| 400 | Cuerpo o campos inválidos; Unable to resolve domain |
| 401 | Unauthorized: firma ausente o inválida (dominio con clave), sesión ausente o Invalid credentials |
| 402 | PAYMENT_REQUIRED: extensión de pago sin transacción aprobada |
| 403 | Sin permiso (dominio ajeno, hook privilegiado sin firma, registro cerrado) |
| 404 | Dominio, extensión o solicitud pendiente inexistente |
| 408 | Timeout de una extensión |
| 409 | Conflicto (email o dominio ya registrado) |
| 413 | Cuerpo mayor de 1 MiB (execute, hooks) |
| 429 | Rate limit (cabecera Retry-After); el límite del backend es solo en memoria por instancia |
| 503 | BD no disponible (Retry-After: 5), pagos o cifrado no configurados |