Commit 1fde7e7a authored by Kourser's avatar Kourser
Browse files

Une clé de flux YouTube est la voix de l'entreprise, pas un réglage

Kastell produit l'image du point presse ; le public la regarde sur les chaînes
de l'organisation. Diffuser deux cents journalistes depuis l'instance de crise
saturerait la ligne dont la cellule se sert pour se parler — les plateformes,
elles, en tiennent deux cent mille sans qu'on les prévienne.

Ces destinations se préparent avec les modèles de communiqués, pas le jour J :
le matin d'une crise, personne n'a le mot de passe du compte YouTube.

Une clé de flux reçoit donc le traitement d'une clé de passerelle SMS, parce
que c'est la même nature — la capacité de s'exprimer au nom de l'entreprise,
devant tout le monde, sans repentir possible. Chiffrée en AES-256-GCM, jamais
relue par l'API ni par la console root, jamais au journal. L'aperçu garde
l'hôte, qui distingue deux chaînes du même compte, et ne laisse de la clé que
ses quatre derniers caractères.

« rtmps » est accepté, sans quoi Facebook serait hors d'atteinte. Plusieurs
destinations peuvent être armées à la fois, contrairement aux passerelles : un
point presse part sur YouTube ET sur X. Ce qu'on empêche, c'est qu'un essai de
l'an dernier se retrouve dans un direct.

Une adresse en « https » ou en « file » est refusée avant d'être scellée : ce
ne serait pas une destination mais une fuite, et l'erreur ne se découvrirait
qu'une fois le point presse commencé — devant la presse, donc.

L'épreuve vérifie la route, PAS la clé, et son message le dit. Elle attrape ce
qui casse le plus souvent : un nom d'hôte fautif, un port sortant filtré. La
savoir bonne demanderait de diffuser vraiment quelques secondes sur la chaîne
de l'entreprise — ce qu'on ne fait pas à l'insu de qui appuie. Un « ✓ » vert
qui laisserait croire le contraire ferait cesser de s'en inquiéter.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent d40c249e
Loading
Loading
Loading
Loading
+52 −0
Changes for apps/api/src/db/migrations/0047_diffusion.sql: 52 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Où part le point presse (EF-808).
--
-- Kastell produit l'image ; le public la regarde ailleurs — sur YouTube, sur
-- Twitch, sur la page d'une plateforme. Ce choix n'est pas une facilité : la
-- bande passante d'un site de crise ne tient pas deux cents spectateurs, et la
-- plateforme, elle, en tient deux cent mille sans qu'on la prévienne.
--
-- Ces destinations se préparent à froid, avec les modèles de communiqués et
-- l'annuaire. Le jour J, on choisit dans une liste ; on ne va pas chercher une
-- clé de flux dans un compte YouTube dont personne n'a le mot de passe.
--
-- L'URL porte la clé de flux : elle est donc chiffrée au repos, comme les
-- identifiants d'une passerelle SMS, et pour la même raison. Qui tient cette
-- clé diffuse au nom de l'entreprise, en direct, devant la presse.
-- ═══════════════════════════════════════════════════════════════════════════

create table destination_diffusion (
  id              uuid        primary key default gen_random_uuid(),
  organisation_id uuid        not null references organisation(id) on delete cascade,

  -- La plateforme est déclarative : elle sert à nommer et à trier, jamais à
  -- décider du protocole. C'est l'URL qui dit comment on pousse.
  plateforme      text        not null
                    check (plateforme in ('youtube','twitch','facebook','x','autre')),
  libelle         text        not null,

  -- L'URL complète, clé de flux comprise, chiffrée en AES-256-GCM. Elle ne
  -- ressort jamais : ni par l'API, ni par la console root, ni au journal.
  url             bytea       not null,
  -- Ce qu'on affiche à la place : de quoi reconnaître sa propre destination,
  -- jamais de quoi diffuser avec.
  apercu          text        not null,

  -- Une destination préparée n'est pas une destination retenue. Le jour J on
  -- ne propose que celles qu'on a armées — sans quoi la liste du point presse
  -- contiendrait les essais de l'an dernier.
  actif           boolean     not null default false,

  cree_at         timestamptz not null default now(),
  cree_par        uuid        references compte(id) on delete set null,
  modifie_at      timestamptz,

  constraint destination_libelle_non_vide check (length(trim(libelle)) > 0)
);
create index destination_organisation
  on destination_diffusion (organisation_id, actif desc, plateforme);

