Crear una extensión
Paso a paso con un ejemplo mínimo.
En esta página
Guía práctica para crear una extensión. Antes, lee el contrato y el modelo de seguridad.
Paso a paso#
- Crea
bloomx-extensions/<nombre>/manifest.jsoncon, como mínimo,id,nameyversion. - Si necesita lógica de servidor, crea
server.jsy declara enapi.functionscada handler que use la interfaz, un mount o un hook. - Cada handler tiene la firma
handler(ctx): un solo parámetro. Leectx.args,ctx.env,ctx.user,ctx.domain… - Si necesita secretos (claves de API), decláralos con
"permissions": ["ENV_READ:MI_CLAVE"]; cada empresa los configura en el panel (extensiones → credenciales). Nunca los pongas en el manifest ni en el código. - Valida:
npm testenbloomx-extensionscomprueba el schema de cada manifest y el contrato de los handlers. - Publica en el backend y luego instálala en un dominio (ver abajo).
- Prueba en la interfaz: el frontend recibe las extensiones del dominio por
GET /api/configy pinta los mounts.
El test de contrato cuenta las extensiones
tests/contract.test.mjs comprueba ids.length igual a 21. Al añadir una carpeta nueva, ese test falla hasta que actualices la constante. Cada carpeta con manifest.json debe validar contra el schema.
Ejemplo mínimo#
Un botón en la barra del editor que llama a una función del servidor y muestra el resultado.
{
"manifestVersion": "1.0",
"id": "core-hello",
"version": "1.0.0",
"name": "Hello",
"description": "Ejemplo mínimo",
"permissions": [],
"api": {
"runtime": "nodejs",
"entry": "server.js",
"functions": { "hello": { "handler": "hello", "timeout": 5000 } }
},
"mounts": [
{
"point": "COMPOSER_TOOLBAR",
"component": {
"type": "BUTTON",
"props": {
"label": "Hello",
"variant": "ghost",
"onClick": {
"action": "CALL_BACKEND",
"function": "hello",
"args": { "name": "${context.subject}" },
"onSuccess": { "action": "TOAST", "message": "${result.message}" },
"onError": { "action": "TOAST", "message": "${error}", "variant": "error" }
}
}
}
}
]
}// bloomx-extensions/hello/server.js
module.exports = {
hello: async (ctx) => { // UN solo parámetro
const name = String((ctx.args && ctx.args.name) || 'mundo').slice(0, 100);
return { success: true, message: `Hola ${name} desde ${ctx.domain.displayName}` };
},
};${context.subject}y${result.message}son expresiones que evalúa el intérprete del frontend (sineval).- Los nombres
label,variant,onClick,onSuccess,onErrorymessagesiguen los de las extensiones existentes (composer-helper,organizer). No se ejecutó este ejemplo contra un backend real: úsalo como punto de partida y ajusta según el renderer.
Un hook#
// Un hook que bloquea el envío si el asunto contiene una palabra prohibida.
// manifest: "intercepts": [{ "point": "EMAIL_PRE_SEND", "handler": "check", "priority": "HIGH", "onError": "block" }]
// y "api.functions": { "check": { "handler": "check", "timeout": 3000 } }
module.exports = {
check: async (ctx) => {
const banned = String(ctx.env.BANNED_WORDS || '').split(',').map((w) => w.trim().toLowerCase()).filter(Boolean);
const text = String(ctx.subject || '').toLowerCase();
const hit = banned.find((w) => text.includes(w));
return hit ? { stop: true, message: 'Palabra no permitida: ' + hit } : { success: true };
},
}; // declara "permissions": ["ENV_READ:BANNED_WORDS"] y configura BANNED_WORDS en el panelDevolver {stop: true, message} bloquea el envío (422). {modify: {subject, html, text}} reescribe el contenido. Ver Hooks para la semántica y los límites (MONITOR, onError).
Usar servicios y eventos#
Además de fetch, una extensión puede usar los servicios autorizados (services.calendar, contacts, storage, notify, formats) y suscribirse a los eventos de ciclo de vida (CONTACT_SAVED, EMAIL_SENT…). Referencia completa: servicios, formats y eventos.
- Declara en
permissionssolo lo necesario:CALENDAR_READ/CALENDAR_WRITE,CONTACTS_READ/CONTACTS_WRITE,STORAGE,NOTIFY,FORMATS. El administrador verá la lista legible y debe confirmarla al instalar (ver permisos). - En el handler toma el servicio de
ctx.servicesy comprueba que existe: solo hay servicios en dominios firmados; en modo legado no existen. - Llama con un objeto de argumentos (
calendar.listEvents({from, to})). EluserIdnunca se pasa: lo fija el host. - Envuelve las llamadas en
try/catch: los errores empiezan porCALENDAR_,CONTACTS_,STORAGE_,NOTIFY_oFORMATS_(..._PERMISSION_DENIED,..._INVALID_ARGS,..._ERROR: <code>,..._CALL_BUDGET…). - Para reaccionar a un evento, añade en el manifest
"hooks": [{ "point": "CONTACT_SAVED", "handler": "onContactSaved" }]y declara el handler enapi.functions(contimeout≤ 10 s). Los eventos no bloquean ni modifican nada, y lo que hagas conservices.*no los vuelve a disparar. - Para pintar un panel en un punto nuevo (
EMAIL_READER_SIDEBAR,SETTINGS_PANEL…) mira antes si ya está montado en la interfaz: la tabla de puntos de montaje lo indica.
// bloomx-extensions/agenda/server.js
// manifest: "permissions": ["CALENDAR_READ", "FORMATS", "STORAGE"]
module.exports = {
today: async (ctx) => {
const { calendar, formats, storage } = ctx.services || {};
if (!calendar) return { success: false, code: 'CALENDAR_SERVICE_UNAVAILABLE' }; // modo legado o sin servicio: degradar
const from = new Date(); from.setUTCHours(0, 0, 0, 0);
const to = new Date(from.getTime() + 86400000);
try {
const { events } = await calendar.listEvents({ from: from.toISOString(), to: to.toISOString(), limit: 20 });
const rows = [];
for (const e of events) {
const start = formats ? await formats.formatDate({ value: e.startsAt, locale: 'es-ES', style: 'time' }) : e.startsAt;
rows.push({ title: e.title, start });
}
if (storage) await storage.set({ key: 'last-run', value: { at: new Date().toISOString(), count: rows.length } }).catch(() => {});
return { success: true, rows };
} catch (error) {
return { success: false, code: String(error.message).split(':')[0] }; // p. ej. CALENDAR_PERMISSION_DENIED
}
},
};Buenos ejemplos reales
summarizer (services.calendar y storage, con confirmación del usuario), hubspot (services.contacts, storage y el hook CONTACT_SAVED), notion y trello (services.formats.renderTemplate para el título) y signature (renderTemplate y sanitizeHtml). Todos degradan sin el servicio.
Publicar e instalar#
# 1) Validar localmente (Node >= 22.6)
cd bloomx-extensions && npm test
# 2) Publicar en el backend (lee ../bloomx-extensions; necesita B2_* y DATABASE_URL reales en .env)
cd ../bloomx-backend && node --env-file=.env sync-extensions.mjs
# 3) Instalar en un dominio (sesión de manager dueño del dominio)
curl -sS -X POST "$BACKEND/api/manager/extensions/install" -H 'content-type: application/json' \
-b 'auth_session=<cookie>' -d '{"domainId":"<id>","extensionId":"core-hello"}'sync-extensions.mjs(en la raíz debloomx-backend) subeserver.jsab2://<bucket>/extensions/<id>/server.jsy haceupsertde la extensión con el manifest completo comotemplate. Las extensiones sinserver.js(solo cliente) se publican conscriptUrl = null. No valida el manifest, por eso el paso 1 es imprescindible. Sale con código 1 si falta alguna variableB2_*oDATABASE_URL.- Existe una variante,
scripts/sync-repository-extensions.mjs(B2 opcional; clave de objetoextensions/<id>-v<version>.js). - Alternativa con el super-admin de plataforma:
POST /api/admin/extensions(multipart, Basic Auth conADMIN_EMAIL/ADMIN_PASSWORD) sí valida el manifest con el schema (400 con detalle) y sube aextensions/<id>/<version>/server.js. - Si cambias el schema (
_shared/manifest-schema.ts), copia el archivo abloomx-backend/src/lib/extensions/y abloomx/src/lib/expansions/; un test comprueba que las copias sean idénticas. - Extensiones de pago (
isPaid): la instalación exige una transacciónAPPROVED(402PAYMENT_REQUIRED).
Lista de comprobación#
- El manifest valida (
npm test) y cada función referenciada existe enapi.functions. - Los handlers tienen un solo parámetro y devuelven JSON serializable (≤ 5 MB) dentro de 25 s (o el
timeoutdeclarado, 100–60000 ms). - Solo usas
fetchhttps y los globals disponibles en el sandbox (sinrequire). - Los secretos van en
ENV_READ:*y se configuran por dominio. - Si usas
services.mail, declarasREAD_EMAILoMAIL_LABELy el dominio está en modo firmado. - Si usas
services.calendar|contacts|storage|notify|formats, declaras su permiso (CALENDAR_*,CONTACTS_*,STORAGE,NOTIFY,FORMATS) y degradas cuandoctx.services.<x>no existe (modo legado). - Si te suscribes a un evento de ciclo de vida, tu handler no lanza (devuelve
{success:false}), termina en menos de 10 s y no depende de bloquear ni modificar nada. - Si dependes de hooks
EMAIL_RECEIVEDoCRON, recuerda que el dominio debe estar en modo firmado y queCRONhoy no lo dispara nadie.
Para una página completa (un mount PAGE con cabecera, indicadores, gráficos y tabla) y su entrada en la barra lateral o en la consola de administración (navEntries, con insignia opcional), sigue la guía Página completa y navegación.
Documentos internos antiguos#
Los archivos de bloomorg-updates/ (planes 01/02) describen el diseño original y están desactualizados (hablan de node:vm, de JWT/secretos compartidos y de Sealer/Organizer deshabilitados). Esta documentación y expansions/howto.md son la referencia vigente. Esos archivos pueden contener credenciales de prueba: no los versiones y rota cualquier credencial que haya estado en ellos.