Commit b5df7d9f authored by Kourser's avatar Kourser
Browse files

Les routes des agents n'avaient jamais été branchées

Importées dans serveur.ts depuis le lot A0, jamais enregistrées : l'écran des
agents, leur ouverture crise par crise, les propositions et le bandeau qui
dit qu'un agent lit la crise (EI-14) répondaient 404. Les garanties
éprouvaient le domaine et le serveur MCP, jamais ces routes, et restaient
vertes.

Le serveur tient maintenant la table des routes qu'il a réellement reçues, et
une garantie y confronte chaque appel littéral de l'interface (EX-41). Retirer
l'enregistrement la fait échouer en nommant les routes manquantes.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent e9158458
Loading
Loading
Loading
Loading
+19 −0
Changes for apps/api/src/serveur.ts: 19 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -44,6 +44,15 @@ declare module "fastify" {
    /** Traduire ce que la réponse dira, quand la phrase porte une valeur. */
    traduire: Traduire;
  }
  interface FastifyInstance {
    /**
     * Les routes déclarées, telles que le routeur les a reçues (EX-41).
     *
     * Pour confronter ce que l'interface appelle à ce que le serveur sert : une
     * route importée et jamais enregistrée n'y figure pas.
     */
    routesDeclarees: { methodes: string[]; chemin: string }[];
  }
}

export interface OptionsServeur {
@@ -96,6 +105,11 @@ export async function construireServeur(
      : { level: env.KASTELL_ENV === "production" ? "info" : "debug" },
    trustProxy: proxiesDeConfiance(env.KASTELL_TRUSTED_PROXY),
  });
  const routesDeclarees: FastifyInstance["routesDeclarees"] = [];
  app.decorate("routesDeclarees", routesDeclarees);
  app.addHook("onRoute", (o) => {
    routesDeclarees.push({ methodes: [o.method].flat().map(String), chemin: o.url });
  });

  // ── En-têtes de sécurité ─────────────────────────────────────────────────
  //
@@ -262,6 +276,11 @@ export async function construireServeur(
    enregistrerRoutesCanaux(api, sql, env);
    enregistrerRoutesContinuite(api, sql);
    enregistrerRoutesVeille(api, sql, env);
    // Importées depuis le lot A0, jamais branchées : l'écran des agents, leur
    // ouverture crise par crise et le bandeau qui dit qu'un agent lit (EI-14)
    // répondaient tous 404. La suite éprouvait le domaine et « /mcp », pas ces
    // routes ; un parcours en navigateur l'a vu. Voir verification/routes-client.ts.
    enregistrerRoutesAgents(api, sql);
  }, { prefix: "/api" });

  // Hors du préfixe « /api » : « /p/<jeton> » est une adresse qu'on donne à la
+3 −0
Changes for apps/api/src/verif.ts: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,8 @@ import { verifierInterface } from "./verification/interface.js";
import { verifierAccessibilite } from "./verification/accessibilite.js";
import { verifierRail } from "./verification/rail.js";
import { verifierServiceWeb } from "./verification/service-web.js";
import { fileURLToPath } from "node:url";
import { verifierRoutesClient } from "./verification/routes-client.js";
import { verifierMobile } from "./verification/mobile.js";
import { verifierApplicationMobile } from "./verification/application-mobile.js";
import { verifierRelais } from "./verification/relais.js";
@@ -349,6 +351,7 @@ await verifierInterface(rapport);
await verifierAccessibilite(rapport);
await verifierRail(rapport);
await verifierServiceWeb(sql, rapport, env);
await verifierRoutesClient(app, rapport, fileURLToPath(new URL("../../web/src/", import.meta.url)));

// ── Appareils mobiles (volet mobile, M0-M1) ───────────────────────────────
await verifierMobile(sql, app, rapport);
+135 −0
Changes for apps/api/src/verification/routes-client.ts: 135 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import type { FastifyInstance } from "fastify";
import type { Rapport } from "./rapport.js";