comment on column destination_diffusion.url is
  'EF-808 : URL RTMP/RTMPS complète, clé de flux comprise, chiffrée au repos.';
comment on column destination_diffusion.apercu is
  'EF-808 : de quoi reconnaître la destination, jamais de quoi diffuser.';
+245 −0
Changes for apps/api/src/diffusion/destinations.ts: 245 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { connect } from "node:net";
import type { Sql } from "../db/client.js";
import { journaliser } from "../noyau/administration.js";
import { ouvrir, sceller } from "../noyau/secrets.js";

/**
 * Où part le point presse (EF-808).
 *
 * ╔══════════════════════════════════════════════════════════════════════════╗
 * ║  Kastell produit l'image. Le public la regarde ailleurs.                 ║
 * ╚══════════════════════════════════════════════════════════════════════════╝
 *
 * Diffuser à deux cents journalistes depuis l'instance de crise demanderait
 * deux cents mégabits par seconde soutenus — c'est-à-dire la ligne que la
 * cellule est elle-même en train d'utiliser pour se parler. On ferait tomber
 * sa propre visio pour tenir un point presse. Les plateformes, elles, tiennent
 * deux cent mille spectateurs sans qu'on les prévienne.
 *
 * Ces destinations se préparent à froid, avec les modèles de communiqués. Le
 * jour J on choisit dans une liste ; on ne va pas chercher une clé de flux
 * dans un compte YouTube dont personne n'a le mot de passe.
 */

export type Plateforme = "youtube" | "twitch" | "facebook" | "x" | "autre";

export interface Destination {
  id: string;
  plateforme: Plateforme;
  libelle: string;
  /** De quoi reconnaître sa propre destination, jamais de quoi diffuser. */
  apercu: string;
  actif: boolean;
  cree_at: Date;
  modifie_at: Date | null;
}

export async function lister(sql: Sql, organisationId: string): Promise<Destination[]> {
  return sql<Destination[]>`
    select id, plateforme, libelle, apercu, actif, cree_at, modifie_at
      from destination_diffusion
     where organisation_id = ${organisationId}
     order by actif desc, plateforme, libelle`;
}

/**
 * Ce qu'on montre d'une URL qu'on ne montrera jamais.
 *
 * L'hôte en clair — il ne vaut rien, et c'est lui qui permet de distinguer sa
 * chaîne principale de sa chaîne de secours. La clé, réduite à ses quatre
 * derniers caractères : assez pour reconnaître celle qu'on vient de coller,
 * pas assez pour la reconstituer.
 *
 * On ne réutilise pas « apercu » des secrets d'organisation : celui-là traite
 * une valeur opaque, et laisserait ici tomber l'hôte, c'est-à-dire la seule
 * partie utile.
 */
export function apercuUrl(url: string): string {
  const propre = url.trim();
  const coupe = propre.lastIndexOf("/");
  if (coupe < 0) return "…";
  const hote = propre.slice(0, coupe + 1);
  const cle = propre.slice(coupe + 1);
  return cle.length <= 4 ? `${hote}…` : `${hote}…${cle.slice(-4)}`;
}

/**
 * Les protocoles qu'on accepte, et pourquoi la liste est courte.
 *
 * « rtmps » d'abord : Facebook n'accepte que lui, et plusieurs plateformes
 * l'imposent désormais. Tout le reste est refusé — une URL en « http » ou en
 * « file » ne serait pas une destination mais une fuite, et l'erreur ne se
 * verrait qu'une fois le point presse commencé.
 */
const PROTOCOLES = ["rtmp:", "rtmps:"];

export class UrlRefusee extends Error {
  constructor(message: string) {
    super(message);
    this.name = "UrlRefusee";
  }
}

export function verifierUrl(url: string): void {
  let analysee: URL;
  try {
    analysee = new URL(url.trim());
  } catch {
    throw new UrlRefusee("Cette adresse n'est pas une URL.");
  }
  if (!PROTOCOLES.includes(analysee.protocol)) {
    throw new UrlRefusee(
      `Seuls rtmp:// et rtmps:// sont acceptés — reçu « ${analysee.protocol}// ».`);
  }
  if (!analysee.hostname) throw new UrlRefusee("Cette adresse n'a pas de serveur.");
}

