Extensiones
Contrato handler(ctx), manifest, permisos, sandbox y hooks.
En esta página
- Modelo
- Contrato handler(ctx)
- Manifest y schema
- Interfaz declarativa
- Permisos
- Credenciales por dominio
- Configuración por dominio vs variables de entorno
- Secretos frente a ajustes
- Secretos de solo escritura (write-only)
- Selector de usuarios: user, users y userMap
- Listas de elementos (objects), acciones y registro
- Prioridad y retrocompatibilidad
- Validación, auditoría y límites
- Webhooks salientes
- Filtros y contenido
- Payload y firma
- Entrega, reintentos y registro
- Grupos por defecto de mail-groups
- Sandbox y sus límites
- Hooks (intercepts)
- Preferencias del usuario y extensiones obligatorias
- Servicios del sandbox (services.*)
- Modelo de seguridad
- services.calendar
- services.contacts
- services.storage
- services.notify
- services.users (solo lectura)
- Estado compartido del dominio (storage.listShared)
- Ejemplo completo
- Límites por invocación
- Errores
- services.formats (utilidades puras)
- Eventos de ciclo de vida
- Eventos v2 (lifecycle.events.v2)
- Reglas y límites
- Puntos de montaje y su contexto
- slashCommands
- Versionado del cliente y compatibilidad
- Identidad del cliente
- requires en el manifest
- Resolución por petición
- Tabla ExtensionVersion
- Publicar una extensión que necesita un cliente nuevo
- Deprecación y ciclo de vida
- Versiones legacy archivadas
- Catálogo actual y estado
Una extensión (en el código y en la interfaz también se llama *expansión*) añade interfaz y lógica a BloomX sin tocar su código. Se compone de un manifest.json (declarativo: qué se muestra, qué funciones expone, qué permisos pide) y, opcionalmente, un server.js que se ejecuta en el backend compartido dentro de un sandbox. Para construir una paso a paso ve a Crear una extensión.
Modelo#
- Las extensiones viven en el repositorio
bloomx-extensions(una carpeta por extensión) y se publican al backend (base de datos + almacenamiento). Cada dominio (empresa) instala las que quiere. - El frontend recibe de
GET /api/configlas extensiones habilitadas del dominio (con sustemplate, es decir el manifest, y sussettingssin secretos) y pinta su interfaz en los *puntos de montaje*. - Cuando la interfaz llama a una función (
CALL_BACKEND) o se dispara un hook, el frontend hace un proxy firmado al backend (/api/extension/executeo/api/extension/hooks), que ejecutaserver.jsen el sandbox. - Extensiones solo cliente (
"clientOnly": true, sinserver.js), comoslash-commands,appointmentsogoogle-sync, no ejecutan nada en el backend.
Contrato handler(ctx)#
Cada función exportada por server.js recibe un único parámetro ctx y devuelve un valor serializable a JSON (hasta 5 MB). Un test del repositorio falla si algún handler declara más de un parámetro.
// server.js (CommonJS, o "export function")
module.exports = {
// UN solo parámetro: ctx
scanContent: async (ctx) => {
const text = String(ctx.emailContent || '');
const words = String(ctx.env.DLP_KEYWORDS || '').split(',').filter(Boolean);
const hit = words.find((w) => text.toLowerCase().includes(w.trim().toLowerCase()));
if (hit) return { stop: true, message: 'Contenido bloqueado por política: ' + hit };
return { success: true };
},
};| Campo de `ctx` | Contenido |
|---|---|
args | Parámetros de la llamada (CALL_BACKEND.args más formData), o {} |
env | Solo las claves declaradas con ENV_READ:* (ver credenciales) |
domain | {id, name, displayName, logo, theme} (el tema sin secretos) |
user | {id, email} o null. Lo fija el servidor a partir de la identidad firmada |
settings | Ajustes de la instalación sin secretos (sin credenciales) |
extension | {id, sourceId, name, manifest} |
services.ai.generate(system, prompt) | Genera texto con la IA de la instancia (puente firmado /api/internal/host/ai, configurada en /admin/ai; máx. 5 llamadas por invocación). Errores tipados: IA |
services.auth.getToken(provider) | Token OAuth del usuario o dominio; string o null. Orden: ctx.auth, authData de la instalación, credencial <PROVEEDOR>_ACCESS_TOKEN, ajuste y, si se permite, variable global |
services.mail.* | listRecent, getEmail, applyBatch, undoRun. Solo si la llamada va firmada por el dominio (la instancia aporta la executionGrant) y el manifest declara READ_EMAIL (lectura) o MAIL_LABEL (etiquetar/deshacer) |
services.calendar, contacts, storage, notify, formats | Solo en dominios firmados y según los permisos del manifest (ausentes en modo legado). Ver Servicios del sandbox |
| Resto | Lo que envía el cliente (emailContent, subject, to, from…). No es fiable: args, env, domain, user, settings y extension nunca los puede pisar el cliente |
- Errores:
throw new Error(msg)devuelve{success:false, error}con HTTP 500;AUTH_REQUIREDda 401; un timeout, 408; una acción no exportada, 404. - Convención (no impuesta): devolver
{success, ...}. - Para los hooks (
ctx.event) el retorno tiene semántica propia, ver Hooks.
Manifest y schema#
El schema canónico es bloomx-extensions/_shared/manifest-schema.ts, con dos copias idénticas (backend y frontend) que un test compara. Los errores impiden cargar o publicar; los avisos no.
| Campo | Regla |
|---|---|
id | Obligatorio. ^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$ |
name | Obligatorio. Texto no vacío, máx. 120 |
version | Obligatorio. Semver X.Y.Z (sufijo opcional) |
description | Opcional, máx. 2000 |
manifestVersion | Opcional. "1.0" o "2.0" (otra da aviso) |
status | Opcional. "active" o "disabled" (otro valor es error) |
permissions | Lista de strings; ver permisos |
auth | {type} con NONE, OAUTH2, API_KEY, WEBHOOK_SECRET o BASIC_AUTH |
api | runtime (solo nodejs), entry (ruta relativa, sin ..) y functions: {nombre: {handler, timeout?}} con timeout de 100 a 60000 ms |
mounts[] | point obligatorio y component o handler; los OVERLAY exigen id |
overlays | Objeto {id: componente} para OPEN_OVERLAY |
intercepts[] / hooks[] | Hooks (hooks es un alias con la misma forma): point, handler, priority, onError, schedule |
slashCommands[] | {key, description, action, arguments?} |
clientOnly | Indicador documental para extensiones sin servidor |
category | Categoría de la página /extensions (mail, composer, calendar, contacts, automation, ai, integrations, settings, other o su alias es/en). Desconocida: aviso, se deriva de los mounts y permisos |
tags | Hasta 12 etiquetas de búsqueda de 1 a 30 caracteres, sin HTML |
screenshots | Hasta 6 URLs https absolutas (sin usuario/clave, máx. 2000 caracteres). http, javascript: y data: se rechazan |
changelog | Hasta 20 entradas {version, date?, notes?}; notes (texto o lista, máx. 1000 caracteres) se muestra como texto plano |
mandatory | true = extensión obligatoria para todos los usuarios (DLP, seguridad): no se puede desactivar y sus hooks de servidor se ejecutan siempre. Ver preferencias del usuario |
conferencingProviders[] | Proveedores de videollamada {id, name, icon?, handlers}; handlers apunta a api.functions (estándar: status, testConnection, createMeeting, updateMeeting, deleteMeeting) y createMeeting es obligatorio |
- Reglas cruzadas: todo
CALL_BACKEND.function,mount.handlereintercept.handlerdebe existir enapi.functions. Profundidad máxima del árbol de componentes: 40. Una acción desconocida es un error; un componente o punto de montaje desconocido, solo un aviso. backendRoutesgenera un aviso: no hay router/api/ext/[id]/*, así que no se sirve.- La lista de tipos de componente se deriva de
ui-schema.ts(los ~70 del kit) más los nombres del formato antiguo (COLUMN,DATA_TABLE…): un tipo del kit nunca da el aviso «componente desconocido». Límites del manifest: 1 MB, 12 etiquetas, 6 capturas, 20 entradas de changelog y 50 000 nodos recorridos. - Validación al publicar: además del manifest, el backend valida todo el UI con el espejo de
ui-schema.ts(ruta del error incluida, p. ej.mounts[0].component.props.tone). Estructura inválida = 400; UI inválido o fuera de límites (600 nodos y 24 niveles por árbol, 256 KB, 200 mounts, 100 overlays, 5000 nodos en total) = 422. Aplica aPOST /api/admin/extensions, a las acciones de/admin(createExtension,syncRepositoryExtensions), async-extensions.mjsyscripts/sync-repository-extensions.mjs(fallan cerrados), a la siembra y a la instalación (422 EXTENSION_INVALID). Al cargar una extensión ya publicada, el frontend trata los problemas de los campos de catálogo como avisos (lenientCatalog), no como errores.
{
"manifestVersion": "1.0",
"id": "core-dlp",
"version": "1.0.0",
"name": "Data Loss Prevention",
"permissions": ["READ_EMAIL", "ENV_READ:DLP_KEYWORDS"],
"api": {
"runtime": "nodejs",
"entry": "server.js",
"functions": { "scanContent": { "handler": "scanContent", "timeout": 5000 } }
},
"intercepts": [
{ "point": "EMAIL_PRE_SEND", "handler": "scanContent", "priority": "HIGH", "onError": "block" }
]
}Interfaz declarativa#
La interfaz es un árbol JSON de componentes (por ejemplo BUTTON, TEXT, INPUT, CARD, ROW, MODAL, TABS, FORM, LIST, DATA_TABLE, MARKDOWN, FILE_UPLOAD, WIZARD…) y acciones (SET_STATE, CALL_BACKEND, CALL_API, TOAST, OPEN_OVERLAY, OPEN_URL, INSERT_CONTENT, APPEND_BODY, SET_SUBJECT, ADD_ATTACHMENT, NEXT_STEP, SECURE_SAVE, OAUTH_CONNECT…). Las expresiones ${...} las evalúa un intérprete propio sin eval; CALL_API solo acepta rutas propias /api/...; OPEN_URL y los enlaces solo http(s), mailto y tel.
Puntos de montaje soportados por el schema: los de correo, barra lateral, editor, calendario, contactos y ajustes (tabla completa con su contexto en Puntos de montaje), más EVENT_LOCATION_BUILDER, SETTINGS_TAB, CUSTOM_SETTINGS_TAB, PAGE, OVERLAY, SLASH_COMMAND y los *_HANDLER de edición. No todos tienen consumidor en la interfaz: BEFORE_SEND_HANDLER, CUSTOM_ROUTE y backendRoutes no hacen nada hoy.
Una extensión puede declarar páginas completas (PAGE) y entradas de navegación (navEntries, capacidades ui.pages.v1 y nav.entries.v1): ver la guía Página completa y navegación.
Permisos#
El manifest declara los permisos que la extensión necesita. El catálogo (PERMISSION_CATALOG en el schema) da a cada uno una etiqueta, una descripción y un riesgo; es lo que ve el administrador del dominio.
| Permiso | Etiqueta | Descripción | Riesgo |
|---|---|---|---|
READ_EMAIL | Leer correo | Lee remitente, asunto y fragmento de los correos de la bandeja del usuario. | Alto |
MAIL_LABEL | Etiquetar correo | Aplica o deshace etiquetas de categoría en correos del usuario (no mueve, borra ni envía). | Medio |
READ_USER | Ver datos del usuario | Ve el identificador y el correo del usuario que ejecuta la extensión. | Bajo |
READ_USER_NAME | Ver nombre del usuario | Ve el nombre del usuario. | Bajo |
AI_GENERATE | Usar IA | Envía texto al proveedor de IA configurado por la instancia en /admin/ai, con guardarraíles y cuotas. Con la IA desactivada la extensión se bloquea (IA). core-composer-helper es la única ayuda de redacción: no hay compositor nativo con IA. | Medio |
HTTP_REQUEST | Llamadas HTTP externas | Hace peticiones HTTPS a servicios externos (filtradas contra SSRF). | Alto |
OAUTH_READ | Leer tokens OAuth | Usa los tokens de las cuentas conectadas del dominio. | Alto |
OAUTH_WRITE | Gestionar conexiones OAuth | Conecta o desconecta cuentas de terceros. | Alto |
API_ROUTE_CREATE, PAGE_ROUTE_CREATE | Crear rutas de API / páginas | Declaran rutas o páginas propias (reservado). | Medio |
DB_READ, DB_WRITE | Leer / escribir base de datos | Datos de la extensión (reservado). | Medio |
local:secure-storage | Almacenamiento seguro del navegador | Guarda datos cifrados en el navegador del usuario. | Bajo |
CALENDAR_READ | Leer calendario | Lista y consulta eventos y huecos libres de los calendarios del usuario (sin tokens de Google). | Medio |
CALENDAR_WRITE | Modificar calendario | Crea, edita, cancela eventos e invita asistentes en calendarios del usuario. | Alto |
CONTACTS_READ | Leer contactos | Busca y lista los contactos del usuario y sugiere duplicados. | Medio |
CONTACTS_WRITE | Modificar contactos | Crea, edita y fusiona contactos del usuario. | Alto |
FORMATS | Formatos | Usa utilidades puras de fechas, números, ICS, vCard, plantillas y saneo de HTML (sin acceso a datos). | Bajo |
STORAGE | Almacenamiento propio | Guarda hasta 256 KB de estado de la extensión por usuario en el servidor. | Bajo |
NOTIFY | Notificaciones | Muestra avisos (toast) al usuario dentro de la aplicación. | Bajo |
ENV_READ:NOMBRE | Variable NOMBRE | Lee la credencial o variable configurada para este dominio. | Alto |
- Validación.
validateManifestcomprueba quepermissionssea una lista de strings; un permiso que no está en el catálogo produce un aviso (Permiso desconocido) y la plataforma lo ignora.ENV_READ:NOMBREse valida aparte (^[A-Z][A-Z0-9_]{1,63}$y no reservada) y un nombre inválido es error. - Confirmación al instalar. El administrador ve la lista legible (
describePermissions: etiqueta, descripción y riesgo; los desconocidos se marcan como tales) y debe confirmar antes de instalar. Pide solo lo necesario: lo que pidas se le muestra tal cual. - Se hacen cumplir en el servidor:
ENV_READ:*,READ_EMAIL,MAIL_LABELy los de servicios (CALENDAR_*,CONTACTS_*,FORMATS,STORAGE,NOTIFY). El ejecutor rechaza una operación de servicio sin su permiso con un error claro (CALENDAR_PERMISSION_DENIED: services.calendar.createEvent requires CALENDAR_WRITE in the extension manifest). El resto (READ_USER,AI_GENERATE,HTTP_REQUEST,OAUTH_*,local:secure-storage) documenta la intención y los reservados (API_ROUTE_CREATE,PAGE_ROUTE_CREATE,DB_*) no tienen efecto. - Detalle de cada operación y su permiso: Servicios del sandbox.
Variables reservadas
No se pueden pedir con ENV_READ: DATABASE_URL, DIRECT_URL, POSTGRES_*, PG*, B2_*, ADMIN_*, MP_*, NEXTAUTH_*, AUTH_*, DATA_ENCRYPTION_KEY, AI_*, OPENAI_*, RESEND_*, VERCEL*, NEXT_*, NODE_*, EXTENSION_*, INTERNAL_*, JWT_*, SESSION_*, AWS_*, GITHUB_*, NPM_*, PATH, HOME, USER*.
Credenciales por dominio#
- Cada empresa configura sus propias credenciales (por ejemplo
GIPHY_API_KEY) en el panel de administración (extensiones → credenciales) o conPUT /api/extension/settings. Se guardan cifradas (enc:v1:, AES-256-GCM conDATA_ENCRYPTION_KEYdel backend) y nunca vuelven al navegador: la interfaz solo ve{name, configured}. - Solo se aceptan nombres declarados con
ENV_READ:*en el manifest y no reservados; valor de hasta 4096 caracteres, sin caracteres de control.nullo cadena vacía borra. - Lectura en ejecución: por cada
ENV_READ:X, en este orden: (1) la credencial del dominio (cifrada, la escribe el manager desde el panel), (2) el valor heredado que guardaban las instalaciones antiguas y (3) la variable global del backend, salvo las variables de plataforma (base de datos, almacenamiento, cifrado, claves privadas,EXTENSION_*, etc.), que ninguna extensión puede leer.EXTENSION_GLOBAL_ENV_FALLBACKes un control de endurecimiento opcional:nonedesactiva el paso 3 y una lista lo limita a esos nombres. El panel muestra la fuente activa de cada variable. - En modo legado (sin firma) la extensión recibe las mismas credenciales, que quedan dentro del sandbox y no se devuelven; no tiene
services.maily cada ejecución se audita.requireSignaturerechaza el modo legado. - Desinstalar una extensión borra sus credenciales y
authData, y intenta revocar los tokens OAuth en el proveedor (Google, HubSpot, Notion, Zoom; mejor esfuerzo, 5 s).
Configuración por dominio vs variables de entorno#
El manifest puede declarar settingsSchema: los campos que el administrador configura por dominio en la consola (extensiones → detalle → pestañas Ajustes y Credenciales), sin tocar variables de entorno del servidor. Cada campo tiene key, type, label/description (texto o {es, en}), default, required, group (secciones definidas en groups), visibleWhen: {key, in:[...]} y los límites propios de su tipo.
"settingsSchema": {
"groups": [{ "id": "scan", "label": { "es": "Análisis", "en": "Scanning" } }],
"fields": [
{ "key": "mode", "type": "enum", "group": "scan", "default": "block", "legacyEnv": "DLP_MODE",
"label": { "es": "Modo", "en": "Mode" },
"options": [{ "value": "block", "label": { "es": "Bloquear", "en": "Block" } }, "warn", "log"] },
{ "key": "keywords", "type": "list", "group": "scan", "maxItems": 100, "itemMaxLength": 100,
"legacyEnv": "DLP_KEYWORDS", "label": { "es": "Palabras clave", "en": "Keywords" } },
{ "key": "GIPHY_API_KEY", "type": "string", "secret": true, "required": true,
"label": { "es": "Clave de API", "en": "API key" } }
]
}| Tipo | Control en la consola | Valor guardado |
|---|---|---|
string, multiline | Campo de texto / área de texto (maxLength, pattern, format: email, url, domain, regex) | Texto |
number | Campo numérico (min, max, integer) | Número |
boolean | Interruptor | Booleano |
enum | Lista desplegable (options) | Uno de los valores de options |
multienum | Casillas, cada una con descripción y ejemplo | Lista de valores de options (puede ser vacía) |
list | Un elemento por línea, con contador (maxItems, itemMaxLength) | Lista de textos sin repetidos |
json | Área de texto con validación | Cualquier JSON de hasta 8 KB |
user, users | Buscador de usuarios del dominio (uno / varios) | Id(s) estable(s) del usuario |
userMap | Tabla de usuarios con un valor cada uno (valueType) | Mapa { idUsuario: valor } |
Secretos frente a ajustes#
- Secretos (
secret: true): tokens, claves y contraseñas. Lakeyes el nombre de variable en MAYÚSCULAS (el mismo deENV_READ:*) y no admitedefault. Se guardan cifrados como credenciales del dominio, se editan solo en la pestaña Credenciales y su valor nunca vuelve al navegador. La pestaña Ajustes solo muestra «n de m secretos configurados» con un enlace. - Ajustes (el resto): valores tipados que se guardan en claro en
ExtensionOnDomain.settings.configy la extensión recibe en `ctx.settings[key]`. No pongas secretos aquí. Claves reservadas:credentials,env,meta,ui,config,configMeta,authData,mandatory.
// server.js: los ajustes NO secretos llegan en ctx.settings; los secretos, en ctx.env
module.exports = {
async onEmailPreSend(ctx) {
const mode = ctx.settings.mode || 'block'; // dominio > heredado > defecto
const keywords = ctx.settings.keywords || [];
const key = ctx.env.GIPHY_API_KEY; // credencial cifrada del dominio
// ...
}
};Secretos de solo escritura (write-only)#
- Un secreto se declara con
secret: true,writeOnly: trueotype: "secret"(equivalentes). Se cifra en reposo (AES-256-GCM, la mismaDATA_ENCRYPTION_KEYque la clave de IA) y nunca se vuelve a leer: la API solo devuelve{ name, configured, set, updatedAt?, last4? }.last4(los 4 últimos caracteres) solo existe si el campo declararevealLast4: true(apagado por defecto; secretos de menos de 12 caracteres no revelan nada). - En la consola cada secreto muestra «Guardado — escribe para reemplazar» con la fecha de la última rotación y las acciones Reemplazar y Borrar (esta última pide confirmación). El campo es
type=passwordconautocomplete="new-password". - Un campo vacío en un guardado no borra nada: solo
null(la acción Borrar) elimina el valor. Reemplazar y borrar quedan en la auditoría con quién y cuándo, nunca con el valor (tampoco en exportaciones, copias, registros de ejecución, errores, estado de páginas ni respuestas deexecute). - Validación en servidor al guardar:
pattern,formatymaxLengthdel campo (tope 4096). El error indica el motivo sin repetir el valor. - Dentro del sandbox: el
server.jsrecibe el secreto descifrado enctx.env(secretos de dominio, solo los declarados conENV_READ:*) o en el propio elemento dectx.settings(secretos por elemento deobjects), solo durante esa ejecución y solo si la extensión lo declara y tiene permiso. - Migración: los valores que aún estuvieran en claro (instalaciones antiguas) se cifran en el primer guardado de la extensión, de forma idempotente (repetirlo no cambia nada). Compatibilidad: cualquier cliente que dependiera de leer un valor guardado ya no podrá hacerlo (solo se devuelve el estado); un cliente antiguo que enviaba
""para borrar debe enviarnull.
Selector de usuarios: user, users y userMap#
Son ajustes que configura el administrador (nivel ≥ 3) eligiendo cuentas del propio dominio. Se guarda siempre el id estable del usuario (nunca el correo); la consola resuelve el nombre y el correo con un buscador asíncrono y paginado. Solo se ven y se validan las cuentas del dominio actual.
| Tipo | Control | Valor guardado / en ctx.settings |
|---|---|---|
user | Un usuario (buscador con resultados paginados) | Texto: el id |
users | Varios usuarios, con maxItems (tope 50) | Lista de ids sin repetidos |
userMap | Tabla buscable y paginada: un valor por usuario; añadir, quitar, «Todos: sí/no» y «Añadir a todos» (boolean) | { [userId]: valor } con entradas explícitas (tope maxItems, hasta 5000) |
userMapusavalueType:boolean(por defecto),string,number(min,max,integer) oselect(options).defaultes el valor de quien no tiene entrada; la tabla solo guarda entradas explícitas.filter: { minLevel?, role? }acota quién se puede elegir (nivel de permiso mínimo o nombre del nivel: user, support, operator, admin, superadmin).- Validación en servidor (el servidor de la consola, contra la base del dominio): cada id nuevo debe existir, no estar desactivado y cumplir el filtro (errores
userMissing,userDisabled,userFilter). Los ids ya guardados que luego desaparecen no bloquean otros cambios: la consola los marca «Usuario eliminado» y ofrece «Quitar eliminados». El backend compartido valida formato, tipos y topes. - En el handler:
ctx.settings.owneres el id,ctx.settings.recipientsla lista yctx.settings.digestel mapa completo (es configuración del administrador, sin secretos de usuarios).ctx.settings.forUser(key, userId)yctx.settings.getUserValue(key, userId)devuelven la entrada explícita o, si no existe, eldefault(funciones no enumerables: no salen enJSON.stringify(ctx.settings)). - API del buscador (nivel 3):
GET /api/admin/extensions/users?q=&page=&limit=y?ids=a,b(resuelve y marca los inexistentes). CLI:extensions users --q ana.
"settingsSchema": {
"fields": [
{ "key": "owner", "type": "user", "label": { "es": "Responsable", "en": "Owner" }, "filter": { "minLevel": 3 } },
{ "key": "recipients", "type": "users", "maxItems": 10, "label": { "es": "Destinatarios", "en": "Recipients" } },
{ "key": "digest", "type": "userMap", "valueType": "boolean", "default": false,
"label": { "es": "Recibe el resumen", "en": "Gets the digest" } },
{ "key": "dailyLimit", "type": "userMap", "valueType": "number", "min": 1, "max": 500, "integer": true, "default": 20,
"label": { "es": "Límite diario por usuario", "en": "Daily limit per user" } },
{ "key": "API_TOKEN", "type": "secret", "pattern": "^tok_[A-Za-z0-9]{16,}$", "revealLast4": true,
"label": { "es": "Token de la API", "en": "API token" } }
]
}// server.js
module.exports = {
async sendDigest(ctx) {
const { owner, recipients, digest } = ctx.settings; // owner = id; recipients = ids; digest = { [userId]: boolean } (solo entradas explicitas)
const wants = (id) => ctx.settings.forUser('digest', id); // entrada explicita o default del schema
const limit = ctx.settings.getUserValue('dailyLimit', ctx.user.id); // numero, o 20 si el usuario no tiene entrada
const token = ctx.env.API_TOKEN; // secreto write-only: solo existe aqui, dentro del sandbox
if (!wants(ctx.user.id)) return { skipped: 'opt-out' };
// ... un id eliminado sigue llegando como id: comprueba con ctx.services.users.get(id) si lo necesitas
}
};Vista previa de los campos de usuario
Datos de ejemplo en memoria (un usuario desactivado, uno eliminado). Los controles son los de la consola de administración.
Responsable user
Ningún usuario seleccionado.
Destinatarios users
- usr_002
1 de 5 usuarios
Recibe el resumen userMap · boolean
Quien no tenga entrada usa: no
| Usuario | Valor | Acciones |
|---|---|---|
| usr_001 | ||
| usr_003 | ||
| usr_099 |
Límite diario userMap · number
Quien no tenga entrada usa: 10
| Usuario | Valor | Acciones |
|---|---|---|
| usr_002 |
Lo que recibe el server.js
{
"recipients": [
"usr_002"
],
"digest": {
"usr_001": true,
"usr_003": true,
"usr_099": true
},
"quota": {
"usr_002": 25
}
}ctx.settings.forUser('digest', 'usr_005') -> false (sin entrada: usa default)
Listas de elementos (objects), acciones y registro#
- `objects`: lista editable de registros (p. ej. los endpoints de un webhook).
itemFieldsdeclara los sub-campos (tipos simples, conrequiredyvisibleWhen); cada elemento lleva unidestable ([a-z0-9-], único, hasta 32 caracteres) que la consola genera a partir de un nombre más un sufijo y no se puede editar después de guardar. Máximo 50 elementos. - Secretos por elemento: un sub-campo con
secret: truese guarda cifrado por elemento (nombrecampo.id.subcampo), es de solo escritura y llega al handler únicamente dentro del sandbox. La consola solo recibesecretsSet(los nombres establecidos): muestra «Configurado / No configurado», un campo de contraseña vacío para establecer o reemplazar y el botón «Quitar». Los secretos viajan ensecretsdel mismoPUTy solo si el administrador los cambió. - `templates`: elementos sugeridos con un botón «Añadir desde plantilla». No quedan activos por defecto: se añade el elemento, el administrador lo revisa y pulsa Guardar.
- `actions`: botones que invocan una función de
api.functionscon{itemId?}(scope: "global"arriba del formulario o"item"dentro de cada tarjeta, que se deshabilita si el elemento tiene cambios sin guardar).confirmpide confirmación. El resultado (status, código HTTP, latencia, mensaje yreport[]) se muestra saneado y nunca incluye secretos; máximo 10 ejecuciones por minuto (HTTP 429). - `runLog: {limit}`: registro acotado de ejecuciones (fecha, evento, destino, estado, intentos, latencia, código y mensaje corto; nunca cuerpos, cabeceras ni secretos). Alimenta «Registro de ejecuciones recientes» en la pestaña Estado y registro.
- Errores de validación: cada error lleva
path,codeyparamsademás demessage(español de respaldo). La consola traduce siempre porcode+params(es/en):type,control,format,pattern,number,integer,min,max,option,options,list,maxItems,maxChars,required,objects,itemId,itemDup,itemUnknown,itemSecret,itemField(con el sub-código enparams.sub),json,jsonSize... Elmessagesolo se usa si el código no tiene traducción. - En un campo
objectsobligatorio, el listado «Configuración completa» cuenta 1 cuando hay al menos un elemento.
"settingsSchema": {
"fields": [{
"key": "endpoints", "type": "objects", "required": true, "maxItems": 20,
"label": { "es": "Endpoints", "en": "Endpoints" },
"itemFields": [
{ "key": "name", "type": "string", "required": true, "label": "Name" },
{ "key": "url", "type": "string", "format": "url", "required": true, "label": "URL" },
{ "key": "secret", "type": "string", "secret": true, "required": true, "label": "Signing secret" }
],
"templates": [{ "id": "slack", "label": "Slack", "value": { "name": "Slack", "url": "https://hooks.example.com/x" } }]
}],
"actions": [{ "id": "send-test", "label": "Send test event", "handler": "sendTest", "scope": "item", "itemsKey": "endpoints" }],
"runLog": { "limit": 50 }
}Prioridad y retrocompatibilidad#
- Valor efectivo de un ajuste: ajuste del dominio > valor heredado del dominio > variable de entorno global (`legacyEnv`) > `default`. Si nada lo define, la clave queda sin valor (
unset). legacyEnvnombra la variable de entorno que respaldaba el ajuste antes de existir el esquema (p. ej.DLP_KEYWORDS). Si el esquema es heredado y la clave ya está en MAYÚSCULAS, esa misma clave hace delegacyEnv. Las extensiones sinsettingsSchemasiguen funcionando igual y la pestaña Ajustes muestra la tabla de solo lectura de antes.- El panel marca cada ajuste que aún se resuelve desde el entorno heredado («usando variable de entorno (heredada): migra a Ajustes») y avisa en el Resumen. Importar desde el entorno (solo si hay algo importable) copia al dominio esos ajustes no secretos; los secretos no se tocan y el valor del entorno global nunca se muestra en la consola: se copia en el servidor. Un campo con
envImport: falsequeda fuera de la importación. - Restablecer a valores por defecto borra los ajustes guardados del dominio (pide confirmación) y no toca las credenciales.
Validación, auditoría y límites#
- La consola valida en vivo con las mismas reglas que el backend (
validateFieldValue, módulo compartidosettings-schema); el servidor vuelve a validar y responde422 {errors:[{path:"values.<clave>", message}]}, que la consola pinta junto al campo. Unrequiredvisible sin valor (propio, heredado o por defecto) cuenta en la lista «Configuración completa X/Y» del Resumen. - API:
GET/PUT/POST /api/extension/config(el proxy de la consola es/api/admin/extensions/config: lectura nivel 3, escritura y importación nivel 4; CLI:extensions config,config set,config reset,config import-env). - Auditoría: cada cambio registra
admin.extension.configcon la acción, el resultado y solo los nombres de las claves cambiadas o importadas, nunca sus valores. - Límites: hasta 60 campos por esquema; texto de 1000 caracteres (5000 en
multiline); listas de 200 elementos de 200 caracteres; JSON de 8 KB; patrones regex de 200 caracteres sin referencias hacia atrás ni lookaround;settings.configcompleto, 32 KB.
Webhooks salientes#
La extensión webhooks (core-webhooks) entrega eventos de correo a las URL que configure el administrador. Se configura en la consola (extensiones → Webhooks → Ajustes): hasta 20 endpoints, cada uno con nombre, URL https, interruptor *Activo*, secreto de firma (cifrado y de solo escritura), eventos, filtros y límites. Sin secreto no se entrega.
| Evento | `type` del payload | Datos |
|---|---|---|
EMAIL_RECEIVED | email.received | emailId, userId y lo que el dominio firmante incluya (carpeta, remitente, etiquetas, asunto/cuerpo si se activan) |
EMAIL_SENT | email.sent | emailId, toCount, ccCount, bccCount, hasAttachments, sentAt (sin destinatarios) |
EMAIL_OPENED | email.opened | emailId, folder, fromEmail, isRead |
Spam, etiquetas y altas de usuario
«Spam detectado», «etiqueta aplicada» y «usuario creado» no existen como hook y no se han simulado. Para añadirlos hay que (1) agregar el valor a LIFECYCLE_EVENTS y a la lista blanca de contexto del ejecutor de hooks, (2) llamar a /api/extension/hooks desde el flujo que lo origina (clasificador, etiquetado, alta de usuario) y (3) añadir el intercept y la opción en events del manifest.
Filtros y contenido#
Los filtros opcionales folder, senderDomain y label solo se evalúan si el evento trae ese dato; si el filtro está configurado y falta el dato, el evento no se entrega (skipped): es más seguro filtrar de más que de menos. includeSubject e includeBody solo añaden asunto/cuerpo cuando el contexto del evento los trae.
Payload y firma#
{ "id": "7b0c2f0e-...", "type": "email.received", "version": 1, "createdAt": "2026-01-01T10:00:00.000Z",
"data": { "emailId": "...", "userId": "...", "timestamp": "..." } }- Cabeceras:
X-BloomX-Signature: sha256=<hex>,X-BloomX-Timestamp(segundos Unix),X-BloomX-EventyX-BloomX-Delivery(uuid, igual en los reintentos). - Firma:
sha256=HMAC-SHA256(secreto, "<timestamp>.<cuerpo exacto>"). El timestamp se renueva en cada reintento; eliddel payload no cambia, así que sirve para deduplicar. - Verificación en el receptor: usa el cuerpo crudo, compara en tiempo constante y rechaza timestamps con más de 5 minutos de diferencia (anti-replay). Guarda además los
X-BloomX-Deliveryya vistos durante 5 minutos.
const crypto = require('node:crypto');
// rawBody: el cuerpo CRUDO recibido (string/Buffer), sin reserializar
function verify(rawBody, headers, secret, toleranceSec = 300) {
const ts = Number(headers['x-bloomx-timestamp']);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false; // anti-replay
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const got = String(headers['x-bloomx-signature'] || '');
return got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}import hashlib, hmac, time
def verify(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
try:
ts = int(headers["X-BloomX-Timestamp"])
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False
signed = f"{ts}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers.get("X-BloomX-Signature", ""))Entrega, reintentos y registro#
- Reintentos de 0 a 5 con espera exponencial (300 ms · 2^n) dentro de un presupuesto de ≈9 s por evento (los hooks se cortan a 10 s) y 18 peticiones por invocación (el sandbox permite 20). Los endpoints se entregan en paralelo.
- Límite de tasa por endpoint (
rateLimitPerMinute), calculado sobre el registro de entregas (máximo 100 entradas guardadas). - SSRF y redirecciones: solo https, sin credenciales en la URL, sin localhost, redes privadas ni IPv6 literal; no se siguen redirecciones (una 3xx se registra como fallo: configura la URL final).
- Enviar evento de prueba: botón dentro de cada endpoint (deshabilitado si tiene cambios sin guardar); hace un único intento de
webhook.testy muestra código, latencia y mensaje. - Registro de entregas: «Registro de ejecuciones recientes» en Estado y registro (fecha, evento, destino, estado, intentos, latencia, código, mensaje). Nunca URLs, cuerpos, cabeceras ni secretos.
- Endpoint heredado (
webhookUrl/WEBHOOK_URL): si no hay endpoints se sintetiza uno solo paraEMAIL_RECEIVED, firmado conWEBHOOK_SECRETsi existe y con el campo antiguoevent: "email_received". Ambos ajustes están obsoletos: crea un endpoint nuevo con su secreto y retira el heredado.
Grupos por defecto de mail-groups#
- `defaultGroups` (desde la 1.1.0) es una lista
objectsde hasta 30 grupos del dominio: alias, dirección opcional, descripción, hasta 200 miembros y visibilidad (domainoprivate). Plantillas sugeridas (no activas): todos, soporte y ventas. - Prioridad del usuario: los grupos del dominio se fusionan por debajo de los alias del usuario; si el usuario tiene un alias con el mismo nombre (con o sin
@), gana el suyo y nunca se modifica ni se borra nada de lo suyo. La expansión es idempotente y no duplica direcciones. - Aplicar ahora (
applyNow) no copia nada: el modelo es de expansión en tiempo de uso, así que la acción devuelve un informe de validación (report[]: aplicado u omitido y el motivo). - `autoAddNewUsers` no existe por una limitación técnica real: añadir usuarios nuevos exige conocer los usuarios del dominio y enterarse de un alta, y el sandbox no tiene ni un servicio de directorio de usuarios (
ctx.useres solo quien ejecuta; losservices.*operan sobre el usuario actual) ni un evento de alta enLIFECYCLE_EVENTS. Haría falta un servicio de hostusers.listcon permiso propio o un eventoUSER_CREATEDcon su lista blanca de contexto.
Sandbox y sus límites#
Cada invocación crea un worker_threads nuevo (sin variables de entorno del proceso) con un contexto node:vm de prototipo nulo y sin eval/new Function. Toda entrada/salida sale por mensajes al hilo principal.
| Límite | Valor |
|---|---|
| Heap / pila | 128 MB viejo, 32 MB joven, 4 MB de pila |
| Timeout por invocación | 25 s por defecto; por función api.functions.<f>.timeout (100–60000 ms). Duro (worker.terminate()): cubre bucles síncronos y asíncronos |
| Entrada / salida | 10 MB de contexto / 5 MB de resultado |
fetch | 20 por invocación; solo https; 10 s; respuesta ≤5 MB |
services.ai | 5 llamadas por invocación |
| Mensajes / logs | 500 mensajes; 200 líneas de log |
| Concurrencia | EXT_SANDBOX_MAX_WORKERS (1–64, por defecto 8); cola de 5 s, luego Sandbox busy |
- Disponible:
console,setTimeout/clearTimeout,URL,URLSearchParams, unBuffermínimo (from,byteLength,isBuffer,toString),cryptoconrandomUUID,sha256HexyhmacSha256Hex,fetch(a través desafe-fetch),module/exportsyprocess.env(solo las variables autorizadas). - No existe:
require,import,eval,setInterval,AbortController,Headers,Request,Response,FormData,Blob. safe-fetchbloquea IP privadas y metadatos de nube, valida la IP al conectar y limita las redirecciones a 3.
No es una frontera fuerte
Un worker con vm no es un sandbox del sistema operativo. Si el código escapara del contexto, seguiría en el mismo proceso y usuario, con acceso a fs, net y child_process; la memoria fuera del heap de V8 tampoco está acotada. Instala solo extensiones en las que confíes. Para código de terceros hostil hace falta un contenedor o microVM por tenant, un usuario sin privilegios y sin credenciales en disco.
Hooks (intercepts)#
Un intercept engancha una función a un evento del sistema. Se declara en intercepts o en su alias hooks. Campos: point (EMAIL_PRE_SEND, EMAIL_RECEIVED, CRON o uno de los nueve eventos de ciclo de vida), handler, priority (HIGH, NORMAL, LOW, MONITOR o número), onError (block o continue, por defecto continue) y, para CRON, schedule (hourly o daily).
| Evento | Quién lo dispara | Semántica |
|---|---|---|
EMAIL_PRE_SEND | POST /api/emails del frontend, justo antes de enviar (proxy firmado a /api/extension/hooks) | Único que puede bloquear: {stop:true, message} responde 422 EXTENSION_BLOCKED. {modify:{subject?,html?,text?}} reescribe el contenido (nunca destinatarios); los cambios se encadenan. {warning} avisa. MONITOR se ejecuta pero no bloquea ni modifica. Si un handler con onError:"block" falla, se bloquea. Presupuesto de 8 s comprobado antes de lanzar cada hook |
EMAIL_RECEIVED | POST /api/webhooks/resend del frontend, tras guardar el correo (en segundo plano) | Solo dominios firmados. Contexto {emailId, userId, domain}. No bloquea ni modifica. Se omite en silencio si el frontend no tiene BLOOMX_DOMAIN_PRIVATE_KEY |
CRON | El endpoint existe en el backend (dominio firmado, o el operador con Authorization: Bearer <BACKEND_CRON_SECRET>) | Nadie lo llama hoy: /api/cron/run del frontend no invoca hooks y ninguna extensión actual declara un intercept CRON |
- Si el backend no responde, el frontend envía igualmente el correo y registra el fallo; con
EXTENSION_HOOKS_FAIL_CLOSED=trueno lo envía.EXTENSION_HOOKS_DISABLED=truedesactiva la llamada. Timeout de la llamada: 10 s. - En modo legado solo
EMAIL_PRE_SENDse acepta;EMAIL_RECEIVED,CRONy los eventos de ciclo de vida responden 403 (estos últimos ni se disparan desde el frontend).
Preferencias del usuario y extensiones obligatorias#
Cada usuario puede desactivar una extensión para sí mismo en /extensions (se guarda en expansionSettings["system:extension-prefs"]). Eso ya no solo oculta la interfaz: en dominios firmados el frontend incluye la lista disabledExtensions (leída de los ajustes del usuario en el servidor, no del navegador; ids válidos, sin duplicados, máx. 200) dentro del cuerpo firmado de hooks y execute, y el backend no ejecuta esas extensiones para ese usuario.
| Caso | Comportamiento |
|---|---|
| Extensión desactivada por el usuario | Sus hooks (EMAIL_PRE_SEND, EMAIL_RECEIVED, ciclo de vida) no se ejecutan para él; execute responde 403 EXTENSION_DISABLED_BY_USER |
| Extensión obligatoria | mandatory: true en el manifest, o política del dominio «Obligatoria para todos» (ExtensionOnDomain.settings.meta.mandatory). Se ejecuta siempre, aunque la lista venga manipulada; la interfaz la muestra bloqueada con una explicación |
CRON | No tiene usuario: ignora la lista |
| Dominio LEGADO (sin firma) | No recibe la lista y se ejecuta todo como antes |
| No se pueden leer las preferencias | No se envía lista: se ejecutan todas (nunca se bloquea el envío por esto) |
El administrador marca una extensión como obligatoria en la consola (/admin/extensions → detalle → «Obligatoria para todos»; pide confirmación, audita extension.mandatory_changed y exige requireAdmin más la sesión de gestor del dominio). Si el manifest ya la declara, el interruptor queda bloqueado. Desinstalar retira la política. La extensión DLP se publica con mandatory: true.
Detalles de seguridad en Extensiones: validación, preferencias y obligatorias.
Servicios del sandbox (services.*)#
Además de services.ai, services.auth y services.mail, el sandbox ofrece cinco servicios con acceso controlado a los datos del usuario: services.calendar, services.contacts, services.storage, services.notify y services.formats. Cada operación es await ctx.services.<servicio>.<operación>(args): un único objeto de argumentos y una Promise que resuelve con el data de la respuesta. Las tablas reflejan la validación real del backend (host-services.ts, formats-service.ts).
Modelo de seguridad#
- Solo dominios firmados. Las llamadas salen firmadas con Ed25519 con la clave del backend hacia
/api/internal/<servicio>del frontend del dominio. En modo legadoctx.services.calendar|contacts|storage|notify|formatsno existe (ausente): la extensión debe degradar (if (!ctx.services?.calendar) ...). Sin clave de firma en el backend solo quedaservices.formats(operaciones locales). - `userId` lo fija el host a partir de la identidad firmada del dominio (
X-User-Id); la extensión nunca puede elegirlo ni pasarlo enargs. ElextensionId(namespacing destoragey atribución denotify) también lo fija el host. - Propiedad por usuario. Toda consulta y escritura filtra por el usuario; un id ajeno se comporta como inexistente (
not_found, sin oráculo). Los calendarios de solo lectura no se pueden escribir (read_only). - Sin secretos. Nunca se exponen tokens de Google,
externalId,syncTokenniinviteUid. Los errores no llevan detalles internos ni datos personales. - Mínimo privilegio. Cada operación exige un permiso del manifest; sin él, falla con
<SERVICIO>_PERMISSION_DENIED(no con un "undefined is not a function"). - Argumentos estrictos. Una clave desconocida es error (
<SERVICIO>_INVALID_ARGS). El frontend revalida con zod estricto. - Anti-bucle. Los cambios hechos por
services.*no disparan hooks de ciclo de vida (p. ej. crear un evento concreateEventno emiteCALENDAR_EVENT_CREATED).
services.calendar#
Fechas en ISO 8601. Un Event es {id, calendarId, title, description, location, startsAt, endsAt, allDay, status, responseStatus, source, organizerEmail, conferenceUrl, attendees:[{email,name,responseStatus}]}.
| Operación | Permiso | Argumentos y límites | Resultado |
|---|---|---|---|
listCalendars | CALENDAR_READ | {} | {calendars:[{id,name,color,source,isReadOnly}]} |
listEvents | CALENDAR_READ | {from, to, limit?, calendarId?}. to > from, rango máx. 92 días, limit 1–200 (50) | {events, truncated} |
getEvent | CALENDAR_READ | {eventId} | {event} (null si no existe) |
findFreeSlots | CALENDAR_READ | {from, to, durationMinutes, limit?, timeZone?, workdayStart?, workdayEnd?, stepMinutes?, calendarId?}. Rango máx. 31 días; duración 5–480; limit 1–50 (10); stepMinutes 5–120 (15); timeZone IANA (UTC); jornada HH:MM (09:00–18:00) | {slots:[{start,end}], timeZone} |
createEvent | CALENDAR_WRITE | {title, startsAt, endsAt, calendarId?, description?, location?, allDay?, attendees?}. Título 1–200, descripción ≤5000, lugar ≤300, endsAt ≥ startsAt, ≤50 asistentes {email, name?}. Sin calendarId: el primer calendario local no de solo lectura | {event} |
updateEvent | CALENDAR_WRITE | {eventId, title?, description?, location?, startsAt?, endsAt?, allDay?}; al menos un campo | {event} |
deleteEvent | CALENDAR_WRITE | {eventId} (cancela el evento, como la ruta de la app) | {deleted:true, eventId} |
addInvite | CALENDAR_WRITE | {eventId, attendees} con 1–20 entradas {email, name?} | {event} |
respondInvite | CALENDAR_WRITE | {eventId, response} con accepted, tentative o declined | {event} |
services.contacts#
Un Contact es {id, email, name, notes, source, createdAt}.
| Operación | Permiso | Argumentos y límites | Resultado |
|---|---|---|---|
search | CONTACTS_READ | {q, limit?}: q 1–100, limit 1–50 (20) | {contacts} |
get | CONTACTS_READ | {contactId} o {email} (exactamente uno) | {contact} (null si no existe) |
list | CONTACTS_READ | {limit?, offset?}: limit 1–100 (50), offset 0–100000 | {contacts, total, nextOffset} |
suggestMerges | CONTACTS_READ | {limit?} 1–50 (20). Solo sugiere; no escribe | {groups:[{reason, contacts}]} (same_email_normalized o same_name) |
create | CONTACTS_WRITE | {email, name?, notes?}: nombre ≤200, notas ≤5000. Si ya existe no sobrescribe | {contact, created} |
update | CONTACTS_WRITE | {contactId, name?, notes?, email?}; al menos un campo. Un email ya existente da conflict | {contact} |
merge | CONTACTS_WRITE | {keepId, mergeIds} con 1–10 ids distintos de keepId. Concatena notas y borra los fusionados | {contact, merged} |
services.storage#
Almacén clave-valor privado por usuario y extensión: una extensión no puede leer el de otra. Claves ^[A-Za-z0-9_.:/-]{1,128}$; el valor es cualquier JSON serializable. Al borrar al usuario se borran sus filas.
| Operación | Permiso | Argumentos y límites | Resultado |
|---|---|---|---|
get | STORAGE | {key} | {value} (null si no existe) |
set | STORAGE | {key, value}: valor ≤ 64 KB (JSON). Cuota total 256 KB (claves + valores) por usuario y extensión; máx. 1000 claves | {ok:true, usedBytes, quotaBytes} |
delete | STORAGE | {key} | {deleted} |
list | STORAGE | {prefix?, limit?}: limit 1–100 (50) | {keys, usedBytes, quotaBytes} |
// Permiso: STORAGE. Contador persistente por usuario y extension.
const storage = ctx.services && ctx.services.storage;
if (!storage) return { success: false, code: 'STORAGE_SERVICE_UNAVAILABLE' };
try {
const current = await storage.get({ key: 'stats/opened' }); // { value: null } si no existe
const count = (current.value && current.value.count) || 0;
await storage.set({ key: 'stats/opened', value: { count: count + 1 } }); // { ok: true, usedBytes, quotaBytes }
const page = await storage.list({ prefix: 'stats/', limit: 20 }); // { keys, usedBytes, quotaBytes }
await storage.delete({ key: 'stats/old' }); // { deleted: boolean }
} catch (error) {
if (String(error.message).startsWith('STORAGE_INVALID_ARGS')) { /* valor > 64 KB o clave invalida */ }
if (error.message === 'STORAGE_ERROR: quota_exceeded') { /* cuota de 256 KB agotada */ }
}services.notify#
| Operación | Permiso | Argumentos y límites | Resultado |
|---|---|---|---|
toast | NOTIFY | {message, title?, level?, url?}: message 1–200, title ≤80, level info (por defecto), success, warning o error; url ruta relativa /... (sin //) o https:// | {delivered:true} |
El aviso se guarda y la interfaz lo muestra como toast (GET /api/expansions/notifications). Tope de 20 pendientes por usuario y extensión y 30 por minuto por extensión (rate_limited).
services.users (solo lectura)#
Permiso `READ_USERS` (riesgo alto: lo aprueba el administrador al instalar o actualizar la extensión). Lista y consulta las cuentas de este dominio: la fuente es la propia instancia (su base de datos) a través del puente firmado con la executionGrant, así que no hay forma de consultar otro dominio. Exige la capacidad lifecycle.events.v2 (cliente con clientApi ≥ 7).
| Operación | Permiso | Argumentos y límites | Resultado |
|---|---|---|---|
list | READ_USERS | {limit?, cursor?, search?}: limit 1–100 (50); cursor opaco de la página anterior; search 1–100 caracteres sobre correo o nombre | {users:[{id,email,name,createdAt}], nextCursor} (nextCursor es null en la última página) |
get | READ_USERS | get("ana@acme.com"), get("<id>"), {id} o {email} (exactamente uno) | {user} (null si no existe) |
- Campos mínimos:
id,email,nameycreatedAt. Nunca contraseña ni hash, tokens, MFA, ajustes ni nivel de permiso. - Paginación estable por cursor (fecha de alta + id): un alta durante el recorrido ni duplica ni salta filas.
- Cuotas: 30 llamadas por ejecución, 120 consultas por minuto por extensión (
USERS_ERROR: rate_limited) y límite por usuario en la ruta. Cada consulta queda en la auditoría (extension.users.read: quién, extensión, operación y número de filas; sin datos de las cuentas). - Permisos reales en dos sitios: el backend (manifest) y la instancia (la
executionGrantfirmada por ella lleva los permisos); sinREAD_USERSfalla conUSERS_PERMISSION_DENIED. - Solo dominios firmados. En modo legado
ctx.services.usersno existe: degrada.
let cursor = null;
do {
const page = await ctx.services.users.list({ limit: 100, cursor: cursor ?? undefined });
for (const u of page.users) { /* u.id, u.email, u.name, u.createdAt */ }
cursor = page.nextCursor;
} while (cursor);Estado compartido del dominio (storage.listShared)#
Las claves de services.storage que empiezan por shared/ se guardan en el espacio del usuario, pero cualquier ejecución de la misma extensión puede leerlas con listShared({prefix: "shared/...", limit?}) (1–200, solo valores, sin quién las escribió). Solo se escriben (`set`/`delete`) desde un hook de servidor: la concesión firmada por la instancia lleva el evento que originó la ejecución, y sin él la escritura falla con STORAGE_ERROR: forbidden; así un usuario no puede añadirse a listas compartidas desde el navegador. mail-groups lo usa para el alta automática en grupos.
Ejemplo completo#
Busca un hueco libre, crea el evento y avisa al usuario. Es el patrón de summarizer (que solo crea eventos tras confirmación explícita), simplificado.
// server.js
// Permisos: CALENDAR_READ, CALENDAR_WRITE, NOTIFY
module.exports = {
bookFirstSlot: async (ctx) => {
const { calendar, notify } = ctx.services || {};
if (!calendar) return { success: false, code: 'CALENDAR_SERVICE_UNAVAILABLE' }; // modo legado o sin permiso: degradar
try {
const from = new Date().toISOString();
const to = new Date(Date.now() + 7 * 86400000).toISOString();
const { slots } = await calendar.findFreeSlots({ from, to, durationMinutes: 30, timeZone: 'Europe/Madrid', limit: 1 });
if (slots.length === 0) return { success: false, code: 'NO_SLOT' };
const title = String((ctx.args && ctx.args.title) || 'Reunion').slice(0, 200);
const { event } = await calendar.createEvent({ title, startsAt: slots[0].start, endsAt: slots[0].end });
if (notify) await notify.toast({ message: 'Evento creado', level: 'success', url: '/calendar' }).catch(() => {});
return { success: true, eventId: event.id };
} catch (error) {
// p. ej. 'CALENDAR_PERMISSION_DENIED: services.calendar.createEvent requires CALENDAR_WRITE ...'
return { success: false, code: String(error.message).split(':')[0] };
}
},
};Límites por invocación#
| Límite | Valor |
|---|---|
| Presupuesto de llamadas | calendar 60, contacts 60, storage 100, notify 10, users 30, formats 200 (y 30 remotas: renderTemplate y sanitizeHtml). Cuentan también las fallidas |
| Timeout por llamada | 15 s (<SERVICIO>_SERVICE_TIMEOUT) |
| Tamaño de respuesta | 512 KB (<SERVICIO>_RESPONSE_TOO_LARGE) |
Cuota de storage | 256 KB por usuario y extensión; valor ≤ 64 KB |
notify | 20 pendientes por usuario y extensión; 30 por minuto por extensión |
Errores#
Todo fallo es un Error cuyo mensaje empieza por el prefijo del servicio (<P>): CALENDAR_, CONTACTS_, STORAGE_, NOTIFY_ o FORMATS_. Atrápalo con try/catch y decide.
| Mensaje | Cuándo |
|---|---|
<P>_PERMISSION_DENIED: services.x.op requires PERMISO in the extension manifest | El manifest no declara el permiso de esa operación |
<P>_INVALID_ARGS: <detalle> | Operación desconocida, args que no son JSON o que no pasan la validación (clave desconocida, rango, tamaño…) |
<P>_ERROR: <code> | El frontend rechazó la operación. code es invalid_args, not_found, forbidden, read_only, conflict, quota_exceeded o rate_limited |
<P>_SERVICE_ERROR (estado) | Respuesta HTTP de error sin code conocido |
<P>_SERVICE_UNAVAILABLE | No hay puente (falta firma o URL), error de red o el frontend no responde |
<P>_SERVICE_TIMEOUT | La llamada superó 15 s |
<P>_CALL_BUDGET | Se superó el presupuesto de llamadas de la invocación |
services.formats (utilidades puras)#
Requiere el permiso FORMATS (riesgo bajo: no accede a datos del usuario). Todo salvo plantillas y HTML se resuelve en el propio backend, sin red; renderTemplate y sanitizeHtml viajan al frontend por el puente firmado.
| Operación | Argumentos y límites | Resultado |
|---|---|---|
formatDate | {value, locale?, timeZone?, style?}: value ISO o epoch ms; locale validado con Intl (en-US); timeZone IANA (UTC); style short, medium, long, full, date, time o datetime (por defecto) | string |
formatNumber | {value, locale?, style?, currency?, minimumFractionDigits?, maximumFractionDigits?}: style decimal, percent o currency (exige currency ISO 4217); dígitos 0–10 | string |
parseIcs | {ics} ≤ 200 KB | {events:[{uid, summary, description, location, startsAt, endsAt, allDay, organizerEmail, attendees:[{email,name,responseStatus}]}]} |
buildIcs | {events, method?, calendarName?}: 1–50 eventos (summary obligatorio; fechas ISO o epoch); method PUBLISH (por defecto), REQUEST o CANCEL | {ics} |
parseVcard | {vcard} ≤ 200 KB | {contacts:[{name, email, phone, org, title, note}]} (máx. 100) |
buildVcard | {contacts, version?}: 1–100 contactos (name obligatorio); version 3.0 (por defecto) o 4.0 | {vcard} |
renderTemplate | {template, variables?}: plantilla ≤ 20000; variables objeto plano de ≤ 50 claves escalares (texto ≤ 5000). Motor Liquid del frontend | {text} |
sanitizeHtml | {html} ≤ 200000 | {html} |
// Permiso: FORMATS. Todo devuelve una Promise; sin permiso falla con FORMATS_PERMISSION_DENIED.
const f = ctx.services && ctx.services.formats;
if (!f) return { success: false, code: 'FORMATS_SERVICE_UNAVAILABLE' };
const when = await f.formatDate({ value: '2026-10-05T15:30:00Z', locale: 'es-ES', timeZone: 'Europe/Madrid', style: 'long' }); // texto con formato local (string)
const price = await f.formatNumber({ value: 1234.5, locale: 'es-ES', style: 'currency', currency: 'EUR' }); // texto con formato local (string)
const { ics } = await f.buildIcs({ // CRLF, escapes y folding a 75 octetos ya aplicados
method: 'REQUEST',
events: [{ summary: 'Demo, con coma; y punto y coma', startsAt: '2026-10-05T15:30:00Z', endsAt: '2026-10-05T16:00:00Z',
attendees: [{ email: 'ana@example.com', name: 'Ana' }] }],
});
const { events } = await f.parseIcs({ ics }); // [{ uid, summary, startsAt, endsAt, allDay, attendees, ... }]
const { vcard } = await f.buildVcard({ version: '4.0', contacts: [{ name: 'Ana Perez', email: 'ana@example.com', org: 'Acme' }] });
const { contacts } = await f.parseVcard({ vcard }); // [{ name, email, phone, org, title, note }]
const { text } = await f.renderTemplate({ template: 'Hola {{ nombre }}', variables: { nombre: 'Ana' } }); // Liquid; un fallo de render es un error, nunca la plantilla cruda
const { html } = await f.sanitizeHtml({ html: '<p onclick="x()">Hola</p><script>x()</script>' });- ICS y vCard seguros. En
buildIcsybuildVcardlos CR/LF y caracteres de control se eliminan de todo valor (no se puede inyectar una propiedad nueva), los textos se escapan (\,;,,y saltos de línea) y las líneas se pliegan a 75 octetos sin partir caracteres UTF-8, con CRLF. Los parámetros (CN) se limpian de comillas y separadores. parseIcsyparseVcarddespliegan las líneas plegadas, desescapan y limpian controles; ignoran lo que no reconocen.renderTemplate: si la plantilla falla (sintaxis, límites) el error esFORMATS_ERROR: invalid_args; nunca se devuelve la plantilla sin renderizar. Escapa los valores de las variables que vayan a un HTML antes de interpolar (así lo hacesignature).sanitizeHtmlusa la misma configuración del sanitizador del frontend (DOMPurify: sin scripts, formularios, iframes ni URI peligrosas; los enlaces recibenrel="noopener noreferrer nofollow"), ejecutada en el servidor con un DOM, así que conserva el formato seguro. Si el DOM del servidor no estuviera disponible, degrada a texto escapado sin etiquetas (seguro, pero sin formato).- Los fallos de las operaciones locales se reportan como
FORMATS_ERROR: invalid_args(sin detalles internos).
Eventos de ciclo de vida#
Son nueve eventos de la aplicación a los que una extensión puede suscribirse. Se declaran como cualquier intercept: en intercepts o en su alias hooks (misma forma {point, handler, priority?}). El handler recibe ctx = {...contexto, event, userId} más services. La tabla muestra el contexto mínimo y exacto: el backend lo filtra con una lista blanca y descarta cualquier otro campo (asuntos, cuerpos, destinatarios…).
| Evento | Contexto | Dónde se dispara |
|---|---|---|
EMAIL_OPENED | {emailId, folder, fromEmail, isRead} | GET /api/emails/[id] (como máximo una vez por minuto por usuario y correo) |
EMAIL_SENT | {emailId, toCount, ccCount, bccCount, hasAttachments, sentAt} | POST /api/emails, tras enviar (no los programados) |
COMPOSE_OPENED | {mode, inReplyToEmailId?, draftId?} con mode new, reply, replyAll o forward | La interfaz avisa al abrir el editor con POST /api/expansions/events |
CALENDAR_EVENT_CREATED, CALENDAR_EVENT_UPDATED, CALENDAR_EVENT_CANCELLED | {eventId, calendarId, startsAt, endsAt, allDay, status, attendeeCount, source} | POST /api/calendar/events, y PUT y DELETE de /api/calendar/events/[id] |
CONTACT_SAVED | {contactId, email, created, source} | POST /api/contacts (created:true) y PUT /api/contacts/[id] (created:false) |
CONTACT_DELETED | {contactId} | DELETE /api/contacts/[id] |
APPOINTMENT_BOOKED | {bookingId, scheduleId, startsAt, endsAt, guestEmail, calendarEventId?} | La reserva pública de citas (POST /api/appointments/book/[scheduleId]) |
{
"manifestVersion": "1.0",
"id": "contact-welcome",
"version": "1.0.0",
"name": "Contact welcome",
"permissions": ["CONTACTS_READ", "STORAGE", "NOTIFY"],
"api": {
"runtime": "nodejs",
"entry": "server.js",
"functions": { "onContactSaved": { "handler": "onContactSaved", "timeout": 8000 } }
},
"hooks": [
{ "point": "CONTACT_SAVED", "handler": "onContactSaved", "priority": "NORMAL" }
]
}// server.js
module.exports = {
// hook CONTACT_SAVED, ctx = { contactId, email, created, source, event, userId, settings, services }
onContactSaved: async (ctx) => {
const { storage, contacts, notify } = ctx.services || {};
if (!storage || !ctx.created) return { skipped: true }; // modo legado: los hooks de ciclo de vida ni se disparan
try {
const key = 'seen/' + ctx.contactId;
const seen = await storage.get({ key });
if (seen.value) return { skipped: true, reason: 'already_seen' };
const found = contacts ? await contacts.get({ contactId: ctx.contactId }) : { contact: null };
const label = (found.contact && found.contact.name) || ctx.email || ctx.contactId;
await storage.set({ key, value: { at: new Date().toISOString() } });
if (notify) await notify.toast({ message: 'Nuevo contacto: ' + String(label).slice(0, 150), level: 'info' });
return { success: true };
} catch (error) {
return { success: false, code: String(error.message).split(':')[0] }; // un hook nunca debe lanzar
}
},
};Eventos v2 (lifecycle.events.v2)#
Cinco eventos más, con payload versionado (v: 1) y mínimo. Cada uno exige un permiso en el manifest (si falta, validateManifest da error y el backend no invoca el handler) y la capacidad lifecycle.events.v2 (requires.clientApi ≥ 7: los clientes antiguos siguen recibiendo la versión anterior de la extensión). No existe USER_DELETED: la plataforma no borra cuentas, solo las deshabilita.
| Evento | Permiso | Contexto | Dónde se dispara |
|---|---|---|---|
USER_CREATED | READ_USERS | {v, eventKey, subjectUserId, email, emailDomain, source, createdAt}; source: register, admin o import | Autoregistro (POST /api/register), alta del administrador y creación de buzones al importar (createUserAccount) |
USER_DISABLED, USER_ENABLED | READ_USERS | {v, eventKey, subjectUserId, email, emailDomain, disabledAt | enabledAt} | Solo cuando el estado cambia (consola de administración); repetir el mismo estado no emite |
EMAIL_SPAM_DETECTED | READ_EMAIL | {v, eventKey, emailId, fromEmail, fromDomain, verdict, action, score?, reasons[]}; verdict: spam, phishing o suspicious; action: junk o flag | El motor anti-spam clasifica un correo entrante como spam o sospechoso. Sin asunto, cuerpo ni destinatarios |
LABEL_APPLIED | READ_EMAIL | {v, eventKey, emailId, labelId, labelName?, source, ruleId?}; source: user, rule o system | Etiqueta aplicada por una regla al recibir o con "Aplicar ahora" (máx. 50 correos por llamada) o por el usuario (editar etiquetas, "Mover a") |
- El handler corre COMO el usuario afectado en
USER_*(ctx.userId == subjectUserId) y como el dueño del buzón en el resto;ctx.services.userspermite leer el directorio. - Procedencia: `ctx.hook = {event, eventKey}` lo fija solo el servidor (en
/executees siemprenull, aunque el cliente envíecontext.hook). Un handler que actúa sobre eventos v2 debe exigirctx.hook?.event === "...": el resto del contexto puede venir de un cliente. - Idempotencia:
eventKeyes determinista por hecho (uc:<id>,sp:<correo>,lb:<correo>:<etiqueta>, …). El frontend descarta repeticiones del mismo hecho durante 10 min (60 s en etiquetas) y los consumidores deben deduplicar poreventKey(los webhooks usan unid/X-BloomX-Deliveryestable derivado de él). - Asíncronos y aislados:
after(), timeout de 5 s en la llamada, ≤ 60 por minuto, y un fallo (backend caído, handler roto, timeout) solo deja una línea de log: nunca bloquea ni revierte el registro, el alta, la deshabilitación, la ingesta ni el etiquetado. - Privacidad por defecto: sin contraseñas, hashes, tokens, asuntos ni cuerpos (lista blanca en el frontend y otra vez en el backend). La importación de correo y lo que hagan las extensiones con
services.*no emiten estos eventos (anti-bucle).
Reglas y límites#
- No bloqueantes. No pueden impedir ni modificar la acción:
stopymodifyse ignoran (soloEMAIL_PRE_SENDlos admite).onError: "block"se ignora yvalidateManifestlo avisa. El frontend los lanza en segundo plano (after()), así que no retrasan la respuesta al usuario. - Solo dominios firmados. En modo legado no se dispara ninguno. El
userIdsale de la identidad firmada, nunca del cuerpo. - Límites. Como máximo 10 handlers por evento (el resto se ignora), ordenados por prioridad; timeout por handler ≤ 10 s (el menor entre el del manifest y 10 s) y presupuesto total de 20 s (pasado el cual los handlers pendientes se omiten). La llamada del frontend espera como máximo 5 s.
- Frecuencia. El frontend emite como máximo 60 disparos por minuto por usuario y evento; el exceso se descarta en silencio.
- Anti-bucle. Lo que hagas con
services.*dentro del handler no vuelve a disparar eventos. - Un
pointdesconocido es un error de manifest;EMAIL_PRE_SEND,EMAIL_RECEIVEDyCRONsiguen su propia semántica (ver Hooks).
Puntos de montaje y su contexto#
Cada punto pasa a la extensión un contexto fijo (el registro MOUNT_POINT_CONTEXT de src/lib/expansions/mount-points.ts); se lee con expresiones ${context.clave}. El contexto nunca incluye auth, user, env ni services: la identidad la pone el servidor. La columna Estado indica si hoy hay un <ExtensionLoader> que lo pinte, según el código del frontend.
| Punto | Dónde | Contexto | Estado |
|---|---|---|---|
EMAIL_TOOLBAR | Barra del correo abierto | email, emailContent, fromContact, content | Montado |
EMAIL_HEADER | Cabecera del correo abierto | Igual que EMAIL_TOOLBAR | Definido, sin pintar |
EMAIL_FOOTER | Pie (hoy, bajo el editor de redacción) | Contexto del editor | Montado (en el editor) |
EMAIL_READER_SIDEBAR | Panel lateral junto al correo abierto | email, emailContent, fromContact, content | Definido, sin pintar |
EMAIL_LIST_ROW_ACTION | Acción por fila de la lista | Igual que EMAIL_TOOLBAR | Definido, sin pintar |
CONTEXT_MENU | Menú contextual de un correo | Igual que EMAIL_TOOLBAR | Definido, sin pintar |
SIDEBAR_HEADER, SIDEBAR_FOOTER | Barra lateral de la app | folder, unreadCounts? | Montados |
SIDEBAR_PANEL | Panel propio en la barra lateral | folder, unreadCounts? | Definido, sin pintar |
COMPOSER_TOOLBAR, COMPOSER_INIT | Editor de redacción | emailContent, subject, to, cc, bcc, sender | Montados |
COMPOSER_SIDEBAR | Panel lateral del editor | Igual que COMPOSER_TOOLBAR | Definido, sin pintar |
CALENDAR_TOOLBAR | Barra del calendario | range:{from,to}, view, isGoogleLinked | Montado |
CALENDAR_EVENT_PANEL | Detalle de un evento | event, calendarId, isReadOnly | Definido, sin pintar |
CALENDAR_HEADER, CALENDAR_SIDEBAR_BOTTOM, CALENDAR_ADD_SOURCES | Calendario | isGoogleLinked | Montados |
CALENDAR_SIDEBAR | Barra lateral del calendario | isGoogleLinked | Definido, sin pintar |
CONTACTS_TOOLBAR | Barra de contactos | contactCount, isGoogleLinked, selectedIds | Montado |
CONTACTS_HEADER, CONTACTS_SIDEBAR, CONTACTS_SIDEBAR_BOTTOM | Contactos | isGoogleLinked (y contactCount en la cabecera) | Montados |
CONTACT_CARD_PANEL | Ficha de un contacto | contact:{id,email,name,notes,source} | Definido, sin pintar |
SETTINGS_PANEL | Ajustes de la extensión | extensionId, settings (valores no secretos) | Definido, sin pintar |
Los demás puntos (EVENT_LOCATION_BUILDER, CUSTOM_SETTINGS_TAB, PAGE, OVERLAY, SLASH_COMMAND, *_HANDLER…) mantienen su comportamiento anterior. Un punto que el schema conoce pero la interfaz aún no pinta valida sin avisos y no se muestra: no falla, pero no tiene efecto hasta que exista el consumidor.
{
"manifestVersion": "1.0",
"id": "sender-context",
"version": "1.0.0",
"name": "Sender context",
"permissions": ["CONTACTS_READ", "STORAGE"],
"api": {
"runtime": "nodejs",
"entry": "server.js",
"functions": {
"lookupSender": { "handler": "lookupSender" },
"savePrefs": { "handler": "savePrefs" }
}
},
"mounts": [
{
"point": "EMAIL_READER_SIDEBAR",
"component": {
"type": "CARD",
"props": { "title": "Sender" },
"children": [
{ "type": "HEADLESS", "props": { "onLoad": {
"action": "CALL_BACKEND", "function": "lookupSender",
"args": { "email": "${context.fromContact.email}" },
"onSuccess": { "action": "SET_STATE", "key": "sender", "value": "${result}" }
} } },
{ "type": "TEXT", "props": { "content": "${state.sender.name}" } }
]
}
},
{
"point": "SETTINGS_PANEL",
"component": {
"type": "FORM",
"props": { "onSubmit": {
"action": "CALL_BACKEND", "function": "savePrefs",
"args": { "label": "${formData.label}" },
"onSuccess": { "action": "TOAST", "message": "Saved" }
} },
"children": [ { "type": "INPUT", "props": { "name": "label", "label": "Label" } } ]
}
}
]
}Estado de montaje
La columna Estado sale de buscar <ExtensionLoader mountPoint=...> en src. Cuando un punto pase de "definido" a "montado" su contexto no cambia: el contrato es el de la tabla.
slashCommands#
Los comandos / del editor de redacción salen de los manifests de las extensiones instaladas. Cada uno es {key, description, action, arguments?} con key ^[a-zA-Z0-9_-]{1,32}$. / al inicio o tras un espacio abre el menú; flechas, Home/End navegan, Enter ejecuta, Tab completa y Esc cierra. El texto tras el comando llega como args/slashArgs. Si dos extensiones repiten la clave gana la primera.
"slashCommands": [
{ "key": "shrug", "description": "Insertar ¯\\_(ツ)_/¯", "action": { "action": "INSERT_CONTENT", "content": "¯\\_(ツ)_/¯" } },
{ "key": "ai", "description": "Redactar con IA", "arguments": "<tema>",
"action": { "action": "OPEN_OVERLAY", "targetId": "ai-dialog", "passArgs": true } }
]Versionado del cliente y compatibilidad#
El backend es compartido y guarda una sola copia de cada extensión, pero cada instancia (frontend) puede desplegar una versión distinta del cliente. La solución es versionar el cliente y negociar capacidades: el cliente dice qué sabe hacer y el backend sirve a cada instancia la versión de la extensión que ese cliente puede ejecutar.
Identidad del cliente#
CLIENT_API_VERSION(entero; hoy 5): contrato cliente↔backend de extensiones. Es independiente de la versión de la app y delBUILD_ID.CLIENT_CAPABILITIESson cadenas estables. Ambos se definen ensrc/lib/expansions/client/capabilities.ts.- Registro único
CAPABILITY_REGISTRYenclient-contract.ts(copia idéntica en el frontend, el backend ybloomx-extensions/_shared). - Cabeceras
X-BloomX-Client-Api(entero) yX-BloomX-Client-Caps(lista separada por comas, máx. 1024 caracteres y 64 capacidades) en todas las llamadas al backend. - Con firma Ed25519 activa, el mensaje firmado pasa a
BLOOMX-SIG-V2= V1 + dos líneas (api y caps), de modo que no se pueden quitar ni añadir. Sin firma (modo legado) viajan como informativas. - Un cliente sin cabeceras es la línea base
legacy:clientApi1 + las capacidades consince: 1.
| Capacidad | Desde clientApi | Qué significa |
|---|---|---|
core.mounts.v1 | 1 (línea base) | Puntos de montaje y manifest basicos (EMAIL_*, COMPOSER_*, CALENDAR_*, SETTINGS_*, EVENT_LOCATION_BUILDER...). |
ui.kit.v1 | 1 (línea base) | Catalogo de componentes de UI original y acciones declarativas (CALL_BACKEND, TOAST, OPEN_OVERLAY...). |
server.execute.v1 | 1 (línea base) | Ejecucion de handlers del servidor (/api/extension/execute) con ctx.args, ctx.env y ctx.services.ai.generate(system, prompt). |
server.hooks.v1 | 1 (línea base) | Hooks de servidor EMAIL_PRE_SEND / EMAIL_RECEIVED / CRON (intercepts) y handlers de middleware del compositor. |
settings.users.v1 | 11 | Ajustes de usuarios: tipos user, users y userMap (selector de usuarios del dominio y un valor por usuario), secretos write-only con pattern/format y ctx.settings.forUser. |
settings.schema.v1 | 2 | Ajustes tipados: settingsSchema en el manifest, formulario en el panel de la extension y ruta /api/extension/config. |
ui.input.onSubmit | 2 | onSubmit (Enter) en INPUT y otros campos de texto. |
ui.kit.v2 | 2 | Componentes STACK, REPEAT, EMPTY, SKELETON y TEXTAREA, y onCancel en dialogos. |
toolbar.compact | 2 | Barras compactas de iconos con acciones ancladas y menu "Extensiones" (propiedad toolbar de los botones). |
ai.v1 | 2 | Servicio de IA de la instancia: bloque ai del manifest, ctx.services.ai.chat/status, cuotas y bloqueo si la IA esta apagada. |
ai.json | 2 | ctx.services.ai.json(prompt, schema): respuesta validada contra un esquema JSON. |
lifecycle.events.v1 | 2 | Eventos de ciclo de vida (EMAIL_OPENED, EMAIL_SENT, CONTACT_SAVED, CALENDAR_EVENT_*, APPOINTMENT_BOOKED...). |
services.host.v1 | 2 | Servicios del anfitrion ctx.services.calendar/contacts/storage/notify/formats/mail-label y sus permisos. |
ext.dependencies.v1 | 3 | Dependencias entre extensiones (requires.extensions): instalacion con dependencias, pausa de dependientes y error EXTENSION_DEPENDENCY_MISSING. |
oauth.provider.v1 | 5 | Proveedores OAuth registrados por extensiones (oauthProviders): ajustes/credenciales, rutas /api/oauth/[provider]/* y cuentas vinculadas por proveedor. |
oauth.provider.v2 | 9 | Proveedores OAuth v2: variables de URL ({tenant}) y emisor por claim (Microsoft), autenticacion Basic (Zoom), OAuth v2 de Slack (bot + usuario), credenciales S2S, nombres de query con $ y parametros acotados por un ajuste del admin. |
oauth.provider.v3 | 12 | Proveedores OAuth v3: credencial BOT del proveedor (token secreto que solo guarda el nucleo, acciones bot-read/bot-write/bot-mod con servidores permitidos y moderacion opcional) y entrega de Interactions de Discord (rutas con firma ed25519 y hook DISCORD_INTERACTION). |
oauth.broker.v1 | 5 | Intermediario OAuth: las extensiones piden acciones al nucleo (ctx.services.oauth) y NUNCA reciben tokens. |
ext.grants.v1 | 6 | Concesiones de ejecucion: la instancia firma con SU clave de dominio una executionGrant de vida corta por ejecucion y el backend la reenvia a los servicios del host (sin clave global del backend). |
lifecycle.events.v2 | 7 | Eventos de ciclo de vida v2 (USER_CREATED, USER_DISABLED, EMAIL_SPAM_DETECTED, LABEL_APPLIED) y servicio de solo lectura ctx.services.users (permiso READ_USERS). |
ext.routes.v1 | 4 | Rutas HTTP propias de extensiones (backendRoutes) servidas por /api/ext/[extensionId]/[...path] con lista exacta de rutas/metodos, limites y auditoria. |
ext.routes.auth.v1 | 4 | Modos de autenticacion de rutas (auth: session, admin + minLevel/stepUp, signature, hmac, none con PUBLIC_ROUTE). |
ext.pages.auth.v1 | 4 | auth/minLevel en paginas de extensiones (mounts PAGE y CUSTOM_ROUTE). |
conferencing.picker | 2 | Proveedores de videollamada (kind: conferencing-provider, conferencingProviders, authModes) y selector de ubicacion del evento. |
ui.pages.v1 | 8 | Componentes de pagina completa (PAGE_HEADER, SPLIT_PANE, KPI_CARD, CHART, TIMELINE, TREE, STEPPER) y tabla avanzada (busqueda, filtros por columna, acciones masivas, estado de error). |
nav.entries.v1 | 8 | Entradas de navegacion de extensiones (navEntries): enlaces a sus paginas en la barra lateral del correo, el menu movil y el menu de administracion, con insignia opcional y control por nivel. |
market.catalog.v1 | 11 | Marketplace de extensiones: el catalogo publico entrega metadatos de tienda (editor, suite, categorias, etiquetas, capturas, changelog, instalaciones, precio). Solo informativa: ningun manifest la declara en requires. |
billing.paypal.v1 | 10 | Pagos con PayPal: compra de extensiones de pago (pago unico y suscripcion), facturacion, recibos y vinculacion de la cuenta del desarrollador. Exige instancia firmada y step-up reciente; solo informativa para manifests (ninguno la declara en requires). |
marketplace.developer.v1 | 10 | Portal de desarrolladores: terminos, validacion en vivo, envio a revision, precio y publicacion de extensiones propias del dominio (/admin/developer). Exige instancia firmada; solo informativa para manifests. |
requires en el manifest#
"requires": { "clientApi": ">=2", "capabilities": ["settings.schema.v1", "ai.v1"] }
// clientApi: 2 | ">=2" | ">=2 <4" (sin "requires" = compatible con legacy / without it = legacy-compatible)- Sin
requiresla extensión es compatible con legacy (todas las 1.0.x). - Las capacidades desconocidas se rechazan al publicar: hay que registrarlas primero (decisión documentada, evita erratas y capacidades futuras sin soporte).
- Un verificador estático (
bloomx-extensions/_shared/feature-rules.mjs, usado porvalidatey por los tests) obliga a declarar la capacidad cuando el manifest oserver.jsusasettingsSchema,onSubmiten campos (no FORM), los componentes STACK/REPEAT/EMPTY/SKELETON/TEXTAREA uonCancel,props.toolbar, un bloqueaioservices.ai.chat/status,ai.json(), eventos de ciclo de vida, permisos de servicios del host, okind/conferencingProviders.
Resolución por petición#
- Se sirve la versión más alta no-yanked cuyos
requirescumple el cliente. - La versión fijada por el dominio (
settings.meta.pinnedVersion, o install conversionexplícita) se respeta si es compatible y no está yanked. - Si hay una versión más nueva incompatible, la respuesta incluye
upgrade { latestVersion, requires, missingCaps, clientApiNeeded }. - Si ninguna es compatible, la extensión no se entrega: aparece en
incompatibleExtensionsde/api/config, y install/update/toggle/execute responden 409EXTENSION_CLIENT_INCOMPATIBLE. - La ejecución en el backend (execute, hooks, settings, config) usa la versión resuelta para ese dominio y cliente.
mandatoryse conserva: si la última versión viva es obligatoria, la servida también. - Se aplica en
/api/config,/api/extensions, public-list,/api/manager/extensions(versionInfo), execute, hooks y settings/config.
Tabla ExtensionVersion#
ExtensionVersion guarda extensionId, version, template, requiresClientApi, requiresCaps, status (published, deprecated, yanked), scriptUrl, scriptSource y contentHash, con UNIQUE(extensionId, version). Extension es el puntero a la última versión. Las versiones son inmutables: el mismo número con otro contenido responde 409 VERSION_EXISTS (el sync aborta; sube la versión, o usa --replace <id>@<ver> de forma explícita).
Publicar una extensión que necesita un cliente nuevo#
- Cliente: registra la capacidad en
CAPABILITY_REGISTRY(las 3 copias), añádela aCLIENT_CAPABILITIES, subeCLIENT_API_VERSIONsi cambia el contrato y añade la regla enfeature-rules.mjs. - Extensión: sube la versión, añade
requires, ejecutanpm run validateynpm test. Conserva la anterior publicable:node --experimental-strip-types scripts/archive-legacy-versions.mjs(desdebloomx-extensions) archiva versiones previas desde git en<ext>/versions/<ver>/. - Sync:
node --env-file=.env scripts/sync-repository-extensions.mjs(osync-extensions.mjs). Conserva la versión que sirve la BD, archiva y mueve el puntero. - Orden de despliegue: (a) crear la tabla
ExtensionVersion(prisma/schema_push.prismaconnpm run prisma:push, o el DDL idempotente deprisma/schema.prisma); (b) desplegar el backend (preview primero, luego prod); (c) ejecutar el sync de extensiones; (d) desplegar los frontends.
Sin la tabla, el código del backend funciona como antes (tolerante), pero el sync falla cerrado.
Deprecación y ciclo de vida#
- Política: se mantiene publicada como mínimo la última versión compatible con la línea base legacy mientras haya instancias legacy, y las 2 últimas versiones menores previas de cada línea.
deprecated= se sirve marcada;yanked= nunca se sirve, instala ni ejecuta. Ambos son reversibles.- CLI:
node --env-file=.env scripts/extension-version.mjs list|yank|deprecate|restore <id> [<version>]. API:POST/GET /api/admin/extensions/versions(Basic Auth de plataforma). - Regla: añadir una capacidad exige registrarla y/o subir
CLIENT_API_VERSION; los tests lo exigen. - Opcional: la variable
BLOOMX_MIN_CLIENT_APIen el backend añade la cabeceraX-BloomX-Min-Client-Apia las respuestas de extensiones. Enlaza con la sección «Cómo se actualiza la PWA» de Operación.
Versiones legacy archivadas#
Extensiones con carpeta versions/ en el repositorio de extensiones: appointments, calendar, composer-helper, dlp, giphy, google-drive, google-meet, google-sync, hubspot, mail-groups, notion, organizer, sealer, signature, slash-commands, smart-reply, summarizer, translator, trello, webhooks, zoom. Se reconstruyen desde el historial git eligiendo, por cada número de versión, el commit más nuevo compatible con la línea base. El contenido que hoy sirva la BD de producción se conserva automáticamente en el primer sync (la BD manda). Si el commit histórico no existe, no se puede reconstruir esa versión.
Catálogo actual y estado#
| Extensión | Estado |
|---|---|
calendar, zoom, google-meet | Funcionales (Zoom exige credenciales de dominio; Meet, cuenta Google vinculada o credenciales) |
notion, trello, hubspot, giphy | Funcionales con credenciales del dominio (GIPHY_API_KEY para Giphy) |
composer-helper, mail-groups, signature, dlp, webhooks, slash-commands | Funcionales (dlp bloquea vía EMAIL_PRE_SEND; slash-commands es solo cliente) |
google-drive | Parcial: listar e insertar enlaces, sin interfaz de subida |
google-sync, appointments | Solo cliente (clientOnly, sin server.js) |
summarizer, translator, smart-reply | Parciales: dependen de que el correo abierto aporte su contenido completo |
organizer | Activo, pero requiere services.mail (dominio firmado: la instancia aporta la executionGrant); si no, falla con ORGANIZER_MAIL_SERVICE_UNAVAILABLE y el hook se omite. Ver Organizer |
sealer | Activo, pero el cifrado ocurre en el navegador; su server.js solo describe el protocolo. Ver Sealer |
Estado según manifests, código y tests del repositorio; no se ejecutó contra proveedores reales (Google, Zoom, Notion…).