Commit 768ed796 authored by Kourser's avatar Kourser
Browse files

Visioconférence : la brique est confinée, le consentement ne l'est pas

Lot L6. C'est le lot qui met à l'épreuve l'argument du §3 : LiveKit n'existe
que dans `adaptateurs/media.ts`, et le remplacer reviendrait à réécrire ce
fichier, pas l'application.

Une brique qui n'apporte aucune interface
  Elle transporte des flux et rend des événements ; toute l'UI reste la nôtre.
  C'est ce qui permettra un panneau de visioconférence flottant, qui survit à
  la navigation, là où une application encapsulée obligerait à quitter l'appel
  pour ouvrir un document. Les salles, les présences, le consentement, les
  repères et la rétention sont notre état, projeté depuis notre journal — sans
  quoi la visioconférence serait une zone d'ombre dans la boîte noire.

Le jeton ne confère jamais le droit d'enregistrer
  Il porte la salle, l'identité, et l'administration pour un animateur — mais
  `roomRecord` reste faux. Enregistrer passe par l'API, donc par le
  consentement et par le journal. Un enregistrement ne peut pas commencer sans
  que la salle en soit avertie.

Le consentement est une présence informée
  Pas une case cochée : on retient qui était là au démarrage, le témoin
  s'allume chez tous par le canal temps réel, et celui qui arrive en cours
  d'enregistrement en est averti avant d'entrer. C'est cette liste qui fait foi
  trois mois plus tard, pas une signature.

Supprimer efface le fichier, jamais la trace
  Le journal conserve qu'un enregistrement a existé, qui l'a effacé et
  pourquoi, et distingue la purge à l'échéance de la suppression décidée.
  Cette dernière est réservée au directeur de crise et exige un motif
  circonstancié. Un enregistrement qui disparaîtrait sans mention ferait douter
  de tout le reste.

Le diagnostic explique au lieu de constater
  « Impossible de rejoindre » n'aide personne à 23 h. Chaque contrôle dit ce
  qui est bloqué et ce qu'il faut demander à qui — jusqu'à la conduite à tenir
  quand même le relais sur 443 échoue : basculer sur un partage de connexion, et
  le signaler, parce que c'est à vérifier avant la prochaine crise et non
  pendant.

Marque-pages raccordés au rembobinage
  L'instant est calculé par le serveur depuis le démarrage : le client n'a rien
  à mesurer. Rembobiner à une séquence propose l'extrait correspondant (EB-19),
  au lieu de faire chercher dans deux heures de vidéo.

Hors périmètre, et dit
  Le partage d'écran, le panneau flottant et le repli audio automatique
  (EF-802, EF-803, EF-805) vivent dans le navigateur : le serveur les rend
  possibles, il ne les fait pas. Marqués comme tels dans le cahier des charges
  plutôt que comptés pour acquis.

Composition
  Le profil « complet » ajoute le serveur média et son enregistreur, isolé pour
  que l'encodage ne dégrade jamais la main courante. Le relais TURN est
  configuré et documenté : en production, son port TLS se publie sur 443, seul
  chemin depuis un réseau d'entreprise verrouillé.

Vérification
  `pnpm verif` passe de 307 à 341 contrôles. La conduite d'une réunion
  s'éprouve sans serveur média, parce qu'aucune de ces règles ne lui appartient ;
  la fabrication du jeton, elle, est vérifiée sur le vrai fournisseur.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 43baf73f
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -25,6 +25,13 @@ S3_BUCKET=kastell
S3_ACCES=kastell
S3_SECRET=CHANGEZ-MOI-secret-stockage

# Visioconférence (profil « complet »)
# Laisser vide désactive la visioconférence ; le produit l'annonce au démarrage.
# La clé doit faire au moins 32 caractères : openssl rand -hex 24
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_CLE=kastell
LIVEKIT_SECRET=CHANGEZ-MOI-secret-media-au-moins-32-caracteres

# Analyse antivirale des dépôts (profil « complet »)
# Laisser vide désactive l'analyse : les documents sont alors marqués
# « non analysée », jamais « saine ».
+29 −2
Original line number Diff line number Diff line
@@ -18,7 +18,8 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L3** | Décisions, actions, points de situation, cockpit | 🟢 fait |
| **L4** | Chat de crise, flux temps réel | 🟢 fait |
| **L5** | Gestion documentaire, stockage objet | 🟢 fait — indexation des PDF à venir avec l'aperçu |
| L6+ | Visio et enregistrement, tableau blanc, mobilisation | ⚪ |
| **L6** | Visioconférence, enregistrement, rétention | 🟢 fait côté serveur — les fonctions d'appel (partage d'écran, panneau flottant, repli audio) vivent dans le client |
| L7+ | Tableau blanc, mobilisation, restitution | ⚪ |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
@@ -29,7 +30,8 @@ invitations, multi-organisation), la console d'administration avec ses quotas,
le dossier de préparation versionné avec son export papier, la boucle
décisionnelle complète (décisions, arbitrages, actions, points de situation,
cockpit), le chat de crise avec son flux temps réel, la gestion documentaire avec dépôts
reprenables et partages tracés, et la pile Docker.
reprenables et partages tracés, la visioconférence avec enregistrement et
rétention, et la pile Docker.