/**
 * Ce que l'interface appelle existe sur le serveur qui la sert (EX-41).
 *
 * Les routes des agents étaient importées dans « serveur.ts » et jamais
 * enregistrées. Quatre écrans et le bandeau qui dit qu'un agent lit la crise
 * (EI-14) répondaient 404 depuis leur livraison, et 1 674 garanties restaient
 * vertes : elles appelaient le domaine, ou le serveur MCP, jamais ces routes.
 *
 * On relève donc chaque appel littéral de l'interface — « api.lire(`…`) »,
 * « api.poster("…") », y compris ceux qu'on coupe en deux avec « + » — et on
 * le confronte à la table des routes que le serveur a réellement reçues, telle
 * que son routeur la tient. Une expression « ${…} » vaut n'importe quel
 * segment : un identifiant, ou un verbe choisi à l'exécution. Le chemin est lu
 * dans le source ; la table, elle, sort du vrai démarrage.
 */
const METHODES: Record<string, string> = {
  lire: "GET", poster: "POST", modifier: "PATCH", remplacer: "PUT", supprimer: "DELETE", deposer: "POST",
};
/** Tient la place d'une expression « ${…} » : n'importe quel segment. */
const EXPR = "@@expression@@";
/** Une expression hors littéral, collée par « + » : la suite est inconnue. */
const SUITE = "@@suite@@";

async function sources(dossier: string): Promise<string[]> {
  const sortie: string[] = [];
  for (const e of await readdir(dossier, { withFileTypes: true })) {
    const chemin = join(dossier, e.name);
    if (e.isDirectory()) sortie.push(...await sources(chemin));
    else if (/\.(ts|tsx)$/.test(e.name)) sortie.push(chemin);
  }
  return sortie;
}

/** Le premier argument littéral de chaque appel « api.x<…>(…) ». */
export function appelsLitteraux(source: string): { methode: string; chemin: string }[] {
  const trouves: { methode: string; chemin: string }[] = [];
  const motif = /\bapi\.(lire|poster|modifier|remplacer|supprimer|deposer)\b/g;
  for (let m = motif.exec(source); m; m = motif.exec(source)) {
    let i = m.index + m[0].length;
    // Les paramètres de type, imbriqués ou non, sur plusieurs lignes.
    if (source[i] === "<") {
      let profondeur = 0;
      for (; i < source.length; i++) {
        if (source[i] === "<") profondeur++;
        else if (source[i] === ">" && source[i - 1] !== "=") {
          profondeur--;
          if (profondeur === 0) { i++; break; }
        }
      }
    }
    while (/\s/.test(source[i] ?? "")) i++;
    if (source[i] !== "(") continue;
    i++;
    while (/\s/.test(source[i] ?? "")) i++;
    let brut = "";
    // Un littéral, puis peut-être « + » et un autre, et ainsi de suite.
    for (;;) {
      const guillemet = source[i];
      if (guillemet !== "`" && guillemet !== "\"") { if (brut) brut += SUITE; break; }
      let accolades = 0;
      for (i++; i < source.length; i++) {
        const c = source[i]!;
        if (guillemet === "`" && accolades === 0 && c === "$" && source[i + 1] === "{") {
          accolades = 1; brut += EXPR; i++; continue;
        }
        if (accolades > 0) {
          if (c === "{") accolades++;
          else if (c === "}") accolades--;
          continue;
        }
        if (c === guillemet) break;
        brut += c;
      }
      i++;
      let j = i;
      while (/\s/.test(source[j] ?? "")) j++;
      if (source[j] !== "+") break;
      j++;
      while (/\s/.test(source[j] ?? "")) j++;
      i = j;
    }
    // Ce qui suit « ? » est la requête, pas le chemin.
    const chemin = brut.split("?")[0]!;
    if (chemin.startsWith("/api/")) trouves.push({ methode: METHODES[m[1]!]!, chemin });
  }
  return trouves;
}

