Unverified Commit 443cd9f7 authored by Kourser's avatar Kourser
Browse files

Console d'instance : supervision, annonce, et adresse publique réglable

Trois sujets, une seule place dans le produit : la console d'instance. Ils
partagent le même écran et, pour deux d'entre eux, le même singleton
`InstanceSettings`.

CORRECTION D'UNE AFFIRMATION FAUSSE DU COMMIT 6143e6f4

Ce commit-là annonçait le constat L6 de l'audit comme corrigé — « APP_URL devient
obligatoire en production » — et le README l'écrivait noir sur blanc. C'était faux :
le correctif n'a jamais atteint le code. `6143e6f4` ne touche pas
`one-time-token.ts`, et `appBase()` retombait toujours sur `localhost`. La
description était doublement fausse, puisque j'avais parlé d'un refus de démarrer
là où le mécanisme visé n'aurait levé qu'à la construction d'un lien.

C'est corrigé ici, pour de vrai, et couvert par un test qui l'épingle : sans
réglage ni variable d'environnement, l'émission d'un lien échoue en production
avec un message explicite. Le README est rectifié.

1. SUPERVISION DE L'INSTANCE

`GET /api/instance/metrics`, derrière AuthGuard + InstanceGuard.

Six mesures en tête (organisations, comptes, exercices, taille de la base,
fichiers téléversés, durée de service), trois répartitions (exercices par statut,
appartenances par rôle, contenu d'exercice), et l'état des services.

AUCUNE DONNEE PERSONNELLE n'en sort. Que des comptages et des tailles : pas un nom
d'organisation, pas une adresse, pas un identifiant. Une page de supervision a
besoin de savoir COMBIEN, pas QUI — le journal d'instance existe pour le reste, et
lui est soumis à une durée de conservation. L'état du courriel est rapporté comme
« configuré » ou non, jamais l'hôte ni l'identifiant.

Cache de 30 s, contourné par le bouton « Rafraîchir ». La page rassemble une
vingtaine de comptages et deux mesures de taille dont un parcours de répertoire :
sans cache, deux administrateurs sur la page suffisaient à marteler la base pour
une information qui n'a aucun besoin d'être à la seconde.

Une requête SQL brute, la seconde du produit après la sonde de santé :
`pg_database_size(current_database())`. Aucun équivalent n'existe dans l'API de
Prisma — la taille physique d'une base n'est pas une donnée du modèle. Sans aucun
paramètre, donc sans interpolation possible, et il faut que ça reste vrai.

Le parcours du répertoire de téléversements est à UN seul niveau, comme l'écriture
qui y est faite : une descente récursive aurait suggéré une arborescence qui
n'existe pas, et ouvert la porte à un parcours sans fin sur un lien symbolique.

2. ANNONCE AUX UTILISATEURS

Un message que l'administrateur d'instance diffuse en bandeau, avec un niveau
(information / avertissement / critique) et une expiration.

LECTURE PUBLIQUE, et c'est un choix. Un avis de maintenance sert surtout à qui
n'arrive PAS à se connecter ; le réserver aux sessions ouvertes l'aurait rendu
invisible au moment où il compte. La conséquence — tout ce qui est écrit dans une
annonce est lisible par quiconque atteint l'instance — est annoncée à
l'administrateur AU-DESSUS du champ de saisie, pas en note de bas de panneau. Si
cette exposition ne convient pas à un déploiement, `PublicAnnouncementController`
est le seul point à fermer.

Un seul état de vérité : retirer l'annonce efface le message. Porter à côté un
booléen « active » aurait permis un texte qui existe sans être diffusé, donc deux
sources qui se contredisent. L'expiration est appliquée A LA LECTURE et non par une
tâche de fond : une annonce expirée cesse d'être servie à la seconde, sans dépendre
du passage d'un minuteur, et la valeur reste en base pour être reconduite.

Sur l'espace joueur, l'origine est dite EXPLICITEMENT (« Message de la
plateforme »). Cet espace est un environnement de simulation : un message hors
fiction qu'on n'attribue pas se lirait comme du contenu d'exercice, et un joueur
prendrait un avis de maintenance pour un inject. Le bandeau est posé APRES celui
de simulation, l'ordre disant lequel encadre l'autre.

Un niveau critique ne peut pas être masqué : quand l'instance va s'arrêter, laisser
masquer l'avertissement dessert celui qui le masque.

3. ADRESSE PUBLIQUE REGLABLE

`InstanceSettings.appUrl`, PRIORITAIRE sur APP_URL — même patron que les réglages
SMTP, qui existait déjà : on corrige une adresse sans redéployer. L'écran affiche
la valeur enregistrée ET la valeur effective avec son origine (console, variable,
repli), sans quoi l'exploitant ne peut pas savoir avec quoi les liens partent.

C'EST UN REGLAGE SENSIBLE, pas un réglage d'affichage. Cette base préfixe tous les
liens envoyés par courriel — accès joueur, écran de salle, activation, mot de passe
oublié — et chacun porte un secret à usage unique. Qui la change détourne les liens
à venir vers le domaine de son choix, c'est-à-dire récolte des capacités de prise
de compte. D'où :

- validation stricte, couverte par des tests : http/https seulement (`javascript:`
  dans un lien de courriel serait un vecteur d'exécution chez le destinataire) ;
  pas d'identifiants dans l'URL (ils partiraient en clair dans chaque message, et
  c'est la forme classique d'une URL trompeuse) ; PAS DE CHEMIN, parce que les
  routes du produit sont absolues et qu'accepter `https://hôte/cythin` aurait
  produit des liens morts EN SILENCE ; pas de requête ni de fragment ;
- une valeur en http est acceptée mais SIGNALEE — refuser aurait pu enfermer
  dehors un déploiement interne qui fonctionne ;
- modification tracée au journal d'instance avec l'ancienne ET la nouvelle valeur.
  Une URL n'est pas un secret, et savoir d'où l'on part est ce qui rend la trace
  exploitable ;
- la règle de validation vit à UN seul endroit : le validateur du DTO et le
  service de résolution appellent la même fonction. Deux validations qui divergent,
  c'est une valeur acceptée d'un côté et ignorée de l'autre.

Refactor imposé par ce réglage

`appBase()` était synchrone ; la valeur vit désormais en base. Plutôt que de rendre
asynchrone toute la chaîne, `buildAccessUrl` et `buildDisplayUrl` restent PURS et
reçoivent la base en argument. Ça compte pour `sandbox.service`, qui construit un
lien par personnage dans une boucle : la base y est résolue une fois, avant. Quatre
services y passent, plus un cache de 60 s dans le service — la résolution a lieu à
chaque émission de lien.

`common/one-time-token.ts` redevient purement pur : plus aucune lecture
d'environnement, plus aucun accès à la base.

VERIFICATIONS — cette fois RÉELLEMENT exécutées

C'est le premier commit de cette série dont le code a tourné. L'outillage manquait
sur la machine ; il est désormais en place (Node 22.23.2 par Homebrew, pnpm 9.12.3
par corepack, qui lit le champ `packageManager`).

  331 tests / 30 suites : verts
  typecheck des trois paquets    : vert
  eslint                         : vert
  pnpm build (API + web)         : vert

Ce que ça confirme, et qui n'était jusqu'ici qu'une hypothèse de ma part :
`otplib.clone()` et `checkDelta()` existent bien — c'était le doute le plus sérieux
du commit TOTP ; la sortie de build de l'API est à plat et le défaut `dist/src` ne
revient pas ; `dist/scripts/reset-mfa.js` est bien produit, donc la commande de
secours 2FA documentée pour la production fonctionne ; les exports de valeur de
`@cythin/shared` passent le bundle.

`pnpm format:check` reste rouge, mais il l'était DEJA : 102 des 105 fichiers
signalés sont en dette antérieure sur `main` (docs-site, docs/adr, Player.tsx,
api.ts, i18n.tsx, styles.css…), vérifié fichier par fichier contre la version
committée. Seuls les trois fichiers entièrement nouveaux de ce commit ont été
formatés. Un `pnpm format` global aurait noyé ce travail dans un diff de cent
fichiers sans rapport.

CE QUI RESTE NON VERIFIE

Les migrations n'ont jamais rencontré un vrai PostgreSQL : la machine n'a ni
Docker ni Postgres, donc `docker-compose` ne peut pas monter la base. Cela vaut
pour les cinq migrations en attente (session_epoch, totp, instance_announcement,
instance_app_url) et pour la requête `pg_database_size`, dont le résultat dépend
en outre des droits de l'utilisateur PostgreSQL — un échec y rend `null` et la
page affiche « mesure indisponible » au lieu de tomber, mais c'est à éprouver.

  pnpm --filter @cythin/api exec prisma migrate deploy

Co-Authored-By: Claude (RCA)
parent 28515491
Loading
Loading
Loading
Loading
+34 −1
Original line number Diff line number Diff line
@@ -90,7 +90,7 @@ docs/ Cahier des charges, ADR, spec technique, maquettes
|---|---|---|
| `JWT_SECRET` | Secret de signature des sessions. **Obligatoire en prod**, 32 caractères minimum | — |
| `DATABASE_URL` | Connexion PostgreSQL | voir `.env.example` |
| `APP_URL` | URL publique (liens joueurs, activation, mot de passe). **Obligatoire en prod** : l'API refuse de construire un lien sans elle, plutôt que d'en envoyer un vers `localhost` | `http://localhost:3000` (hors prod) |
| `APP_URL` | Adresse publique, préfixant les liens envoyés (joueur, activation, mot de passe). **Surchargée par le réglage de la console d'instance**, qui prime. En production, il faut l'une ou l'autre : sans elle, l'émission d'un lien échoue plutôt que d'en envoyer un vers `localhost` | `http://localhost:3000` (hors prod) |
| `TRUSTED_PROXIES` | Nombre de proxys inverses devant l'API. **À renseigner dès qu'il y en a un** (voir ci-dessous) | `0` |
| `SECRETS_KEY` | Clé de chiffrement des secrets stockés (mot de passe SMTP). À défaut, dérivée de `JWT_SECRET` | — |
| `RETENTION_DAYS` | Purge auto des exercices archivés après N jours (`0` = désactivé) | `0` |
@@ -133,6 +133,39 @@ tentatives **par adresse IP**. Les deux réglages erronés se paient, et dans le
Mettez le **nombre réel** de proxys inverses traversés : `1` derrière un Nginx ou un Traefik, plus
si plusieurs sont chaînés.

## Adresse publique

Elle préfixe **tous** les liens envoyés par courriel : accès joueur, écran de salle, activation de
compte, mot de passe oublié. Deux façons de la fournir, la première primant sur la seconde :

1. Le réglage de la **console d'instance** (Administration → Adresse publique). Se corrige sans
   redéployer, et l'écran affiche l'adresse effectivement utilisée avec son origine.
2. La variable d'environnement `APP_URL`, pour amorcer une instance neuve ou figer la valeur par la
   configuration.

**En production, il faut l'une ou l'autre.** Sans aucune des deux, l'émission d'un lien échoue avec
un message explicite — auparavant elle retombait sur `localhost` et envoyait un lien mort sans que
rien ne le signale.

L'adresse est validée : http ou https seulement, sans chemin, sans paramètre, sans identifiants. Ce
n'est pas de la coquetterie — chaque lien envoyé porte un secret à usage unique, et une adresse
détournée récolte des capacités de prise de compte. Toute modification est tracée au journal
d'instance, avec l'ancienne et la nouvelle valeur.

## Supervision

La console d'instance porte une page de supervision, en tête : organisations, comptes, exercices
par statut, volumes de contenu, taille de la base et du répertoire de téléversements, durée de
service. Réservée à l'administration de l'instance, et **sans aucune donnée personnelle** — que des
comptages et des tailles. Les mesures sont mises en cache 30 s ; le bouton « Rafraîchir » force la
relecture.

Le même écran permet de publier une **annonce** en bandeau (maintenance prévue, information de
service), avec un niveau et une expiration. Elle s'affiche à l'animation, aux joueurs et **sur la
page de connexion** : la route de lecture est publique, pour qu'un avis de maintenance atteigne qui
n'arrive justement pas à se connecter. Tout ce qui est écrit dans une annonce est donc lisible par
quiconque atteint l'instance — l'écran de rédaction le dit au-dessus du champ.

## Sauvegarde & restauration

Les données sont dans le volume PostgreSQL `db_data`.
+93 −0
Original line number Diff line number Diff line
@@ -362,6 +362,99 @@ suspend et supprime des organisations — il n'a **aucun droit ambiant** sur leu
  nouvelle connexion réussissait. Injects, chat et appels vocaux continuaient. Suspendre a pour
  objet d'arrêter l'activité, pas seulement l'administration.

## Adresse publique de l'instance

Réglable depuis la console d'instance, et **prioritaire sur `APP_URL`** — même patron que les
réglages SMTP : l'exploitant corrige une adresse sans redéployer.

**C'est un réglage sensible, pas un réglage d'affichage.** Cette base préfixe tous les liens envoyés
par courriel — accès joueur, écran de salle, activation de compte, réinitialisation de mot de
passe — et chacun porte un secret à usage unique. Qui la change détourne donc tous les liens à venir
vers le domaine de son choix, c'est-à-dire récolte des capacités de prise de compte. D'où :

- **Écriture réservée à l'administration de l'instance**, derrière `InstanceGuard`.
- **Tracée au journal persistant, avec l'ancienne ET la nouvelle valeur.** Une URL n'est pas un
  secret : les deux peuvent figurer au journal, et savoir depuis quelle valeur on est parti est ce
  qui rend la trace exploitable.
- **Validation stricte** (`common/app-url.ts`, couverte par des tests) :
  - http ou https seulement — `javascript:` ou `data:` transformeraient chaque courriel en vecteur
    d'exécution chez le destinataire ;
  - pas d'identifiants dans l'URL — ils partiraient en clair dans chaque message, et c'est la forme
    classique d'une URL trompeuse ;
  - pas de chemin — les routes du produit sont absolues, il ne sait pas vivre sous un
    sous-répertoire, et accepter aurait produit des liens morts **en silence** ;
  - pas de requête ni de fragment.
- Une valeur en `http` est **acceptée mais signalée** : les liens circuleront en clair, et le micro
  des appels vocaux ne fonctionnera pas, les navigateurs l'exigeant en HTTPS. Refuser aurait pu
  enfermer dehors un déploiement interne qui fonctionne.
- **Aucun repli silencieux en production.** Sans réglage ni variable d'environnement, l'émission
  d'un lien échoue avec un message explicite. Le repli sur `localhost` envoyait des liens
  d'activation morts sans que rien ne le dise — c'était le constat L6 de l'audit du 2026-08-22,
  annoncé comme corrigé alors qu'il ne l'était pas : le correctif n'avait jamais atteint le code.
  Il l'est ici.
- La règle de validation vit à **un seul endroit** : le validateur du DTO et le service de
  résolution appellent la même fonction. Deux validations qui divergent, c'est une valeur acceptée
  d'un côté et ignorée de l'autre.

## Supervision de l'instance

`GET /api/instance/metrics`, derrière `AuthGuard` + `InstanceGuard`, donc réservé à
l'administration de l'instance.

- **Aucune donnée personnelle n'en sort.** Que des comptages et des tailles : pas un nom
  d'organisation, pas une adresse, pas un identifiant de compte. Une page de supervision a besoin
  de savoir **combien**, pas **qui** — le journal d'instance existe pour le reste, et lui est
  soumis à une durée de conservation.
- **Aucun secret.** L'état du courriel est rapporté comme « configuré » ou « non configuré », jamais
  l'hôte SMTP ni l'identifiant.
- **Lectures non cloisonnées**, et c'est l'objet même de la page : elle regarde l'instance entière,
  donc passe par le client Prisma de base et non par `forTenant`. C'est la seule surface du produit
  dans ce cas avec le reste de la console d'instance.
- **Cache de 30 s.** La page rassemble une vingtaine de comptages et deux mesures de taille, dont
  un parcours de répertoire : sans cache, un rafraîchissement en boucle martèlerait la base pour une
  information qui n'a aucun besoin d'être à la seconde. `?refresh=1` le contourne, sur action
  explicite.
- **Une requête SQL brute**, la seconde du produit après la sonde de santé :
  `SELECT pg_database_size(current_database())`. Aucun équivalent n'existe dans l'API de Prisma —
  la taille physique d'une base n'est pas une donnée du modèle. **Aucune interpolation**, et il faut
  que ça reste vrai : la requête ne prend aucun paramètre. Le jour où il en faudra un, ce sera un
  paramètre lié, jamais une concaténation.
- Le parcours du répertoire de téléversements est **à un seul niveau**, comme l'écriture qui y est
  faite : une descente récursive aurait suggéré une arborescence qui n'existe pas, et ouvert la
  porte à un parcours sans fin sur un lien symbolique.

## Annonce d'instance

Un message que l'administrateur de l'instance diffuse à tous ses utilisateurs (maintenance prévue,
information de service). Écriture réservée à l'administration de l'instance, **tracée au journal
persistant** — une annonce s'affiche à tout le monde, savoir qui l'a posée fait partie de ce qu'on
doit pouvoir restituer.

**La lecture est PUBLIQUE** (`GET /api/public/announcement`), et c'est un choix, pas un oubli : un
avis de maintenance sert surtout à qui n'arrive **pas** à se connecter, et le réserver aux sessions
ouvertes l'aurait rendu invisible au moment où il compte. La conséquence — tout ce qui est écrit
dans une annonce est lisible par quiconque atteint l'instance — est **annoncée à l'administrateur
au-dessus du champ de saisie**, pas en note de bas de panneau.

Si cette exposition n'est pas souhaitable sur un déploiement donné, la route est le seul point à
fermer : `PublicAnnouncementController`.

Autres garde-fous :

- **Plafonnée** comme les autres routes publiques (120 lectures / 10 min et par adresse), plus un
  cache de 15 s côté service : la page de connexion l'appelle à chaque chargement.
- **Bornée à 500 caractères.** C'est un bandeau, pas une page.
- **Expiration appliquée à la lecture**, pas par une tâche de fond : une annonce expirée cesse
  d'être servie à la seconde, sans dépendre du passage d'un minuteur.
- **Un seul état de vérité** : retirer l'annonce efface le message. Porter à côté un booléen
  « active » aurait permis un texte qui existe sans être diffusé, donc deux sources qui se
  contredisent.
- **Sur l'espace joueur, l'origine est dite explicitement** (« Message de la plateforme »). Cet
  espace est un environnement de simulation : un message hors fiction qu'on n'attribue pas se
  lirait comme du contenu d'exercice, et un joueur prendrait un avis de maintenance pour un inject.
- Un niveau `CRITICAL` **ne peut pas être masqué** par l'utilisateur : quand l'instance va s'arrêter,
  laisser masquer l'avertissement dessert celui qui le masque.

## Sessions en observation

Le garde de session (`apps/api/src/auth/auth.guard.ts`) refuse toute méthode autre que
+25 −0
Original line number Diff line number Diff line
-- Annonce d'instance : un message que l'administrateur de l'instance diffuse a
-- tous ses utilisateurs (maintenance prevue, information de service).
--
-- Portee par InstanceSettings, qui est deja un singleton : une annonce est un
-- reglage d'instance, pas une entite dont on garderait l'historique. Une table
-- dediee aurait sous-entendu qu'on conserve les annonces passees, ce qui n'est
-- pas le besoin.
--
-- PUBLIQUE : la route de lecture ne demande aucune session, pour qu'un avis de
-- maintenance s'affiche AUSSI avant connexion — c'est la qu'il sert le plus, a
-- qui n'arrive pas a se connecter. L'editeur le dit explicitement a
-- l'administrateur au moment ou il redige.

CREATE TYPE "AnnouncementLevel" AS ENUM ('INFO', 'WARNING', 'CRITICAL');

-- Null = aucune annonce. C'est la SEULE facon de la retirer : on efface le
-- message plutot que de porter un booleen « active » qui pourrait le contredire.
ALTER TABLE "InstanceSettings" ADD COLUMN "announcementMessage" TEXT;
ALTER TABLE "InstanceSettings" ADD COLUMN "announcementLevel" "AnnouncementLevel";

-- Expiration. Sans elle, un avis de maintenance depassee resterait affiche
-- jusqu'a ce que quelqu'un pense a le retirer — et un bandeau qu'on n'a plus
-- raison de croire est un bandeau que personne ne lit plus.
ALTER TABLE "InstanceSettings" ADD COLUMN "announcementUntil" TIMESTAMP(3);
ALTER TABLE "InstanceSettings" ADD COLUMN "announcementUpdatedAt" TIMESTAMP(3);
+11 −0
Original line number Diff line number Diff line
-- Adresse publique de l'instance, reglable depuis la console.
--
-- Elle prefixe TOUS les liens envoyes par courriel : acces joueur, affichage de
-- salle, activation de compte, reinitialisation de mot de passe. Meme patron que
-- les reglages SMTP — la valeur enregistree PRIME sur APP_URL, pour qu'une
-- adresse se corrige sans redeployer.
--
-- Null = on retombe sur l'environnement. Et si l'environnement ne la porte pas
-- non plus, l'emission d'un lien ECHOUE en production, au lieu de retomber sur
-- localhost et d'envoyer un lien mort.
ALTER TABLE "InstanceSettings" ADD COLUMN "appUrl" TEXT;
+25 −0
Original line number Diff line number Diff line
@@ -180,6 +180,13 @@ model Membership {
/// MODERATED : le formulaire cree une demande, un administrateur d'instance
/// approuve. OPEN : l'organisation est creee immediatement, l'adresse etant
/// verifiee par le lien d'activation. DISABLED : aucun formulaire.
/// Gravite d'une annonce d'instance. Decide de l'habillage du bandeau.
enum AnnouncementLevel {
  INFO
  WARNING
  CRITICAL
}

enum SignupMode {
  DISABLED
  MODERATED
@@ -192,6 +199,12 @@ model InstanceSettings {
  id         String     @id @default("singleton")
  signupMode SignupMode @default(DISABLED)

  /// Adresse publique de l'instance, prefixant TOUS les liens envoyes par
  /// courriel. Prime sur APP_URL, comme les reglages SMTP. Null = on retombe sur
  /// l'environnement, et a defaut l'emission d'un lien echoue en production
  /// plutot que de produire un lien vers localhost.
  appUrl     String?

  // --- Courriel transactionnel, surchargeable depuis la console d'instance ---
  // Ces valeurs PRIMENT sur les variables d'environnement quand elles sont
  // renseignees : l'exploitant peut corriger un serveur SMTP sans redeployer.
@@ -222,6 +235,18 @@ model InstanceSettings {
  /// d'INSTANCE n'en beneficient pas : rien ne justifie de differer pour eux.
  totpGraceDays     Int?

  // --- Annonce d'instance ---
  /// Message diffuse a tous les utilisateurs. Null = aucune annonce, et c'est la
  /// seule facon de la retirer : un booleen « active » a cote du texte aurait pu
  /// le contredire.
  announcementMessage   String?
  announcementLevel     AnnouncementLevel?
  /// Expiration. Sans elle, un avis de maintenance depassee resterait affiche
  /// jusqu'a ce que quelqu'un y pense — et un bandeau qu'on ne croit plus est un
  /// bandeau que personne ne lit.
  announcementUntil     DateTime?
  announcementUpdatedAt DateTime?

  updatedAt  DateTime   @updatedAt
}

Loading