---

@@ -103,6 +105,8 @@ apps/api/src/
    indicateur.ts    ce qui manque, et le geste qui le comble
    campagne.ts      vérification semestrielle des coordonnées
    export.ts        tirage papier et tableur
  domaine/reunions.ts   salles, consentement, marque-pages, rétention
  adaptateurs/media.ts      LiveKit — et nulle part ailleurs dans le produit
  domaine/documents.ts  dépôt reprenable, versions, partages à durée limitée
  adaptateurs/stockage.ts   S3 : MinIO en auto-hébergement, hébergeur en SaaS
  adaptateurs/antivirus.ts  ClamAV, ou rien — mais alors le produit le dit
@@ -158,6 +162,29 @@ promesse « cloner et composer suffit » est une exigence produit. Le format
stocke ses paramètres, une migration reste possible sans invalider les
empreintes.

## Visioconférence : la brique est confinée

LiveKit n'existe que dans `adaptateurs/media.ts`. C'est la démonstration de
l'argument du §3 : on retient une brique qui **n'apporte aucune interface**, qui
transporte des flux et rend des événements. Le remplacer reviendrait à réécrire
ce fichier, pas l'application — et c'est ce qui permettra un panneau de visio
flottant qui survit à la navigation, là où une application encapsulée obligerait
à quitter l'appel pour ouvrir un document.

**Le jeton média ne confère jamais le droit d'enregistrer.** Il passe par
l'API, donc par le consentement et par le journal. Un enregistrement ne peut
pas commencer sans que la salle en soit avertie.

Le consentement n'est pas une case cochée mais une **présence informée** : on
retient qui était là au démarrage, le témoin s'allume chez tout le monde par le
canal temps réel, et celui qui arrive en cours d'enregistrement en est averti
**avant** d'entrer.

Supprimer un enregistrement efface le fichier, **jamais la trace** : le journal
conserve qu'il a existé, qui l'a effacé et pourquoi — et distingue la purge à
l'échéance de la suppression décidée. Un enregistrement qui disparaîtrait sans
mention ferait douter de tout le reste.

## Documents : les octets dehors, l'empreinte dedans

Les fichiers vivent dans le stockage objet ; le journal ne reçoit que la
+1 −0
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
    "@kastell/shared": "workspace:*",
    "fastify": "^5.2.0",
    "ioredis": "^6.0.0",
    "livekit-server-sdk": "^2.18.0",
    "postgres": "^3.4.5",
    "zod": "^3.24.1"
  },
+229 −0
Original line number Diff line number Diff line
import { AccessToken, EgressClient, EncodedFileOutput, EncodedFileType, RoomServiceClient }
  from "livekit-server-sdk";

/**
 * Serveur média.
 *
 * ╔══════════════════════════════════════════════════════════════════════════╗
 * ║  C'est ici, et nulle part ailleurs, que LiveKit existe dans le produit.  ║
 * ╚══════════════════════════════════════════════════════════════════════════╝
 *
 * La brique est retenue parce qu'elle n'apporte aucune interface : elle
 * transporte des flux et rend des événements. Toute l'UI est la nôtre — c'est
 * ce qui permet un panneau de visioconférence flottant, qui survit à la
 * navigation, là où une application encapsulée obligerait à quitter l'appel
 * pour ouvrir un document (§3).
 *
 * Le remplacer reviendrait à réécrire ce fichier, pas l'application.
 */

export interface Jeton {
  jeton: string;
  url: string;
  expireAt: Date;
}

export interface DemandeJeton {
  salle: string;
  identite: string;
  nomAffiche: string;
  /** Seul l'animateur peut lancer un enregistrement ou exclure quelqu'un. */
  animateur?: boolean;
  dureeMinutes?: number;
}

export interface Enregistrement {
  tacheId: string;
  cleObjet: string;
}

export interface DemandeEnregistrement {
  salle: string;
  cleObjet: string;
}

export interface ParticipantMedia {
  identite: string;
  nomAffiche: string;
  rejointAt: Date;
}

