Skip to main content

Visioconférence — détails techniques

Fiche fonctionnelle

Description et fonctionnalités de ce module : Visioconférence.

Deux artefacts Element Call, une seule version

L'appel est rendu par Element Call, déployé sous deux formes qui participent aux mêmes appels :

ArtefactÀ quoi il sert
Image Docker ghcr.io/element-hq/element-call (full package)host dédié call.ent-scolaire.fr (chart helm/matrix)fenêtre d'appel autonome : « Ouvrir dans un onglet », et seule voie pour un invité extérieur sans session ENT
Paquet npm @element-hq/element-call-embedded (embedded package)embarqué dans le module, servi sous /chat/public/element-call/appel cadré dans la page de l'ENT

Pourquoi deux : le full package suppose d'être servi à la racine de son origine (chemins d'assets absolus, service worker), il n'est donc pas hébergeable sous un sous-chemin de l'ENT. Le paquet embedded a des chemins relatifs — c'est ce qui permet de le servir depuis l'ENT lui-même, donc en même origine : pas de stockage tiers partitionné par le navigateur, et le jeton Matrix ne quitte pas l'origine de l'ENT.

Verrou de version

Le tag de l'image (elementCall.image.tag, helm/matrix/values.yaml) et la version du paquet npm (modules/chat-nats/frontend/package.json, épinglée sans ^) doivent rester identiquesv0.25.0 / 0.25.0 aujourd'hui. Deux générations de client MatrixRTC ne se voient pas nécessairement dans un même appel : la 0.25.0 supprime le « MatrixRTC legacy mode ». Monter l'un sans l'autre expose à des participants invisibles selon la porte d'entrée qu'ils ont empruntée.

Comment l'appel démarre sans écran de connexion

  1. POST /chat/rooms/:id/call (RoomController.startCall) s'assure du salon Matrix, puis renvoie un descripteur d'appel (RoomController.callTargets) : mode, embedUrl (relative, servie par l'ENT), callUrl (absolue, host dédié) et un code de transfert à usage unique.
  2. L'iframe charge bootstrapUrl : une page de transfert servie à côté de l'app embarquée (/chat/public/element-call/bootstrap.html, générée par copy-element-call.mjs). C'est elle qui échange le code contre la session Matrix du compte fantôme @ent-* (GET /chat/matrix/handoff/:code) et l'écrit dans localStorage["matrix-auth-store"] — la clé qu'Element Call lit quand il ne reçoit pas de widgetId (mode autonome) — avant de se remplacer par l'app elle-même.
  3. Le jeton est retiré du stockage local au démontage du panneau (CallFrame), après l'iframe — Element Call réécrit lui-même cette clé, un nettoyage trop précoce serait annulé.
Pourquoi une page de transfert et non la page parente

Une page parente ne peut écrire le stockage local de l'iframe que si elle partage son origine. C'est toujours vrai pour la messagerie autonome (servie par le module), mais pas pour le widget du dashboard : même origine en production (<origine>/dashboard), origines distinctes en développement (:3001 face à :8090). La page de transfert, elle, est servie par le module donc toujours à la même origine que l'app encadrée — le cadrage marche partout, et aucune page parente ne manipule plus le jeton Matrix. Ne pas la confondre avec le bootstrap.html du chart helm/matrix, qui sert la fenêtre autonome sur son host dédié et reste en place.

Clavardage pendant l'appel : ne rien réimplémenter

Element Call n'expose aucune fonction de clavardage (relevé sur ses 155 libellés d'interface : aucune clé de message, de tableau blanc, de sondage ni de captation — les deux clés « enregistrement » du paquet concernent l'inscription d'un invité). Inutile d'en ajouter une : la conversation d'où part l'appel porte déjà l'échange écrit.

Le panneau d'appel de la messagerie est donc en flux dans la colonne du fil (.thread-column), à 55 % de sa hauteur, et non plus en recouvrement. Le mode plein écran reste disponible via une classe (.call-frame.expanded) — jamais par un démontage de l'iframe, qui équivaudrait à quitter la séance. C'est ce que vérifie 05_appel_cadre_dans_lent : le src de l'iframe est identique avant, pendant et après la bascule, et les hauteurs sont mesurées plutôt que la « visibilité » du composer (un élément simplement recouvert reste visible au sens de Playwright).

