Commit e1fd2e6d authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

On ne renforce pas une cellule par le relais de courriel du SI qui est tombé

Question posée à l'écran : comment ajouter un membre si le relais SMTP n'est pas
configuré, ou hors service ? La réponse était : on ne peut pas. Et c'est une
contradiction avec le principe fondateur — Kastell existe pour le jour où le
système d'information du client est tombé, et sa propre façon de faire entrer
quelqu'un dépendait du relais de courriel de ce même système.

Le défaut était pire que « le lien n'est pas affiché ». L'envoi était attendu
**dans la transaction**, sans capture : un relais en panne annulait l'invitation
elle-même. Pas de ligne en attente, pas de jeton, rien — une erreur, et voilà.
Et comme l'instance refuse de démarrer en production sans SMTP_HOST, il n'y avait
pas non plus le repli des traces du serveur.

── Ce qui change

L'envoi sort de la transaction. L'invitation existe d'abord ; ce qui échoue à
partir se dit, canal par canal, et ne détruit rien.

Les canaux se cochent : courriel, SMS, ou aucun des deux. Le SMS passe par la
passerelle de l'organisation si elle en a une, sinon par celle de l'instance —
celle qui mobilise déjà ces mêmes personnes à trois heures du matin.

Le lien est rendu une fois, copiable, avec l'état de chaque canal. Le jeton n'est
stocké que haché : il ne sera pas réaffiché, et l'écran le dit. Corollaire
assumé — pour retrouver un lien perdu, il faut réinviter.

── Le dernier recours

Ouvrir le compte sur place, avec un laissez-passer de seize caractères sans O,
sans 0, sans I ni 1 : il se dicte au téléphone, la nuit. Ce n'est pas un mot de
passe, c'est un laissez-passer à usage unique, et le mécanisme existait déjà pour
le compte d'installation — la porte du serveur bloque toutes les routes jusqu'à
son remplacement. Je l'ai réemployé tel quel.

Deux garde-fous, dont le second n'est pas décoratif.

Le secret est engendré par le serveur, jamais choisi par qui invite : laissé au
choix, il serait faible ou réemployé d'un compte à l'autre.

Et un directeur de crise ne peut pas ouvrir un compte propriétaire par ce
chemin. Convier un propriétaire ne pose pas ce problème — le lien part dans la
boîte d'un tiers, celui qui invite ne le détient jamais. Ici il le détient : sans
cette règle, le chemin serait une élévation de privilège en deux gestes.

Le risque résiduel, que je ne peux pas supprimer : entre la création et la
première connexion, celui qui a ouvert le compte connaît le secret. C'est
inhérent à un secret dicté. D'où le motif obligatoire et la trace nominative au
journal d'administration — c'est ce qui permet de le constater après coup, et
c'est pourquoi l'écran présente ce chemin comme un recours, pas un raccourci.

Treize garanties, éprouvées avec un transport de courriel volontairement en
panne : l'invitation survit, l'échec est rapporté, le lien rendu ouvre bien
l'invitation, et le compte ouvert sur place se heurte à un 403 sur toute autre
route que le remplacement de son laissez-passer.

Co-Authored-By: Claude (RCA)
parent a7400787
Loading
Loading
Loading
Loading
+198 −5
Changes for apps/api/src/auth/comptes.ts: 198 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,8 @@ import type { Sql } from "../db/client.js";
import * as typesCrise from "../preparation/typesCrise.js";
import { journaliser } from "../noyau/administration.js";
import { envoyer } from "../adaptateurs/notify.js";
import { passerelle } from "../adaptateurs/sms.js";
import { fournisseurActif } from "../canaux/organisation.js";
import * as codes from "./codes_secours.js";
import { empreinte, engendrer } from "./jetons.js";
import { LIMITES, effacerTentatives, noterTentative, verifierLimite } from "./limites.js";
@@ -512,15 +514,66 @@ export async function roleDans(
  return r?.role ?? null;
}

export type RoleOrganisation =
  "proprietaire" | "preparation" | "directeur_crise" | "membre" | "observateur";

