Commit 1157fd3b authored by Kourser's avatar Kourser
Browse files

Un banc d'essai contre la vraie pile, et la clé masquée qu'il a trouvée

« pnpm verif » éprouve le produit contre des doubles, et les doubles mentaient :
en une session, un appel sans son, une piste refusée par l'enregistreur, un
modèle de transcription jamais téléchargé — trois défauts passés au vert.
« pnpm banc » joue les mêmes parcours contre les services de la composition,
par les adaptateurs du produit : une voix synthétisée entre dans une salle, sa
piste est enregistrée puis transcrite, la réunion est enregistrée, la salle est
diffusée vers un puits RTMP et vers des destinations mortes, un courriel fait
l'aller-retour. Ses auxiliaires sont figés par empreinte et retirés, réussite
ou échec. Ce que le poste ne permet pas d'éprouver est dit, et ne compte jamais
comme réussi.

Au premier passage, il a trouvé ce que la suite laissait passer. LiveKit rend
les URL de diffusion avec la clé masquée — « {abc...nop} » — et Kastell
reconnaissait chaque destination par égalité d'URL. En production, aucune
plateforme qui lâche n'était donc jamais reconnue : l'écran gardait un carré
vert pour une destination morte. Le double rendait l'URL intacte.

L'adaptateur reconnaît désormais une destination sous le masque : même début
d'URL, une clé qui commence et finit comme la sienne. Ni par position — mesuré,
l'ordre n'est pas conservé — ni en recalculant un masque dont la règle peut
changer. Deux destinations indiscernables ne sont attribuées à aucune : mieux
vaut ne pas marquer d'échec que déclarer morte la plateforme qui diffuse. Le
double masque comme le vrai, relevé forme par forme.

Le banc a aussi trouvé une adresse média figée dans la configuration locale,
qu'un changement de DHCP rendait fausse : il le vérifie maintenant.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 5f657365
Loading
Loading
Loading
Loading
+19 −0
Changes for README.md: 19 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -106,6 +106,25 @@ falsification par un vérificateur qui n'utilise pas la base, et reconstitution
d'un état passé. Chaque ligne renvoie à l'exigence du cahier des charges qu'elle
démontre.

### Éprouver la vraie pile

```bash
pnpm banc
```

`pnpm verif` éprouve le produit contre des doubles, et un double peut mentir :
il rendait intacte l'URL d'une plateforme que LiveKit masque, et Kastell ne
reconnaissait donc aucune diffusion qui lâche — au vert. Le banc parle aux
services de la composition **par les adaptateurs du produit** : une voix
synthétisée entre dans une salle, sa piste est enregistrée puis transcrite,
la réunion est enregistrée, la salle est diffusée vers un puits RTMP et vers
des destinations mortes aux formes de YouTube, Twitch et Facebook, et un
courriel fait l'aller-retour. Il lève ses auxiliaires jetables et les retire,
réussite ou échec.

La composition doit tourner. Ce que le poste ne permet pas d'éprouver — pas de
synthèse vocale, pas de relais SMTP — est dit et **ne compte pas comme réussi**.

---

## Structure
+1 −0
Changes for apps/api/package.json: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@
    "dev": "tsx watch --env-file=../../.env src/index.ts",
    "migrate": "tsx --env-file=../../.env src/db/migrate.ts",
    "verif": "tsx --env-file=../../.env src/verif.ts",
    "banc": "tsx --env-file=../../.env src/banc.ts",
    "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\" && esbuild src/racine.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/racine.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\" && esbuild src/amorce-cli.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/amorce.js --banner:js=\"import{createRequire}from'module';const require=createRequire(import.meta.url);\"",
    "amorcer": "node --env-file=../../.env --import tsx src/amorce-cli.ts"
  },