La bulle du dashboard, large de 340 px, ne peut pas présenter les deux : son panneau d'appel reste plein écran et propose « Écrire dans la conversation », qui ouvre /chat sans le lien profond ?call= — rejoindre l'appel une seconde fois évincerait la première session (un seul appareil de conférence par personne).

Enregistrement : la garde de consentement

Le module workflowhub livre le modèle droit-image (profil DECISIONNEL) et expose GET /workflowhub/consentements/image?userIds=…&anneeScolaire=…, qui renvoie pour chaque personne ACCORDE, REFUSE ou ABSENT.

Trois distinctions structurent cette vérification, et chacune est une erreur classique :

DistinctionPourquoi elle compte
Déposant ≠ personne concernéequand un responsable répond pour son enfant, dossier.owner_user_id porte le parent. La clé utile est payload.personne.userId, préremplie à l'ouverture du formulaire
Refus ≠ absence de réponsele formulaire porte un booléen autorisation.enregistrementVisio, lu en plus de l'état du dossier : un dossier ACCEPTE portant un refus ne vaut pas autorisation
En cours d'examen ≠ accordétant que l'établissement n'a pas décidé, il n'y a pas d'autorisation

La décision la plus récente fait foi (DISTINCT ON … ORDER BY decided_at DESC) : une démarche peut être refaite, après correction ou changement d'avis.

Le détail (référence du dossier, date de décision) n'est renvoyé que sur soi-même. Savoir qui bloque un enregistrement est nécessaire pour lever le blocage ; connaître la référence du dossier d'autrui ne l'est pas.

La garde est appliquée par le serveur, dans chat-nats. Le navigateur ne fait que lire l'état qu'il publie : un contrôle écrit dans le navigateur se contourne, et l'engagement pris envers les familles ne peut pas reposer sur du code que l'utilisateur maîtrise. C'est la raison pour laquelle la vérification est passée du frontend (appel direct à WorkflowHub, supprimé depuis) au serveur.

ConsentGate interroge WorkflowHub sur le bus d'événements (workflowhub.consentements, consommateur clusterisé), et non par HTTP avec le cookie de l'appelant : la décision ne doit pas dépendre de la session de qui la déclenche — celle-ci peut être celle d'un participant, pas celle du serveur qui applique la règle. GET /chat/rooms/:id/recording publie l'état complet (available, allowed, canRecord, blocking nominatif, recording) et fait autorité.

Trois choix à ne pas défaire :

  • la vérification porte sur tous les membres du salon, pas sur les seules personnes présentes : quelqu'un peut rejoindre en cours de séance, et n'autoriser que les présents reviendrait à contourner la règle en arrivant en retard ;
  • si la vérification est indisponible, on n'affiche rien plutôt que d'affirmer que l'enregistrement est possible. Une garde qui échoue en s'ouvrant n'est pas une garde. Pendant une captation, l'indisponibilité de la vérification arrête l'enregistrement : le doute ne profite jamais à la captation ;
  • l'organisateur est celui que désigne activeCall.startedBy du salon, tranché côté serveur — le navigateur ne peut pas le deviner, il a pu rejoindre l'appel en cours de route.

Les noms des membres sont résolus sans filtre de visibilité de l'annuaire : ce sont les membres d'un salon dont l'appelant fait partie, il les côtoie déjà dans le fil ; les masquer donnerait une liste incomplète des personnes qui bloquent.

Où atterrira l'enregistrement

Le chart helm/matrix porte désormais les pièces nécessaires (désactivées par défaut) : redis (file de travail entre le SFU et les workers), egress (le worker de captation, avec son volume de travail) et la clé redis ajoutée à la configuration de LiveKit.

