# Procédure de déploiement — la nôtre

Établie le 06/08/2026 par inspection directe du serveur. **Aucune information n'a été demandée
à l'équipe externe** : tout ce qui suit a été constaté sur la machine.

---

## Comment le système est câblé

### Serveur web

Apache 2 (nginx est installé mais inactif), un vhost par domaine dans
`/etc/apache2/sites-enabled/`, HTTPS via Let's Encrypt.

| Site | Racine servie |
|---|---|
| API production | `/var/www/api.efektiv-academie.com` |
| Front production | `/var/www/efektiv-academie.com` |

**Particularité à connaître** : la racine de l'API est le dossier **parent**, pas le `public/`
de Laravel. D'où la forme des URL : `api.efektiv-academie.com/e-learning-api/public/api/…`.
Le chemin `e-learning-api/public` fait partie de l'URL publique. C'est inhabituel, mais tout en
dépend — front, emails, liens. **Ne pas y toucher sans reprendre l'application de bout en bout.**

### Application

| Élément | Valeur |
|---|---|
| Racine Laravel | `/var/www/api.efektiv-academie.com/e-learning-api` |
| Propriétaire | `ubuntu:webgroup`, permissions `2775` (setgid) |
| `storage/` et `bootstrap/cache/` | `www-data:www-data` — Apache tourne en `www-data` |
| Caches compilés | **aucun** (`config.php`, `routes.php`, `views.php` absents) |
| Base | MySQL 8.0.46, base `e-learning-prod`, accès en local uniquement |

L'absence de caches compilés explique pourquoi une modification du `.env` prend effet
immédiatement. C'est pratique en exploitation, moins performant qu'un `config:cache`.

### Front

Le dossier servi contient un **build React déjà compilé** : `index.html` plus un dossier
`assets/` aux noms hachés. Le build est donc fait ailleurs et déposé. Dernier dépôt constaté :
1ᵉʳ juillet 2026.

### Certificats SSL

`certbot.timer` est **actif**. Le renouvellement est automatique et géré par systemd, sans
intervention humaine. Il ne dépend d'aucun compte de l'équipe externe et ne sera donc pas affecté
par la coupure de leurs accès.

---

## Ce que la suite de tests ne peut PAS attraper — à vérifier avant chaque mise en prod

Deux angles morts sont structurels : ils ne viennent pas d'un oubli de couverture, mais du
fait que l'environnement de test n'est pas l'environnement de production. Les deux ont
produit un incident évité de justesse le 09/08 (lot L4).

### 1. La suite tourne sur SQLite, la production sur MySQL

`phpunit.xml` exécute la suite sur **SQLite en mémoire**. Une requête peut donc être verte
en CI et refusée par MySQL.