export interface Media {
  readonly nom: string;
  readonly url: string;
  disponible(): Promise<boolean>;
  ouvrirSalle(nom: string): Promise<void>;
  fermerSalle(nom: string): Promise<void>;
  participants(nom: string): Promise<ParticipantMedia[]>;
  jeton(d: DemandeJeton): Promise<Jeton>;
  demarrerEnregistrement(d: DemandeEnregistrement): Promise<Enregistrement>;
  arreterEnregistrement(tacheId: string): Promise<void>;
}

export interface ConfigurationMedia {
  url: string;
  cle: string;
  secret: string;
  /** Seau et identifiants du stockage où l'enregistrement composite atterrit. */
  stockage: {
    endpoint?: string | undefined;
    region: string;
    seau: string;
    acces: string;
    secret: string;
  };
}

export function mediaLiveKit(c: ConfigurationMedia): Media {
  const salles = new RoomServiceClient(c.url, c.cle, c.secret);
  const egress = new EgressClient(c.url, c.cle, c.secret);

  return {
    nom: "livekit",
    url: c.url,

    async disponible() {
      try {
        await salles.listRooms();
        return true;
      } catch {
        return false;
      }
    },

    async ouvrirSalle(nom) {
      await salles.createRoom({
        name: nom,
        // Une salle de cellule reste ouverte : on doit pouvoir y revenir à
        // 4 h du matin sans que personne l'ait « planifiée ».
        emptyTimeout: 60 * 60 * 12,
        departureTimeout: 60 * 60 * 12,
      });
    },

    async fermerSalle(nom) {
      await salles.deleteRoom(nom).catch(() => undefined);
    },

    async participants(nom) {
      const liste = await salles.listParticipants(nom).catch(() => []);
      return liste.map((p) => ({
        identite: p.identity,
        nomAffiche: p.name || p.identity,
        rejointAt: new Date(Number(p.joinedAt) * 1000),
      }));
    },

    async jeton(d) {
      const duree = (d.dureeMinutes ?? 240) * 60;
      const jeton = new AccessToken(c.cle, c.secret, {
        identity: d.identite,
        name: d.nomAffiche,
        ttl: duree,
      });
      jeton.addGrant({
        room: d.salle,
        roomJoin: true,
        canPublish: true,
        canSubscribe: true,
        canPublishData: true,
        // L'enregistrement se déclenche par notre API, jamais par le client :
        // il doit passer par le consentement et le journal.
        roomRecord: false,
        roomAdmin: d.animateur ?? false,
      });
      return {
        jeton: await jeton.toJwt(),
        url: c.url,
        expireAt: new Date(Date.now() + duree * 1000),
      };
    },

    async demarrerEnregistrement(d) {
      const sortie = new EncodedFileOutput({
        fileType: EncodedFileType.MP4,
        filepath: d.cleObjet,
        output: {
          case: "s3",
          value: {
            accessKey: c.stockage.acces,
            secret: c.stockage.secret,
            bucket: c.stockage.seau,
            region: c.stockage.region,
            ...(c.stockage.endpoint ? { endpoint: c.stockage.endpoint } : {}),
            forcePathStyle: true,
          },
        },
      });
      const info = await egress.startRoomCompositeEgress(d.salle, { file: sortie },
        { layout: "grid" });
      return { tacheId: info.egressId, cleObjet: d.cleObjet };
    },

    async arreterEnregistrement(tacheId) {
      await egress.stopEgress(tacheId);
    },
  };
}

/**
 * Implémentation d'essai : la conduite d'une réunion — salles, jetons,
 * consentement, marque-pages, rétention — s'éprouve sans serveur média, parce
 * qu'aucune de ces règles n'appartient au fournisseur.
 */
export function mediaEssai(): Media & { finirEnregistrement(tacheId: string): void } {
  const salles = new Map<string, ParticipantMedia[]>();
  const taches = new Map<string, { salle: string; cleObjet: string; fini: boolean }>();
  let compteur = 0;

  return {
    nom: "essai",
    url: "ws://essai.invalide",
    async disponible() { return true; },
    async ouvrirSalle(nom) { if (!salles.has(nom)) salles.set(nom, []); },
    async fermerSalle(nom) { salles.delete(nom); },
    async participants(nom) { return salles.get(nom) ?? []; },
    async jeton(d) {
      compteur += 1;
      return {
        jeton: `essai.${d.salle}.${d.identite}.${compteur}`,
        url: "ws://essai.invalide",
        expireAt: new Date(Date.now() + (d.dureeMinutes ?? 240) * 60_000),
      };
    },
    async demarrerEnregistrement(d) {
      compteur += 1;
      const tacheId = `essai-egress-${compteur}`;
      taches.set(tacheId, { salle: d.salle, cleObjet: d.cleObjet, fini: false });
      return { tacheId, cleObjet: d.cleObjet };
    },
    async arreterEnregistrement(tacheId) {
      const t = taches.get(tacheId);
      if (t) t.fini = true;
    },
    finirEnregistrement(tacheId) {
      const t = taches.get(tacheId);
      if (t) t.fini = true;
    },
  };
}

