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

M2 complet : la photo de terrain et le lieu

« Sur le site, devant la baie éteinte. Une phrase, une photo, l'heure exacte. »
Les deux moitiés manquantes du jalon, et le principe qui les gouverne : rien
n'est collecté sans qu'on l'ait demandé, et l'autorisation se demande au moment
où elle sert.

Aucune voie rapide pour la photo
  EM-34 le dit sans ambiguïté : « une photo jointe depuis le terrain suit le
  chemin documentaire complet — quota, analyse antivirale, empreinte, versement
  au dossier de la cellule. Aucune voie rapide n'est ouverte au prétexte que le
  fichier vient d'un appareil photo. »

  On ouvre donc au téléphone la route de dépôt que le web utilise déjà, parce
  qu'elle fait tout ce travail — et elle seule. La recherche documentaire, les
  dossiers, les versions, les partages restent fermés : ce n'est pas un des six
  gestes, et deux garanties le vérifient.

  L'ordre compte : la photo part d'abord, la consignation la désigne ensuite.
  Si le dépôt échoue, rien n'est consigné — mieux vaut une entrée qui n'est pas
  partie qu'une entrée qui parle d'une photo absente.

L'appareil photo, jamais la galerie
  EM-35 demande trois gestes au plus, « sans passer par la galerie de
  l'appareil ». L'autorisation d'accéder aux photos personnelles n'est donc
  jamais demandée : elle n'a rien à voir avec ce qu'on demande de faire. Une
  garantie vérifie qu'aucune source n'appelle la photothèque.

La position, une fois, sur un doigt
  « Jamais collectée en continu. » Ni abonnement, ni suivi en arrière-plan, ni
  relevé au démarrage, ni données EXIF dans le cliché — les quatre sont
  contrôlés. Elle s'affiche en clair avant l'envoi, coordonnées et rayon
  d'incertitude, et se retire d'un bouton : une pastille « position jointe »
  qu'on ne pourrait pas vérifier ne serait pas un consentement.

Ce que ces contrôles ont de particulier : ils portent sur des absences. Une
collecte ajoutée « pour améliorer l'expérience » ne casse aucun écran et
n'apparaît dans aucun test fonctionnel. Elle apparaît ici.

777 garanties, dont 13 nouvelles.

Éprouvé à l'écran : la demande de position affiche la phrase du produit, au
moment où elle sert ; le relevé s'affiche en clair, se retire, et arrive au
journal avec origine « mobile ». Le dépôt d'une photo par le chemin documentaire
est éprouvé côté serveur — empreinte, verdict d'analyse, lien depuis l'entrée.

Ce qui n'a pas pu être éprouvé ici, et que je ne prétends pas avoir vu : le
déclenchement de l'obturateur. La caméra simulée s'ouvre — avec la bonne
autorisation et la bonne phrase — mais n'accepte pas de déclenchement injecté.
Cette dernière ligne se vérifiera sur un appareil réel.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 78d5fd56
Loading
Loading
Loading
Loading
Loading
+7 −1
Original line number Diff line number Diff line
@@ -127,12 +127,17 @@ export interface Consignation {
  genre: Genre;
  texte: string;
  diffusion?: Diffusion;
  liens?: { genre: string; id: string; libelle?: string }[];
  liens?: { genre: string; id: string; libelle?: string | undefined }[];
  /** 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;
  /** EM-36 : attachée sur action explicite, jamais collectée en continu. */
  position?: {
    latitude: number; longitude: number;
    precision_m?: number | undefined; releve_at: string;
  };
}