**Cas vécu (#160, 09/08)** — `UPDATE users … WHERE NOT EXISTS (SELECT … FROM users …)` est
accepté par SQLite et refusé par MySQL :

```
SQLSTATE[HY000] 1093 You can't specify target table 'users' for update in FROM clause
```

Les dix tests de l'issue passaient. La migration serait tombée au déploiement. Réécrite en
deux temps — repérer, puis mettre à jour par identifiants.

**Le corollaire est aussi vrai** : SQLite applique les clés étrangères, MyISAM en production
ne les a jamais appliquées. Certains états de données présents en production **ne peuvent
pas être reproduits** sur la base de test (c'est le cas des `parent_id` orphelins de #160).
Un test qui désactive la contrainte pour recréer l'état ne prouve alors plus rien d'utile.

> **Geste à faire** : toute migration ou requête non triviale est rejouée sur la base MySQL
> locale (`efektiv_local`) avant la PR, et la PR le mentionne. Pour une vérification sans
> laisser de trace : `artisan tinker` + `DB::beginTransaction()` / `DB::rollBack()`.

### 2. `Mail::fake()` ne compile pas les gabarits

**Cas vécu (#55, 09/08)** — un `@if` accolé à un mot (« vous@if ») a été lu par Blade comme
une arobase échappée, laissant un `@endif` orphelin. **Toute la création de compte tombait
en 500.** Les tests de l'issue, qui utilisaient `Mail::fake()`, étaient verts : le faux
n'appelle jamais le moteur de rendu.

> **Geste à faire** : tout gabarit d'e-mail a au moins un test qui appelle `->render()`,
> et non seulement `Mail::assertSent()`. Voir `17_DOCTRINE_TESTS.md`.

### 3. `APP_LOCALE` décide de la langue de TOUTES les réponses de l'API

Constaté le 09/08 en recettant les référentiels d'EA-041 : le `.env` local portait
`APP_LOCALE=en` alors que `.env.example` dit `fr`. Conséquence immédiate — les messages
d'erreur métier partaient en anglais :

```
1 user(s) still hold this job profile. Reassign them before deleting it.
```

au lieu de :

```
1 utilisateurs portent encore ce profil métier. Réaffectez-les avant de le supprimer.
```

Le code est bon, les deux fichiers de langue sont complets : c'est une variable
d'environnement. Comme les `.env` de staging et de production n'ont pas été créés depuis
`.env.example`, **rien ne garantit qu'ils portent `fr`**.

> **Geste à faire** (issue **#111**) : vérifier `APP_LOCALE=fr` sur le serveur avant de conclure une
> recette. Un message anglais dans l'interface n'est pas un oubli de traduction — c'est
> presque toujours cette variable.
>
> ```bash
> grep -E '^APP_LOCALE|^APP_FALLBACK_LOCALE' .env
> php artisan config:clear   # après toute modification
> ```

---

## Notre procédure

### Déploiement de l'API

```bash
# 1. Sauvegarde préalable
ssh ubuntu@57.129.1.122
cd /var/www/api.efektiv-academie.com/e-learning-api
mysqldump -u root -p"$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)" \
  --single-transaction --quick e-learning-prod > ~/backups/avant-deploiement-$(date +%Y%m%d-%H%M%S).sql

# 2. Récupération du code depuis git (et non plus par dépôt de fichiers)
git fetch origin
git checkout <branche-ou-tag>
git pull

# 3. Dépendances (sans les paquets de développement)
composer install --no-dev --optimize-autoloader

# 4. Migrations — désormais sûr, l'état a été recalé le 06/08
php artisan migrate --force

# 5. Remise en place des permissions
sudo chown -R www-data:www-data storage bootstrap/cache

# 6. Vidage des caches
php artisan config:clear && php artisan route:clear && php artisan view:clear

# 6 bis. Sentry — la version déployée (BACK#393). À jouer APRÈS le `git pull`, sans
#        quoi la release désigne le code précédent. Se raccorde au point 13 : c'est
#        cette valeur qui dira quelle version a produit une erreur.
sed -i "s/^SENTRY_RELEASE=.*/SENTRY_RELEASE=$(git rev-parse --short HEAD)/" .env
#        Puis vérifier que l'environnement est bien celui de la machine — `staging`
#        sur le staging, `production` en production. Il n'y a PAS de repli sur
#        APP_ENV : sans cette variable, Sentry est éteint et AUCUNE erreur serveur
#        n'est capturée. Une valeur fausse est pire — elle range les erreurs de
#        staging parmi celles de la production.
grep -E '^SENTRY_(ENVIRONMENT|RELEASE|LARAVEL_DSN)=' .env
#        Rejouer l'étape 6 après toute modification du .env.

# 7. Langue de l'API — DOIT afficher fr. Si la valeur est absente ou vaut `en`,
#    toute l'API répond en anglais (cf. « Ce que la suite de tests ne peut PAS
#    attraper », point 3). Corriger le .env puis rejouer l'étape 6.
grep -E '^APP_LOCALE=' .env || echo 'APP_LOCALE ABSENT — a corriger avant de continuer'
```

**Vérification immédiate** :

```bash
# a. l'API répond
curl -s -o /dev/null -w "%{http_code}\n" https://api.efektiv-academie.com/e-learning-api/public/api/plans

# b. elle répond EN FRANÇAIS — un 404 sur une route inexistante doit dire
#    « Aucun enregistrement trouvé », pas « No record found »
curl -s https://api.efektiv-academie.com/e-learning-api/public/api/invitations/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
```

`a` doit renvoyer `200`. `b` doit renvoyer un message **en français** : s'il est en anglais,
`APP_LOCALE` n'est pas à `fr` et il ne s'agit pas d'un oubli de traduction.

### Déploiement du front

Le build se fait sur un poste de développement, puis le contenu est déposé :

```bash
# En local
npm ci --legacy-peer-deps
npm run build

# Dépôt (le contenu de dist/, pas le dossier lui-même)
rsync -avz --delete dist/ ubuntu@57.129.1.122:/var/www/efektiv-academie.com/
```

⚠️ **Depuis FRONT#143, le build exige aussi `VITE_SENTRY_DSN` et `VITE_SENTRY_ENVIRONMENT`**
(en plus des cinq variables de FRONT#45). Sans elles il **échoue** — c'est l'intention : un
bundle sans environnement Sentry mélange les erreurs de staging à celles de la production.
La `release`, elle, est calculée toute seule depuis le commit ; il n'y a rien à saisir.

```bash
VITE_SENTRY_DSN="…" VITE_SENTRY_ENVIRONMENT="staging" …autres variables… npx vite build
```

⚠️ Le `--delete` supprime ce qui n'est plus dans le build. À valider d'abord sur le staging.

> **Incident du 09/08/2026 — le `.htaccess` est la victime du `--delete`.**
> Le dossier servi contient un `.htaccess` de routage SPA que **Vite ne produit pas** :
>
> ```apache
> Options -MultiViews
> RewriteEngine On
> RewriteCond %{REQUEST_FILENAME} !-f
> RewriteRule ^ index.html [QSA,L]
> ```
>
> Sans lui, **tout lien profond renvoie 404** — `/courses/mon-cours`, `/admin/users`, un lien
> partagé, un favori. La page d'accueil, elle, continue de répondre : la panne est invisible
> depuis la racine, ce qui la rend facile à manquer.
>
> **Procédure corrigée** — sauvegarder puis restaurer, à chaque dépôt :
>
> ```bash
> # Avant : sauvegarde du dossier servi (fait déjà office de retour arrière)
> sudo cp -a /var/www/<site> /var/www/<site>-backup-$(date +%Y%m%d-%H%M)
>
> rsync -avz --delete dist/ ubuntu@57.129.1.122:/var/www/<site>/
>
> # Après : restauration du routage SPA
> sudo cp -a /var/www/<site>-backup-<horodatage>/.htaccess /var/www/<site>/.htaccess
> ```
>
> **Vérification obligatoire après tout dépôt front** — la racine ne suffit pas :
>
> ```bash
> curl -s -o /dev/null -w "%{http_code}\n" https://<site>/courses/<un-slug-existant>
> ```
>
> Doit renvoyer `200`. À terme, le plus sûr est de **committer le `.htaccess` dans `public/`**
> du dépôt front : Vite le recopie alors dans `dist/`, et le problème disparaît.

### Retour arrière

Le déploiement passant désormais par git, revenir en arrière consiste à repositionner la branche
sur le commit précédent et à rejouer les étapes 3 à 6. Pour la base, restaurer le fichier produit
à l'étape 1.

---

## Le staging comme filet

`staging.efektiv-academie-dev.com` et `api-staging.efektiv-academie-dev.com` sont hébergés sur
**le même serveur**, dans `/var/www/`. Tout déploiement doit y être joué d'abord.

Rappel de la limite : même machine, donc pas d'isolation réelle. Le staging valide un
déploiement, il ne protège pas d'un incident système.

**La base, elle, est bien séparée** : `elearning-staging` contre `e-learning-prod`. Vérifié
le 09/08 avant de déployer L4 — c'est le point à contrôler en premier, puisqu'une migration
jouée sur la base de production serait irréversible.

### Ce qu'il faut savoir pour déployer sur le staging (constaté le 09/08, lot L4)

**1. Les commandes artisan se lancent sous `www-data`, pas sous `ubuntu`.**
`storage/` et `bootstrap/cache/` appartiennent à `www-data`. Lancées en `ubuntu`, les
commandes échouent sur « Permission denied » — y compris `composer install`, dont le hook
`package:discover` écrit dans le journal.

```bash
sudo -u www-data composer install --no-dev --optimize-autoloader --no-interaction
sudo -u www-data php artisan migrate --force
sudo -u www-data php artisan config:clear
```

`sudo` est disponible **sans mot de passe** pour `ubuntu`.

⚠️ **Mais les commandes GIT, elles, se lancent sous `ubuntu`** — et c'est la répartition
à ne pas inverser. Voir « Le partage des rôles entre `ubuntu` et `www-data` » ci-dessous :
confondre les deux a coûté une heure le 15/08.

**2. Le build front exige `VITE_APP_API`.** Il n'y a **ni `.env` ni `.env.example`** dans le
dépôt front : sans cette variable, `import.meta.env.VITE_APP_API` vaut `undefined` et tous
les appels partent sur une URL invalide — un build cassé qui ne dit rien. Valeur pour le
staging :

```bash
VITE_APP_API="https://api-staging.efektiv-academie-dev.com/e-learning-api/public/api" npx vite build
```

**2 bis. Où retrouver les 5 valeurs de build si le `.env` local a disparu** (vécu le
12/08 : le `.env` du poste s'était volatilisé, le garde de build FRONT#45 a
correctement refusé de produire un bundle incomplet — il a fait son travail).

Le `.env` front est **gitignoré et local au poste** : rien ne le sauvegarde. Les
valeurs se reconstituent sans deviner :

| Variable | Où la retrouver |
|---|---|
| `VITE_APP_API` | La procédure ci-dessus (URL de l'API de l'environnement visé) |
| `VITE_APP_MEDIA_URL`, `VITE_APP_PROFILE_URL` | `<APP_URL back>/e-learning-api/public/storage/` — vérifiable en cherchant `storage/` dans le bundle EN SERVICE : `curl -s <front>/assets/index-*.js \| grep -o 'https://[^"]*storage/'` |
| `VITE_APP_PUSHER_APP_KEY`, `VITE_APP_PUSHER_APP_CLUSTER` | Le `.env` **du back** du même environnement (`PUSHER_APP_KEY`, `PUSHER_APP_CLUSTER`) — front et back doivent viser la MÊME application Pusher, sinon le chat est muet sans erreur |

Après reconstitution, vérifier que la clé est bien dans le bundle produit
(`grep -c <clé> dist/assets/index-*.js` → 1) et ouvrir la page dans un
navigateur : l'incident du 09/08 se manifestait par une page blanche, pas par
une erreur de build.

**3. `npm run build` échoue toujours** — le hook `postbuild` lance `react-snap`, qui cherche
`build/` alors que Vite écrit dans `dist/`. Le build est pourtant bon. Appeler `npx vite build`
directement en attendant le correctif (FRONT#44). ⚠️ Un build qui « échoue » quand il réussit
apprend à ignorer l'échec : le jour où il échouera vraiment, l'erreur ressemblera à celle de
tous les jours.

**4. Une route d'API appelée sans `Accept: application/json` rend 500, pas 401.** Laravel tente
une redirection vers la route nommée `login`, qui n'existe pas dans une application d'API. Ce
n'est pas une panne : avec l'en-tête, la réponse est un `401 {"message":"Unauthenticated."}`
propre. À savoir avant de conclure à une régression en testant au curl.

**5. Cache SPA (posé le 10/08 après un incident de recette).** `index.html` est servi en
`Cache-Control: no-cache, must-revalidate` et les assets hachés en `immutable` (règles dans
le `.htaccess` du front staging, `mod_headers` activé le 10/08). Sans cela, un navigateur
qui garde un `index.html` périmé pointe vers des bundles que `rsync --delete` a supprimés :
l'écran clignote puis retombe sur la page de connexion — vécu par Enguerran pendant la
recette L4, alors même que ses `POST /session/login` répondaient 200. **À reporter sur la
prod au prochain déploiement** (même `.htaccess`, `mod_headers` déjà actif désormais).

**6. Comptes de recette.** Poser un mot de passe connu sur un compte de TEST staging
(jamais un compte réel, jamais la prod) :
```bash
sudo -u www-data php artisan tinker --execute="\$u = App\Models\User::where('email','<compte-de-test>')->first(); \$u->password = bcrypt('<mdp-recette>'); \$u->save();"
```

### Le partage des rôles entre `ubuntu` et `www-data` (anomalie du 15/08, corrigée)

**Deux comptes, deux métiers, et ils ne se recouvrent pas** :

| Compte | Ce qu'il fait | Ce qu'il possède |
|---|---|---|
| `ubuntu` | **git** (fetch, pull, checkout) — il porte la clé de déploiement `~/.ssh/deploy_efektiv_back` et l'alias `github-efektiv-back` | tout le dépôt, en `ubuntu:webgroup 2775` |
| `www-data` | **artisan, composer** — c'est le compte d'Apache | `storage/`, `bootstrap/cache/`, et le lien `public/storage` |

C'est la configuration de la **production**, et c'est la référence.

#### Le symptôme, si la répartition a glissé

```
error: cannot open .git/FETCH_HEAD: Permission denied
```

**Diagnostic en une commande** :

```bash
stat -c "%U:%G %a %n" . .git app storage bootstrap/cache
```

Si `.git` et `app` appartiennent à `www-data` au lieu d'`ubuntu:webgroup`, la répartition a
été écrasée — typiquement par un `chown -R www-data` de dépannage. `ubuntu` ne peut alors
plus faire `git pull`, et `www-data` ne peut pas le faire non plus : **il n'a pas la clé**,
et l'erreur qu'il renvoie est trompeuse (« make sure you have the correct access rights »,
qui fait croire à un problème GitHub alors que c'est un problème de compte).

#### La correction

```bash
R=/var/www/api-staging.efektiv-academie-dev.com/e-learning-api
sudo chown -R ubuntu:webgroup $R
sudo chown -R www-data:www-data $R/storage $R/bootstrap/cache
sudo chown -h www-data:www-data $R/public/storage
```

Puis vérifier les trois choses qui pourraient casser — dans cet ordre :

```bash
git fetch origin                      # doit passer en ubuntu, sans sudo
curl -s -o /dev/null -w "%{http_code}\n" <API>/plans                    # 200
curl -s -o /dev/null -w "%{http_code}\n" <API>/../storage/avatars/<un-fichier-existant>   # 200
```

Et surtout **le test d'écriture**, celui que les trois précédents ne couvrent pas :
téléverser un avatar via `PUT /moi/profil` et constater que le fichier est créé et servi.
C'est le seul qui prouve que `www-data` écrit encore là où il doit après le changement de
propriétaire du reste de l'arbre.

#### ⛔ Ce qu'il ne faut PAS faire

- **Ne pas donner la clé de déploiement à `www-data`.** C'est le compte sous lequel tourne
  Apache : toute exécution de code arbitraire dans l'application y donnerait accès. La clé
  est en lecture seule sur GitHub (« Serveur OVH ns3233390 », `read_only=true`), donc
  l'impact serait la fuite du code et de son historique — pas une compromission de la chaîne
  de livraison. Cela reste un risque qu'on n'a aucune raison de prendre, puisque la
  répartition ci-dessus le supprime.
- **Ne pas faire tourner git en `root`** avec `sudo git -c core.sshCommand="ssh -F
  /home/ubuntu/.ssh/config -i /home/ubuntu/.ssh/deploy_efektiv_back"`. C'est le contournement
  employé le 15/08 pour débloquer un déploiement urgent : il fonctionne, mais il laisse
  l'arbre appartenant à `root` et oblige à un `chown -R` derrière. Un oubli de ce `chown` et
  le déploiement suivant échoue autrement. **Corriger les permissions, ne pas contourner.**

### Vérification de bout en bout après déploiement du staging

```bash
# API en ligne
curl -s -o /dev/null -w "%{http_code}\n" https://api-staging.efektiv-academie-dev.com/e-learning-api/public/api/plans

# API en FRANÇAIS — le seul test qui prouve APP_LOCALE, parce qu'il traverse
# tout l'empilement (Apache → PHP → .env → traductions) au lieu de lire un fichier
curl -s https://api-staging.efektiv-academie-dev.com/e-learning-api/public/api/invitations/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

# Routage SPA : la racine ne suffit JAMAIS, il faut un lien profond
curl -s -o /dev/null -w "%{http_code}\n" https://staging.efektiv-academie-dev.com/pilotage/dashboard
```

---

## Ce que nous n'avons pas besoin de leur demander

Le câblage complet, les chemins, les permissions, la configuration Apache, le renouvellement
SSL : tout a été constaté directement. **Nous pouvons définir et exécuter notre propre procédure
sans leur concours.**

## Ce qui relève réellement d'eux

Une seule chose, et elle n'a rien à voir avec le déploiement : **à quels comptes appartiennent
les services tiers** dont les clés figurent dans le `.env` — Stripe, Pusher, Mailjet, AWS. Les
clés sont en notre possession, mais si les comptes sont ouverts à leur nom, les renouveler exige
d'y accéder. Même question pour le compte OVH.

C'est le seul point de dépendance qui subsiste. Il est contractuel, pas technique.

---

## Améliorations à prévoir (lot L5)

1. **Marquer la version déployée** : écrire le commit déployé dans un fichier lisible, pour
   savoir en un coup d'œil ce qui tourne.
2. **Compiler les caches** (`config:cache`, `route:cache`) pour les performances — en pensant à
   les vider à chaque déploiement. → issue **#210**. Prérequis déjà tenu : plus aucun appel à
   `env()` hors de `config/` (test `ConfigCacheCompatibiliteTest`), sans quoi `config:cache`
   ferait silencieusement disparaître les liens des e-mails.
3. **Mettre le site en maintenance** pendant l'opération (`php artisan down` / `up`).
4. **Sauvegarde hors serveur** : aujourd'hui les exports restent sur la machine qu'ils sont
   censés protéger.
5. **File d'attente** : `QUEUE_CONNECTION=database` et un worker en service systemd — nécessaire
   au tracking du lot L1 et au correctif EA-083.

   > ⚠️ **Ce point en réveille un autre.** Aujourd'hui PHP reconstruit toute l'application à
   > chaque requête HTTP : une valeur mémoïsée dans une instance ne survit pas d'une requête à
   > l'autre, et un réglage modifié en base est donc vu immédiatement. **Un worker de file
   > d'attente, lui, est un processus long** : il garde ses instances entre deux jobs et
   > continuerait de servir l'ancienne valeur d'un seuil ou d'un délai après sa modification.
   > L'audit correspondant est l'issue **#208**, à traiter *avant* de mettre le worker en
   > service — pas après. Même remarque le jour où Octane serait envisagé.
---

## Checklist de mise en PROD — l'ordre fait partie de la procédure

À dérouler dans cet ordre, sans en sauter. Chaque case renvoie à la section détaillée.

**Avant de commencer (paramètres à vérifier, pas seulement des commandes) :**

- [ ] **1. Fenêtre + sauvegarde.** Annoncer la fenêtre. `sudo cp -a` des deux racines
  (API + front) horodatées, dump de la base `e-learning-prod`.
- [ ] **2. Paramètres `.env` back** — liste NOMINATIVE à relire à chaque mise en prod :
  `APP_ENV=production`, `APP_DEBUG=false`, `APP_LOCALE=fr`, `FRONT_URL` (porte les liens
  des e-mails d'invitation !), `MAIL_MAILER/HOST/PORT/USERNAME/PASSWORD`,
  `MAIL_FROM_ADDRESS`/`MAIL_FROM_NAME` (**domaine authentifié Brevo — voir #261 :
  SPF + DKIM + DMARC vérifiés au `dig`, sinon Gmail jette silencieusement**),
  `QUEUE_CONNECTION`, `DB_*`, `SANCTUM_STATEFUL_DOMAINS`/CORS.
- [ ] **3. Paramètres de build front** — les 5 variables (`VITE_APP_API`,
  `VITE_APP_MEDIA_URL`, `VITE_APP_PROFILE_URL`, `VITE_APP_PUSHER_APP_KEY`,
  `VITE_APP_PUSHER_APP_CLUSTER`) aux valeurs de PROD ; le garde de build refuse un
  build incomplet, ne pas le contourner.

**Déploiement (l'ordre back → migrations → front évite de servir un front qui appelle
des routes pas encore déployées) :**

- [ ] **4. Back** : `git pull` (voir « accès git de www-data »), `composer install`,
  `migrate --force`, `config:clear`/`route:clear`/`view:clear` — tout sous `www-data`.
- [ ] **5. Rejouer les requêtes non triviales sur MySQL** si le lot en introduit
  (règle SQLite ≠ MySQL, incidents 1093/1824 vécus).
- [ ] **6. Front** : build avec les variables de prod, rsync via répertoire relais +
  `sudo rsync --delete --exclude=.htaccess`, `chown www-data`.
- [ ] **7. `.htaccess` front** : rewrite SPA + **règles de cache** (`index.html` en
  `no-cache, must-revalidate`, assets hachés en `immutable`) — `mod_headers` requis
  (actif depuis le 10/08). Sans elles : « écran qui clignote » post-déploiement, vécu.

**Vérification (aucune mise en prod n'est finie sans les 5) :**

- [ ] **8.** API en ligne (`/plans` → 200) · **9.** API en français (message d'erreur
  d'invitation) · **10.** lien SPA profond → 200 · **11.** `Cache-Control` présent sur
  `/` et sur un asset · **12.** un e-mail d'invitation réel arrive EN BOÎTE sur un
  Gmail (pas yopmail — yopmail accepte tout, Gmail est le juge) · **13.** le HASH du
  bundle servi a CHANGÉ (`curl -s <front>/ | grep -o 'index-[^"]*\.js'` avant/après) —
  incident vécu le 10/08 : un build échoué en silence dans une chaîne de commandes a
  fait resservir l'ancien dist/, et « redéployé » s'affichait quand même.
