Reuniones (Zoom y Meet)
Crear reuniones de Zoom, Google Meet o enlace propio desde calendario, correo y citas.
En esta página
Crear una reunión de Zoom o Google Meet (o pegar un enlace propio) funciona igual desde el calendario, el composer (/zoom, /meet), las citas y el lector de correo. La lógica de cada proveedor vive en su extensión (core-zoom, core-google-meet; core-calendar orquesta eventos e invitaciones); el frontend aporta la capacidad de núcleo conferencing: registro de proveedores, API única, selector compartido, idempotencia y validación de enlaces.
UI (selector de reuniones, evento, composer, citas)
| POST /api/calendar/conferencing/{google-meet|zoom|custom} sesion + limite + Idempotency-Key + zod
v
Host (frontend): registro ConferenceMeeting (idempotencia y propiedad) --> puente firmado
v
Backend compartido POST /api/extension/execute (modo firmado = con credenciales del dominio)
v
Extension core-zoom | core-google-meet --safe-fetch https--> api.zoom.us | googleapis.com
^
Panel de administracion (manager dueno del dominio) --> credenciales cifradas del dominio (ENV_READ)Modos de autenticación#
| Proveedor | Modo | Qué usa | Quién lo configura |
|---|---|---|---|
| Zoom | server-to-server | ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET de una app Server-to-Server | Administrador (panel) |
| Zoom | user-oauth | La cuenta Zoom del propio usuario (Ajustes → Integraciones → Conectar) | Cada usuario; requiere ZOOM_CLIENT_ID/SECRET (app OAuth) en el frontend |
| Zoom 2.x (ZoomLib) | user-oauth y server-to-server | Las credenciales viven cifradas en la instancia, en los ajustes de core-zoomlib: app OAuth de usuario (ZOOM_CLIENT_ID/SECRET) y app S2S propia (ZOOM_ACCOUNT_ID, ZOOM_S2S_CLIENT_ID/SECRET, ZOOM_HOST_EMAIL). La extensión no ve tokens ni usa ENV_READ. Exige clave de dominio; sin ella se sirve core-zoom 1.4.1 | Administrador (ajustes de ZoomLib) y cada usuario (Ajustes → Integraciones → Conectar) |
| Microsoft Teams | microsoft-account | La cuenta Microsoft de trabajo o escuela del propio usuario, vinculada con MicrosoftLib (core-microsoftlib, ajustes MICROSOFT_CLIENT_ID/SECRET/TENANT); las reuniones se crean con Graph (onlineMeetings o evento con Teams). Exige clave de dominio | Administrador (ajustes de MicrosoftLib) y cada usuario |
service-account | JSON de cuenta de servicio (máx. 16 KB) + GOOGLE_IMPERSONATE_USER (delegación en Workspace) | Administrador (panel) | |
google-account | Cuenta organizadora de la instancia (OAuth, scopes mínimos calendar.events openid email) o la cuenta Google vinculada de cada usuario | Administrador o usuario | |
| Enlace propio | custom-link | Enlace https pegado por el usuario; sin credenciales | Nadie |
Sin *_AUTH_MODE explícito el modo es automático: Zoom usa Server-to-Server si hay credenciales y, si no, la cuenta del usuario; Google usa la cuenta de servicio si hay JSON, luego la cuenta organizadora y, por último, la cuenta vinculada del usuario. La fuente activa (instancia / cuenta del usuario / ninguna) aparece en Ajustes → Integraciones.
Compatibilidad
Lo que ya funcionaba sigue funcionando: GOOGLE_MEET_ADMIN_REFRESH_TOKEN equivale a la cuenta organizadora, ZOOM_ACCOUNT_ID/CLIENT_ID/CLIENT_SECRET siguen siendo los nombres de las credenciales S2S, la ruta antigua POST /api/calendar/conferencing/meet es un alias de la nueva y, si la extensión no está instalada o el backend no responde, un adaptador fino del frontend usa la cuenta vinculada del propio usuario.
Qué debe configurar el operador#
- Instalar
core-zoomy/ocore-google-meet(ycore-calendar) desde Extensiones y pulsar Credenciales, o usar Ajustes → Integraciones (solo el manager dueño del dominio puede escribirlas). - Elegir el modo y completar los valores (siempre enmascarados; el servidor nunca los devuelve). Pulsar Probar conexión: llama al proveedor y muestra OK o el motivo legible.
- Para modo por usuario: definir en el frontend
GOOGLE_CLIENT_ID/SECRET(scopes Calendar + Meet en/api/auth/google) y/oZOOM_CLIENT_ID/SECRETcon{NEXT_PUBLIC_APP_URL}/api/auth/callback/zoomcomo redirect. - Para la cuenta organizadora: registrar
{NEXT_PUBLIC_APP_URL}/api/auth/callback/google-organizercomo redirect autorizado en Google Cloud y pulsar Conectar cuenta de Google. - Dominio firmado: las credenciales del dominio solo llegan a las extensiones si la instancia firma sus peticiones (
BLOOMX_DOMAIN_PRIVATE_KEY, ver Arquitectura). En un dominio legado solo funciona el modo por usuario.
// Zoom Server-to-Server (Ajustes -> Integraciones -> Zoom, o "Credenciales" de la extension)
ZOOM_AUTH_MODE=server-to-server
ZOOM_ACCOUNT_ID=<id-de-cuenta>
ZOOM_CLIENT_ID=<client-id>
ZOOM_CLIENT_SECRET=<client-secret>
// Google: cuenta de servicio con delegacion de todo el dominio (Workspace)
GOOGLE_AUTH_MODE=service-account
GOOGLE_SERVICE_ACCOUNT_JSON=<JSON completo, una linea>
GOOGLE_IMPERSONATE_USER=organizador@tu-dominio.com
// Google: cuenta organizadora (boton "Conectar cuenta de Google")
GOOGLE_AUTH_MODE=google-account
GOOGLE_ORGANIZER_REFRESH_TOKEN=<lo guarda el callback, nunca lo veras>API#
| Ruta | Uso |
|---|---|
GET /api/calendar/conferencing/providers | Estado por proveedor para el usuario: configured, connected, mode, source, reason, connect |
POST /api/calendar/conferencing/[provider] | Crear: cuerpo { topic, startsAt, endsAt, timeZone, attendees, customUrl, attachToEventId }, cabecera Idempotency-Key. Devuelve { meeting: { provider, joinUrl, hostUrl, meetingId, passcode, dialIn, attachment? } } |
DELETE /api/calendar/conferencing/[provider]?meetingId= | Borrar; solo reuniones creadas por el mismo usuario |
POST /api/calendar/conferencing/[provider]/test | Probar conexión (solo admin) |
POST /api/calendar/conferencing/meet | Alias compatible de google-meet ({ title, startsAt, endsAt } → { meetUrl }) |
GET /api/admin/conferencing | Panel: instalaciones y claves configuradas (solo admin) |
| Código | HTTP | Significado y acción |
|---|---|---|
not_connected | 409 | Falta conectar: botón Conectar (OAuth o Ajustes) |
token_revoked | 401 | Token revocado o caducado: Reconectar |
invalid_credentials | 424 | Credenciales de la instancia inválidas: avisar al administrador |
rate_limited | 429 | Demasiadas peticiones; retryAfter en segundos |
provider_error | 502 | Fallo del proveedor o de red: reintentar (misma Idempotency-Key) |
invalid_input / unavailable / not_supported | 400 / 503 / 501 | Entrada inválida, extensión o backend no disponible, operación no soportada |
Garantías#
- Propiedad estricta: la cuenta usada sale de la sesión del servidor; nunca se usa la cuenta de otro usuario y un
context.authdel navegador se descarta. Solo se puede borrar una reunión creada por uno mismo (tablaConferenceMeeting; en modo instancia el token es de toda la cuenta). - Idempotencia persistente: la misma
Idempotency-Keydevuelve la misma reunión (también con peticiones simultáneas y varias instancias). En Google además se envía unrequestIdderivado. - Enlaces validados: el resultado de una extensión se valida (https, sin credenciales, sin puertos raros) antes de llegar al navegador.
- Secretos: el JSON de la cuenta de servicio y los tokens se cifran en reposo y nunca vuelven al navegador ni a los registros; los mensajes de error se sanean.
- Cuenta Zoom: los tokens son de un solo uso (se rota el par al refrescar); si Zoom responde
invalid_grantla cuenta queda marcada y se pide reconectar.
Correo: invitaciones y botón Unirse#
- Las invitaciones
.icsllevanLOCATION,URL,CONFERENCE(RFC 7986),X-GOOGLE-CONFERENCE/X-ZOOM-JOIN-URLsegún el host exacto del enlace y el código de acceso y números de marcación en la descripción; todo con saneo de CR/LF (CWE-93). - El cuerpo incluye un botón Unirse (con datos de marcación si existen) solo para enlaces https válidos.
- El lector muestra Unirse a la reunión con el icono del proveedor para Meet, Zoom, Teams, Webex y Jitsi. Un enlace https de otro host se muestra como texto con un aviso, sin botón de confianza.
- Responder (RSVP), actualizar o cancelar no crea reuniones nuevas: el
UIDes estable,SEQUENCEsolo crece y una cancelación marca el evento cancelado. Al borrar un evento o cancelar una cita se cancela también la reunión del proveedor (mejor esfuerzo).
Datos y eventos existentes#
CalendarEvent gana tres columnas opcionales (conferenceUrl, conferenceProvider, conferenceMeetingId, creadas por npm run db:ensure). Los eventos anteriores solo guardaban el enlace en location: al leerlos, el enlace reconocido se deriva de location, así que nada se migra ni se rompe. npm run meet:fix y POST /api/google/fix-meet-rooms consideran ambas columnas.
Pruebas#
npx vitest run src/lib/conferencing src/components/conferencing(proveedores y rutas con dobles, selector con jsdom) ynpm run test:pg(registro, idempotencia concurrente y descifrado deAccount.refresh_tokencon Postgres real).npm testenbloomx-extensions(Zoom, Meet y Calendar confetchsimulado, ambos modos, JWT de cuenta de servicio, errores tipados) y enbloomx-backend(crypto.signRs256del sandbox).- E2E local con servidores falsos:
scripts/fake-conferencing.mjs(Google y Zoom falsos),scripts/fake-backend.mjs(ejecuta las extensiones reales) yscripts/e2e-conferencing-seed.ts. Las basesGOOGLE_API_BASE,MEET_API_BASE,GOOGLE_TOKEN_URL,ZOOM_API_BASEyZOOM_OAUTH_BASEsolo se aceptan conNODE_ENVdistinto deproductiono hacia localhost.
Límites#
- Nada se probó contra Google o Zoom reales; el E2E usa servidores falsos.
- El sandbox de extensiones solo ofrece
crypto.signRs256desde esta versión del backend; con un backend antiguo la cuenta de servicio respondenot_supported. - El modo cuenta de servicio necesita delegación de todo el dominio (Workspace): una cuenta de servicio sin ella no puede crear enlaces de Meet.
- Si el límite de una credencial en el backend sigue en 4096 caracteres, un JSON de cuenta de servicio con clave de 2048 bits (≈2,5 KB minificado) cabe; uno mayor requiere el límite de 16 KB.
- Las agendas (
AppointmentSchedule.conferencing) solo admitenmeetyzoom; Teams y el enlace propio solo existen en eventos y en el composer. - Quitar una reunión ya guardada en un evento solo borra el enlace del formulario; la reunión se cancela en el proveedor al borrar el evento.