Commit aa958d78 authored by Kourser's avatar Kourser
Browse files

Une salle démarre sans relais de courriel, et le dit (EX-44)

La production refusait de démarrer sans relais SMTP : le transport « console »
écrirait des liens de connexion valides dans les traces. Pour une instance
exposée, c'est juste. Pour un poste posé le matin dans la salle de crise,
c'était fermer la cellule faute d'une fonction dont elle se passe : on y entre
par la porte de jonction, et le premier compte reçoit un mot de passe
temporaire.

En posture « salle », sans relais, le transport devient « absent » : rien ne
part, rien n'est tracé, et chaque appelant apprend qu'aucun message ne partira
— jamais qu'il est parti. Ce qui n'a de sens qu'avec un courriel — inscription,
lien de connexion, campagne de vérification, abonnement à la page publique —
est refusé avant d'écrire quoi que ce soit, par un refus lisible et non une
erreur 500. La sonde publique dit « courriel : non » ; l'écran de connexion
retire le lien et la création d'espace, l'invitation ne coche plus le
courriel. Un relais posé depuis la console reprend la main sans redémarrer.
Une instance exposée, elle, refuse toujours de démarrer.

L'amorce passe par l'inscription : sans relais, elle crée désormais son compte
sans lien de vérification. C'est une installation réelle qui l'a trouvé — la
boîte de la suite remet tout, « absent » ne remet rien — et la suite porte
maintenant le contrôle qui l'aurait vu. Le message de l'amorce ne réclame plus
de second facteur là où l'instance n'en exige pas.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent b5589998
Loading
Loading
Loading
Loading
+56 −0
Changes for apps/api/src/adaptateurs/notify.ts: 56 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -45,6 +45,42 @@ export const transportConsole: Transport = {
  },
};

/**
 * Refus d'envoyer, faute de relais — et non panne du relais.
 *
 * Une classe à part pour que les routes en fassent un refus lisible plutôt
 * qu'une erreur 500 : ce n'est pas un incident, c'est l'état déclaré de
 * l'instance.
 */
export class CourrielAbsent extends Error {
  constructor() {
    super("Aucun relais de courriel n'est configuré sur cette instance : rien ne part "
      + "par courriel. Les membres entrent par la porte de jonction ; un relais se "
      + "renseigne depuis la console d'administration, sans redémarrer.");
    this.name = "CourrielAbsent";
  }
}

/**
 * Transport d'une instance de salle sans relais : il refuse, et n'écrit rien.
 *
 * Le transport « console » écrirait dans les traces des liens de connexion
 * valides — c'est pourquoi la production le refuse. Mais une instance levée
 * dans la salle de crise, le matin même, n'a souvent aucun relais sous la main,
 * et n'en a pas besoin pour servir : on y entre par la porte de jonction, et le
 * premier compte reçoit un mot de passe temporaire. Refuser de démarrer pour
 * cela reviendrait à fermer la cellule faute d'une fonction dont elle se passe.
 *
 * Il ne fait donc ni l'un ni l'autre : rien ne part, rien n'est tracé, et
 * chaque appelant apprend qu'aucun message ne partira — jamais qu'il est parti.
 */
export const transportAbsent: Transport = {
  nom: "absent",
  async envoyer() {
    throw new CourrielAbsent();
  },
};

let transport: Transport = transportConsole;

export function definirTransport(t: Transport): void {
@@ -55,6 +91,26 @@ export function transportActuel(): Transport {
  return transport;
}

/**
 * Un courriel partirait-il réellement d'ici ?
 *
 * Ni « console », qui écrit dans les traces, ni « absent », qui refuse. C'est
 * la question que posent ceux qui choisissent entre un lien et un mot de passe
 * temporaire, ou qui préviennent l'écran avant qu'on clique.
 */
export function courrielRemis(): boolean {
  return transport.nom !== "console" && transport.nom !== "absent";
}

/**
 * Refuser AVANT d'écrire quoi que ce soit qui n'aurait de sens qu'avec le
 * courriel : un compte qui attend une vérification qui ne viendra pas, des
 * jetons de campagne que personne ne recevra.
 */
export function exigerCourriel(): void {
  if (transport.nom === "absent") throw new CourrielAbsent();
}

export async function envoyer(message: Message): Promise<void> {
  await transport.envoyer(message);
}
+12 −1
Changes for apps/api/src/administration/reglages.ts: 12 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -4,7 +4,9 @@ import {
} from "@kastell/shared";
import { antivirusClamav, definirAntivirus } from "../adaptateurs/antivirus.js";
import { definirMedia, mediaLiveKit } from "../adaptateurs/media.js";
import { definirTransport, envoyer, transportActuel, transportConsole } from "../adaptateurs/notify.js";
import {
  courrielRemis, definirTransport, envoyer, transportAbsent, transportActuel, transportConsole,
} from "../adaptateurs/notify.js";
import { definirPoussee, pousseeRelais } from "../adaptateurs/poussee.js";
import { transportSmtp } from "../adaptateurs/smtp.js";
import type { Sql, Tx } from "../db/client.js";
@@ -151,6 +153,11 @@ export async function appliquerReglages(sql: Sql, env: Env): Promise<void> {
    // Retirer la surcharge d'un relais doit rendre la main au transport de
    // développement, et non laisser en place celui qu'on vient d'effacer.
    definirTransport(transportConsole);
  } else if (env.KASTELL_POSTURE === "salle") {
    // Une instance de salle démarre sans relais : rien ne part, et rien n'est
    // écrit dans les traces. Sans ce transport, elle refuserait de démarrer
    // — voir index.ts — pour une fonction dont la salle se passe.
    definirTransport(transportAbsent);
  }
}

