Commit 9bb5a5dd authored by Kourser's avatar Kourser
Browse files

Trois portes pour faire entrer quelqu'un en pleine crise

L'accès à une crise vient de l'appartenance à l'organisation : le garde
vérifie « roleDans », jamais l'appartenance à une cellule. Renforcer une
cellule, c'est donc inviter. Or la route d'invitation n'avait aucun
appelant dans l'interface — ni web ni mobile. Le geste existait et se
faisait en curl.

Un écran, dans l'espace de l'organisation. Il montre aussi qui est là et
quelles invitations dorment encore dans une boîte : séparés, on réinvite
quelqu'un dont le lien attend depuis une heure et on cherche pourquoi il
n'arrive pas, au pire moment pour chercher. Cela demandait une route de
lecture, ajoutée ici — un écran d'invitation qui ne montre pas l'équipe
est un formulaire.

Une route pour l'ajout en cellule. « ajouterMembre » portait EF-108 — un
intervenant externe exige une date d'expiration — et n'était appelée que
par la suite de vérification : la garantie ne protégeait personne. Elle
gagne au passage trois refus qui lui manquaient : une échéance déjà
passée, une cellule prise dans une autre crise, un compte étranger à
l'organisation. Et un type d'erreur propre, pour que le refus arrive en
400 avec son motif plutôt qu'en « erreur interne ».

Un parcours d'acceptation qui crée le compte. L'invité sans compte lisait
« créez un compte, puis rouvrez ce lien » : trois étapes et un
aller-retour, pour la personne qu'on appelle en renfort, la nuit, depuis
un téléphone. Il pose un mot de passe et il entre. Aucune organisation
fantôme n'est créée au passage, l'adresse vient de l'invitation et non du
formulaire, et un échec tardif ne brûle plus le jeton.

Deux défauts trouvés en éprouvant, et qui ne se voyaient pas autrement.

  La fixture de « verif » créait un compte et une organisation sans jamais
  les relier : elle exerçait depuis toujours un état que le produit
  interdit. Le nouveau garde l'a fait tomber au premier passage.

  L'atterrissage se dédisait. La porte de chargement démontait l'écran
  pendant le rafraîchissement qui suit un succès ; au remontage, l'effet
  reprenait un jeton à usage unique déjà consommé et remplaçait « vous
  avez rejoint » par « invitation invalide ou expirée ». L'utilisateur
  avait bel et bien rejoint. Le défaut préexistait pour la branche
  déjà-connectée, et aucune vérification côté serveur ne pouvait le voir :
  elle constatait le compte créé et n'avait rien à redire. Il a fallu
  ouvrir le lien.

Vingt et une garanties nouvelles, éprouvées en cassant chaque condition
plutôt qu'en les regardant passer au vert. 963 au total.