export interface DemandeInvitation {
  organisationId: string;
  email: string;
  role: "proprietaire" | "preparation" | "directeur_crise" | "membre" | "observateur";
  role: RoleOrganisation;
  parCompteId: string;
  urlPublique: string;
  /** Le numéro à qui envoyer le lien par SMS, quand ce canal est demandé. */
  telephone?: string | null | undefined;
  /**
   * Par quels canaux transmettre le lien.
   *
   * Le courriel seul était le défaut, et c'était le défaut d'un produit qui
   * n'aurait jamais dû l'accepter : Kastell existe pour le jour où le système
   * d'information du client est tombé, et renforcer une cellule dépendait du
   * relais de courriel de ce même système.
   */
  canaux?: { courriel?: boolean; sms?: boolean } | undefined;
  /** Nécessaire pour ouvrir les identifiants de la passerelle de l'organisation. */
  secretInstance?: string | undefined;
}

export interface Remise {
  canal: "courriel" | "sms";
  remis: boolean;
  motif: string | null;
}

export interface Invitation {
  invitationId: string;
  /**
   * Le lien, en clair, et cette fois seulement.
   *
   * Le jeton n'est stocké que haché : il ne pourra pas être réaffiché. C'est la
   * même règle que pour une clé d'intégration, et elle a la même conséquence —
   * « renvoyer » ne remontre pas un lien perdu, il en fabrique un autre.
   *
   * Le rendre à qui invite est ce qui rend le produit utilisable quand aucun
   * canal ne fonctionne : le lien se dicte au téléphone, se recopie dans une
   * messagerie, se lit à voix haute en salle de crise.
   */
  lien: string;
  envois: Remise[];
}

