Commit f15cbe77 authored by Kourser's avatar Kourser
Browse files

Une salle de presse, une page publique, et des questions qu'on modère

Trois pièces d'une seule chose, et elles se tiennent : la convocation a besoin
de l'adresse publique, la clôture de la crise a besoin d'arrêter la diffusion.
Les séparer aurait été une fausse séparation.

── La salle de presse ────────────────────────────────────────────────────

Une salle d'un genre à part, et le domaine refuse de diffuser ce qui n'en est
pas une. L'erreur serait irréversible : des journalistes qui entendent
délibérer la direction de crise. Elle n'apparaît pas non plus dans le rail des
salles de réunion, où « rejoindre l'appel » ferait entrer dans un direct.

Mesuré contre LiveKit 1.13.5 avant d'être écrit : une seule tâche pousse vers
plusieurs plateformes, une destination injoignable est isolée avec son propre
motif pendant que les autres continuent, et l'on ajoute une destination en
cours de diffusion sans couper. C'est ce qui permet de se passer d'un relais
intermédiaire.

Ce qui échoue se dit. Une plateforme lâche en silence — clé expirée, compte
suspendu, ingestion saturée — et le serveur média le sait avant nous. Un carré
vert qui ment pendant que la moitié de la presse ne reçoit rien est pire que
pas d'écran du tout. La tâche entière peut disparaître, aussi : c'est le cas le
plus silencieux, et il est vu.

On ne passe pas à l'antenne depuis un studio vide : l'enregistreur attend une
image que personne ne publie, reste en démarrage et s'annule. Et fermer une
salle coupe d'abord ce qui en sortait, sans quoi l'enregistreur pousserait vers
YouTube pendant que Kastell croirait la salle close.

« speaker » plutôt que « grid » : à trois dans la salle, le public recevait une
mosaïque au lieu du porte-parole.

── La page publique ──────────────────────────────────────────────────────

Le dark site de la communication de crise, préparé à froid. Sans modèle, rien
ne se publie : improviser un nom public et des consignes le jour J est ce qu'on
cherche à éviter.

Un fil chronologique et non des versions qui se remplacent — un public suit une
suite d'heures. La page est recomposée à chaque lecture : figée, une réponse
donnée après la dernière publication n'apparaissait nulle part, et le flux RSS
disait autre chose qu'elle.

Deux kilo-octets et demi, aucun JavaScript, aucune requête vers un tiers. Elle
doit s'ouvrir sur un réseau saturé, sur un vieux téléphone, et quand Kastell est
tombé — d'où le fichier téléchargeable, qui survit à une panne de l'instance.
Mais elle est aussi servie : une règle élégante qui ne distribue rien vaut moins
qu'un service imparfait qui distribue.

Elle dit de qui elle est et où la recouper. De faux communiqués circulent
pendant les crises ; une page nue sous un jeton aléatoire demandait au public de
nous croire sur parole.

Clore la crise ferme ce qui parlait au dehors. On n'écrit rien à la place de
l'organisation — la page se recompose et dit qu'elle n'est plus mise à jour.
Elle reste lisible : des gens l'ont mise en favori, des articles la citent.

La coque se traduit ; l'information non. Une consigne de mise à l'abri traduite
approximativement envoie quelqu'un du mauvais côté d'un panache, et la page dit
donc en quelle langue elle est publiée.

── Les questions ─────────────────────────────────────────────────────────

Rien ne devient public sans qu'un humain l'ait décidé : une salle de presse où
les questions passent en direct est inutilisable en crise, parce que c'est
précisément le moment où quelqu'un veut nuire. Écarter demande un motif —
écarter sans motif, c'est censurer sans le dire.

Trois gardes sur le seul point ouvert : le jeton, un compteur par IP, un
plafond de file. Le plafond n'est pas redondant : cent adresses différentes
avec une question chacune ne déclenchent aucun compteur et noient la
modération. Un champ leurre arrête les robots sans imposer une épreuve d'image
à quelqu'un qui cherche une consigne. Le refus ne dit jamais si le jeton était
bon.

