Commit 494f0c44 authored by Kourser's avatar Kourser
Browse files

Une variable vide n'est pas une variable absente

Renoncer au relais de heurtoir empêchait l'instance entière de démarrer. Pas la
notification mobile : l'instance, en bloc.

Ce qui se passait
  KASTELL_RELAIS_POUSSEE est validée comme une adresse. « optional() » attrape
  l'absence, jamais le vide — et la composition ne sait pas laisser une variable
  absente : « ${KASTELL_RELAIS_POUSSEE:-} » la définit toujours, à vide quand le
  .env ne la renseigne pas. Le conteneur recevait donc une chaîne que « url() »
  refuse, et le serveur s'arrêtait sur une configuration invalide.

  Le défaut ne se voyait pas en lisant le schéma. Il se voyait en lisant ce que
  la composition transmet — c'est-à-dire ailleurs, dans un autre fichier, écrit
  à un autre moment.

La garantie plutôt que le rustine
  Le correctif tient en trois lignes : la chaîne vide redevient « absente ».
  Mais corriger cette variable-là n'aurait rien dit de la prochaine.

  Le contrôle relève donc dans compose.yaml toutes les variables déclarées
  « ${VAR:-} » — douze aujourd'hui — les met toutes à vide, et exerce
  chargerEnv() dessus. Il exerce le schéma plutôt que de relire son texte : une
  lecture par expression rationnelle manquerait la variable ajoutée demain.

Éprouvé en rétablissant l'ancienne écriture le temps du contrôle : la garantie
rougit en nommant KASTELL_RELAIS_POUSSEE, puis reverdit. Un contrôle qui ne
rougit jamais ne garantit rien.

Reste ouvert, et signalé plutôt que corrigé : z.string().url() accepte
« relais:8081 », que la norme WHATWG lit comme un schéma. Une faute de frappe
dans l'adresse passe donc la validation et n'échoue qu'à l'appel.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent acda2f23
Loading
Loading
Loading
Loading
+15 −1
Changes for apps/api/src/env.ts: 15 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -48,8 +48,22 @@ const schema = z.object({
   * l'éditeur de l'application : une instance auto-hébergée ne peut pas, seule,
   * faire sonner une application publiée par quelqu'un d'autre. Sans relais,
   * l'instance reste entière — elle ne réveille simplement personne, et le dit.
   *
   * Définie mais vide vaut absente. « optional() » attrape l'absence, pas le
   * vide — et la composition ne sait pas laisser une variable absente :
   * « ${KASTELL_RELAIS_POUSSEE:-} » la définit toujours, à vide quand le .env
   * ne la renseigne pas. Le conteneur recevait donc une chaîne que « url() »
   * refuse, et l'instance entière refusait de démarrer — pour avoir renoncé à
   * la seule fonction facultative que cette variable commande.
   *
   * Le défaut ne se voyait pas en lisant ce schéma : il se voyait en lisant ce
   * que la composition transmet. Une garantie le vérifie désormais pour toutes
   * les variables qu'elle laisse vides, et non pour celle-ci seule.
   */
  KASTELL_RELAIS_POUSSEE: z.string().url().optional(),
  KASTELL_RELAIS_POUSSEE: z.preprocess(
    (v) => (v === "" ? undefined : v),
    z.string().url().optional(),
  ),
  KASTELL_RELAIS_CLE: z.string().optional(),
  CLAMAV_HOTE: z.string().optional(),
  CLAMAV_PORT: z.coerce.number().int().positive().default(3310),
+33 −0
Changes for apps/api/src/verification/composition.ts: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { Rapport } from "./rapport.js";
import { chargerEnv } from "../env.js";

/**
 * Contrôles sur la composition et l'image — Kastell.
@@ -55,4 +56,36 @@ export async function verifierComposition(r: Rapport): Promise<void> {
    bloc.length > 0 && !/^\s+volumes:/m.test(bloc));
  r.verifie("EM-82", "il ne démarre que sous un profil, jamais par défaut",
    /profiles:\s*\[[^\]]*mobile/.test(bloc));

  // ── Ce que la composition laisse vide, le schéma doit l'accepter ─────────
  //
  // La composition ne sait pas laisser une variable absente : « ${VAR:-} » la
  // définit toujours, à vide quand le .env ne la renseigne pas. Une chaîne vide
  // n'est donc pas « rien » — elle traverse jusqu'au schéma, où « url() » la
  // refuse. Le symptôme est le pire de sa classe : l'instance entière ne
  // démarre pas, parce qu'on a renoncé à une fonction facultative.
  //
  // Le contrôle exerce le schéma plutôt que de relire son texte : c'est le
  // comportement qui compte, et une lecture par expression rationnelle
  // manquerait la prochaine variable ajoutée.
  const laisseesVides = [...compose.matchAll(
    /^\s+([A-Z][A-Z0-9_]*):\s*\$\{[A-Z][A-Z0-9_]*:-\}\s*$/gm)].map((m) => m[1]!);

  // Le minimum qu'une instance renseigne toujours ; le reste est mis à vide.
  const minimum: Record<string, string> = {
    KASTELL_URL_PUBLIQUE: "http://localhost:8080",
    DATABASE_URL: "postgres://kastell:x@postgres:5432/kastell",
    SECRET_SESSION: "0".repeat(64),
  };
  for (const cle of laisseesVides) minimum[cle] = "";

  let motif = "";
  try {
    chargerEnv(minimum);
  } catch (e) {
    motif = e instanceof Error ? e.message.replace(/\n/g, " ") : String(e);
  }
  r.verifie("EX-26",
    `les ${laisseesVides.length} variables que la composition laisse vides sont tolérées`,
    laisseesVides.length > 0 && motif === "", motif);
}