Au passage : « pnpm dev » ne démarrait pas, les arguments de tsx étant
dans l'ordre qui lui fait chercher un fichier nommé « watch ».

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 22d2c7f0
Loading
Loading
Loading
Loading
Loading
+1 −1
Changes for apps/api/package.json: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -5,7 +5,7 @@
  "license": "AGPL-3.0-or-later",
  "type": "module",
  "scripts": {
    "dev": "tsx --env-file=../../.env watch src/index.ts",
    "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",
    "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);\"",
+148 −0
Changes for apps/api/src/auth/comptes.ts: 148 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -559,6 +559,154 @@ export async function inviter(sql: Sql, d: DemandeInvitation): Promise<string> {
  return invitationId;
}

export interface ApercuInvitation {
  organisation: string;
  role: string;
  email: string;
  /** Vrai si un compte porte déjà cette adresse : il faut alors se connecter. */
  compteExiste: boolean;
}

/**
 * Ce que l'invitation annonce, sans la consommer.
 *
 * L'écran d'atterrissage a besoin de dire où l'on entre et à quel titre avant
 * de demander quoi que ce soit. Le faire en consommant le jeton condamnerait
 * l'invitation au premier aperçu — y compris à l'aperçu que fait un client de
 * messagerie qui déplie les liens pour les prévisualiser.
 */
export async function apercuInvitation(sql: Sql, jeton: string): Promise<ApercuInvitation> {
  const [j] = await sql<{ donnees: { invitation_id?: string } }[]>`
    select donnees from jeton_usage_unique
     where empreinte = ${empreinte(jeton)} and genre = 'invitation'
       and consomme_at is null and expire_at > now()`;
  const invitationId = j?.donnees?.invitation_id;
  if (!invitationId) throw new ErreurAuth("invitation_invalide", "Invitation invalide ou expirée.");

  const [inv] = await sql<{ email: string; role: string; nom: string }[]>`
    select i.email, i.role, o.nom
      from invitation i join organisation o on o.id = i.organisation_id
     where i.id = ${invitationId} and i.accepte_at is null and i.revoque_at is null`;
  if (!inv) throw new ErreurAuth("invitation_invalide", "Invitation déjà utilisée ou révoquée.");

  const [compte] = await sql`select 1 from compte where lower(email) = ${inv.email}`;
  return {
    organisation: inv.nom, role: inv.role, email: inv.email,
    compteExiste: compte !== undefined,
  };
}

export interface InscriptionSurInvitation {
  jeton: string;
  nom: string;
  motDePasse: string;
  ip?: string | null;
  urlPublique: string;
}

/**
 * Créer son compte depuis l'invitation, et rejoindre dans le même geste.
 *
 * Ce que cela remplace : l'invité arrivait sur « Connectez-vous ou créez un
 * compte, puis rouvrez ce lien ». Trois étapes et un aller-retour, pour la
 * personne qu'on appelle en renfort, souvent la nuit, depuis un téléphone.
 *
 * Deux différences avec l'inscription ordinaire, et elles comptent.
 *
 *   Aucune organisation n'est créée. `inscrire` en fabrique une et vous en
 *   fait propriétaire — c'est juste pour qui arrive de lui-même, absurde pour
 *   qui est invité ailleurs : il repartirait avec une organisation vide dont
 *   personne ne saurait quoi faire, et qui compterait dans les quotas.
 *
 *   L'adresse vient de l'invitation, jamais du formulaire. Le jeton autorise à
 *   rejoindre CETTE organisation sous CETTE adresse ; laisser l'invité en
 *   saisir une autre ferait du lien un droit de créer un compte à n'importe
 *   quel nom. Le courriel de vérification part malgré tout à l'adresse
 *   invitée : si le lien avait été transféré, son destinataire légitime
 *   l'apprend.
 *
 * Le tout dans une seule transaction. `accepterInvitation` consomme le jeton
 * en premier ; ici un échec plus loin — mot de passe faible, quota atteint —
 * brûlerait une invitation sans rien créer, et il faudrait la réémettre.
 */
export async function inscrireSurInvitation(
  sql: Sql, i: InscriptionSurInvitation,
): Promise<{ compteId: string; organisationId: string; role: string; email: string }> {
  const nom = i.nom.trim();
  if (nom.length < 2) throw new ErreurAuth("operation_refusee", "Le nom est requis.");
  if (i.ip) await verifierLimite(sql, "inscription_ip", i.ip);

  const resultat = (await sql.begin(async (tx) => {
    const [j] = await tx<{ donnees: { invitation_id?: string } }[]>`
      update jeton_usage_unique set consomme_at = now()
       where empreinte = ${empreinte(i.jeton)} and genre = 'invitation'
         and consomme_at is null and expire_at > now()
      returning donnees`;
    const invitationId = j?.donnees?.invitation_id;
    if (!invitationId) {
      throw new ErreurAuth("invitation_invalide", "Invitation invalide ou expirée.");
    }

    const [inv] = await tx<{ organisation_id: string; role: string; email: string }[]>`
      select organisation_id, role, email from invitation
       where id = ${invitationId} and accepte_at is null and revoque_at is null
       for update`;
    if (!inv) throw new ErreurAuth("invitation_invalide", "Invitation déjà utilisée ou révoquée.");

    const email = normaliserEmail(inv.email);
    const [existant] = await tx`select 1 from compte where lower(email) = ${email}`;
    if (existant) {
      throw new ErreurAuth("email_deja_pris",
        "Un compte porte déjà cette adresse. Connectez-vous, puis rouvrez ce lien.");
    }

    const faiblesse = mdp.evaluer(i.motDePasse, [nom, email, email.split("@")[0] ?? ""]);
    if (faiblesse) throw new ErreurAuth("mot_de_passe_faible", faiblesse);

    await exigerQuota(tx, inv.organisation_id, "membres");

    const [compte] = await tx<{ id: string }[]>`
      insert into compte (email, nom, mdp_hash)
      values (${email}, ${nom}, ${await mdp.hacher(i.motDePasse)}) returning id`;
    if (!compte) throw new ErreurAuth("operation_refusee", "Inscription refusée.");

    await tx`update invitation set accepte_at = now(), accepte_par = ${compte.id}
              where id = ${invitationId}`;
    await tx`insert into membre (organisation_id, compte_id, role)
             values (${inv.organisation_id}, ${compte.id}, ${inv.role})`;

    await journaliser(tx, {
      type: "compte.inscrit", acteurId: compte.id, organisationId: inv.organisation_id,
      charge: { compte_id: compte.id, organisation_id: inv.organisation_id },
    });
    await journaliser(tx, {
      type: "invitation.acceptee", acteurId: compte.id, organisationId: inv.organisation_id,
      charge: {
        invitation_id: invitationId, organisation_id: inv.organisation_id,
        compte_id: compte.id,
      },
    });
    await journaliser(tx, {
      type: "membre.role_modifie", acteurId: compte.id, organisationId: inv.organisation_id,
      charge: {
        organisation_id: inv.organisation_id, compte_id: compte.id,
        de: null, vers: inv.role,
      },
    });

    return {
      compteId: compte.id, organisationId: inv.organisation_id,
      role: inv.role, email,
    };
  })) as { compteId: string; organisationId: string; role: string; email: string };

  if (i.ip) await noterTentative(sql, "inscription_ip", i.ip, true);
  // Hors transaction : un relais lent ou en panne ne doit pas défaire un
  // compte qui existe déjà et une invitation déjà honorée.
  await envoyerVerification(sql, resultat.compteId, resultat.email, nom, i.urlPublique);
  return resultat;
}

export async function accepterInvitation(
  sql: Sql, jeton: string, compteId: string,
): Promise<{ organisationId: string; role: string }> {
+83 −1
Changes for apps/api/src/domaine/crise.ts: 83 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -116,6 +116,21 @@ export async function definirPont(
  }))) as EvenementEnregistre;
}