/** Un chemin appelé trouve-t-il une route déclarée ? Segment par segment. */
export function trouveRoute(appel: string, route: string): boolean {
  const a = appel.split("/").filter((x, i) => i === 0 || x !== "");
  const b = route.split("/").filter((x, i) => i === 0 || x !== "");
  const suite = a.at(-1)?.includes(SUITE) === true;
  if (suite) a[a.length - 1] = a.at(-1)!.replace(SUITE, "");
  if (suite && a.at(-1) === "") a.pop();
  for (let k = 0; k < Math.max(a.length, b.length); k++) {
    const x = a[k];
    const y = b[k];
    if (y === "*") return true;
    if (x === undefined) return suite && y !== undefined;
    if (y === undefined) return false;
    if (x.includes(EXPR) || y.startsWith(":")) continue;
    if (x !== y) return false;
  }
  return true;
}

export async function verifierRoutesClient(app: FastifyInstance, r: Rapport, web: string): Promise<void> {
  r.titre("Ce que l'interface appelle existe");
  const appels = new Map<string, string[]>();
  for (const f of await sources(web)) {
    for (const a of appelsLitteraux(await readFile(f, "utf8"))) {
      const cle = `${a.methode} ${a.chemin}`;
      appels.set(cle, [...(appels.get(cle) ?? []), relative(web, f)]);
    }
  }
  await app.ready();
  const routes = app.routesDeclarees;
  const absentes: string[] = [];
  for (const [cle, fichiers] of appels) {
    const [methode, chemin] = cle.split(" ") as [string, string];
    const servie = routes.some((x) => x.methodes.includes(methode) && trouveRoute(chemin, x.chemin));
    if (!servie) {
      absentes.push(`${cle.replaceAll(EXPR, "…").replaceAll(SUITE, "…")} (${[...new Set(fichiers)].join(", ")})`);
    }
  }
  r.verifie("EX-41", `les ${appels.size} appels littéraux de l'interface trouvent une route sur le serveur`,
    appels.size > 200 && routes.length > 200 && absentes.length === 0,
    absentes.length ? `${absentes.length} absente(s) : ${absentes.slice(0, 6).join(" ; ")}` : `${appels.size} appels`);
}
+1 −0
Changes for cahier-des-charges.html: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -1066,6 +1066,7 @@ donc immuable, et la règle est portée par le schéma.</span></li>
    <li><span class="rid">EX-37</span><span class="rtx">La révision exacte de l&rsquo;artefact est inscrite à la construction et publiée avec l&rsquo;offre de code&nbsp;; à défaut, la console d&rsquo;administration signale que l&rsquo;offre ne désigne pas la version en cours d&rsquo;exécution.</span></li>
    <li><span class="rid">EX-38</span><span class="rtx">Un accord de contribution — personnes physiques et entités — encadre les apports externes&nbsp;: titularité conservée par le contributeur, licence large incluant le relicenciement, engagement réciproque de publication libre. Un registre tient lieu de signature et sa couverture de l&rsquo;historique est contrôlée par un outil.</span></li>
    <li><span class="rid">EX-39</span><span class="rtx">Chaque garantie de la suite de vérification porte l&rsquo;identifiant d&rsquo;une exigence de ce cahier, et la suite refuse de passer si l&rsquo;un d&rsquo;eux n&rsquo;y figure pas. Sans cela, une garantie peut survivre à l&rsquo;exigence qui la justifiait, et qui remonte d&rsquo;un contrôle vers sa raison d&rsquo;être tombe sur un cul-de-sac — ce qui s&rsquo;est produit pour douze d&rsquo;entre elles.</span></li>
    <li><span class="rid">EX-41</span><span class="rtx">Toute route que l&rsquo;interface appelle existe sur le serveur qui la sert. Des routes importées et jamais enregistrées ont laissé quatre écrans et le bandeau d&rsquo;agent répondre 404 pendant que la suite restait verte.</span></li>
  </ul>

  <div class="note">