/**
@@ -179,6 +184,7 @@ export async function consigner(sql: Sql, c: Consignation, ctx: Contexte): Promi
        liens: (c.liens ?? []) as never,
        ...(c.pourLeCompteDe ? { pour_le_compte_de: c.pourLeCompteDe } : {}),
        ...(c.intentionId ? { intention_id: c.intentionId } : {}),
        ...(c.position ? { position: c.position } : {}),
      },
      saisiAt: c.saisiAt ?? null,
      ctx,
+19 −0
Original line number Diff line number Diff line
@@ -103,17 +103,36 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
      texte: z.string().trim().min(1).max(10000),
      diffusion: z.enum(DIFFUSIONS).default("cellule"),
      pourLeCompteDe: z.string().uuid().optional(),
      // EM-34 : une photo de terrain est un document comme un autre. Elle est
      // déposée par le chemin documentaire, puis désignée ici — pas glissée
      // dans la charge de l'entrée.
      liens: z.array(z.object({
        genre: z.enum(["message", "document", "action", "decision", "tiers",
          "enregistrement", "tableau", "veille"]),
        id: z.string(),
        libelle: z.string().optional(),
      })).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(),
      // EM-36 : facultative, explicite, et affichée avant l'envoi côté
      // appareil. Le serveur ne la déduit jamais d'une adresse réseau.
      position: z.object({
        latitude: z.number().min(-90).max(90),
        longitude: z.number().min(-180).max(180),
        precision_m: z.number().nonnegative().optional(),
        releve_at: z.string().datetime(),
      }).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 } : {}),
      ...(c.position ? { position: c.position } : {}),
      ...(c.liens ? { liens: c.liens } : {}),
      saisiAt: c.saisiAt ?? null,
    }, ctx(req));
    return reply.code(201).send({ seq: ev.seq, occurredAt: ev.occurred_at });
+21 −5
Original line number Diff line number Diff line
@@ -5,7 +5,9 @@ import * as documents from "../domaine/documents.js";
import * as chat from "../domaine/chat.js";
import * as kit from "../preparation/kit.js";
import { stockage } from "../adaptateurs/stockage.js";
import { REDACTEURS, requerirAccesCrise, requerirMembre } from "./garde.js";
import {
  REDACTEURS, contexteDe, requerirAccesCrise, requerirAccesCriseOuAppareil, requerirMembre,
} from "./garde.js";

/**
 * Gestion documentaire (§6.9) et kit de préparation (EF-208).
@@ -49,13 +51,27 @@ export function enregistrerRoutesDocuments(app: FastifyInstance, sql: Sql): void
    (_req, corps, fait) => fait(null, corps));

  const acces = { preHandler: requerirAccesCrise(sql) };
  /**
   * Le dépôt direct, et lui seul, s'ouvre à un téléphone enrôlé (EM-34).
   *
   * « Une photo jointe depuis le terrain suit le chemin documentaire complet :
   * quota, analyse antivirale, empreinte, versement au dossier de la cellule.
   * Aucune voie rapide n'est ouverte au prétexte que le fichier vient d'un
   * appareil photo. » C'est exactement pour cela qu'on ouvre cette route-ci et
   * pas une autre : elle fait déjà tout le travail.
   *
   * Le reste de la gestion documentaire — recherche, versions, partages,
   * rangement, suppression — reste fermé : ce n'est pas un des six gestes.
   */
  const depotMobile = { preHandler: requerirAccesCriseOuAppareil(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 se déduit du porteur : une photo prise sur le terrain s'inscrit
  // au journal comme « mobile », une pièce déposée au clavier comme « humaine »
  // (EM-33).
  const ctx = (req: FastifyRequest) => contexteDe(req);

  /** Diffuse un objet, en respectant l'en-tête de plage pour l'audio et la vidéo (EF-904). */
  async function servir(
@@ -150,7 +166,7 @@ export function enregistrerRoutesDocuments(app: FastifyInstance, sql: Sql): void
    reply.send(await documents.acheverDepot(sql, criseId(req), sousId(req), ctx(req))));

  /** Voie courte pour les petites pièces : métadonnées en requête, octets en corps. */
  app.post("/crises/:criseId/documents", acces, async (req, reply) => {
  app.post("/crises/:criseId/documents", depotMobile, async (req, reply) => {
    const c = z.object({
      celluleId: z.string().uuid().nullish(),
      dossier: z.string().max(120).default(""),
+33 −0
Original line number Diff line number Diff line
@@ -326,6 +326,39 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
    rendu.includes("EXERCICE")
      && rendu.indexOf("EXERCICE") < rendu.indexOf("crise.intitule"));

  // ── EM-35 à EM-38 : ce que l'application ne collecte pas ────────────────
  //
  // Ces contrôles portent sur des absences, et c'est pour cela qu'ils comptent :
  // une collecte ajoutée « pour améliorer l'expérience » ne casse aucun écran
  // et ne se voit dans aucun test fonctionnel. Elle se voit ici.
  const terrain = sansCommentaires(lus.get("src/crise/constat.ts") ?? "");
  const toutesSources = [...lus].map(([, t]) => sansCommentaires(t)).join("\n");

  r.verifie("EM-35", "la photo passe par l'appareil, jamais par la galerie",
    /launchCameraAsync/.test(terrain)
      && !/launchImageLibraryAsync|requestMediaLibraryPermissions/.test(toutesSources));
  r.verifie("EM-36", "la position n'est jamais suivie en continu",
    /getCurrentPositionAsync/.test(terrain)
      && !/watchPositionAsync|startLocationUpdatesAsync|BackgroundLocation/
        .test(toutesSources));
  r.verifie("EM-36", "et le cliché ne transporte pas de position en douce",
    /exif: false/.test(terrain));

  // EM-37 : « aucune demande n'est faite au premier lancement en bloc. » Les
  // demandes vivent donc dans les modules qui s'en servent, jamais au
  // démarrage de l'application.
  const demarrage = sansCommentaires(lus.get("src/App.tsx") ?? "");
  r.verifie("EM-37", "aucune autorisation n'est demandée au démarrage",
    !/requestCameraPermissions|requestForegroundPermissions|requestMediaLibrary/
      .test(demarrage));

  // EM-38 : « aucune télémétrie comportementale. »
  const mouchards = ["analytics", "Analytics", "amplitude", "mixpanel", "posthog",
    "firebase/analytics", "trackEvent", "logEvent"]
    .filter((m) => toutesSources.includes(m));
  r.verifie("EM-38", "aucune télémétrie comportementale n'est embarquée",
    mouchards.length === 0, mouchards.join(", "));

  // ── EM-42, EM-74 : la clause réseau ne s'arrête pas au navigateur ───────
  const apropos = lus.get("src/ecrans/APropos.tsx") ?? "";
  r.verifie("EM-74", "l'écran « à propos » affiche la licence et l'adresse du code",
+69 −0
Original line number Diff line number Diff line
@@ -593,6 +593,75 @@ export async function verifierMobile(
    horsPerimetre.statusCode === 403
      && (horsPerimetre.json() as { code: string }).code === "non_membre");

  // ── La photo de terrain suit le chemin documentaire complet (EM-34) ──────
  //
  // « Aucune voie rapide n'est ouverte au prétexte que le fichier vient d'un
  // appareil photo. » On ouvre donc la route de dépôt existante — celle qui
  // fait déjà quota, analyse antivirale, empreinte et versement — plutôt que
  // d'en écrire une plus courte pour le mobile.
  const cliche = Buffer.from(
    "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
    + "photo-de-terrain-simulee", "binary");
  const depot = await app.inject({
    method: "POST",
    url: `/api/crises/${crise.criseId}/documents`
      + `?titre=${encodeURIComponent("Baie B12 éteinte")}&nomFichier=constat.jpg`
      + `&typeMime=image%2Fjpeg`,
    headers: { ...entete, "content-type": "application/octet-stream" },
    payload: cliche,
  });
  const depose = depot.json() as { documentId?: string; empreinte?: string; verdict?: string };
  r.verifie("EM-34", "l'appareil dépose une photo par le chemin documentaire",
    depot.statusCode === 201 && typeof depose.documentId === "string",
    `reçu ${depot.statusCode}`);
  r.verifie("EM-34", "et le dépôt porte son empreinte et son verdict d'analyse",
    typeof depose.empreinte === "string" && depose.empreinte.length >= 64
      && typeof depose.verdict === "string");

  const [documentInscrit] = await sql<{ origine: string; appareil_id: string | null }[]>`
    select origine, appareil_id from evenement
     where crise_id = ${crise.criseId} and type = 'document.depose'
     order by seq desc limit 1`;
  r.verifie("EM-33", "le journal dit que la pièce vient du terrain",
    documentInscrit?.origine === "mobile"
      && documentInscrit.appareil_id === identite.appareilId);

  // La photo est désignée par la consignation, pas glissée dans sa charge.
  const avecPhoto = await app.inject({
    method: "POST", url: `/api/crises/${crise.criseId}/main-courante`, headers: entete,
    payload: {
      genre: "fait", texte: "Constat photographié sur place.",
      liens: [{ genre: "document", id: depose.documentId, libelle: "constat.jpg" }],
      // EM-36 : attachée sur action explicite, jamais collectée en continu.
      position: {
        latitude: 48.3904, longitude: -4.4861, precision_m: 12,
        releve_at: new Date().toISOString(),
      },
    },
  });
  r.verifie("EM-34", "la consignation désigne la photo au lieu de la contenir",
    avecPhoto.statusCode === 201);

  const [avecPosition] = await sql<{ charge: { position?: { latitude: number };
    liens?: { genre: string }[] } }[]>`
    select charge from evenement
     where crise_id = ${crise.criseId} and type = 'main_courante.consignee'
     order by seq desc limit 1`;
  r.verifie("EM-36", "la position est inscrite telle que la personne l'a attachée",
    avecPosition?.charge.position?.latitude === 48.3904);
  r.verifie("EM-34", "et le lien vers la pièce accompagne l'entrée",
    avecPosition?.charge.liens?.[0]?.genre === "document");

  // Le reste de la gestion documentaire n'est pas un des six gestes.
  for (const [quoi, chemin] of [
    ["la recherche documentaire", `/api/crises/${crise.criseId}/documents/recherche?q=a`],
    ["les dossiers", `/api/crises/${crise.criseId}/dossiers`],
  ] as [string, string][]) {
    const rep = await app.inject({ method: "GET", url: chemin, headers: entete });
    r.verifie("EM-04", `${quoi} reste hors de portée d'un appareil`,
      rep.statusCode === 401, `reçu ${rep.statusCode}`);
  }

  // ── Un relais joignable mais muet ne doit pas être annoncé (EM-76) ───────
  //
  // Le cas le plus traître : le relais répond, l'adresse est bonne, tout a
Loading