Commit 7fca94f3 authored by Kourser's avatar Kourser
Browse files

Mobile M0 : le tuyau, avant l'application

Le volet mobile met la notification avant tout le reste, et le serveur avant
l'application : c'est ce qui est livré ici. Aucune ligne d'application mobile.

Choisir son instance, d'abord
  Remarque juste : Kastell est libre et auto-hébergeable, une application liée
  à un serveur choisi par nous ferait de l'auto-hébergeur un utilisateur de
  seconde zone. Le cahier des charges gagne une section entière — la 4 — et
  douze exigences, EM-75 à EM-86.

  Cela ne se règle pas dans un écran de paramètres. Le choix déplace la
  question de confiance, et casse l'hypothèse implicite de la notification :
  quel serveur a le droit de faire sonner ce téléphone ?

  Les serveurs d'Apple n'acceptent que des messages signés par la clé de
  l'éditeur de l'application. Une instance auto-hébergée ne peut donc pas,
  seule, faire sonner une application publiée par quelqu'un d'autre — contrainte
  de plateforme, aucune ruse n'en sort. D'où le relais : facultatif, désigné
  par l'instance, refusable appareil par appareil, et acceptable seulement
  parce qu'il ne transporte rien de plus que le heurtoir.

  Sans relais, l'instance reste entière. Elle ne réveille personne, et le dit —
  à la sonde d'état, à la page Sécurité, et à l'application avant tout
  enrôlement. Le SMS et l'appel vocal restent les canaux de premier rang, ce
  qui rend cette situation vivable plutôt que bancale.

La règle du heurtoir portée par le type, pas par la discipline
  « Heurtoir » ne prend ni texte, ni nom d'organisation, ni intitulé de crise :
  il n'existe aucun champ où les mettre. Un contrôle sérialise ce qui part et
  vérifie que ni l'organisation, ni la personne, ni son adresse n'y figurent.

Un téléphone ne s'enrôle pas tout seul
  Le code naît d'une session web munie d'un second facteur récent — autoriser
  un appareil est du même ordre qu'un acte d'administration, et une session
  ouverte le matin ne vaut pas consentement le soir. Dix minutes, un seul
  usage, alphabet sans O ni 0 ni I ni 1, et il porte l'adresse de l'instance :
  le téléphone n'a rien à retaper, ce qui est aussi le meilleur garde-fou
  contre l'instance imitée.

  L'appareil reçoit une identité distincte du compte. On le révoque seul, la
  session web n'est pas affectée, son jeton est effacé, et il est refusé dès la
  requête suivante avec un message qui dit quoi faire.

Ce qui coupe le canal, éprouvé cas par cas
  Notification refusée, relais refusé, jeton mort, appareil révoqué, relais
  absent : cinq façons de ne pas sonner, cinq contrôles. Un jeton mort est
  consigné comme échec, pas avalé en silence — c'est le genre de chose qu'on
  découvre la seule nuit où cela compte.

Deux corrections de mes propres tests
  Le code TOTP de l'activation ne se rejoue pas pour la connexion : c'est la
  protection anti-rejeu qui parle, et le produit a raison. Et « digest() »
  n'existe pas sans pgcrypto — l'empreinte se calcule là où elle se calcule
  déjà, en JavaScript.

486 → 517 garanties. Éprouvé dans le navigateur : la page Sécurité annonce que
cette instance ne fera sonner aucun téléphone, et refuse d'autoriser un
appareil à un compte sans second facteur.