Le jeton du lien est une adresse, pas un secret : il finit en clair dans la
page et dans la barre d'adresse de chaque visiteur.

── Être prévenu, et ne pas être conservé pour rien ───────────────────────

Le RSS répondait aux rédactions et à personne d'autre. Le courriel est le seul
canal que tout le monde a — et celui qui engage le plus, puisqu'on garde une
liste de gens qui n'avaient rien demandé avant ce jour-là. Double consentement,
sans quoi n'importe qui abonne n'importe qui sous le nom de l'organisation ; un
lien de retrait dans chaque message ; et jamais un mot sur qui est déjà inscrit,
sous peine de faire de la page un oracle.

Clore annonce la date d'effacement des questions et des abonnés — quatre-vingt-
dix jours, le temps du retour d'expérience. L'entretien périodique l'exécute :
annoncer une date et ne jamais effacer serait pire que ne rien annoncer.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 1fde7e7a
Loading
Loading
Loading
Loading
+155 −1
Changes for apps/api/src/adaptateurs/media.ts: 155 added lines, 1 removed line.
Original line number Diff line number Diff line
import { AccessToken, DirectFileOutput, EgressClient, EncodedFileOutput, EncodedFileType,
  RoomServiceClient, TrackSource }
  RoomServiceClient, StreamOutput, StreamProtocol, TrackSource }
  from "livekit-server-sdk";

/**
@@ -43,6 +43,29 @@ export interface DemandeEnregistrement {
  cleObjet: string;
}

/**
 * Comment le public voit la salle.
 *
 * « grid » était en dur : à trois personnes dans la salle de presse, le public
 * recevait une mosaïque au lieu du porte-parole. Pour un point presse, c'est
 * l'inverse de ce qu'on veut — on regarde qui parle, et l'écran partagé quand
 * il y en a un.
 */
export type MiseEnPage = "speaker" | "grid";

/** Une destination, telle qu'on la donne au serveur média : l'URL en clair. */
export interface Sortie {
  id: string;
  url: string;
}

/** Ce que le serveur média rapporte d'une destination, pendant et après. */
export interface EtatSortie {
  url: string;
  statut: "ouverte" | "fermee" | "echec";
  motif: string | null;
}

export interface DemandePiste {
  salle: string;
  /** L'identité chez le fournisseur, c'est-à-dire l'identifiant de compte. */
@@ -75,6 +98,31 @@ export interface Media {
   */
  demarrerPisteParticipant(d: DemandePiste): Promise<Enregistrement>;
  arreterEnregistrement(tacheId: string): Promise<void>;

  /**
   * Pousser une salle vers des plateformes (EF-808).
   *
   * Une seule tâche, plusieurs destinations : mesuré contre LiveKit 1.13.5,
   * une destination injoignable est isolée avec son propre motif d'échec
   * pendant que les autres continuent de diffuser. C'est ce qui permet de se
   * passer d'un relais intermédiaire, et c'est ce que « etatDiffusion » rend
   * lisible à la cellule.
   */
  demarrerDiffusion(salle: string, sorties: readonly Sortie[],
    miseEnPage: MiseEnPage): Promise<string>;
  /** Ouvrir ou fermer une destination sans couper les autres. */
  majDiffusion(tacheId: string, ouvrir: readonly string[], fermer: readonly string[])
    : Promise<void>;
  /**
   * L'état d'une diffusion.
   *
   * « vivante » compte autant que le détail des sorties : une tâche qui meurt
   * 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.
   */
  etatDiffusion(tacheId: string): Promise<{ vivante: boolean; sorties: EtatSortie[] }>;
  arreterDiffusion(tacheId: string): Promise<void>;
}

export interface ConfigurationMedia {
@@ -278,6 +326,43 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
    async arreterEnregistrement(tacheId) {
      await egress.stopEgress(tacheId);
    },

