Verified Commit 6bdb8cb1 authored by Kourser's avatar Kourser
Browse files

Mobile M2 : lire et consigner, y compris sans réseau

Le téléphone lit ce qui se passe et écrit ce qu'il constate. Là où M1 servait
à réveiller quelqu'un, M2 sert à ce qu'il fasse quelque chose une fois debout.

Une seule API, et une authentification qui s'élargit
  EM-03 exige que l'application et le web parlent à la même API, sans point de
  terminaison réservé au mobile. Ce ne sont donc pas des routes qui s'ajoutent :
  c'est le garde d'accès à une crise qui accepte une identité d'appareil en
  plus d'un cookie de session.

  Ce qui compte autant que ce que le téléphone atteint, c'est ce qu'il
  n'atteint pas. Ouvrir ce garde à tout le cockpit d'un seul geste aurait été
  la dérive que le §2 refuse — et aurait cassé, en silence, chaque route qui lit
  encore une session qu'un appareil ne renseigne pas. Il est donc posé route par
  route, sur six d'entre elles. Le rembobinage, le journal brut, le tableau
  blanc, la gestion documentaire, l'administration et l'enrôlement d'un second
  appareil restent fermés — et une garantie le vérifie pour chacun.

La file hors ligne, et le cas normal qu'elle traite
  Quelqu'un devant une baie éteinte, dans un local sans réseau, note ce qu'il
  voit. Ce qu'il tape ne doit pas dépendre d'une barre de signal : le geste est
  le même avec ou sans réseau. Demander à quelqu'un de choisir entre « envoyer »
  et « mettre en file » serait lui demander de diagnostiquer son réseau au
  moment où il regarde une baie éteinte.

  L'entrée porte son heure de saisie ; le serveur y ajoute l'heure de remise, et
  l'écart s'affiche. L'ordre du journal reste celui de la remise : le journal ne
  se réécrit pas pour accommoder un téléphone.

  Un refus du serveur — crise close, droit retiré — rend l'entrée avec son motif
  et son texte intact. Elle ne repart pas toute seule : le serveur a dit non, et
  réessayer en boucle masquerait le problème au lieu de le montrer.

