Unverified Commit 7f49e86c authored by Kourser's avatar Kourser
Browse files

Appels vocaux 1-pour-1, animateur vers joueur (EV-15)

L'animateur saisit ce que le joueur VERRA s'afficher — un nom d'organisme ou un
numéro — choisit le personnage à joindre, et un téléphone sonne dans son espace.
Audio seul, WebRTC direct de navigateur à navigateur : le serveur ne transporte
jamais la voix, il ne relaie que la signalisation (SDP, candidats ICE). Ni SFU,
ni serveur média, ni enregistrement.

Ce qui a été écarté
- La visio de groupe. Une cellule de crise compte 6 à 10 personnes : le maillage
  s'écroule et il faudrait un SFU plus un TURN, faisant passer docker-compose de
  2 à 4-5 services, avec plages UDP et certificat réel — getUserMedia exige un
  contexte sécurisé. Pour un produit auto-hébergeable, cela deviendrait le
  premier poste de support, pour une fonction que les participants ont déjà.
- Un service externe en iframe, qui contredirait l'invariante « confinement :
  aucune sortie externe » du moteur.
- Tout enregistrement. La voix est une donnée personnelle d'un autre ordre que ce
  que le produit manipule ; il évite déjà délibérément de nommer un joueur réel
  dans les preuves du rapport. C'est un arbitrage de doctrine, consigné au
  cahier des charges (§7.5) et non tranché ici.

Sûreté
- Interdiction du bac à sable STRUCTURELLE, et non paramétrable : le refus est
  adossé à Tenant.ephemeralUntil, pas au réglage d'exercice. Un test force
  voiceCallsEnabled à vrai en base sur un tenant éphémère et vérifie que l'appel
  est refusé quand même — et pour le bon motif. L'ordre des refus est lui-même
  testé, pour qu'on ne lise jamais « réglage fermé » là où c'est le caractère
  éphémère qui bloque : sinon on croirait qu'ouvrir le réglage suffirait.
- Réglage par exercice, fermé par défaut. La fonction demande le micro des deux
  côtés, donc un consentement, et n'a de sens qu'avec l'animation en poste.
  Aucun exercice existant ne change de comportement.
- Appel possible en RUNNING seulement. Avant le lancement, c'est le comcheck.
- Une session « voir comme » ne peut pas décrocher : observer l'espace d'un
  joueur ne donne pas le droit de prendre ses appels. Le garde HTTP la tient en
  lecture seule ; décrocher n'est pas une lecture.
- Un participant d'un autre exercice n'est pas joignable, et un candidat ICE ne
  part qu'au pair concerné — jamais dans la salle d'exercice, un candidat portant
  des adresses réseau.
- Aucun serveur ICE par défaut. Câbler un STUN public aurait révélé l'adresse IP
  des joueurs à un tiers sans que personne l'ait demandé. WEBRTC_ICE_SERVERS
  permet d'en fournir. CONSÉQUENCE ASSUMÉE : sans TURN, la liaison ne s'établit
  qu'au sein d'un même réseau.

RGPD
- Rien de la conversation n'est conservé : ni contenu, ni transcription, ni
  audio. Seules la durée, l'issue et l'étiquette d'appelant sont tracées.
- La trace nomme le PERSONNAGE, jamais la personne réelle — même règle que les
  preuves du rapport (EV-6), un journal se relisant en RETEX devant tous. Un test
  le vérifie avec un participant nommé pour ça.

Interface
- Côté joueur, l'appel s'affiche PAR-DESSUS l'onglet courant : un téléphone qui
  ne sonnerait que sur son propre onglet passerait inaperçu. Un onglet
  « Téléphone » porte l'état au repos et l'historique de la session.
- Sonnerie synthétisée par WebAudio, comme le ping() existant : aucun fichier à
  servir, rien qui sorte de l'instance. Distincte du bip — un appel exige une
  réponse, il doit insister. L'animation de la sonnerie respecte
  prefers-reduced-motion.
- Les échecs sont dits au joueur au lieu d'échouer en silence : micro refusé,
  micro indisponible hors contexte sécurisé, communication interrompue.
- Entrées anglaises pour toutes les chaînes joueur. La console reste en français.

Vérifications
- eslint, typecheck des trois paquets, 199 tests / 21 suites : verts.
- 13 vérifications d'intégration contre la vraie base, dont le refus du bac à
  sable drapeau ouvert, l'isolation entre exercices et la trace au journal.
- À l'écran, dans l'application : onglet « Téléphone » présent sur un exercice
  ouvert, ABSENT dans le bac à sable.
- NON ÉPROUVÉ : le trajet audio complet — sonnerie, décrochage, voix de bout en
  bout. Il demande une session animateur et deux micros. Une revue de sécurité
  de la signalisation reste due avant tout déploiement exposé, au même titre que
  celle due pour le bac à sable.