/**
 * Éprouver une destination — et dire exactement ce qu'on a éprouvé.
 *
 * ╔══════════════════════════════════════════════════════════════════════════╗
 * ║  Ceci vérifie la route, PAS la clé de flux.                             ║
 * ╚══════════════════════════════════════════════════════════════════════════╝
 *
 * Ce que ça attrape, et c'est la majorité des pannes du jour J : un nom d'hôte
 * fautif, un port fermé par le pare-feu sortant, un service de la plateforme
 * indisponible. Une organisation qui n'a jamais poussé de RTMP depuis son
 * réseau découvrirait sinon en direct que le 1935 sortant est bloqué.
 *
 * Ce que ça n'attrape pas : une clé expirée ou révoquée. Le savoir demanderait
 * de diffuser vraiment quelques secondes, et donc d'apparaître en direct sur la
 * chaîne de l'entreprise — ce qu'on ne fait pas à l'insu de qui appuie sur le
 * bouton. Le message le dit, plutôt que de laisser un « ✓ » vert le promettre.
 */
export interface Epreuve {
  joignable: boolean;
  detail: string;
  millisecondes: number;
}

export async function eprouver(
  sql: Sql, secretInstance: string, organisationId: string, destinationId: string,
  delaiMs = 5000,
): Promise<Epreuve> {
  const [ligne] = await sql<{ url: Buffer }[]>`
    select url from destination_diffusion
     where organisation_id = ${organisationId} and id = ${destinationId}`;
  if (!ligne) throw new Error("Destination inconnue.");
  const url = new URL(ouvrir(secretInstance, ligne.url));
  // Les ports par défaut, quand l'URL n'en porte pas : 1935 pour RTMP, 443
  // pour RTMPS — c'est ce que servent les plateformes.
  const port = Number(url.port || (url.protocol === "rtmps:" ? 443 : 1935));
  const depart = Date.now();

  return new Promise<Epreuve>((resoudre) => {
    const fini = (joignable: boolean, detail: string) => {
      prise.destroy();
      resoudre({ joignable, detail, millisecondes: Date.now() - depart });
    };
    const prise = connect({ host: url.hostname, port, timeout: delaiMs });
    prise.once("connect", () =>
      fini(true, `${url.hostname}:${port} répond. La clé de flux, elle, `
        + "ne sera éprouvée qu'au premier direct."));
    prise.once("timeout", () =>
      fini(false, `Aucune réponse de ${url.hostname}:${port} en `
        + `${Math.round(delaiMs / 1000)} s — port sortant filtré, le plus souvent.`));
    prise.once("error", (e: NodeJS.ErrnoException) =>
      fini(false, e.code === "ENOTFOUND"
        ? `Le nom « ${url.hostname} » ne résout pas.`
        : `${url.hostname}:${port} : ${e.code ?? e.message}`));
  });
}

export async function configurer(
  sql: Sql, secretInstance: string, organisationId: string,
  d: { plateforme: Plateforme; libelle: string; url: string },
  parCompteId: string,
): Promise<{ id: string; apercu: string }> {
  verifierUrl(d.url);
  const vue = apercuUrl(d.url);

  return (await sql.begin(async (tx) => {
    const [ligne] = await tx<{ id: string }[]>`
      insert into destination_diffusion
        (organisation_id, plateforme, libelle, url, apercu, cree_par)
      values (${organisationId}, ${d.plateforme}, ${d.libelle},
              ${sceller(secretInstance, d.url.trim())}, ${vue}, ${parCompteId})
      returning id`;
    // Au journal : qu'une destination existe, chez qui, sous quel nom. Jamais
    // l'URL — elle porte la clé de flux, et un journal en ajout seul est le
    // pire endroit où poser un secret, puisqu'on ne peut pas l'en retirer.
    await journaliser(tx, {
      type: "organisation.destination_configuree", acteurId: parCompteId, organisationId,
      charge: { destination_id: ligne!.id, plateforme: d.plateforme, libelle: d.libelle },
    });
    return { id: ligne!.id, apercu: vue };
  })) as { id: string; apercu: string };
}