Reste l'application elle-même — M1. Elle demande une chaîne de construction
native que je ne peux pas éprouver ici, et je préfère ne pas livrer d'écran
mobile que je n'aurais pas vu tourner.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 8d66c0a0
Loading
Loading
Loading
Loading
+33 −1
Original line number Diff line number Diff line
@@ -24,7 +24,7 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L9** | Rembobinage complet, dossier de crise, retour d'expérience | 🟢 fait |
| **L10** | Intégration exercice, page d'état, vérificateur, CI, documentation | 🟢 fait côté serveur — application installable, accessibilité et bilinguisme suivent le client |
| **Interface** | Coque, visio flottante, rembobinage, dossier de préparation, console root, tableaux, mobilisation, clôture | 🟢 fait |
| **L11** | Applications mobiles iOS et Android | 📄 spécifié — [volet mobile](cahier-des-charges-mobile.html), 74 exigences, aucun code écrit |
| **L11** | Applications mobiles iOS et Android | 🟡 M0 fait côté serveur — enrôlement, heurtoir, découverte d'instance ; l'application reste à écrire |
| L12 | Téléphonie entrante, transcription | ⚪ hors d'atteinte sans infrastructure SIP |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
@@ -411,6 +411,38 @@ domaine résolvable. En production, activez `tls_port: 443` dans
pare-feux laissent passer, et souvent le seul chemin depuis l'hôtel où la
cellule s'est repliée.

## Appareils mobiles

Le serveur sait déjà accueillir un téléphone — l'application, elle, reste à
écrire. Voir le [volet mobile](cahier-des-charges-mobile.html), 86 exigences.

**Un téléphone ne s'enrôle pas tout seul.** Le code naît d'une session web
munie de son second facteur récent, vit dix minutes, ne sert qu'une fois, et
porte l'adresse de l'instance — le téléphone n'a rien à retaper. C'est la seule
barrière entre un appareil trouvé et un appareil autorisé.

L'appareil reçoit **une identité distincte du compte** : on le révoque seul,
sans toucher au mot de passe ni aux autres téléphones. La base n'en garde que
l'empreinte du secret.

**Le heurtoir.** Une notification Kastell est un coup frappé à la porte, pas un
message glissé dessous : un jeton, une référence opaque, un degré d'urgence.
La contrainte est portée par le type — `Heurtoir` n'a aucun champ où glisser
une phrase, et le contrôle vérifie qu'aucun nom d'organisation ni de personne
n'en sort.

```
KASTELL_RELAIS_POUSSEE=https://relais.exemple.org
KASTELL_RELAIS_CLE=…
```

Les serveurs d'Apple n'acceptent que des messages signés par la clé de
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.** C'est une
contrainte de plateforme, pas un choix. Sans relais, l'instance reste entière —
elle ne réveille personne, et le dit : à la sonde d'état, à la page Sécurité,
et à l'application avant tout enrôlement.

## Diagnostic et supervision

L'onglet **État** de la console root dit ce que l'instance *contient*. L'onglet
+141 −0
Original line number Diff line number Diff line
/**
 * Notification mobile — le heurtoir (EM-08, EM-82 à EM-85).
 *
 * Une notification Kastell est un coup frappé à la porte, pas un message
 * glissé dessous. Cette interface ne sait rien transporter d'autre : elle ne
 * prend ni texte, ni nom d'organisation, ni intitulé de crise. Ce n'est pas
 * une politesse de conception, c'est ce qui rend le relais acceptable — un
 * relais qui verrait les contenus serait un point de collecte, quel que soit
 * le sérieux de qui l'exploite.
 *
 * La contrainte est portée par le type, pas par la discipline de l'appelant :
 * il n'existe aucun champ où glisser une phrase.
 */

export type Urgence = "mobilisation" | "arbitrage" | "information";

export interface Heurtoir {
  /** Jeton remis par le système d'exploitation de l'appareil. */
  jeton: string;
  plateforme: "ios" | "android";
  /**
   * Ce que l'application ira chercher après déverrouillage. Opaque : il ne
   * désigne rien pour qui ne peut pas interroger l'instance.
   */
  reference: string;
  urgence: Urgence;
  /**
   * Le degré d'insistance demandé. Le relais fait au mieux ; l'appareil
   * décide en dernier ressort, selon ce que la personne a accordé.
   */
  reveiller: boolean;
}