let courant: Media | null = null;

export function definirMedia(m: Media): void {
  courant = m;
}

export function media(): Media {
  if (!courant) {
    throw new Error(
      "Aucun serveur média configuré. Renseignez LIVEKIT_URL, LIVEKIT_CLE et "
      + "LIVEKIT_SECRET, ou démarrez la composition sous le profil « complet ».",
    );
  }
  return courant;
}

export function mediaConfigure(): boolean {
  return courant !== null;
}
+87 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- 0010 — Visioconférence et enregistrement (§6.8)
--
-- Le serveur média ne détient rien : les salles, les participations et les
-- enregistrements sont notre état, projeté depuis notre journal. LiveKit
-- transporte des flux et rend des événements que l'on réinscrit — c'est
-- précisément ce qui rend la brique remplaçable, et ce qui empêche la
-- visioconférence d'être une zone d'ombre dans la boîte noire.
-- ═══════════════════════════════════════════════════════════════════════════

create table salle (
  crise_id     uuid        not null references crise(id) on delete restrict,
  id           uuid        not null,
  cellule_id   uuid        references cellule(id) on delete set null,
  code         text        not null,
  libelle      text        not null,
  genre        text        not null check (genre in ('cellule', 'ponctuelle')),
  -- Nom de la salle chez le fournisseur : notre identifiant reste le nôtre.
  nom_fournisseur text     not null,
  seq_creation bigint      not null,
  ouverte_at   timestamptz not null,
  fermee_at    timestamptz,
  primary key (crise_id, id)
);
create unique index salle_fournisseur on salle (nom_fournisseur);
create index salle_cellule on salle (crise_id, cellule_id) where fermee_at is null;

create table participation (
  crise_id    uuid        not null,
  id          uuid        not null,
  salle_id    uuid        not null,
  compte_id   uuid        references compte(id) on delete set null,
  nom_affiche text        not null,
  rejoint_at  timestamptz not null,
  quitte_at   timestamptz,
  seq_arrivee bigint      not null,
  seq_depart  bigint,
  primary key (crise_id, id)
);
create index participation_salle on participation (crise_id, salle_id)
  where quitte_at is null;

create table enregistrement (
  crise_id         uuid        not null references crise(id) on delete restrict,
  id               uuid        not null,
  salle_id         uuid        not null,
  demarre_par      uuid        references compte(id) on delete set null,
  demarre_at       timestamptz not null,
  arrete_at        timestamptz,
  duree_secondes   integer,
  -- Identifiant de la tâche d'enregistrement chez le fournisseur.
  tache_fournisseur text,
  cle_objet        text,
  document_id      uuid,
  taille           bigint,
  empreinte        bytea,
  statut           text        not null default 'en_cours'
                     check (statut in ('en_cours','traitement','disponible','echec','supprime')),
  motif_echec      text,
  -- EF-806 : qui était présent au démarrage. Le consentement n'est pas une
  -- case cochée mais une présence informée, et c'est cette liste qui en fait foi.
  participants     jsonb       not null default '[]'::jsonb,
  -- EF-808 : au-delà, le fichier est purgé ; la trace, elle, demeure.
  conserve_jusqu_a timestamptz,
  supprime_at      timestamptz,
  seq_demarrage    bigint      not null,
  primary key (crise_id, id)
);
create index enregistrement_salle on enregistrement (crise_id, salle_id);
create index enregistrement_purge on enregistrement (conserve_jusqu_a)
  where statut = 'disponible' and supprime_at is null;

-- ── Marque-pages (EF-809, EB-19) ───────────────────────────────────────────
-- Un participant signale un instant ; le rembobinage y renvoie ensuite
-- directement, au lieu de faire chercher dans deux heures de vidéo.
create table marque_page (
  crise_id          uuid        not null,
  id                uuid        not null,
  enregistrement_id uuid        not null,
  pose_par          uuid        references compte(id) on delete set null,
  pose_at           timestamptz not null,
  offset_secondes   integer     not null check (offset_secondes >= 0),
  libelle           text        not null,
  seq               bigint      not null,
  primary key (crise_id, id)
);
create index marque_page_enregistrement on marque_page (crise_id, enregistrement_id, offset_secondes);
Loading