/**
 * Armer ou désarmer.
 *
 * Contrairement aux passerelles, plusieurs destinations peuvent être armées à
 * la fois : c'est tout l'objet — un point presse part sur YouTube ET sur X. Ce
 * qu'on empêche, c'est qu'une destination oubliée depuis l'an dernier se
 * retrouve dans la liste du jour J.
 */
export async function armer(
  sql: Sql, organisationId: string, destinationId: string, actif: boolean,
  parCompteId: string,
): Promise<void> {
  await sql.begin(async (tx) => {
    const [d] = await tx<{ plateforme: Plateforme; libelle: string }[]>`
      select plateforme, libelle from destination_diffusion
       where id = ${destinationId} and organisation_id = ${organisationId}`;
    if (!d) throw new Error("Destination inconnue.");
    await tx`
      update destination_diffusion set actif = ${actif}, modifie_at = now()
       where id = ${destinationId}`;
    await journaliser(tx, {
      type: "organisation.destination_armee", acteurId: parCompteId, organisationId,
      charge: { destination_id: destinationId, plateforme: d.plateforme,
        libelle: d.libelle, actif },
    });
  });
}

export async function retirer(
  sql: Sql, organisationId: string, destinationId: string, parCompteId: string,
): Promise<boolean> {
  return (await sql.begin(async (tx) => {
    const [d] = await tx<{ plateforme: Plateforme; libelle: string }[]>`
      delete from destination_diffusion
       where id = ${destinationId} and organisation_id = ${organisationId}
      returning plateforme, libelle`;
    if (!d) return false;
    await journaliser(tx, {
      type: "organisation.destination_retiree", acteurId: parCompteId, organisationId,
      charge: { destination_id: destinationId, plateforme: d.plateforme, libelle: d.libelle },
    });
    return true;
  })) as boolean;
}

/**
 * Les URL, pour les donner au serveur média et à personne d'autre.
 *
 * Cette fonction est la seule qui déchiffre. Elle ne rend pas de « Destination »
 * enrichie d'une URL : elle rend des URL, pour qu'aucun appelant ne puisse par
 * mégarde renvoyer l'objet entier dans une réponse d'API.
 */