export interface Poussee {
  readonly nom: string;
  /** Vrai si l'instance peut réellement faire sonner un téléphone (EM-76). */
  disponible(): Promise<boolean>;
  frapper(h: Heurtoir): Promise<void>;
}

/**
 * Aucun relais configuré.
 *
 * L'instance reste entière : lecture, consignation, appel. Elle ne réveille
 * personne, et le dit — à la sonde d'état, à la console, et à l'application au
 * moment où l'on choisit l'instance. Le SMS et l'appel vocal restent les
 * canaux de premier rang, ce qui rend cette situation vivable plutôt que
 * bancale.
 */
export const pousseeInactive: Poussee = {
  nom: "inactif",
  async disponible() { return false; },
  async frapper() {
    throw new Error(
      "Aucun relais de notification n'est configuré sur cette instance. "
      + "Renseignez KASTELL_RELAIS_POUSSEE, ou laissez le SMS faire le travail.",
    );
  },
};

/** Transport de développement : le heurtoir est écrit dans les traces. */
export function pousseeConsole(): Poussee {
  return {
    nom: "console",
    async disponible() { return true; },
    async frapper(h) {
      console.log(
        `\n┌─ heurtoir (non remis, transport « console »)\n`
        + `│ appareil  : ${h.plateforme} ${h.jeton.slice(0, 12)}…\n`
        + `│ urgence   : ${h.urgence}${h.reveiller ? " · réveiller" : ""}\n`
        + `│ référence : ${h.reference}\n└─\n`,
      );
    },
  };
}

/**
 * Relais de heurtoir.
 *
 * Les serveurs d'Apple n'acceptent que des messages signés par la clé de
 * l'éditeur de l'application. Une instance auto-hébergée ne peut donc pas,
 * seule, faire sonner une application publiée par quelqu'un d'autre : c'est
 * une contrainte de plateforme, et aucune ruse n'en sort. Elle passe par un
 * relais qui, lui, détient cette clé.
 *
 * Ce que le relais apprend est borné par ce qu'on lui envoie, et on ne lui
 * envoie que le heurtoir. Le protocole tient en un objet JSON, précisément
 * pour qu'un auto-hébergeur puisse exploiter le sien (EM-84).
 */
export function pousseeRelais(url: string, cle: string, delaiMs = 8000): Poussee {
  const base = url.replace(/\/+$/, "");
  return {
    nom: `relais(${new URL(base).host})`,

    async disponible() {
      try {
        const r = await fetch(`${base}/etat`, {
          signal: AbortSignal.timeout(delaiMs),
        });
        return r.ok;
      } catch {
        return false;
      }
    },

    async frapper(h) {
      const r = await fetch(`${base}/heurtoir`, {
        method: "POST",
        headers: { "content-type": "application/json", authorization: `Bearer ${cle}` },
        body: JSON.stringify({
          jeton: h.jeton,
          plateforme: h.plateforme,
          reference: h.reference,
          urgence: h.urgence,
          reveiller: h.reveiller,
        }),
        signal: AbortSignal.timeout(delaiMs),
      });
      if (!r.ok) {
        // Un jeton mort est une information utile : il vaut mieux le savoir
        // avant la crise que pendant (EM-15, EM-51).
        const detail = await r.text().catch(() => "");
        throw new Error(`Le relais a refusé le heurtoir (${r.status}) ${detail.slice(0, 120)}`);
      }
    },
  };
}

let courant: Poussee = pousseeInactive;

export function definirPoussee(p: Poussee): void {
  courant = p;
}

export function poussee(): Poussee {
  return courant;
}

export function pousseeConfiguree(): boolean {
  return courant !== pousseeInactive;
}
+79 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Appareils mobiles (EM-17 à EM-23, EM-75 à EM-86)
--
-- Un appareil est une identité distincte du compte. On le révoque seul, sans
-- toucher au mot de passe ni aux autres téléphones : perdre un téléphone ne
-- doit pas coûter une réinitialisation de compte à trois heures du matin.
-- ═══════════════════════════════════════════════════════════════════════════