Deux conséquences à connaître avant d'activer :

  • activer Redis impose de recharger LiveKit — les appels en cours sont coupés le temps du redémarrage. À faire hors temps scolaire ;
  • le volume d'Egress n'est PAS le stockage final. La pile matrix et l'ENT ne tournent pas sur le même cluster, et un volume local-path en RWO ne se partage ni entre nœuds ni entre clusters. Le fichier est donc écrit là pendant la captation, puis déposé dans l'espace documentaire de l'ENT par API — où il devient un vrai document (quota, partage, suppression) — et retiré du volume de travail.

Le poste le plus coûteux de toute la pile sera ce worker : il compose la scène dans un Chrome sans interface (1 cœur, 2 Gio demandés, et un volume de mémoire partagée dédié — sans lui, Chrome plante sur les 64 Mio par défaut d'un conteneur).

L'onglet Stockage de la configuration OpenENT a été ajouté pour cela : le volume de fichiers de l'ENT fait 5 Gio, ce que quelques heures de vidéo suffisent à remplir.

La chaîne côté chat-nats

PièceRôle
ConsentGateinterroge WorkflowHub sur le bus ; withoutConsent est une fonction pure, testée
LiveKitEgress / DefaultLiveKitEgresspilote la captation en Twirp/JSON sur le SFU. Ne décide rien — il ignore les autorisations : le jour où la captation changera de technologie, la règle n'aura pas à être réécrite
RecordingService / RecordingServiceMongoImplla décision, le registre chat.recordings, l'étiquetage nominatif et les demandes d'effacement

Le jeton d'API LiveKit est un JWT HS256 signé avec le secret du SFU, écrit à la main (pas de SDK Java) et portant le seul droit roomRecord : ni publication, ni administration. Sa signature et la restriction de ses droits sont vérifiées par un test.

chat.recordings porte la référence lisible (VISIO-<AAAAMMJJ>-<6 car.>), qui sert à la fois de nom de fichier et de référence citable dans une demande de suppression : une personne doit pouvoir désigner la vidéo à supprimer sans manipuler un identifiant technique.

Routes exposées (toutes en chat.access, réservées aux membres du salon) : POST/DELETE/GET /chat/rooms/:id/recording, GET /chat/recordings, POST /chat/recordings/:id/erasure. Le refus pour défaut d'autorisation est un 403 porteur : il renvoie blocking nominativement, parce qu'un refus opaque ne permet pas d'agir.

Métadonnées @SecuredAction à déployer

Ces cinq routes sont nouvelles : le répertoire securedaction/ généré doit être déployé avec les classes, sinon elles répondent 404 avec un corps vide. Vaut aussi pour workflowhub, dont le consommateur de bus est nouveau.

Comment la vidéo sort du cluster de visio

LiveKit ne sait déposer que sur un stockage objet (S3, GCS, Azure), dont la plateforme ne dispose pas — le stockage de l'ENT est un système de fichiers. Et le volume de travail d'Egress vit dans k3sprd tandis que l'ENT vit dans k3srct : un volume local-path en RWO ne se partage ni entre nœuds ni entre clusters.

D'où un collecteur (conteneur adjoint au worker Egress, egress.collector du chart) : un script shell qui surveille le répertoire de sortie et POSTe chaque fichier terminé vers POST /chat/recordings/ingest/<référence>. Le flux va de la visio vers l'ENT : aucune vidéo n'est exposée publiquement, et il n'y a pas de composant de stockage supplémentaire à exploiter.

DétailPourquoi
deux mesures de taille espacées avant l'envoiun fichier encore en cours d'écriture grossit ; sans cette attente on enverrait une vidéo tronquée
200 ⇒ le fichier local est suppriméc'est ce qui vide le volume de travail
400/401/404/410 ⇒ déplacé dans refuses/refus définitif : réessayer ne changerait rien, mais supprimer perdrait une vidéo — et le laisser en place ferait boucler la collecte
autre code ⇒ conservé, nouvelle tentativepanne passagère (ENT en cours de redémarrage, réseau)

La route d'ingestion reçoit des vidéos sans session ENT : mal gardée, ce serait un dépôt de fichiers ouvert sur l'internet. Trois verrous — jeton partagé comparé en temps constant, référence contrainte à VISIO-AAAAMMJJ-XXXXXX (elle sert de nom de fichier), et fiche d'enregistrement existante obligatoire — plus une taille plafonnée à 4 Gio, faute de quoi un seul envoi pourrait remplir le volume de fichiers de l'ENT.

La vidéo est déposée dans l'espace documentaire de l'organisateur et n'est pas partagée automatiquement avec les participants : multiplier les copies d'une vidéo où figurent des mineurs irait contre l'objectif même de l'encadrement. Le dépôt est idempotent (filtre sur l'absence de documentId) — une livraison répétée ne crée pas de doublon.

Reste à faire

  • l'activation : le bloc chat.matrix.livekit dans ent-core.yaml (url, clés du SFU, ingest-token, output-dir) et, dans helm/matrix, redis.enabled + egress.enabled + le tag d'image d'Egress + egress.collector.entIngestUrl/ingestToken. Sans le bloc de configuration, l'état renvoie available: false, aucun bouton n'apparaît et la route d'ingestion répond 410 chat.recording.notconfigured — état par défaut, et état actuel de la production ;
  • activer Redis recharge LiveKit et coupe les appels en cours : à faire hors temps scolaire.

Les trois endroits d'où part un appel

SurfaceCe qui se passe
Messagerie autonome /chatpanneau d'appel dans la page, sous le bandeau ENT (CallFrame du module)
Widget de dialogue du dashboardmême appel cadré, en plein écran par-dessus le dashboard (components/chat/CallFrame.tsx) — visible que la bulle soit ouverte ou fermée
Compagnon de bureaunotification native + bandeau + sonnerie ; « Rejoindre » ouvre l'ENT sur le lien profond ?call=<roomId>

Le lien profond /chat?call=<roomId> rejoint directement l'appel du salon (l'endpoint est idempotent : démarrer un appel déjà en cours revient à le rejoindre). C'est la seule voie d'entrée du compagnon de bureau, qui ne connaît que l'identifiant reçu en temps réel.

Compagnon de bureau : pourquoi une connexion permanente

Le compagnon (modules/open-ent-desktop, Tauri) sonde l'ENT toutes les 30 s au mieux (poll_interval_secs.max(30), cf. poller.rs). Un appel ne sonne qu'une trentaine de secondes : un sondage arriverait après la sonnerie. src-tauri/src/chat_ws.rs ouvre donc une connexion permanente sur /chat/ws — la passerelle temps réel du module, qui publie déjà call-started — et notifie immédiatement.

  • Authentification : le cookie de session signé oneSessionId, envoyé en en-tête à la poignée de main. Le webview ne peut pas ouvrir cette connexion lui-même : la session vit dans le client HTTP Rust, pas dans le navigateur embarqué.
  • Libellés sans requête supplémentaire : la trame rooms, émise à la connexion, porte à la fois l'identifiant de l'utilisateur (pour ne pas se notifier soi-même) et les noms de salons.
  • Ce que voit l'utilisateur : notification système (visible fenêtre fermée ou en arrière-plan), bandeau persistant avec sonnerie synthétisée (aucun fichier audio embarqué — les sonneries d'Element Call ne sont pas redistribuables ici), indicateur dans la zone de notification, et fenêtre ramenée au premier plan. Préférence videoconference et heures calmes respectées pour tout ce qui s'impose ; le bandeau, lui, est toujours émis.
  • Extinction de l'indicateur : au bout de 3 minutes (la fenêtre de fraîcheur du serveur), ou dès que la personne rejoint ou ignore. Il n'existe aucun signal de fin d'appel côté pont Matrix, donc personne ne peut nous dire mieux.