L'idempotence n'est pas un raffinement, c'est le cas courant
  Le serveur inscrit, la réponse se perd dans un ascenseur, le téléphone
  réessaie. Sans identifiant d'intention, la main courante porterait deux fois
  le même constat et personne, au rejeu, ne saurait si le fait s'est produit
  une fois ou deux. L'unicité est portée par un index — un « vérifier puis
  écrire » laisserait passer deux remises simultanées.

  Deux défauts trouvés en écrivant les garanties : la seconde remise renvoyait
  la séquence en chaîne là où la première la renvoyait en nombre, et deux écrans
  pouvaient vider la file en même temps, faisant réapparaître des entrées déjà
  parties. Un troisième dans le contrôle lui-même : « Consigner( » se faisait
  passer pour « signer( », et le contrôle criait au loup sur du code
  irréprochable — un contrôle qui crie au loup finit désactivé.

Le journal dit désormais quel appareil a saisi, à côté de la session (EM-33).
La colonne n'entre pas dans l'empreinte, comme session_id : élargir le matériel
scellé invaliderait tous les journaux existants et tous les vérificateurs déjà
distribués, pour un gain nul — UPDATE et DELETE sont refusés par déclencheur.

749 garanties, dont 20 nouvelles.

Reste à M2 : la photo de terrain, le dossier gelé téléchargé au déclenchement,
la position attachée à un constat, et l'appel en un geste depuis une fiche. Les
écrans n'ont pas encore été exercés sur simulateur — la composition était en
cours de reconstruction.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent e115de1e
Loading
Loading
Loading
Loading
+54 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- L'appareil qui a saisi, inscrit au journal (EM-33)
--
-- « Le journal enregistre l'origine mobile d'un événement et le modèle
-- d'appareil, comme il enregistre déjà la session. Au rejeu, on doit pouvoir
-- distinguer ce qui a été saisi sur le terrain de ce qui a été saisi en salle
-- de crise. »
--
-- Ce n'est pas une curiosité d'archiviste. Une consignation faite debout,
-- devant une baie éteinte, sur cinq pouces, n'a ni la même précision ni le
-- même contexte qu'une consignation faite assis devant un grand écran. Un
-- retour d'expérience qui les confond attribue au dispositif ce qui revient
-- aux conditions de saisie — et corrige la mauvaise chose.
--
-- La colonne accompagne session_id plutôt que de la remplacer : un même compte
-- peut avoir une session web ouverte et un téléphone enrôlé, et l'événement
-- doit dire lequel des deux a écrit.
-- ═══════════════════════════════════════════════════════════════════════════

alter table evenement
  add column appareil_id uuid references appareil(id) on delete set null;

create index evenement_appareil on evenement (crise_id, appareil_id)
  where appareil_id is not null;

-- Sur ce que la colonne n'est pas
--
-- Elle n'entre pas dans l'empreinte de la chaîne, et c'est délibéré :
-- session_id n'y entre pas davantage. Le matériel scellé est celui qu'un
-- vérificateur indépendant sait recalculer (EB-18), et sa formule est publiée.
-- L'élargir invaliderait tous les journaux existants et tous les vérificateurs
-- déjà distribués — pour un gain nul, puisque UPDATE et DELETE sont refusés par
-- déclencheur : la colonne est aussi immuable que le reste de la ligne.

-- ═══════════════════════════════════════════════════════════════════════════
-- L'intention, et son unicité (EM-31)
--
-- « Chaque intention porte un identifiant unique tiré sur l'appareil, pour que
-- sa remise soit idempotente. Un réseau qui coupe entre l'envoi et l'accusé ne
-- produit jamais deux entrées. »
--
-- C'est le cas normal d'une file hors ligne, pas le cas limite : le téléphone
-- envoie, le serveur inscrit, la réponse se perd dans un ascenseur, et le
-- téléphone réessaie. Sans cet index, la main courante porterait deux fois le
-- même constat, à deux secondes d'écart — et personne, au rejeu, ne saurait
-- dire si le fait s'est produit une fois ou deux.
--
-- L'unicité est portée par la base plutôt que par une lecture préalable : deux
-- remises simultanées du même identifiant passeraient entre les mailles d'un
-- « vérifier puis écrire ».
-- ═══════════════════════════════════════════════════════════════════════════

create unique index evenement_intention on evenement (crise_id, (charge ->> 'intention_id'))
  where charge ? 'intention_id';
+34 −0
Original line number Diff line number Diff line
@@ -131,9 +131,42 @@ export interface Consignation {
  /** EF-411 : consigner au nom d'un tiers présent en salle. */
  pourLeCompteDe?: string;
  saisiAt?: Date | null;
  /** EM-31 : l'identifiant tiré par l'appareil avant l'envoi. */
  intentionId?: string;
}

/**
 * Une intention déjà inscrite, retrouvée par son identifiant (EM-31).
 *
 * On la cherche avant d'écrire, pour rendre à l'appareil la même réponse que
 * la première fois plutôt qu'une erreur d'unicité. L'index reste la garantie :
 * ce chemin-ci n'est qu'une politesse, il ne protège pas d'une course.
 */
async function dejaInscrite(
  sql: Sql, criseId: string, intentionId: string,
): Promise<EvenementEnregistre | null> {
  const [e] = await sql<(Omit<EvenementEnregistre, "seq"> & { seq: string })[]>`
    select id, crise_id, seq, type, occurred_at, acteur_id, cellule_id, origine, charge,
           encode(empreinte, 'hex') as empreinte,
           encode(empreinte_precedente, 'hex') as empreinte_precedente
      from evenement
     where crise_id = ${criseId} and charge ->> 'intention_id' = ${intentionId}`;
  // Un bigint revient de la base en chaîne. Le rendre tel quel ferait répondre
  // « 12 » à la seconde remise là où la première avait répondu 12 : deux
  // réponses différentes pour la même intention, ce qui est exactement ce que
  // l'idempotence promet d'éviter.
  return e ? { ...e, seq: Number(e.seq) } : null;
}

export async function consigner(sql: Sql, c: Consignation, ctx: Contexte): Promise<EvenementEnregistre> {
  if (c.intentionId) {
    const deja = await dejaInscrite(sql, c.criseId, c.intentionId);
    // Rendre l'entrée existante plutôt qu'un conflit : du point de vue de
    // l'appareil, sa consignation est passée — et elle l'est. Lui répondre
    // « erreur » le pousserait à réessayer indéfiniment, ou pire, à effacer
    // un texte que l'utilisateur croirait perdu.
    if (deja) return deja;
  }
  return (await sql.begin((tx) =>
    enregistrer(tx, {
      criseId: c.criseId,
@@ -145,6 +178,7 @@ export async function consigner(sql: Sql, c: Consignation, ctx: Contexte): Promi
        diffusion: c.diffusion ?? "cellule",
        liens: (c.liens ?? []) as never,
        ...(c.pourLeCompteDe ? { pour_le_compte_de: c.pourLeCompteDe } : {}),
        ...(c.intentionId ? { intention_id: c.intentionId } : {}),
      },
      saisiAt: c.saisiAt ?? null,
      ctx,
+10 −1
Original line number Diff line number Diff line
@@ -27,6 +27,13 @@ export interface Contexte {
  /** null pour un événement produit par le système lui-même. */
  acteurId: string | null;
  sessionId?: string | null;
  /**
   * L'appareil qui a saisi, quand la saisie vient d'un téléphone enrôlé
   * (EM-33). Il accompagne sessionId plutôt que de la remplacer : un même
   * compte peut avoir une session web ouverte et un téléphone en poche, et
   * l'événement doit dire lequel des deux a écrit.
   */
  appareilId?: string | null;
  origine: Origine;
}

@@ -95,12 +102,14 @@ export async function enregistrer<T extends TypeEvenement>(
  const charge = sansOctetNul(schema.parse(ecriture.charge));

  const [ligne] = await tx<Ligne[]>`
    insert into evenement (crise_id, type, acteur_id, session_id, cellule_id, origine, charge, saisi_at)
    insert into evenement (crise_id, type, acteur_id, session_id, appareil_id, cellule_id,
                           origine, charge, saisi_at)
    values (
      ${ecriture.criseId},
      ${ecriture.type},
      ${ecriture.ctx.acteurId},
      ${ecriture.ctx.sessionId ?? null},
      ${ecriture.ctx.appareilId ?? null},
      ${ecriture.celluleId ?? null},
      ${ecriture.ctx.origine},
      ${tx.json(charge as never)},
+25 −9
Original line number Diff line number Diff line
@@ -10,7 +10,9 @@ import * as points from "../domaine/points.js";
import { etatALaSeq, mainCourante } from "../noyau/rejeu.js";
import { exporterJournal, verifierEnBase } from "../noyau/integrite.js";
import { enregistrer } from "../noyau/enregistrer.js";
import { requerirAccesCrise, requerirMembre } from "./garde.js";
import {
  contexteDe, requerirAccesCrise, requerirAccesCriseOuAppareil, requerirMembre,
} from "./garde.js";

/**
 * Routes de crise : cockpit, main courante, décisions, actions, points de
@@ -23,14 +25,23 @@ import { requerirAccesCrise, requerirMembre } from "./garde.js";

export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
  const acces = { preHandler: requerirAccesCrise(sql) };
  /**
   * Les six gestes du chemin critique, et eux seuls (EM-04).
   *
   * Ce garde s'écrit route par route. Le poser par défaut ouvrirait au
   * téléphone le tableau blanc, la gestion documentaire et la console — c'est
   *-à-dire tout ce que le §2 du volet mobile refuse explicitement d'y porter.
   */
  const accesMobile = { preHandler: requerirAccesCriseOuAppareil(sql) };
  const orgLecture = { preHandler: requerirMembre(sql) };

  const criseId = (req: FastifyRequest) =>
    z.object({ criseId: z.string().uuid() }).parse(req.params).criseId;
  const sousId = (req: FastifyRequest) =>
    z.object({ sousId: z.string().uuid() }).parse(req.params).sousId;
  const ctx = (req: FastifyRequest) =>
    ({ acteurId: req.auth!.compte.id, sessionId: req.auth!.session.id, origine: "humaine" as const });
  // L'origine — « humaine » ou « mobile » — se déduit du porteur, jamais d'un
  // argument recopié à la main (EM-33).
  const ctx = (req: FastifyRequest) => contexteDe(req);

  // ── Ouverture et cockpit ─────────────────────────────────────────────────
  app.post("/organisations/:id/crises", { preHandler: requerirMembre(sql,
@@ -60,7 +71,7 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
    };
  });

  app.get("/crises/:criseId/cockpit", acces, async (req, reply) => {
  app.get("/crises/:criseId/cockpit", accesMobile, async (req, reply) => {
    const etat = await cockpit.etat(sql, criseId(req));
    if (!etat) return reply.code(404).send({ code: "introuvable", message: "Crise introuvable." });
    return reply.send(etat);
@@ -76,7 +87,7 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
  });

  // ── Main courante (§6.4) ─────────────────────────────────────────────────
  app.get("/crises/:criseId/main-courante", acces, async (req) => {
  app.get("/crises/:criseId/main-courante", accesMobile, async (req) => {
    const f = z.object({
      jusquASeq: z.coerce.number().int().positive().optional(),
      celluleId: z.string().uuid().optional(),
@@ -85,7 +96,7 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
    return { entrees: await mainCourante(sql, criseId(req), f) };
  });

  app.post("/crises/:criseId/main-courante", acces, async (req, reply) => {
  app.post("/crises/:criseId/main-courante", accesMobile, async (req, reply) => {
    const c = z.object({
      celluleId: z.string().uuid().nullish(),
      genre: z.enum(GENRES),
@@ -93,11 +104,16 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
      diffusion: z.enum(DIFFUSIONS).default("cellule"),
      pourLeCompteDe: z.string().uuid().optional(),
      saisiAt: z.coerce.date().optional(),
      // EM-31 : tiré par l'appareil avant l'envoi, pour que la remise soit
      // idempotente. Le web ne l'envoie pas : au clavier, une coupure entre
      // l'envoi et l'accusé se voit à l'écran et se rejoue à la main.
      intentionId: z.string().uuid().optional(),
    }).parse(req.body);
    const ev = await crises.consigner(sql, {
      criseId: criseId(req), celluleId: c.celluleId ?? null, genre: c.genre,
      texte: c.texte, diffusion: c.diffusion,
      ...(c.pourLeCompteDe ? { pourLeCompteDe: c.pourLeCompteDe } : {}),
      ...(c.intentionId ? { intentionId: c.intentionId } : {}),
      saisiAt: c.saisiAt ?? null,
    }, ctx(req));
    return reply.code(201).send({ seq: ev.seq, occurredAt: ev.occurred_at });
@@ -120,7 +136,7 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
  });

  // ── Décisions (§6.5) ─────────────────────────────────────────────────────
  app.get("/crises/:criseId/decisions", acces, async (req) => {
  app.get("/crises/:criseId/decisions", accesMobile, async (req) => {
    const f = z.object({
      enAttente: z.coerce.boolean().optional(),
      celluleId: z.string().uuid().optional(),
@@ -168,7 +184,7 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
  });

  // ── Actions (§6.5) ───────────────────────────────────────────────────────
  app.get("/crises/:criseId/actions", acces, async (req) => {
  app.get("/crises/:criseId/actions", accesMobile, async (req) => {
    const f = z.object({
      celluleId: z.string().uuid().optional(),
      responsableId: z.string().uuid().optional(),
@@ -242,7 +258,7 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
  });

  // ── Points de situation (§6.6) ───────────────────────────────────────────
  app.get("/crises/:criseId/points", acces, async (req) => {
  app.get("/crises/:criseId/points", accesMobile, async (req) => {
    const { celluleId } = z.object({ celluleId: z.string().uuid().optional() }).parse(req.query);
    return { trame: TRAME_POINT, points: await points.lister(sql, criseId(req), celluleId) };
  });
+120 −4
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
import type { Sql } from "../db/client.js";
import { roleDans } from "../auth/comptes.js";
import * as sessions from "../auth/sessions.js";
import * as appareils from "../mobile/appareils.js";

/** Gardes d'accès par organisation et par crise, partagés par les routes métier. */

@@ -11,9 +12,26 @@ export type RoleOrganisation =
/** Qui a qualité pour modifier le dossier de préparation. */
export const REDACTEURS: RoleOrganisation[] = ["proprietaire", "preparation", "directeur_crise"];

/**
 * Qui écrit, quel que soit le chemin par lequel il s'est authentifié.
 *
 * Le web présente un cookie de session ; un téléphone enrôlé présente son
 * identité d'appareil. EM-03 exige que les deux parlent à la même API, sans
 * point de terminaison réservé au mobile : c'est donc l'authentification qui
 * s'élargit, pas les routes qui se dédoublent.
 */
export interface Porteur {
  compteId: string;
  /** La session web, ou null quand c'est un appareil. */
  sessionId: string | null;
  /** L'appareil enrôlé, ou null quand c'est un navigateur (EM-33). */
  appareil: { id: string; modele: string | null } | null;
}

declare module "fastify" {
  interface FastifyRequest {
    role?: RoleOrganisation;
    porteur?: Porteur;
  }
}

@@ -31,9 +49,63 @@ async function session(sql: Sql, req: FastifyRequest, reply: FastifyReply) {
    return null;
  }
  req.auth = resolue;
  req.porteur = { compteId: resolue.compte.id, sessionId: resolue.session.id, appareil: null };
  return resolue;
}

/**
 * Une identité d'appareil, présentée par un téléphone enrôlé.
 *
 * Ce que cela n'ouvre pas, et qui est le point important : un appareil ne
 * donne accès qu'à ce que ce garde-ci protège. L'administration, la sécurité
 * du compte, l'enrôlement d'un autre appareil et la console root continuent
 * d'exiger une session web — EM-23 : « administrer une instance depuis un
 * téléphone est un risque sans contrepartie ; le bris de glace demande un
 * motif écrit, et un motif écrit demande un clavier. »
 */
async function appareil(sql: Sql, req: FastifyRequest): Promise<Porteur | null> {
  const entete = req.headers.authorization;
  const [genre, valeur] = (entete ?? "").split(" ");
  if (genre !== "Appareil" || !valeur) return null;
  const [id, secret] = valeur.split(".");
  if (!id || !secret) return null;

  const resolu = await appareils.resoudre(sql, id, secret);
  if (!resolu) return null;
  const [a] = await sql<{ compte_id: string; modele: string | null }[]>`
    select compte_id, modele from appareil where id = ${id}`;
  if (!a) return null;
  return { compteId: a.compte_id, sessionId: null, appareil: { id, modele: a.modele } };
}

/**
 * Session web ou appareil enrôlé, indifféremment.
 *
 * L'ordre compte peu — les deux ne se présentent pas en même temps — mais le
 * message d'échec, lui, doit distinguer les deux cas : un navigateur à qui on
 * dit « cet appareil n'est pas enrôlé » cherche au mauvais endroit.
 */
async function porteur(
  sql: Sql, req: FastifyRequest, reply: FastifyReply,
): Promise<Porteur | null> {
  if (req.headers.authorization?.startsWith("Appareil ")) {
    const p = await appareil(sql, req);
    if (!p) {
      // EM-22 : la révocation est effective à la requête suivante, et le
      // message dit quoi faire — l'appareil effacera son cache.
      reply.code(401).send({
        code: "appareil_revoque",
        message: "Cet appareil a été révoqué. Effacez ses données et enrôlez-le à nouveau.",
      });
      return null;
    }
    req.porteur = p;
    return p;
  }
  const resolue = await session(sql, req, reply);
  return resolue ? req.porteur! : null;
}

/**
 * Appartenance à l'organisation, avec un rôle suffisant. Le paramètre d'URL
 * porte l'organisation : on ne déduit jamais le périmètre de la session, sans
@@ -64,10 +136,12 @@ export function requerirMembre(sql: Sql, roles?: RoleOrganisation[]) {
}

/** Accès à une crise : passe par l'organisation qui la porte. */
export function requerirAccesCrise(sql: Sql) {
function acces(sql: Sql, avecAppareil: boolean) {
  return async (req: FastifyRequest, reply: FastifyReply) => {
    const resolue = await session(sql, req, reply);
    if (!resolue) return;
    const p = avecAppareil
      ? await porteur(sql, req, reply)
      : (await session(sql, req, reply)) && req.porteur!;
    if (!p) return;

    const criseId = (req.params as { criseId?: string }).criseId;
    const [crise] = await sql<{ organisation_id: string }[]>`
@@ -75,7 +149,7 @@ export function requerirAccesCrise(sql: Sql) {
    if (!crise) {
      return reply.code(404).send({ code: "introuvable", message: "Crise introuvable." });
    }
    const role = await roleDans(sql, resolue.compte.id, crise.organisation_id);
    const role = await roleDans(sql, p.compteId, crise.organisation_id);
    if (!role) {
      return reply.code(403).send({ code: "non_membre", message: "Accès refusé." });
    }
@@ -83,3 +157,45 @@ export function requerirAccesCrise(sql: Sql) {
    return undefined;
  };
}

/** Accès à une crise depuis un navigateur : la porte de tout le cockpit. */
export function requerirAccesCrise(sql: Sql) {
  return acces(sql, false);
}

/**
 * Accès à une crise depuis un navigateur **ou** un téléphone enrôlé.
 *
 * À poser explicitement, route par route, et jamais par défaut. C'est la
 * traduction d'EM-04 : « l'application couvre les six gestes du chemin
 * critique et rien d'autre. Ajouter un septième écran est une décision de
 * produit, prise et écrite, jamais une dérive de développement. »
 *
 * Ouvrir ce garde à tout le cockpit d'un seul geste serait exactement cette
 * dérive — et, accessoirement, casserait chaque route qui lit encore
 * `req.auth`, qu'un appareil ne renseigne pas.
 */
export function requerirAccesCriseOuAppareil(sql: Sql) {
  return acces(sql, true);
}

/**
 * Le contexte d'écriture, quel que soit le porteur.
 *
 * L'origine se déduit du chemin d'authentification plutôt que d'être passée
 * en argument : personne ne peut alors se tromper en la recopiant, et une
 * saisie faite sur le terrain ne peut pas se faire passer pour une saisie de
 * salle de crise (EM-33).
 */
export function contexteDe(req: FastifyRequest): {
  acteurId: string; sessionId: string | null; appareilId: string | null;
  origine: "humaine" | "mobile";
} {
  const p = req.porteur!;
  return {
    acteurId: p.compteId,
    sessionId: p.sessionId,
    appareilId: p.appareil?.id ?? null,
    origine: p.appareil ? "mobile" : "humaine",
  };
}
Loading