export async function urlsArmees(
  sql: Sql, secretInstance: string, organisationId: string, ids: readonly string[],
): Promise<{ id: string; libelle: string; plateforme: Plateforme; url: string }[]> {
  if (ids.length === 0) return [];
  const lignes = await sql<{
    id: string; libelle: string; plateforme: Plateforme; url: Buffer;
  }[]>`
    select id, libelle, plateforme, url from destination_diffusion
     where organisation_id = ${organisationId} and actif
       and id = any(${sql.array(ids as string[])}::uuid[])`;
  return lignes.map((l) => ({
    id: l.id, libelle: l.libelle, plateforme: l.plateforme,
    url: ouvrir(secretInstance, l.url),
  }));
}
+4 −0
Changes for apps/api/src/langues/en.ts: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -36,6 +36,10 @@ export const EN: Record<string, string> = {
    "No snapshot existed at that moment: the board had not been used yet.",
  "C'est noté, {nom}.": "Noted, {nom}.",
  "Campagne inconnue.": "Unknown campaign.",
  "Destination armée.": "Destination armed.",
  "Destination désarmée.": "Destination disarmed.",
  "Destination inconnue.": "Unknown destination.",
  "Destination retirée.": "Destination removed.",
  "Canal inconnu.": "Unknown channel.",
  "Ce code est inconnu ou périmé. Demandez-en un nouveau depuis votre session web.":
    "This code is unknown or out of date. Ask for a new one from your web session.",
+72 −0
Changes for apps/api/src/routes/canaux.ts: 72 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ import { langueSchema } from "@kastell/shared";
import { CATALOGUE } from "../canaux/fournisseurs.js";
import * as canaux from "../canaux/organisation.js";
import * as masse from "../canaux/masse.js";
import * as destinations from "../diffusion/destinations.js";

/**
 * Canaux de diffusion et envois de masse (EF-1105).
@@ -36,6 +37,77 @@ export function enregistrerRoutesCanaux(app: FastifyInstance, sql: Sql, env: Env
    canaux: await canaux.lister(sql, org(req)),
  }));

  /**
   * Les destinations du point presse (EF-808).
   *
   * Rangées ici plutôt qu'avec les salles : une clé de flux YouTube est de la
   * même nature qu'une clé de passerelle SMS — la capacité de s'exprimer au nom
   * de l'entreprise, devant tout le monde, sans repentir possible. Elle se
   * prépare à froid, et elle ne se relit jamais.
   */
  app.get("/organisations/:id/diffusion/destinations", proprietaire, async (req) => ({
    destinations: await destinations.lister(sql, org(req)),
  }));

  app.post("/organisations/:id/diffusion/destinations", proprietaire, async (req, reply) => {
    const c = z.object({
      plateforme: z.enum(["youtube", "twitch", "facebook", "x", "autre"]),
      libelle: z.string().trim().min(1).max(80),
      url: z.string().trim().min(1).max(500),
    }).parse(req.body);
    try {
      const r = await destinations.configurer(sql, env.SESSION_SECRET, org(req), c, moi(req));
      // On rend l'aperçu, jamais l'URL : ce que l'appelant vient d'envoyer, il
      // l'a déjà ; ce qu'il ne doit pas pouvoir relire plus tard, il ne le
      // relira pas non plus tout de suite.
      return reply.code(201).send(r);
    } catch (e) {
      if (e instanceof destinations.UrlRefusee) {
        return reply.code(400).send({ code: "url_refusee", message: e.message });
      }
      throw e;
    }
  });

  app.post("/organisations/:id/diffusion/destinations/:destinationId/armement",
    proprietaire, async (req, reply) => {
      const { destinationId } = z.object({ destinationId: z.string().uuid() })
        .parse(req.params);
      const { actif } = z.object({ actif: z.boolean() }).parse(req.body ?? {});
      await destinations.armer(sql, org(req), destinationId, actif, moi(req));
      return reply.send({
        message: actif ? "Destination armée." : "Destination désarmée.",
      });
    });

  /**
   * Éprouver la route jusqu'à la plateforme.
   *
   * On dit dans la réponse ce qui a été éprouvé et ce qui ne l'a pas été :
   * un « ✓ » qui laisserait croire que la clé est bonne serait pire que rien,
   * puisqu'on cesserait de s'en inquiéter.
   */
  app.post("/organisations/:id/diffusion/destinations/:destinationId/epreuve",
    proprietaire, async (req, reply) => {
      const { destinationId } = z.object({ destinationId: z.string().uuid() })
        .parse(req.params);
      const r = await destinations.eprouver(sql, env.SESSION_SECRET, org(req), destinationId);
      return reply.send(r);
    });

  app.delete("/organisations/:id/diffusion/destinations/:destinationId",
    proprietaire, async (req, reply) => {
      const { destinationId } = z.object({ destinationId: z.string().uuid() })
        .parse(req.params);
      const retire = await destinations.retirer(sql, org(req), destinationId, moi(req));
      if (!retire) {
        return reply.code(404).send({
          code: "introuvable", message: "Destination inconnue.",
        });
      }
      return reply.send({ message: "Destination retirée." });
    });

  app.post("/organisations/:id/canaux", proprietaire, async (req, reply) => {
    const c = z.object({
      genre: z.enum(["sms", "courriel", "voix"]),
+2 −0
Changes for apps/api/src/verif.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import { verifierComposition } from "./verification/composition.js";
import { verifierRenforts } from "./verification/renforts.js";
import { verifierInstallateur } from "./verification/installateur.js";
import { verifierCanaux } from "./verification/canaux.js";
import { verifierDiffusion } from "./verification/diffusion.js";
import { verifierContinuite } from "./verification/continuite.js";
import { verifierVeille } from "./verification/veille.js";
import { interceptet } from "./verification/boite.js";
@@ -361,6 +362,7 @@ await verifierInstallateur(rapport);

// ── Canaux de diffusion et envois de masse (§6.11) ────────────────────────
await verifierCanaux(sql, app, rapport);
await verifierDiffusion(sql, app, rapport);

// ── Continuité, parties prenantes, échéances (§6.12) ──────────────────────
await verifierContinuite(sql, app, rapport);
Loading