Le code de transfert étant à usage unique et commun aux deux URL, une seule des deux vues peut l'emprunter ; « Ouvrir dans un onglet » redemande donc un descripteur au serveur.

Paramètres passés à Element Call dans le fragment (jamais envoyé au serveur) : roomId, homeserver (issu de MATRIX_URL), header=none (le bandeau Open ENT est le seul chrome), confineToRoom=true, background=solid, theme=light et lang — cette dernière résolue côté serveur (I18n.acceptLanguage), pour que les trois surfaces n'aient pas à la recomposer chacune.

chat.matrix.call-mode (bloc chat.matrix d'ent-core.yaml) bascule le comportement sans reconstruire le frontend : iframe (défaut) ou tab (comportement historique). Les trois surfaces ci-dessus le respectent.

Un seul appareil Matrix par personne

Le device Matrix est stable par utilisateur ENT (voulu : sinon la même personne apparaît en double dans la conférence). Conséquence : rejoindre le même appel simultanément depuis le panneau et depuis l'onglet fait s'évincer les deux sessions (même appartenance m.rtc.member). L'interface ne présente jamais les deux à la fois.

Content-Security-Policy : rien à ajouter (et ce n'est pas évident)

L'iframe est en même origine, mais elle n'hérite pas de la CSP de l'ENT : une CSP ne se propage aux documents encadrés que pour les schémas locaux (about:blank, blob:, data:, srcdoc), et entcore ne pose l'en-tête CSP que sur les vues rendues, pas sur les ressources statiques (vérifié en local et en production). Le document encadré n'a donc pas de CSP et ses appels au homeserver et au SFU LiveKit ne sont pas filtrés. Seul frame-src 'self' — déjà présent — est requis, pour autoriser le cadre lui-même.

Corollaire à retenir : poser une CSP globale au niveau de Traefik casserait l'appel. Il faudrait alors y autoriser le homeserver (MATRIX_URL), le service de jetons LiveKit (https://livekit-jwt.…) et le SFU (wss://livekit.…).

Activation sur un déploiement

Le pont est désactivé par défaut : sans jeton d'Application Service, toute demande d'appel est refusée avec le message « Les appels ne sont pas activés sur ce serveur. ».

L'activer suppose :

  1. un serveur Matrix et un Element Call déployés (voir le chart helm/matrix du dépôt open-ent-mods) ;
  2. l'enregistrement de l'Application Service chat-nats auprès du homeserver, qui produit les jetons as_token (sortant) et hs_token (transactions entrantes) ;
  3. le renseignement de ces jetons dans la configuration du module (CHAT_MATRIX_AS_TOKEN, CHAT_MATRIX_HS_TOKEN).

Couverture de tests

Scénarios e2e Playwright dans apps/open-ent-e2e/src/modules/40_messagerie_instantanee/ :

  • 03_appel_visio.spec.ts — affichage des actions d'appel (vidéo, vocal, invitation d'un contact externe) dans l'en-tête d'un salon.
  • 05_appel_cadre_dans_lent.spec.ts — appel cadré dans l'ENT, sans effet de bord : le descripteur d'appel et l'échange du code de transfert sont interceptés (aucun salon Matrix créé, personne notifié). Vérifie le panneau, l'URL réellement chargée dans l'iframe (/chat/public/ element-call/), la délégation des permissions média (allow=), le démarrage effectif d'Element Call dans l'iframe (il applique le theme=light demandé sur son propre <body>), la persistance du bandeau ENT, les deux commandes du panneau et le retrait du jeton Matrix du stockage local à la fermeture. Produit la capture de la fiche fonctionnelle.
  • 04_appel_en_cours.spec.ts — notification du destinataire lors d'un appel entrant. Ce test écrit : il crée un salon Matrix et notifie le destinataire, donc il est verrouillé sur une conversation précise entre deux comptes de test — si elle n'est pas trouvée, le test s'arrête sans appeler, plutôt que de sonner dans un salon quelconque. Le salon visé se règle par la variable E2E_CHAT_PEER_LABEL (libellé tel qu'affiché dans la liste des conversations).

Reste non illustrée la séance de visioconférence elle-même (grille des participants, partage d'écran, couper le son) : elle appartient à Element Call, pas à l'ENT, et exige deux participants réels avec une session de conférence établie — non simulable en e2e. 05_appel_cadre_dans_lent s'arrête donc au démarrage de la fenêtre d'appel, et 04_appel_en_cours ne capture l'appel réel que sur une instance dont le pont Matrix est configuré.

Conformité

Maillon de la chaîne qualitéRéférence
🎯 Fonctionnalités attenduesfiche fonctionnelle
🧪 Tests réaliséscouverture e2e ci-dessus (à élargir)
✅ Tests de conformitétableau de conformité