    async demarrerDiffusion(salle, sorties, miseEnPage) {
      const info = await egress.startRoomCompositeEgress(salle, {
        stream: new StreamOutput({
          protocol: StreamProtocol.RTMP,
          urls: sorties.map((s) => s.url),
        }),
      }, { layout: miseEnPage });
      return info.egressId;
    },

    async majDiffusion(tacheId, ouvrir, fermer) {
      await egress.updateStream(tacheId, [...ouvrir], [...fermer]);
    },

    async etatDiffusion(tacheId) {
      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 —
      // le cas le plus silencieux, et celui qu'on cherche précisément à voir.
      const vivante = e !== undefined && [0, 1, 2].includes(e.status);
      // 0 = ACTIVE, 1 = FINISHED, 2 = FAILED. On traduit plutôt que de laisser
      // fuir des entiers de protocole jusque dans le domaine.
      return {
        vivante,
        sorties: (e?.streamResults ?? []).map((s) => ({
          url: s.url,
          statut: s.status === 2 ? "echec" as const
            : s.status === 1 ? "fermee" as const : "ouverte" as const,
          motif: s.error || null,
        })),
      };
    },

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

@@ -289,6 +374,12 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
export function mediaEssai(): Media & {
  finirEnregistrement(tacheId: string): void;
  refuserPistes(motif: string | null): void;
  /** Faire lâcher une plateforme en pleine diffusion, comme elles le font. */
  faireEchouerSortie(url: string, motif: string): void;
  /** Faire disparaître la tâche entière, comme un enregistreur qui tombe. */
  perdreDiffusion(tacheId: string): void;
  /** Ce qu'on a demandé au fournisseur : sans quoi la mise en page est invérifiable. */
  miseEnPageDe(tacheId: string): MiseEnPage | null;
} {
  const salles = new Map<string, ParticipantMedia[]>();
  const taches = new Map<string, { salle: string; cleObjet: string; fini: boolean }>();
@@ -303,6 +394,20 @@ export function mediaEssai(): Media & {
   * toujours oui.
   */
  let refus: string | null = null;
  /**
   * L'état des destinations, par tâche de diffusion.
   *
   * Le double le tient parce que le vrai le tient : mesuré contre LiveKit
   * 1.13.5, une destination injoignable passe en échec avec son propre motif
   * pendant que les autres continuent. Un double qui dirait « tout va bien »
   * 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>>();
  /** 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. */
  const pages = new Map<string, MiseEnPage>();

  return {
    nom: "essai",
@@ -313,6 +418,11 @@ export function mediaEssai(): Media & {
    async participants(nom) { return salles.get(nom) ?? []; },
    async jeton(d) {
      compteur += 1;
      const dans = salles.get(d.salle) ?? [];
      if (!dans.some((p) => p.identite === d.identite)) {
        dans.push({ identite: d.identite, nomAffiche: d.nomAffiche, rejointAt: new Date() });
        salles.set(d.salle, dans);
      }
      return {
        jeton: `essai.${d.salle}.${d.identite}.${compteur}`,
        url: "ws://essai.invalide",
@@ -342,6 +452,50 @@ export function mediaEssai(): Media & {
      if (t) t.fini = true;
    },
    refuserPistes(motif) { refus = motif; },

    async demarrerDiffusion(salle, sorties, miseEnPage) {
      compteur += 1;
      const tacheId = `essai-diffusion-${compteur}`;
      diffusions.set(tacheId, new Map(sorties.map((s) =>
        [s.url, { url: s.url, statut: "ouverte" as const, motif: null }])));
      pages.set(tacheId, miseEnPage);
      void salle;
      return tacheId;
    },

    async majDiffusion(tacheId, ouvrir, fermer) {
      const d = diffusions.get(tacheId);
      if (!d) throw new Error("Diffusion inconnue.");
      for (const url of ouvrir) d.set(url, { url, statut: "ouverte", motif: null });
      for (const url of fermer) {
        const avant = d.get(url);
        if (avant) d.set(url, { ...avant, statut: "fermee" });
      }
    },

    async etatDiffusion(tacheId) {
      const d = diffusions.get(tacheId);
      return { vivante: d !== undefined && !perdues.has(tacheId),
        sorties: [...(d?.values() ?? [])] };
    },

    async arreterDiffusion(tacheId) {
      const d = diffusions.get(tacheId);
      if (!d) return;
      for (const [url, e] of d) {
        if (e.statut === "ouverte") d.set(url, { ...e, statut: "fermee" });
      }
    },

    perdreDiffusion(tacheId: string) { perdues.add(tacheId); },

    miseEnPageDe(tacheId: string) { return pages.get(tacheId) ?? null; },

    faireEchouerSortie(url: string, motif: string) {
      for (const d of diffusions.values()) {
        if (d.has(url)) d.set(url, { url, statut: "echec", motif });
      }
    },
  };
}

+13 −0
Changes for apps/api/src/auth/limites.ts: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -33,6 +33,19 @@ export const LIMITES = {
   * une soirée de préparation ordinaire, pas une attaque.
   */
  enrolement_ip: { seuil: 20, fenetreSecondes: 900 },
  /**
   * Poser une question depuis la page publique de crise (EF-810).
   *
   * Le point est nécessairement ouvert : un journaliste n'a pas de compte, et
   * c'est tout l'objet. Mais une page publique pendant une crise est
   * exactement ce que quelqu'un voudra marteler — pour noyer la file de
   * modération, ce qui revient à faire taire les vraies questions.
   *
   * Le seuil est large pour une rédaction derrière un même NAT, et court dans
   * le temps : dix questions par quart d'heure, c'est plus que ce qu'un
   * journaliste écrit, et bien moins que ce qu'un script envoie.
   */
  question_presse_ip: { seuil: 10, fenetreSecondes: 900 },
} as const satisfies Record<string, Limite>;

export type NomLimite = keyof typeof LIMITES;
+65 −0
Changes for apps/api/src/db/migrations/0048_salle_presse.sql: 65 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- La salle de presse et sa diffusion (EF-808).
--
-- Une salle de plus, et surtout une salle À PART. Le point presse ne se tient
-- pas dans la salle de la cellule décisionnelle : une erreur là-dessus est
-- irréversible — des journalistes qui entendent délibérer la direction de
-- crise. Le genre porte cette séparation dans le schéma, pas seulement dans
-- les intentions.
--
-- Ce qui en sort part chez YouTube, Twitch, X ou Facebook. Kastell produit
-- l'image ; le public la regarde ailleurs, sur des plateformes qui tiennent
-- deux cent mille spectateurs là où l'instance de crise en tiendrait deux
-- cents avant de saturer la ligne dont la cellule se sert pour se parler.
-- ═══════════════════════════════════════════════════════════════════════════

alter table salle drop constraint if exists salle_genre_check;
alter table salle add constraint salle_genre_check
  check (genre in ('cellule', 'ponctuelle', 'canal', 'presse'));

create table diffusion (
  crise_id     uuid        not null references crise(id) on delete restrict,
  id           uuid        not null,
  salle_id     uuid        not null,
  -- L'identifiant de la tâche chez le serveur média. Nul tant qu'elle n'a pas
  -- démarré, et c'est par lui qu'on ajoute une destination en cours de route.
  tache        text,
  demarre_par  uuid        references compte(id) on delete set null,
  demarre_at   timestamptz not null,
  arrete_at    timestamptz,
  statut       text        not null default 'en_cours'
                 check (statut in ('en_cours', 'arretee', 'echec')),
  motif_echec  text,
  primary key (crise_id, id)
);
create index diffusion_salle on diffusion (crise_id, salle_id);
-- Une seule diffusion en cours par salle : deux tâches sur la même salle
-- pousseraient deux fois la même image, et l'arrêt n'en couperait qu'une.
create unique index diffusion_en_cours on diffusion (crise_id, salle_id)
  where statut = 'en_cours';

-- ── L'état, destination par destination ────────────────────────────────────
--
-- Le serveur média isole les échecs : mesuré, une destination injoignable est
-- marquée en échec avec son propre motif pendant que les autres continuent de
-- diffuser. Cette table est ce qui permet de le DIRE à la cellule — « Facebook
-- échoue depuis 14:12 » plutôt qu'un silence dont on ne sait quoi conclure.
create table diffusion_destination (
  crise_id       uuid        not null,
  diffusion_id   uuid        not null,
  destination_id uuid        not null,
  -- Recopiés : la destination peut être retirée de l'organisation après coup,
  -- et le journal d'une crise doit rester lisible sans elle.
  plateforme     text        not null,
  libelle        text        not null,
  statut         text        not null default 'ouverte'
                   check (statut in ('ouverte', 'fermee', 'echec')),
  motif_echec    text,
  ouverte_at     timestamptz not null,
  fermee_at      timestamptz,
  primary key (crise_id, diffusion_id, destination_id),
  foreign key (crise_id, diffusion_id) references diffusion (crise_id, id) on delete cascade
);

comment on table diffusion_destination is
  'EF-808 : où le point presse est parti, et ce qui y a échoué.';
+101 −0
Changes for apps/api/src/db/migrations/0049_site_crise.sql: 101 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- La page publique de crise, et les questions qu'on y pose (EF-809, EF-810).
--
-- ╔═════════════════════════════════════════════════════════════════════════╗
-- ║  Kastell PUBLIE la page. Kastell n'est jamais dans le chemin de la      ║
-- ║  requête publique.                                                       ║
-- ╚═════════════════════════════════════════════════════════════════════════╝
--
-- Deux raisons, et la seconde est la vraie. La charge, d'abord : un pic de
-- concurrence sur l'instance de crise, c'est la cellule qui rame. Mais surtout,
-- si l'outil de crise tombe, la communication publique ne doit pas tomber avec
-- lui. Des fichiers statiques déposés ailleurs survivent à tout, y compris à
-- nous. D'où « rendu » : le HTML complet est conservé ici, prêt à être déposé
-- où l'exploitant veut.
--
-- Les lectures sont donc statiques et illimitées ; seules les questions
-- reviennent vers l'instance, et elles se comptent en centaines, pas en
-- centaines de milliers.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Ce qui se prépare à froid, avec les modèles de communiqués ─────────────
create table site_modele (
  organisation_id uuid        primary key references organisation(id) on delete cascade,
  -- Le nom sous lequel l'organisation parle au public : pas toujours sa raison
  -- sociale, et jamais son nom dans Kastell.
  nom_public      text        not null,
  -- Ce qu'on sait déjà avant la crise et qui ne changera pas ce jour-là.
  contacts        text        not null default '',
  consignes       text        not null default '',
  mentions        text        not null default '',
  modifie_at      timestamptz not null default now(),
  modifie_par     uuid        references compte(id) on delete set null
);

-- ── Chaque publication est un acte, et se relit ────────────────────────────
--
-- Six mois plus tard, savoir ce qu'on disait à 14 h le jour J vaut mieux que
-- de savoir ce qu'on dit aujourd'hui. On garde donc chaque version, avec son
-- rendu, plutôt que d'écraser une page unique.
create table site_publication (
  crise_id     uuid        not null references crise(id) on delete restrict,
  id           uuid        not null,
  rang         integer     not null,
  -- « en_cours », « stabilise », « termine » : trois états, parce qu'un public
  -- qui ne sait pas si c'est fini revient toutes les dix minutes.
  etat         text        not null check (etat in ('en_cours', 'stabilise', 'termine')),
  titre        text        not null,
  message      text        not null,
  -- Le HTML complet, autonome, tel qu'il a été rendu. On le garde plutôt que
  -- de le refabriquer : un rendu qu'on refait n'est pas celui qu'on a publié.
  rendu        text        not null,
  publie_at    timestamptz not null,
  publie_par   uuid        references compte(id) on delete set null,
  retire_at    timestamptz,
  primary key (crise_id, id)
);
create unique index site_publication_rang on site_publication (crise_id, rang);

-- ── Le lien public, et ce qu'on en sait ────────────────────────────────────
create table site_lien (
  crise_id   uuid        not null references crise(id) on delete restrict,
  -- L'empreinte du jeton, jamais le jeton : une base lue ne doit pas donner
  -- l'adresse publique d'une crise en cours.
  empreinte  bytea       primary key,
  ouvert_at  timestamptz not null,
  ouvert_par uuid        references compte(id) on delete set null,
  clos_at    timestamptz,
  -- Combien de pseudonymes peuvent s'inscrire. Un plafond, parce qu'un lien
  -- public sans plafond est une invitation à le marteler.
  plafond    integer     not null default 500
);
create index site_lien_crise on site_lien (crise_id) where clos_at is null;

-- ── Les questions ──────────────────────────────────────────────────────────
--
-- Elles arrivent dans une file que seule la cellule voit. Rien ne s'affiche
-- publiquement sans qu'un humain l'ait décidé : une salle de presse où les
-- questions passent en direct est inutilisable en crise, parce que c'est
-- précisément le moment où quelqu'un veut vous nuire.
create table question_presse (
  crise_id    uuid        not null references crise(id) on delete restrict,
  id          uuid        not null,
  -- Déclaratif, et l'interface le dit. Un pseudonyme n'est pas une identité.
  pseudo      text        not null,
  media       text,
  texte       text        not null,
  statut      text        not null default 'recue'
                check (statut in ('recue', 'publiee', 'repondue', 'ecartee')),
  reponse     text,
  motif_ecart text,
  recue_at    timestamptz not null,
  traitee_at  timestamptz,
  traitee_par uuid        references compte(id) on delete set null,
  primary key (crise_id, id)
);
create index question_file on question_presse (crise_id, statut, recue_at);

comment on table site_publication is
  'EF-809 : ce que l''organisation a dit publiquement, version par version.';
comment on table question_presse is
  'EF-810 : les questions du public, et ce que la cellule en a fait.';
+32 −0
Changes for apps/api/src/db/migrations/0050_lien_public.sql: 32 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Le lien public cesse d'être un secret (EF-809, EF-810).
--
-- Il était conservé en empreinte, comme un partage de document, et l'interface
-- le mettait en scène comme tel : « à copier maintenant », « affiché une seule
-- fois ». C'était faux par construction — ce jeton finit écrit en clair dans
-- une page publique et dans la barre d'adresse de chaque visiteur. N'importe
-- qui fait « afficher le source ».
--
-- Ce n'est donc pas un secret mais une ADRESSE : elle s'affiche en permanence,
-- se copie, se remplace. La garder en empreinte rendait surtout impossible de
-- la réafficher — c'est-à-dire de s'en servir.
--
-- On ne convertit pas les liens existants : une empreinte ne se retourne pas.
-- Ils sont clos, et il suffit d'en rouvrir un.
-- ═══════════════════════════════════════════════════════════════════════════

alter table site_lien add column if not exists jeton text;
create unique index if not exists site_lien_jeton on site_lien (jeton);

update site_lien set clos_at = now() where jeton is null and clos_at is null;

-- ── Le prochain point, la donnée que le public réclame le plus ─────────────
--
-- « Quand saurons-nous la suite ? » est la question qui ramène un public
-- toutes les dix minutes. Y répondre coûte une colonne.
alter table site_publication add column if not exists prochain_point timestamptz;

comment on column site_lien.jeton is
  'EF-809 : l''adresse publique de la page. Publique par construction.';
comment on column site_publication.prochain_point is
  'EF-809 : l''heure annoncée du prochain point, ou nul si on ne s''engage pas.';
Loading