export async function inviter(sql: Sql, d: DemandeInvitation): Promise<string> {
/**
 * Convier quelqu'un (EF-107a).
 *
 * L'envoi a lieu **après** la transaction, et c'est tout le correctif : il
 * était attendu à l'intérieur, sans capture. Un relais de courriel en panne
 * annulait donc l'invitation elle-même — pas de ligne en attente, pas de jeton,
 * rien. On ne pouvait pas renforcer une cellule le jour où le courriel ne part
 * plus, c'est-à-dire précisément le jour où l'on en a besoin.
 *
 * Désormais l'invitation existe d'abord. Ce qui échoue à partir se dit, et le
 * lien reste transmissible autrement.
 */
export async function inviter(sql: Sql, d: DemandeInvitation): Promise<Invitation> {
  const email = normaliserEmail(d.email);
  await verifierLimite(sql, "invitation_organisation", d.organisationId);

@@ -533,6 +586,7 @@ export async function inviter(sql: Sql, d: DemandeInvitation): Promise<string> {
  const invitationId = randomUUID();
  const expireAt = new Date(Date.now() + TTL_INVITATION_MS);

  let nomOrganisation = "votre organisation";
  await sql.begin(async (tx) => {
    const [org] = await tx<{ nom: string }[]>`
      select nom from organisation where id = ${d.organisationId}`;
@@ -548,20 +602,159 @@ export async function inviter(sql: Sql, d: DemandeInvitation): Promise<string> {
      charge: { invitation_id: invitationId, organisation_id: d.organisationId, role: d.role },
    });

    nomOrganisation = org?.nom ?? "votre organisation";
  });

  await noterTentative(sql, "invitation_organisation", d.organisationId, false);

  // ── L'envoi, hors transaction ────────────────────────────────────────────
  //
  // L'invitation est déjà écrite : ce qui suit peut échouer sans rien détruire.
  const lien = `${d.urlPublique}/invitation?jeton=${jeton}`;
  const veut = d.canaux ?? { courriel: true };
  const envois: Remise[] = [];

  if (veut.courriel !== false) {
    try {
      await envoyer({
        destinataire: email,
      sujet: `Rejoignez la cellule de crise de ${org?.nom ?? "votre organisation"} — Kastell`,
        sujet: `Rejoignez la cellule de crise de ${nomOrganisation} — Kastell`,
        texte:
        `${d.urlPublique}/invitation?jeton=${jeton}\n\n` +
          `${lien}\n\n` +
          `Cette invitation est valable 14 jours.\n\n` +
          `Important : préférez une adresse et un appareil indépendants du système\n` +
          `d'information de votre organisation. Kastell sert précisément les\n` +
          `situations où celui-ci n'est plus disponible ni digne de confiance.`,
      });
      envois.push({ canal: "courriel", remis: true, motif: null });
    } catch (e) {
      envois.push({
        canal: "courriel", remis: false,
        motif: e instanceof Error ? e.message : "le relais a refusé",
      });
    }
  }

  if (veut.sms === true) {
    const texte = `Kastell — ${nomOrganisation} vous convie à sa cellule de crise. `
      + `Valable 14 jours : ${lien}`;
    try {
      if (!d.telephone) throw new Error("aucun numéro renseigné");
      // La passerelle de l'organisation d'abord : c'est son contrat, son
      // expéditeur, et elle ne dépend pas de l'éditeur. Celle de l'instance
      // ensuite — c'est déjà celle par laquelle ces mêmes personnes sont
      // mobilisées à trois heures du matin.
      const propre = d.secretInstance
        ? await fournisseurActif(sql, d.secretInstance, d.organisationId, "sms")
        : null;
      if (propre) await propre.envoyer({ destinataire: d.telephone, texte });
      else await passerelle().envoyer({ destinataire: d.telephone, texte });
      envois.push({ canal: "sms", remis: true, motif: null });
    } catch (e) {
      envois.push({
        canal: "sms", remis: false,
        motif: e instanceof Error ? e.message : "la passerelle a refusé",
      });
    }
  }

  return { invitationId, lien, envois };
}

export interface DemandeLaissezPasser {
  organisationId: string;
  email: string;
  nom: string;
  role: RoleOrganisation;
  parCompteId: string;
  /** Ce qui a conduit à ouvrir le compte sur place plutôt qu'à convier. */
  motif: string;
}

export interface LaissezPasser {
  compteId: string;
  /**
   * Le laissez-passer, montré une seule fois.
   *
   * Seize caractères sur un alphabet sans O, sans 0, sans I et sans 1 : il se
   * dicte au téléphone, la nuit, sous pression. C'est le même alphabet que les
   * codes de secours, et pour la même raison.
   */
  code: string;
}

/**
 * Ouvrir un compte sur place, quand aucun canal ne part (EF-107b).
 *
 * Le dernier recours, et il faut qu'il existe : une instance refuse de démarrer
 * en production sans relais de courriel, si bien qu'un relais en panne ne laisse
 * aucun repli — pas même les traces du serveur. On ne pouvait alors pas
 * renforcer une cellule, le jour même où Kastell est censé servir.
 *
 * Ce n'est pas un mot de passe qu'on remet, c'est un laissez-passer à usage
 * unique : le compte ne peut rien faire d'autre que le remplacer, la porte du
 * serveur s'en assure pour toutes les routes à la fois. Le mécanisme existait
 * déjà pour le compte amorcé à l'installation ; il est réemployé tel quel.
 *
 * Deux garde-fous, et le second n'est pas décoratif.
 *
 * Le secret est engendré par le serveur, jamais choisi par qui invite : laissé
 * au choix, il serait faible, ou réemployé d'un compte à l'autre.
 *
 * Et un directeur de crise ne peut pas ouvrir un compte propriétaire. Convier
 * un propriétaire ne pose pas ce problème — le lien part dans la boîte d'un
 * tiers, celui qui invite ne le détient jamais. Ici il le détient : sans cette
 * règle, le chemin serait une élévation de privilège en deux gestes.
 */
export async function ouvrirCompteAvecLaissezPasser(
  sql: Sql, d: DemandeLaissezPasser,
): Promise<LaissezPasser> {
  const email = normaliserEmail(d.email);
  await verifierLimite(sql, "invitation_organisation", d.organisationId);

  const role = await roleDans(sql, d.parCompteId, d.organisationId);
  if (role !== "proprietaire" && role !== "directeur_crise") {
    throw new ErreurAuth("operation_refusee",
      "Seul un propriétaire ou un directeur de crise ouvre un compte.");
  }
  if (d.role === "proprietaire" && role !== "proprietaire") {
    throw new ErreurAuth("operation_refusee",
      "Un directeur de crise n'ouvre pas un compte propriétaire par ce chemin : "
      + "il en connaîtrait le laissez-passer. Conviez-le, ou demandez à un propriétaire.");
  }

  const [existe] = await sql<{ id: string }[]>`
    select id from compte where lower(email) = ${email}`;
  if (existe) {
    throw new ErreurAuth("operation_refusee",
      "Un compte porte déjà cette adresse. Conviez-le : il rejoindra avec ses "
      + "identifiants habituels.");
  }

  const code = codes.engendrer();
  const hash = await mdp.hacher(code);

  const compteId = (await sql.begin(async (tx) => {
    const [compte] = await tx<{ id: string }[]>`
      insert into compte (email, nom, mdp_hash, mdp_a_changer)
      values (${email}, ${d.nom}, ${hash}, true)
      returning id`;
    if (!compte) throw new ErreurAuth("operation_refusee", "Ouverture refusée.");
    await tx`insert into membre (organisation_id, compte_id, role)
             values (${d.organisationId}, ${compte.id}, ${d.role})`;
    await journaliser(tx, {
      type: "compte.ouvert_par_tiers", acteurId: d.parCompteId,
      organisationId: d.organisationId,
      charge: {
        compte_id: compte.id, organisation_id: d.organisationId,
        role: d.role, motif: d.motif,
      },
    });
    return compte.id;
  })) as string;

  await noterTentative(sql, "invitation_organisation", d.organisationId, false);
  return invitationId;
  return { compteId, code };
}

export interface ApercuInvitation {
+58 −2
Changes for apps/api/src/routes/auth.ts: 58 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -336,12 +336,68 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
    const c = z.object({
      email: z.string().email(),
      role: z.enum(["proprietaire", "preparation", "directeur_crise", "membre", "observateur"]),
      /** Par quels canaux le lien part. Le courriel seul, à défaut. */
      canaux: z.object({
        courriel: z.boolean().default(true),
        sms: z.boolean().default(false),
      }).default({ courriel: true, sms: false }),
      telephone: z.string().trim().max(40).nullish(),
    }).parse(req.body);
    const invitationId = await comptes.inviter(sql, {

    if (c.canaux.sms && !c.telephone) {
      return reply.code(400).send({
        code: "requete_invalide",
        message: "Le SMS demande un numéro : renseignez-le, ou décochez ce canal.",
      });
    }

    const r = await comptes.inviter(sql, {
      organisationId: id, email: c.email, role: c.role,
      parCompteId: req.auth!.compte.id, urlPublique: env.KASTELL_PUBLIC_URL,
      canaux: c.canaux, telephone: c.telephone ?? null,
      secretInstance: env.SESSION_SECRET,
    });

    // Le lien est rendu ici, et cette fois seulement : le jeton n'est stocké que
    // haché. C'est ce qui rend l'invitation utilisable quand aucun canal ne
    // fonctionne — il se dicte, se recopie, se lit à voix haute.
    return reply.code(201).send({
      ...r,
      note: r.envois.every((e) => e.remis)
        ? "Notez le lien si vous voulez le transmettre autrement : il ne sera pas réaffiché."
        : "Un canal au moins n'est pas parti. L'invitation existe quand même — "
          + "transmettez le lien ci-dessus par n'importe quel moyen.",
    });
  });

  /**
   * Ouvrir un compte sur place (EF-107b).
   *
   * Le dernier recours, quand aucun canal ne part. Ce n'est pas un mot de passe
   * qu'on remet mais un laissez-passer à usage unique : le compte ne peut rien
   * faire d'autre que le remplacer, et la porte du serveur s'en assure pour
   * toutes les routes à la fois.
   */
  app.post("/organisations/:id/comptes", { preHandler: requerirSession() },
    async (req, reply) => {
      const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
      const c = z.object({
        email: z.string().email(),
        nom: z.string().trim().min(2).max(160),
        role: z.enum(["proprietaire", "preparation", "directeur_crise", "membre", "observateur"]),
        motif: z.string().trim().min(3).max(300),
      }).parse(req.body);

      const r = await comptes.ouvrirCompteAvecLaissezPasser(sql, {
        organisationId: id, email: c.email, nom: c.nom, role: c.role,
        parCompteId: req.auth!.compte.id, motif: c.motif,
      });

      return reply.code(201).send({
        ...r,
        note: "Dictez ce laissez-passer à la personne : il ne sera pas réaffiché. "
          + "Sa première connexion ne lui permettra que de le remplacer.",
      });
    return reply.code(201).send({ invitationId });
    });

  /**
+113 −0
Changes for apps/api/src/verification/auth.ts: 113 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,8 @@ import * as codesSecours from "../auth/codes_secours.js";
import { LIMITES } from "../auth/limites.js";
import * as sessions from "../auth/sessions.js";
import * as totp from "../auth/totp.js";
import { etatAmorce } from "../auth/amorce.js";
import { definirTransport, transportActuel } from "../adaptateurs/notify.js";
import { boite, jetonDans, vider } from "./boite.js";
import type { Rapport } from "./rapport.js";

@@ -239,6 +241,117 @@ export async function verifierAuthentification(
          ? simple.compteId : etranger, urlPublique: URL_TEST,
      }), "operation_refusee");

    // ── Convier quand le relais ne part pas (EF-107a) ──────────────────────
    //
    // L'envoi était attendu à l'intérieur de la transaction, sans capture : un
    // relais de courriel en panne annulait l'invitation elle-même. Pas de ligne
    // en attente, pas de jeton, rien — on ne pouvait pas renforcer une cellule
    // le jour où le courriel ne part plus, c'est-à-dire le jour où il le faut.
    r.titre("Convier sans courriel");

    const transportAvant = transportActuel();
    definirTransport({
      nom: "en-panne",
      async envoyer() { throw new Error("relais injoignable"); },
    });
    vider();

    const emailPanne = adresse("relais-en-panne");
    const malgre = await comptes.inviter(sql, {
      organisationId: inscription.organisationId, email: emailPanne,
      role: "membre", parCompteId: inscription.compteId, urlPublique: URL_TEST,
    });
    r.verifie("EF-107a", "l'invitation existe malgré le relais en panne",
      malgre.invitationId.length === 36);
    r.verifie("EF-107a", "l'échec du canal est rapporté, non tu",
      malgre.envois.length === 1 && malgre.envois[0]?.remis === false
        && (malgre.envois[0]?.motif ?? "").includes("injoignable"),
      JSON.stringify(malgre.envois));
    r.verifie("EF-107a", "et le lien est rendu à qui invite, à transmettre autrement",
      malgre.lien.includes("/invitation?jeton="));

    const [enAttente] = await sql<{ n: string }[]>`
      select count(*) as n from invitation where email = ${emailPanne.toLowerCase()}`;
    r.verifie("EF-107a", "la ligne figure bien en attente — rien n'a été annulé",
      Number(enAttente?.n ?? 0) === 1, String(enAttente?.n));

    // Le lien rendu est utilisable : c'est tout l'intérêt.
    const jetonRendu = new URL(malgre.lien).searchParams.get("jeton")!;
    const apercu = await comptes.apercuInvitation(sql, jetonRendu);
    r.verifie("EF-107a", "ce lien ouvre bien l'invitation",
      apercu?.email === emailPanne.toLowerCase(), apercu?.email);

    definirTransport(transportAvant);

    // ── Ouvrir un compte sur place (EF-107b) ───────────────────────────────
    //
    // Le dernier recours : une instance refuse de démarrer en production sans
    // relais, si bien qu'un relais en panne ne laisse aucun repli — pas même
    // les traces du serveur.
    r.titre("Ouvrir un compte sur place");

    const emailSurPlace = adresse("sur-place");
    const ouvert = await comptes.ouvrirCompteAvecLaissezPasser(sql, {
      organisationId: inscription.organisationId, email: emailSurPlace,
      nom: "Maëlle Le Goff", role: "membre", parCompteId: inscription.compteId,
      motif: "Relais SMTP hors service et aucune passerelle SMS.",
    });
    r.verifie("EF-107b", "le compte est ouvert et membre de l'organisation",
      (await comptes.roleDans(sql, ouvert.compteId, inscription.organisationId)) === "membre");
    r.verifie("EF-107b", "le laissez-passer se dicte : ni O, ni 0, ni I, ni 1",
      /^[A-HJ-NP-Z2-9]{4}(-[A-HJ-NP-Z2-9]{4}){3}$/.test(ouvert.code),
      ouvert.code.replace(/[A-Z2-9]/g, "x"));

    // Il ouvre une session, et rien d'autre : c'est ce qui distingue un
    // laissez-passer d'un mot de passe.
    const sessionSurPlace = await comptes.connecterParMotDePasse(sql,
      { email: emailSurPlace, motDePasse: ouvert.code });
    r.verifie("EF-107b", "il ouvre une session", sessionSurPlace.jeton.length > 20);
    const etat = await etatAmorce(sql, ouvert.compteId);
    r.verifie("EF-107b", "et le parcours est bloqué tant qu'il n'est pas remplacé",
      etat.doitChangerMotDePasse === true);

    const bloque = await app.inject({
      method: "GET", url: `/api/organisations/${inscription.organisationId}/membres`,
      headers: { cookie: `${sessions.NOM_COOKIE}=${sessionSurPlace.jeton}` },
    });
    r.verifie("EF-107b", "aucune route ne s'ouvre avant le remplacement",
      bloque.statusCode === 403 && bloque.json().code === "amorce_inachevee",
      `reçu ${bloque.statusCode}`);

    const [trace] = await sql<{ motif: string | null; acteur: string | null }[]>`
      select charge->>'motif' as motif, acteur_id::text as acteur
        from evenement_administration
       where type = 'compte.ouvert_par_tiers'
         and charge->>'compte_id' = ${ouvert.compteId}`;
    r.verifie("EF-107b", "le journal retient qui l'a ouvert, et pourquoi",
      trace?.acteur === inscription.compteId
        && (trace?.motif ?? "").includes("SMTP"), String(trace?.motif));

    // L'élévation en deux gestes, refusée : convier un propriétaire ne pose pas
    // ce problème — le lien part chez lui. Ici, celui qui ouvre détient le
    // secret.
    const emailDir = adresse("directeur-tiers");
    const dir = await comptes.inscrire(sql, {
      email: emailDir, nom: "Erwan Salaün", motDePasse: "raz-blanchard-38",
      urlPublique: URL_TEST,
    });
    await sql`insert into membre (organisation_id, compte_id, role)
              values (${inscription.organisationId}, ${dir.compteId}, 'directeur_crise')`;
    await r.refuse("EF-107b", "un directeur de crise n'ouvre pas un compte propriétaire "
      + "par ce chemin — il en connaîtrait le laissez-passer",
      () => comptes.ouvrirCompteAvecLaissezPasser(sql, {
        organisationId: inscription.organisationId, email: adresse("proprio-vole"),
        nom: "Compte convoité", role: "proprietaire", parCompteId: dir.compteId,
        motif: "Tentative d'élévation.",
      }), "operation_refusee");
    await r.refuse("EF-107b", "et une adresse déjà connue n'est pas réouverte",
      () => comptes.ouvrirCompteAvecLaissezPasser(sql, {
        organisationId: inscription.organisationId, email: emailSurPlace,
        nom: "Doublon", role: "membre", parCompteId: inscription.compteId,
        motif: "Doublon.",
      }), "operation_refusee");

    // ── Transport HTTP ─────────────────────────────────────────────────────
    r.titre("Transport");
    const reponse = await app.inject({
+197 −5

File changed.

Preview size limit exceeded, changes collapsed.

+16 −0
Changes for packages/shared/src/administration.ts: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -181,6 +181,22 @@ export const catalogueAdministration = {
    organisation_id: z.string().uuid(),
    role: z.string(),
  }),
  /**
   * Un compte ouvert par un tiers, avec un laissez-passer dicté (EF-107b).
   *
   * Journalisé nominativement, et il faut dire pourquoi : entre la création et
   * la première connexion, celui qui a ouvert le compte connaît le secret. Cela
   * n'est pas contournable — un secret dicté au téléphone est un secret partagé
   * — et c'est donc précisément ce que la trace doit permettre de constater
   * après coup.
   */
  "compte.ouvert_par_tiers": z.object({
    compte_id: z.string().uuid(),
    organisation_id: z.string().uuid(),
    role: z.string(),
    /** Pourquoi ce chemin plutôt qu'une invitation : « aucun canal ne part ». */
    motif: z.string().min(1),
  }),
  "invitation.acceptee": z.object({
    invitation_id: z.string().uuid(),
    organisation_id: z.string().uuid(),