@@ -181,6 +188,10 @@ export async function essayerCourriel(
      + "parti dans les traces du serveur, et personne ne l'a reçu. "
      + "Renseignez SMTP_HOST et SMTP_FROM." };
  }
  if (!courrielRemis()) {
    return { ok: false, detail: "Aucun relais n'est configuré : rien n'est parti, et "
      + "rien n'a été écrit dans les traces. Renseignez un relais ci-dessus." };
  }

  try {
    await envoyer({
+11 −2
Changes for apps/api/src/amorce-cli.ts: 11 added lines, 2 removed lines.
Original line number Diff line number Diff line
import { transportActuel } from "./adaptateurs/notify.js";
import { courrielRemis } from "./adaptateurs/notify.js";
import { appliquerReglages } from "./administration/reglages.js";
import { AmorceRefusee, amorcerInstance } from "./auth/amorce.js";
import { ouvrirBase } from "./db/client.js";
import { relacheSecondFacteur } from "./noyau/administration.js";
import { chargerEnv } from "./env.js";

/**
@@ -44,7 +45,7 @@ try {
  // depuis la page de configuration doit servir à ce courriel-là comme aux
  // autres.
  await appliquerReglages(sql, env);
  const transportUtile = transportActuel().nom !== "console";
  const transportUtile = courrielRemis();

  const r = await amorcerInstance(sql, {
    email, nom, nomOrganisation: organisation, urlPublique: env.KASTELL_PUBLIC_URL,
@@ -63,9 +64,17 @@ try {
  }

  console.log(`  ${env.KASTELL_PUBLIC_URL}\n`);
  // En salle comme en mode test, l'octroi n'attend plus de second facteur :
  // annoncer le contraire enverrait chercher une application
  // d'authentification dont personne n'a besoin.
  if (await relacheSecondFacteur(sql)) {
    console.log("  Le droit d'administration sera accordé dès que le mot de passe aura été");
    console.log("  posé : cette instance n'exige pas de second facteur.\n");
  } else {
    console.log("  Le droit d'administration sera accordé au bout du parcours, quand le");
    console.log("  second facteur sera actif — pas avant, pour qu'il ne soit jamais un");
    console.log("  droit inexerçable.\n");
  }
} catch (e) {
  if (e instanceof AmorceRefusee) {
    console.error(`✗ ${e.message}`);
+3 −0
Changes for apps/api/src/auth/amorce.ts: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -103,6 +103,9 @@ export async function amorcerInstance(
  const { compteId, organisationId } = await comptes.inscrire(sql, {
    email: d.email, nom: d.nom, motDePasse, nomOrganisation: d.nomOrganisation,
    urlPublique: d.urlPublique,
    // Sans relais, aucun lien ne partirait : le mot de passe temporaire est
    // la preuve qu'on tient la machine, et c'est la seule qu'on demande ici.
    sansVerification: !transportUtile,
  });

  const motif = d.motif ?? "Amorçage de l'instance.";
+19 −1
Changes for apps/api/src/auth/comptes.ts: 19 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ import { exigerQuota } from "../administration/quotas.js";
import type { Sql, Tx } from "../db/client.js";
import * as typesCrise from "../preparation/typesCrise.js";
import { journaliser } from "../noyau/administration.js";
import { envoyer } from "../adaptateurs/notify.js";
import { envoyer, exigerCourriel } from "../adaptateurs/notify.js";
import { passerelle } from "../adaptateurs/sms.js";
import { fournisseurActif } from "../canaux/organisation.js";
import * as codes from "./codes_secours.js";
@@ -69,9 +69,22 @@ export interface Inscription {
  nomOrganisation?: string | undefined;
  ip?: string | null;
  urlPublique: string;
  /**
   * Ne pas envoyer de lien de vérification — pour l'amorce seule.
   *
   * Sans relais, l'amorce remet un mot de passe temporaire à qui tient la
   * machine : il n'y a personne à qui faire vérifier une adresse, et exiger
   * un courriel ici fermerait l'instance à son premier compte. L'adresse
   * reste non vérifiée, comme celle d'un compte entré par la porte de
   * jonction, et la connexion ne l'exige pas.
   */
  sansVerification?: boolean;
}

export async function inscrire(sql: Sql, i: Inscription): Promise<{ compteId: string; organisationId: string }> {
  // Avant tout : l'inscription s'achève par un lien de vérification. Sans
  // relais, le compte naîtrait pour attendre un courriel qui ne viendra pas.
  if (!i.sansVerification) exigerCourriel();
  const email = normaliserEmail(i.email);
  const nom = i.nom.trim();
  if (nom.length < 2) throw new ErreurAuth("operation_refusee", "Le nom est requis.");
@@ -118,7 +131,9 @@ export async function inscrire(sql: Sql, i: Inscription): Promise<{ compteId: st
  })) as { compteId: string; organisationId: string };

  if (i.ip) await noterTentative(sql, "inscription_ip", i.ip, true);
  if (!i.sansVerification) {
    await envoyerVerification(sql, resultat.compteId, email, nom, i.urlPublique);
  }
  return resultat;
}

@@ -223,6 +238,9 @@ export async function connecterParMotDePasse(sql: Sql, c: Connexion): Promise<Ou
export async function demanderLienConnexion(
  sql: Sql, email: string, urlPublique: string, ip?: string | null,
): Promise<void> {
  // Refusé avant de regarder l'adresse : la réponse vaut pour l'instance
  // entière, et n'apprend donc rien de l'existence d'un compte.
  exigerCourriel();
  const propre = normaliserEmail(email);
  await verifierLimite(sql, "lien_email", propre);
  await noterTentative(sql, "lien_email", propre, false);
Loading