create table appareil (
  id              uuid        primary key default gen_random_uuid(),
  compte_id       uuid        not null references compte(id) on delete cascade,
  -- Nom donné par la personne : « iPhone d'astreinte ». Il sert à reconnaître
  -- une ligne dans la liste, et à repérer un enrôlement qu'on n'a pas fait.
  nom             text        not null,
  plateforme      text        not null check (plateforme in ('ios','android')),
  modele          text,
  systeme         text,

  -- Le secret d'appareil n'est jamais stocké en clair, comme les jetons de
  -- session : la base volée ne donne accès à rien.
  empreinte_secret bytea      not null,

  -- Jeton de notification, remis par le système d'exploitation. Il change, il
  -- expire, il doit être renouvelé — d'où sa date, que l'écran de préparation
  -- relit pour dire si l'appareil est encore joignable (EM-13, EM-51).
  jeton_poussee   text,
  jeton_poussee_at timestamptz,
  -- Ce que la personne a réellement accordé sur ce téléphone. Sans alerte
  -- critique, un téléphone en silencieux ne sonnera pas, et le produit doit
  -- pouvoir le dire avant la nuit qui compte (EM-12).
  autorisation    text        not null default 'inconnue'
                  check (autorisation in ('inconnue','refusee','ordinaire','urgente','critique')),
  -- EM-82 : le relais est refusable appareil par appareil.
  relais_accepte  boolean     not null default true,

  enrole_at       timestamptz not null default now(),
  dernier_acces_at timestamptz,
  revoque_at      timestamptz,
  motif_revocation text
);

create index appareil_compte on appareil (compte_id) where revoque_at is null;
create unique index appareil_jeton on appareil (jeton_poussee)
  where jeton_poussee is not null and revoque_at is null;

-- ── Enrôlement ─────────────────────────────────────────────────────────────
-- Un téléphone ne s'enrôle pas tout seul (EM-18) : le code naît d'une session
-- web munie de son second facteur, vit dix minutes, et ne sert qu'une fois.
create table enrolement_appareil (
  id            uuid        primary key default gen_random_uuid(),
  compte_id     uuid        not null references compte(id) on delete cascade,
  empreinte_code bytea      not null,
  cree_at       timestamptz not null default now(),
  expire_at     timestamptz not null,
  utilise_at    timestamptz,
  appareil_id   uuid        references appareil(id) on delete set null,
  -- Une session ne fabrique pas de code à la chaîne.
  constraint enrolement_duree check (expire_at > cree_at)
);

create index enrolement_compte on enrolement_appareil (compte_id)
  where utilise_at is null;

-- ── Heurtoirs remis ────────────────────────────────────────────────────────
-- On garde la trace de ce qui a été frappé à la porte, pas de ce qui a été
-- dit : la charge utile n'est pas conservée, parce qu'elle n'existe pas
-- (EM-08). Ce registre alimente l'état de mobilisation et le diagnostic.
create table heurtoir (
  id           uuid        primary key default gen_random_uuid(),
  appareil_id  uuid        not null references appareil(id) on delete cascade,
  reference    uuid        not null,
  urgence      text        not null check (urgence in ('mobilisation','arbitrage','information')),
  emis_at      timestamptz not null default now(),
  remis        boolean     not null,
  motif_echec  text
);

create index heurtoir_appareil on heurtoir (appareil_id, emis_at desc);
create index heurtoir_reference on heurtoir (reference);
+22 −5
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import { envoyer as envoyerCourriel } from "../adaptateurs/notify.js";
import { passerelle } from "../adaptateurs/sms.js";
import { publier } from "../adaptateurs/diffusion.js";
import { empreinte as empreinteJeton, engendrer } from "../auth/jetons.js";
import { frapper } from "../mobile/appareils.js";

/**
 * Mobilisation (§6.11).
@@ -18,7 +19,7 @@ import { empreinte as empreinteJeton, engendrer } from "../auth/jetons.js";
 * pas.
 */