/**
 * Un refus que l'appelant peut corriger, par opposition à une panne.
 *
 * La distinction n'est pas cosmétique : sans elle, un externe proposé sans
 * date d'expiration remontait en « erreur interne », c'est-à-dire au seul
 * message qui n'apprend rien à qui pourrait réparer sa demande en dix
 * secondes. En crise, on n'a pas dix minutes pour deviner.
 */
export class RefusCellule extends Error {
  constructor(message: string) {
    super(message);
    this.name = "RefusCellule";
  }
}

export async function ajouterMembre(
  sql: Sql, criseId: string, celluleId: string, compteId: string,
  role: "animateur" | "membre" | "externe" | "observateur",
@@ -124,7 +139,29 @@ export async function ajouterMembre(
  // EF-108 : un intervenant externe sans date d'expiration serait un accès
  // permanent accordé dans l'urgence puis oublié.
  if (role === "externe" && !expireAt) {
    throw new Error("Un intervenant externe exige une date d'expiration (EF-108).");
    throw new RefusCellule("Un intervenant externe exige une date d'expiration (EF-108).");
  }
  // Une expiration déjà passée est une manière polie de contourner EF-108 :
  // elle satisfait la lettre du contrôle et n'ouvre rien, ou pire, elle est
  // une faute de frappe sur l'année qu'on ne verra jamais.
  if (expireAt && expireAt.getTime() <= Date.now()) {
    throw new RefusCellule("Cette date d'expiration est déjà passée.");
  }
  // La cellule doit appartenir à cette crise. Sans ce contrôle, un identifiant
  // de cellule pris ailleurs inscrirait quelqu'un dans une crise voisine —
  // l'événement, lui, serait consigné sous la bonne, et le journal mentirait.
  const [cellule] = await sql`
    select 1 from cellule where id = ${celluleId} and crise_id = ${criseId}`;
  if (!cellule) throw new RefusCellule("Cette cellule n'appartient pas à cette crise.");

  const [compte] = await sql`
    select 1 from membre m join crise c on c.organisation_id = m.organisation_id
     where c.id = ${criseId} and m.compte_id = ${compteId}`;
  if (!compte) {
    throw new RefusCellule(
      "Cette personne n'est pas membre de l'organisation. Invitez-la d'abord : "
      + "l'accès à la crise vient de là, pas de la cellule.",
    );
  }
  return (await sql.begin((tx) =>
    enregistrer(tx, {
@@ -138,6 +175,51 @@ export async function ajouterMembre(
  )) as EvenementEnregistre;
}

export interface MembreCellule {
  compte_id: string;
  nom: string;
  role: "animateur" | "membre" | "externe" | "observateur";
  expire_at: Date | null;
}

export interface CelluleOuverte {
  id: string;
  code: string;
  libelle: string;
  membres: MembreCellule[];
}

/**
 * Les cellules ouvertes et qui les compose.
 *
 * `retire_at is null` seulement : un retrait est un fait du journal, pas un
 * effacement, et la liste sert à savoir qui est là maintenant. Les expirés
 * restent visibles avec leur date — c'est ce qui permet de voir qu'un accès
 * externe court encore, et de s'en étonner à temps.
 */
export async function cellulesEtMembres(
  sql: Sql, criseId: string,
): Promise<CelluleOuverte[]> {
  const cellules = await sql<{ id: string; code: string; libelle: string }[]>`
    select id, code, libelle from cellule
     where crise_id = ${criseId} and fermee_at is null
     order by code`;
  if (cellules.length === 0) return [];

  const membres = await sql<(MembreCellule & { cellule_id: string })[]>`
    select cm.cellule_id, cm.compte_id, c.nom, cm.role, cm.expire_at
      from cellule_membre cm
      join compte c on c.id = cm.compte_id
     where cm.cellule_id = any(${cellules.map((c) => c.id)}) and cm.retire_at is null
     order by c.nom`;

  return cellules.map((c) => ({
    ...c,
    membres: membres.filter((m) => m.cellule_id === c.id).map(
      ({ cellule_id: _, ...m }) => m),
  }));
}

export interface Consignation {
  criseId: string;
  celluleId: string | null;
+66 −0
Changes for apps/api/src/routes/auth.ts: 66 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -5,10 +5,12 @@ import type { Sql } from "../db/client.js";
import * as comptes from "../auth/comptes.js";
import { ErreurAuth } from "../auth/comptes.js";
import { QuotaAtteint } from "../administration/quotas.js";
import { RefusCellule } from "../domaine/crise.js";
import { TropDeTentatives } from "../auth/limites.js";
import * as sessions from "../auth/sessions.js";
import * as amorce from "../auth/amorce.js";
import { estRoot } from "../noyau/administration.js";
import { requerirMembre } from "./garde.js";

/**
 * Routes d'authentification.
@@ -68,6 +70,10 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
        message: erreur.message,
      });
    }
    if (erreur instanceof RefusCellule) {
      // 400 et non 500 : la demande est corrigible, et le message dit comment.
      return reply.code(400).send({ code: "requete_invalide", message: erreur.message });
    }
    if (erreur instanceof ErreurAuth) {
      return reply.code(STATUTS[erreur.code]).send({ code: erreur.code, message: erreur.message });
    }
@@ -284,6 +290,27 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
  });

  // ── Invitations (EF-107) ─────────────────────────────────────────────────
  //
  // Qui est là, et qui a été convié sans être encore venu. Les deux ensemble :
  // séparés, on réinvite quelqu'un dont l'invitation dort dans sa boîte, et on
  // ne sait jamais pourquoi il n'arrive pas.
  app.get("/organisations/:id/membres", { preHandler: requerirMembre(sql) }, async (req) => {
    const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
    return {
      membres: await sql`
        select m.compte_id, c.nom, c.email, m.role
          from membre m join compte c on c.id = m.compte_id
         where m.organisation_id = ${id}
         order by c.nom`,
      // Ni les acceptées ni les révoquées : elles n'appellent aucun geste.
      invitations: await sql`
        select id, email, role, expire_at from invitation
         where organisation_id = ${id} and accepte_at is null and revoque_at is null
           and expire_at > now()
         order by expire_at`,
    };
  });

  app.post("/organisations/:id/invitations", { preHandler: requerirSession() }, async (req, reply) => {
    const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
    const c = z.object({
@@ -297,6 +324,45 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
    return reply.code(201).send({ invitationId });
  });

  /**
   * Ce que l'invitation annonce, avant qu'on demande quoi que ce soit.
   *
   * Sans session : l'invité n'en a pas encore, et c'est tout le problème qu'on
   * répare ici. Le jeton est le seul justificatif, et il est à usage unique —
   * l'aperçu ne le consomme donc pas.
   */
  app.get("/auth/invitation", async (req, reply) => {
    const { jeton } = z.object({ jeton: z.string().min(1) }).parse(req.query);
    return reply.send(await comptes.apercuInvitation(sql, jeton));
  });

  /**
   * Créer son compte depuis l'invitation, et rejoindre dans le même geste.
   *
   * L'adresse n'est pas un paramètre : elle vient de l'invitation. Un lien
   * transféré ne doit pas devenir un droit de créer un compte à n'importe quel
   * nom.
   */
  app.post("/auth/invitation/inscription", async (req, reply) => {
    const c = z.object({
      jeton: z.string().min(1),
      nom: z.string().trim().min(2).max(120),
      motDePasse: z.string().min(12),
    }).parse(req.body);
    const r = await comptes.inscrireSurInvitation(sql, {
      ...c, ip: req.ip, urlPublique: env.KASTELL_PUBLIC_URL,
    });
    // La session s'ouvre ici plutôt que de renvoyer l'invité vers l'écran de
    // connexion : il vient de poser son mot de passe, le lui redemander dans
    // la seconde n'apprend rien à personne et perd celui qu'on appelle en
    // renfort.
    const o = await comptes.connecterParMotDePasse(sql, {
      email: r.email, motDePasse: c.motDePasse, ...contexte(req),
    });
    poserCookie(reply, o.jeton);
    return reply.code(201).send({ organisationId: r.organisationId, role: r.role });
  });

  app.post("/auth/invitation", { preHandler: requerirSession() }, async (req, reply) => {
    const { jeton } = z.object({ jeton: z.string().min(1) }).parse(req.body);
    const r = await comptes.accepterInvitation(sql, jeton, req.auth!.compte.id);
+41 −0
Changes for apps/api/src/routes/crise.ts: 41 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -43,6 +43,47 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
  // argument recopié à la main (EM-33).
  const ctx = (req: FastifyRequest) => contexteDe(req);

  // ── Cellules et leur composition (EF-108) ────────────────────────────────
  //
  // Qui ouvre l'accès à une cellule : la direction de crise, ou l'animateur de
  // cette cellule-là. Le simple membre d'organisation entre déjà dans la crise
  // — c'est le garde `acces` qui le dit — mais faire entrer autrui est un
  // autre geste, et il laisse une trace nominative au journal.
  const peutComposer = async (req: FastifyRequest, celluleId: string) => {
    if (req.role === "directeur_crise" || req.role === "proprietaire") return true;
    const [r] = await sql`
      select 1 from cellule_membre
       where cellule_id = ${celluleId} and compte_id = ${req.porteur!.compteId}
         and role = 'animateur' and retire_at is null`;
    return r !== undefined;
  };

  app.get("/crises/:criseId/cellules", acces, async (req) => ({
    cellules: await crises.cellulesEtMembres(sql, criseId(req)),
  }));

  app.post("/crises/:criseId/cellules/:sousId/membres", acces, async (req, reply) => {
    const celluleId = sousId(req);
    if (!await peutComposer(req, celluleId)) {
      return reply.code(403).send({
        code: "role_insuffisant",
        message: "Seul le directeur de crise ou l'animateur de la cellule y fait entrer quelqu'un.",
      });
    }
    const c = z.object({
      compteId: z.string().uuid(),
      role: z.enum(["animateur", "membre", "externe", "observateur"]),
      // Datée, jamais une durée : « 72 heures » se compte depuis un instant
      // qu'on ne connaît pas au moment de relire le journal.
      expireAt: z.string().datetime().nullable().default(null),
    }).parse(req.body);

    const ev = await crises.ajouterMembre(
      sql, criseId(req), celluleId, c.compteId, c.role,
      c.expireAt ? new Date(c.expireAt) : null, ctx(req));
    return reply.code(201).send({ seq: ev.seq });
  });

  // ── Ouverture et cockpit ─────────────────────────────────────────────────
  app.post("/organisations/:id/crises", { preHandler: requerirMembre(sql,
    ["proprietaire", "directeur_crise"]) }, async (req, reply) => {
Loading