+78 −7
Changes for apps/api/src/adaptateurs/media.ts: 78 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -61,11 +61,69 @@ export interface Sortie {

/** Ce que le serveur média rapporte d'une destination, pendant et après. */
export interface EtatSortie {
  url: string;
  /**
   * La destination reconnue, ou null quand aucune ne correspond sans doute.
   *
   * Pas l'URL : le fournisseur ne rend pas celle qu'on lui a donnée (voir
   * « reconnaitreSortie »), et l'apparier plus haut ne marchait qu'avec le
   * double.
   */
  id: string | null;
  statut: "ouverte" | "fermee" | "echec";
  motif: string | null;
}

/**
 * La clé de flux telle que LiveKit la rend : masquée.
 *
 * Mesuré contre l'enregistreur 1.14.1 : le dernier segment de l'URL — requête
 * comprise — devient « {tête...queue} », tête et queue gardant chacune
 * min(3, ⌊longueur/3⌋) caractères. « abcd-efgh-ijkl-mnop » rend « {abc...nop} »,
 * « abcdef » rend « {ab...ef} », « abc » rend « {a...c} ».
 *
 * Le fournisseur a raison de le faire : une clé de flux est la voix de
 * l'entreprise. Mais le produit appariait chaque état à sa destination par
 * égalité d'URL, et le double rendait l'URL intacte. Tout passait au vert ; en
 * production, aucune plateforme qui lâche n'était jamais reconnue, et l'écran
 * gardait un carré vert pour une destination morte. Le banc l'a trouvé.
 *
 * Exporté pour que le double masque exactement comme le vrai.
 */
export function masquerCle(url: string): string {
  const coupure = url.lastIndexOf("/") + 1;
  const cle = url.slice(coupure);
  const n = Math.min(3, Math.floor(cle.length / 3));
  if (coupure === 0 || n === 0) return url;
  return `${url.slice(0, coupure)}{${cle.slice(0, n)}...${cle.slice(-n)}}`;
}

/**
 * Retrouver, derrière une URL rendue par le fournisseur, la destination confiée.
 *
 * Ni par position — mesuré, LiveKit ne rend pas les sorties dans l'ordre où on
 * les a données — ni en recalculant son masque, dont la règle peut changer
 * d'une version à l'autre sans prévenir : on garde ce qui ne peut pas mentir.
 * Même début d'URL, une clé qui commence par la tête et finit par la queue.
 *
 * Deux destinations indiscernables sous le masque — même hôte, mêmes trois
 * premiers et derniers caractères — ne sont attribuées à aucune des deux. Mieux
 * vaut ne pas marquer d'échec que marquer en échec la plateforme qui diffuse.
 */
export function reconnaitreSortie(rendue: string, sorties: readonly Sortie[]): string | null {
  const exactes = sorties.filter((s) => s.url === rendue);
  if (exactes.length === 1) return exactes[0]!.id;
  const masque = /^(.*\/)\{([^{}]*)\.\.\.([^{}]*)\}$/.exec(rendue);
  if (!masque) return null;
  const [, debut, tete, queue] = masque as unknown as [string, string, string, string];
  const candidates = sorties.filter((s) => {
    const coupure = s.url.lastIndexOf("/") + 1;
    const cle = s.url.slice(coupure);
    return s.url.slice(0, coupure) === debut && cle.length >= tete.length + queue.length
      && cle.startsWith(tete) && cle.endsWith(queue);
  });
  return candidates.length === 1 ? candidates[0]!.id : null;
}

export interface DemandePiste {
  salle: string;
  /** L'identité chez le fournisseur, c'est-à-dire l'identifiant de compte. */
@@ -120,8 +178,12 @@ export interface Media {
   * entièrement — l'enregistreur tombe, le serveur média la perd — laissait
   * l'écran afficher « En direct » indéfiniment. Un carré vert qui ment est
   * pire que pas d'écran du tout.
   *
   * On lui redonne les destinations confiées : lui seul sait comment le
   * fournisseur les rend, donc lui seul peut les reconnaître.
   */
  etatDiffusion(tacheId: string): Promise<{ vivante: boolean; sorties: EtatSortie[] }>;
  etatDiffusion(tacheId: string, sorties: readonly Sortie[])
    : Promise<{ vivante: boolean; sorties: EtatSortie[] }>;
  arreterDiffusion(tacheId: string): Promise<void>;
}

@@ -341,7 +403,7 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
      await egress.updateStream(tacheId, [...ouvrir], [...fermer]);
    },

    async etatDiffusion(tacheId) {
    async etatDiffusion(tacheId, confiees) {
      const [e] = await egress.listEgress({ egressId: tacheId });
      // Statuts de tâche : 0 démarrage, 1 actif, 2 fin en cours, 3 terminé,
      // 4 échec, 5 annulé. Une tâche absente de la liste est une tâche perdue —
@@ -352,7 +414,7 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
      return {
        vivante,
        sorties: (e?.streamResults ?? []).map((s) => ({
          url: s.url,
          id: reconnaitreSortie(s.url, confiees),
          statut: s.status === 2 ? "echec" as const
            : s.status === 1 ? "fermee" as const : "ouverte" as const,
          motif: s.error || null,
@@ -403,7 +465,10 @@ export function mediaEssai(): Media & {
   * rendrait invérifiable la seule chose qui compte ici — qu'une plateforme
   * qui lâche n'emporte pas le point presse.
   */
  const diffusions = new Map<string, Map<string, EtatSortie>>();
  // En interne, le double tient l'URL en clair, comme l'enregistreur ; c'est
  // à la sortie qu'il la masque.
  const diffusions = new Map<string, Map<string,
    { url: string; statut: EtatSortie["statut"]; motif: string | null }>>();
  /** Les tâches que le serveur média a perdues : le cas le plus silencieux. */
  const perdues = new Set<string>();
  /** La mise en page demandée, pour qu'un contrôle puisse la lire. */
@@ -473,10 +538,16 @@ export function mediaEssai(): Media & {
      }
    },

    async etatDiffusion(tacheId) {
    async etatDiffusion(tacheId, confiees) {
      const d = diffusions.get(tacheId);
      // Masquées comme le vrai les rend, puis reconnues par la même fonction
      // que le vrai : un double qui rendait l'URL intacte a laissé passer au
      // vert un appariement qui ne marchait nulle part ailleurs.
      return { vivante: d !== undefined && !perdues.has(tacheId),
        sorties: [...(d?.values() ?? [])] };
        sorties: [...(d?.values() ?? [])].map((x) => ({
          id: reconnaitreSortie(masquerCle(x.url), confiees),
          statut: x.statut, motif: x.motif,
        })) };
    },

    async arreterDiffusion(tacheId) {

apps/api/src/banc.ts

0 → 100644
+499 −0

File added.

Preview size limit exceeded, changes collapsed.

+12 −11
Changes for apps/api/src/domaine/presse.ts: 12 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -445,7 +445,17 @@ export async function rafraichir(
  if (!d) throw new Error("Diffusion inconnue.");

  if (d.tache && d.statut === "en_cours") {
    const etat = await media().etatDiffusion(d.tache);
    const lignes = await sql<{ destination_id: string; statut: string }[]>`
      select destination_id, statut from diffusion_destination
       where crise_id = ${criseId} and diffusion_id = ${diffusionId}`;
    // On rouvre les URL et on les confie à l'adaptateur : lui seul sait sous
    // quelle forme le fournisseur les rend. Une destination retirée de
    // l'organisation depuis n'a plus d'URL à rouvrir — son dernier état connu
    // reste, plutôt qu'un état faux.
    const sorties = await destinations.urlsArmees(sql, secretInstance, organisationId,
      lignes.map((l) => l.destination_id));
    const etat = await media().etatDiffusion(d.tache,
      sorties.map((s) => ({ id: s.id, url: s.url })));

    /*
      La tâche entière a disparu.
@@ -469,17 +479,8 @@ export async function rafraichir(
         order by ouverte_at, libelle`;
    }

    const lignes = await sql<{ destination_id: string; statut: string }[]>`
      select destination_id, statut from diffusion_destination
       where crise_id = ${criseId} and diffusion_id = ${diffusionId}`;
    // On rouvre les URL pour les apparier : le fournisseur ne connaît que
    // celles-là. Une destination retirée de l'organisation depuis n'a plus
    // d'URL à rouvrir — son dernier état connu reste, plutôt qu'un état faux.
    const sorties = await destinations.urlsArmees(sql, secretInstance, organisationId,
      lignes.map((l) => l.destination_id));
    const parUrl = new Map(sorties.map((s) => [s.url, s.id]));
    for (const sortie of etat.sorties) {
      const id = parUrl.get(sortie.url);
      const id = sortie.id;
      const avant = lignes.find((l) => l.destination_id === id);
      if (!id || !avant || avant.statut !== "ouverte" || sortie.statut !== "echec") continue;
      await fermerDestination(sql, secretInstance, criseId, organisationId,
Loading