export type Canal = "notification" | "email" | "sms";
export type Canal = "notification" | "email" | "sms" | "mobile";

export interface Cible {
  personneId?: string | null | undefined;
@@ -46,7 +47,7 @@ export interface Resultat {
}

async function expedier(
  criseId: string, mobilisationId: string, envoiId: string,
  sql: Sql, criseId: string, mobilisationId: string, envoiId: string,
  cible: Cible, message: string, urlPublique: string,
): Promise<{ canaux: Canal[]; echecs: { canal: string; motif: string }[]; jeton: string }> {
  const jeton = engendrer();
@@ -92,6 +93,22 @@ async function expedier(
        message },
    });
    canaux.push("notification");

    // Le heurtoir (EM-08). On frappe à la porte des téléphones enrôlés : la
    // référence est l'envoi lui-même, opaque pour qui ne peut pas interroger
    // l'instance. Ni le message, ni le nom de la crise ne partent d'ici — il
    // n'existe aucun champ où les mettre.
    try {
      const remise = await frapper(sql, cible.compteId, envoiId, "mobilisation");
      if (remise.remis > 0) canaux.push("mobile");
      // Un jeton mort est une panne de canal, pas un détail : il bascule sur
      // le SMS, déjà tenté ci-dessus, et il se voit (EM-15).
      for (const e of remise.echecs) {
        echecs.push({ canal: "mobile", motif: `${e.appareil_id.slice(0, 8)}${e.motif}` });
      }
    } catch (e) {
      echecs.push({ canal: "mobile", motif: e instanceof Error ? e.message : "heurtoir refusé" });
    }
  }

  return { canaux, echecs, jeton };
@@ -120,7 +137,7 @@ export async function lancer(

  for (const cible of l.cibles) {
    const envoiId = randomUUID();
    const expedition = await expedier(l.criseId, mobilisationId, envoiId, cible,
    const expedition = await expedier(sql, l.criseId, mobilisationId, envoiId, cible,
      l.message, urlPublique);
    for (const c of expedition.canaux) canauxGlobaux.add(c);
    for (const e of expedition.echecs) echecs.push({ nom: cible.nom, ...e });
@@ -268,7 +285,7 @@ export async function relancerEtEscalader(

  for (const e of aRelancer) {
    const rang = e.relances + 1;
    await expedier(e.crise_id, e.mobilisation_id, e.id, {
    await expedier(sql, e.crise_id, e.mobilisation_id, e.id, {
      nom: e.nom, email: e.email, telephone: e.telephone, compteId: e.compte_id,
    }, `Rappel ${rang}${e.message}`, urlPublique).catch(() => undefined);

@@ -301,7 +318,7 @@ export async function relancerEtEscalader(
    if (!suppleant) continue;

    const envoiId = randomUUID();
    const expedition = await expedier(e.crise_id, e.mobilisation_id, envoiId, {
    const expedition = await expedier(sql, e.crise_id, e.mobilisation_id, envoiId, {
      nom: suppleant.nom,
      email: suppleant.email_perso ?? suppleant.email_pro,
      telephone: suppleant.tel_perso,
+10 −0
Original line number Diff line number Diff line
@@ -41,6 +41,16 @@ const schema = z.object({
  LIVEKIT_URL_INTERNE: z.string().optional(),
  LIVEKIT_CLE: z.string().optional(),
  LIVEKIT_SECRET: z.string().optional(),
  /**
   * Relais de heurtoir (EM-82 à EM-85).
   *
   * Les serveurs d'Apple n'acceptent que des messages signés par la clé de
   * 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.
   */
  KASTELL_RELAIS_POUSSEE: z.string().url().optional(),
  KASTELL_RELAIS_CLE: z.string().optional(),
  CLAMAV_HOTE: z.string().optional(),
  CLAMAV_PORT: z.coerce.number().int().positive().default(3310),
  SECRET_SESSION: z.string().min(32),
Loading