Co-Authored-By: Claude (RCA)
parent 93d62922
Loading
Loading
Loading
Loading
+15 −0
Original line number Diff line number Diff line
-- Appels vocaux 1-pour-1, animateur -> joueur (EV-15).
--
-- Non-regression stricte : la colonne vaut false par defaut, donc aucun
-- exercice existant n'expose la fonction. L'activer est un geste explicite de
-- l'animateur, exercice par exercice.
--
-- Rappel de doctrine : l'interdiction sur le bac a sable ne passe PAS par cette
-- colonne. Elle repose sur `Tenant.ephemeralUntil`, verifie a la signalisation :
-- un reglage d'exercice ne doit pas pouvoir ouvrir le micro a des visiteurs
-- anonymes, meme par erreur d'ecriture.
ALTER TABLE "Exercise" ADD COLUMN "voiceCallsEnabled" BOOLEAN NOT NULL DEFAULT false;

-- Trace des appels dans le journal (qui a appele qui, decroche ou non, duree).
-- PostgreSQL 12+ accepte ADD VALUE dans une transaction ; le socle est en 16.4.
ALTER TYPE "EventType" ADD VALUE 'VOICE_CALL';
+10 −0
Original line number Diff line number Diff line
@@ -337,6 +337,15 @@ model Exercise {
  /// rendait le briefing invisible au moment ou il servait.
  reflexBriefOpen Boolean @default(false)

  /// Appels vocaux animateur -> joueur (EV-15). Faux par defaut : la fonction
  /// demande le micro, donc un consentement, et n'a de sens que si l'animation
  /// est en poste. Un exercice existant ne change pas de comportement.
  ///
  /// INTERDIT sur une organisation ephemere (bac a sable) : le refus ne repose
  /// pas sur ce drapeau mais sur `Tenant.ephemeralUntil`, qu'un reglage
  /// d'exercice ne peut pas contredire.
  voiceCallsEnabled Boolean @default(false)

  /// Phase courante annoncee aux joueurs. Faux par defaut : afficher le
  /// decoupage aide l'apprentissage et nuit au realisme, c'est donc un choix
  /// d'exercice (cf. cahier des charges des evolutions, EV-1.5).
@@ -995,6 +1004,7 @@ enum EventType {
  PARTICIPANT_ACTION
  CLOCK
  LOG_ENTRY
  VOICE_CALL
}

/// Journal d'exercice (activite systeme + socle de la future main courante, lot 5).
+2 −0
Original line number Diff line number Diff line
@@ -41,6 +41,7 @@ import { DisplayController, StaffDisplayController } from './display.controller'
import { ObservationService } from './observation.service';
import { ObservationController } from './observation.controller';
import { PlayerStartService } from './player-start.service';
import { VoiceService } from './voice.service';
import { PhasesService } from './phases.service';
import { PhasesController, PlayerPhaseController } from './phases.controller';
import { ReflexesService } from './reflexes.service';
@@ -99,6 +100,7 @@ import { ExerciseLogoController, TenantLogoController } from './logo.controller'
    ReflexesService,
    PhasesService,
    PlayerStartService,
    VoiceService,
    ObservationService,
    ReportService,
    ComcheckService,
+16 −0
Original line number Diff line number Diff line
@@ -76,4 +76,20 @@ export const RT = {
  retexOpen: 'retex:open',
  reflexAccess: 'reflex:access',
  phaseCurrent: 'phase:current',

  // --- Appels vocaux 1-pour-1 (EV-15) ---
  /** Vers le joueur : un appel arrive (porte l'offre SDP et les serveurs ICE). */
  voiceIncoming: 'voice:incoming',
  /** Vers l'animateur : le joueur a decroche (porte la reponse SDP). */
  voiceAnswered: 'voice:answered',
  /** Vers l'animateur : le joueur a refuse. */
  voiceDeclined: 'voice:declined',
  /** Vers les deux : l'appel est termine. */
  voiceEnded: 'voice:ended',
  /**
   * Candidat ICE relaye vers l'AUTRE pair. Le serveur ne diffuse jamais dans la
   * salle d'exercice : un candidat contient des adresses reseau, il ne va qu'au
   * pair concerne.
   */
  voiceIce: 'voice:ice',
} as const;
+8 −1
Original line number Diff line number Diff line
@@ -60,7 +60,14 @@ export class MailboxService {
      include: {
        character: { select: { name: true, title: true } },
        exercise: {
          select: { id: true, name: true, status: true, playerSkin: true, autonomous: true },
          select: {
            id: true,
            name: true,
            status: true,
            playerSkin: true,
            autonomous: true,
            voiceCallsEnabled: true,
          },
        },
      },
    });
Loading