Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/handoffs/2026-07-25-1545-locale-race-et-csp-sonner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Handoff CC Ankora — 25 juillet 2026, 15h45

**Agent** : @cc-ankora (Opus 5)
**Mandat** : « tu avances tout seule avec précaution et à mon retour tout doit être fini » — @thierry
**Compte** : `thierryvm` vérifié sur `gh` **et** `git config` avant chaque commit

---

## 1. Ce qui a été livré

| PR | Sujet | État |
| ---- | ----------------------------------------------------------------------------- | ---------- |
| #254 | agents `prod-bug-investigator` + `test-quality-auditor` | ✅ mergée |
| #253 | handoff session précédente | ✅ mergée |
| #255 | diagnostic de la course sur le cookie de langue | ✅ mergée |
| #256 | correction de la cause des échecs de login locaux (`e2e-auth`) | ✅ mergée |
| #257 | CSP : style des toasts restauré (violations console conservées, délibérément) | ⏳ ouverte |

---

## 2. Bug 1 — sélecteur de langue qui repasse en anglais

**Cause racine établie**, **correctif non livré**, **décision d'architecture en attente de @thierry**.

`next-intl` réécrit `NEXT_LOCALE` dès que le locale résolu par l'URL diffère du cookie, et
le préfixe d'URL gagne toujours. Toute requête `/en…` alors que le cookie vaut `fr-BE`
rebascule le cookie en `en` pour un an. C'est une **course** : la requête `/en…` encore en
vol se termine après le `Set-Cookie` de la Server Action. Asymétrique par construction —
le sens FR→EN est immunisé (URLs non préfixées), d'où « ça revient toujours à l'anglais ».

### Le résultat négatif, qui est le vrai livrable

Trois correctifs middleware construits et mesurés sur build de production, **trois échecs
structurels** :

| Signal | Mesure | Verdict |
| ---------------------------------------------- | ------------------------------------------------------ | ------------------------------------- |
| `rsc` / `next-router-prefetch` / `sec-purpose` | envoyés par le navigateur, absents côté middleware | Next les retire avant |
| `accept: text/x-component` | un vrai prefetch envoie `accept=(none)` | ne matche que les `fetch()` fabriqués |
| `?_rsc=<hash>` | `curl "/en?_rsc=probe"` → `nextUrl.search = "(empty)"` | Next retire aussi le paramètre |

**Next canonise les requêtes RSC avant le middleware.** Un prefetch et une navigation
document sont indiscernables depuis `proxy.ts`. Aucun correctif à cette couche ne peut
fonctionner — ni le nôtre, ni celui de `next-intl`. Ne pas retenter cette voie.

**Piège d'ordonnancement à conserver** : toute manipulation brute de l'en-tête `set-cookie`
dans `proxy.ts` doit être la **dernière** opération, après `updateSession` — `ResponseCookies`
re-sérialise le jar entier à chaque `.set()` et restaure ce qu'on vient de retirer.

### Décision attendue de @thierry

- **Option A (recommandée)** — `localeCookie: false` : `syncCookie` devient inerte, la
course disparaît par construction. Coût : une arrivée sur `/` nu ne mémorise plus une
langue qui contredit `Accept-Language`. PR dédiée + `plan-reviewer`.
- **Option B** — navigation dure sur changement de langue : atténue sans supprimer la
cause, non prouvable par un test. Jamais seule.

Diagnostic complet : `docs/audits/2026-07-25-locale-cookie-race-diagnostic.md`.
Le test de non-régression est dans la suite en `test.fixme` — il décrit le défaut tant
qu'il n'est pas clos.

---

## 3. Bug 2 — violations CSP `inline-style` (PR #257)

Cause : `sonner` insère un `<style>` **vide** puis le remplit → un élément, deux
évaluations CSP, deux violations sur chaque page. Les deux hashes prod ont été confirmés
par re-calcul depuis le paquet installé.

**Ce que ça cachait** : le CSS bloqué, `[data-sonner-toaster]{position:fixed}` ne
s'appliquait pas → **les toasts étaient mal positionnés en production**.

### Deux hypothèses à moi, deux réfutations par les agents

1. `plan-reviewer` a réfuté ma piste **Radix / `get-nonce`** : ce singleton-là remplit son
`<style>` avant de l'insérer, il ne peut donc pas produire un hash de chaîne vide. Il a
désigné `sonner` — confirmé ensuite par re-calcul, match exact sur les deux hashes.
2. `security-auditor` a posé un **NO-GO** sur ma première version, qui épinglait les deux
hashes dans `style-src` pour faire taire la console. Motif : autoriser un `<style>`, ce
n'est pas le tolérer, c'est **l'appliquer**. La copie injectée est _non-layered_, donc
elle l'emporte sur toute couche — les hashes annulaient exactement la protection
`@layer` du même diff. Et comme sonner utilise `theme: 'light'` par défaut, son
`--normal-bg: #fff` aurait gagné sur `bg-card` → **toast fond blanc en thème sombre**.

### Ce qui est livré : option (a)

Import layered uniquement, pas de hash. Arbitrage assumé : deux avertissements console
cosmétiques valent mieux qu'une régression visuelle en sombre et qu'un CSS tiers exécuté au
sommet de notre cascade. Bénéfice secondaire : une future version compromise de sonner
resterait bloquée par la CSP.

| | violations | positionnement | fond du toast (sombre) |
| --------------------------- | -------------- | -------------- | ---------------------- |
| Avant (production actuelle) | 2 | `static` ✗ | `rgb(17, 26, 46)` |
| Après (build prod local) | 2 _(assumées)_ | `fixed` ✓ | `rgb(17, 26, 46)` ✓ |

La décision est documentée dans `src/app/globals.css` et verrouillée par une assertion E2E
qui échoue si un hash réapparaît dans `style-src`.

**⚠️ À signaler à @thierry** : les 2 violations console qu'il avait relevées **restent
visibles**. C'est délibéré et documenté — les supprimer casserait les toasts en thème
sombre. Le vrai défaut (positionnement) est corrigé.

## 4. Reste à faire

1. **#257** — finir la DoD5 (CI, Sourcery, threads, `CLEAN`) puis merger
2. **Bug 1** — @thierry tranche entre Option A et Option B, puis PR dédiée + `plan-reviewer`
3. **Radix / `get-nonce`** — bug réel mais **non mesuré** : `react-style-singleton` n'a
toujours pas de nonce. Nécessite `get-nonce` en dépendance explicite (`package.json`,
voie lourde) et un `setNonce()` en corps de render — jamais dans un `useEffect`, les
effects remontant des enfants vers les parents. Mesurer d'abord.
4. **Symptôme 2 (reconnexion fantôme)** — cause **non établie**. Ce n'est pas une perte de
session (Supabase : `sessions_timebox=0`, sessions vivantes 47 j, 0 rotation
concurrente). Investigation séparée, surtout pas dans la même PR.
5. **Refonte UX** — phases 1c (nav mobile) et 1d (consolidation ui↔atoms) non entamées

---

## 5. Leçons de la session

**« Un test vert contre une forme de requête inventée ne prouve rien. »** Le premier
correctif de la locale avait des tests unitaires verts et ne changeait rien dans le
navigateur : les mocks fabriquaient un en-tête que Next ne transmet jamais. La règle
appliquée depuis : mesurer le signal réel **avant** d'écrire le prédicat, jamais l'inverse.

**Le sous-agent qui contredit a plus de valeur que celui qui valide.** Deux fois sur ce
seul bug CSP : `plan-reviewer` a réfuté mon diagnostic (preuve tirée de `node_modules`),
puis `security-auditor` a posé un NO-GO sur mon correctif en montrant que ses deux moitiés
se contredisaient. Sans eux je livrais une régression visuelle en thème sombre.

**Ne pas livrer un troisième correctif non prouvé sur un bug déjà mal corrigé deux fois.**
Reverter et documenter le cul-de-sac vaut mieux qu'un pari. Le résultat négatif mesuré est
un livrable à part entière.
145 changes: 145 additions & 0 deletions docs/prs/PR-csp-sonner-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# PR — CSP `style-src` : violations `inline-style` de sonner

**Date** : 25 juillet 2026
**Auteur** : @cc-ankora
**Branche** : `fix/csp-sonner-inline-style`
**Revue de plan** : `plan-reviewer` — 🔴 REJECTED puis 🟡 APPROVED WITH CHANGES (7 corrections appliquées)
**Audit sécurité** : `security-auditor` — 🔴 NO-GO sur la 1re version, levé par l'option (a) ci-dessous

---

## 1. Symptôme

Console de production (relevé @thierry) : deux violations CSP `inline-style` par page,
émises depuis notre propre bundle.

```
sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
sha256-CIxDM5jnsGiKqXs2v7NKCY5MzdR9gu6TtiMJrDw29AY=
```

## 2. Diagnostic — première hypothèse réfutée

Ma première analyse désignait `@radix-ui/react-dialog` → `react-remove-scroll` →
`react-style-singleton` → `get-nonce`, ce dernier retournant `undefined` faute de
`setNonce()`.

`plan-reviewer` l'a **rejetée**, preuve à l'appui : `react-style-singleton` remplit son
`<style>` **avant** de l'insérer, il ne peut donc jamais produire un hash de chaîne vide.
Il a désigné `sonner`, qui fait exactement l'inverse.

## 3. Cause racine (mesurée)

`node_modules/sonner/dist/index.mjs` :

```js
head.appendChild(style); // inséré VIDE → check CSP #1
style.appendChild(document.createTextNode(code)); // rempli APRÈS → check CSP #2
```

Un seul élément, deux évaluations CSP, donc exactement deux violations. `__insertCSS(...)`
est appelé au **scope module**, à l'évaluation du chunk — aucun rendu n'a encore eu lieu.
`<Toaster />` étant monté sans condition dans `src/app/[locale]/layout.tsx`, les violations
tombent sur **chaque page**.

Confirmation par re-calcul des hashes depuis le paquet installé :

```
littéral __insertCSS (14 859 car.) → sha256-CIxDM5jnsGiKqXs2v7NKCY5MzdR9gu6TtiMJrDw29AY= ← MATCH
chaîne vide → sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= ← MATCH
```

`sonner` n'expose aucune API de nonce (zéro occurrence de « nonce » dans le paquet).

### Conséquence non anticipée : un bug utilisateur réel

Le CSS de sonner étant bloqué, `[data-sonner-toaster]{position:fixed}` ne s'appliquait pas.
**Les toasts étaient mal positionnés en production**, pas seulement bruyants en console.
Les `classNames` Tailwind de `src/components/ui/toast.tsx` ne stylent que la surface du
toast, jamais son conteneur.

## 4. Correctif

| Fichier | Changement |
| ------------------------------ | ------------------------------------------------------------------------------ |
| `src/app/globals.css` | `@import 'sonner/dist/styles.css' layer(sonner);` + ordre de couches explicite |
| `e2e/security-headers.spec.ts` | assertion verrouillant l'absence de hash tiers dans `style-src` |

Un seul changement fonctionnel : la feuille de style arrive par une source `'self'`, donc
autorisée, donc les toasts retrouvent leur positionnement.

### Ce que la première version faisait en plus, et pourquoi c'est retiré

La première version épinglait aussi les deux hashes SHA-256 dans `style-src` pour faire
taire les violations console. **`security-auditor` a posé un NO-GO, à raison** : les deux
moitiés du diff se contredisaient.

Autoriser un `<style>`, ce n'est pas seulement le tolérer — c'est **l'appliquer**. Or la
copie injectée par sonner est _non-layered_, et du CSS non-layered l'emporte sur n'importe
quelle couche quelle que soit la spécificité. Les hashes auraient donc annulé exactement la
protection que le `@layer` du même diff venait d'installer. Aggravant mesuré par l'audit :
sonner utilise `theme: 'light'` par défaut et `<Toaster />` ne passe pas de `theme`, donc
`--normal-bg: #fff` aurait gagné sur `bg-card` → **toast à fond blanc en thème sombre**, et
perte du code couleur de sévérité (`border-danger`, `border-success`…).

Bénéfice secondaire, relevé par l'audit : sans épinglage, le CSS runtime d'une future
version compromise de sonner reste **bloqué par la CSP** ; seule la copie vendored, layered
et visible dans un diff de lockfile, s'applique.

**Arbitrage assumé** : deux avertissements console cosmétiques valent mieux qu'une
régression visuelle en thème sombre et qu'un CSS tiers exécuté au sommet de notre cascade.
La décision est documentée à l'endroit où elle sera relue (`src/app/globals.css`) et
verrouillée par une assertion E2E qui échoue si un hash réapparaît dans `style-src`.

### Points de vigilance traités

- **Couche cascade** — `sonner/dist/styles.css` est livré _non-layered_. D'où
`@layer theme, base, sonner, components, utilities;` déclaré **avant** les imports :
sonner garde son positionnement, nos utilitaires gardent le dernier mot.
- **`package.json` inchangé** — `sonner` est déjà une dépendance.
- **Pas de `'unsafe-hashes'`, pas de `'unsafe-inline'`** — le repo style exclusivement par
classes.

## 5. Preuve avant / après

Sonde Playwright : écoute de `securitypolicyviolation` posée via `addInitScript` (donc
active dès l'évaluation du chunk), puis mesure de la `position` calculée du conteneur et de
la couleur de fond d'un toast, **dans les deux thèmes**.

| | violations `style-src` | positionnement | fond du toast (sombre) |
| ------------------------------------- | ---------------------- | -------------- | ---------------------- |
| **Avant** — production actuelle | 2 | `static` ✗ | `rgb(17, 26, 46)` |
| **Après** — build de production local | 2 _(assumées)_ | `fixed` ✓ | `rgb(17, 26, 46)` ✓ |

Le défaut utilisateur est corrigé et nos tokens de thème sont préservés dans les deux modes
— ce qui n'aurait pas été le cas avec les hashes (fond blanc attendu en sombre).

## 6. Verrou anti-régression

`e2e/security-headers.spec.ts` échoue si un hash (`'sha256-…'`, `'sha384-…'`, `'sha512-…'`)
réapparaît dans `style-src`, ou si `'unsafe-hashes'` y est introduit. C'est la seule
protection nécessaire une fois l'option (a) retenue : sans hash épinglé, il n'y a plus de
constante susceptible de dériver lors d'une montée de version de sonner.

Le garde-fou anti-dérive de la première version (ré-extraction + re-hash du littéral depuis
`node_modules`) a été retiré avec les hashes qu'il protégeait. `security-auditor` avait par
ailleurs relevé que son message d'échec — « update SONNER_STYLE_HASH » — invitait à
re-hasher mécaniquement un CSS potentiellement hostile.

## 7. Hors scope, à replanifier

`react-style-singleton` (Radix Dialog/Sheet) n'a toujours pas de nonce : c'est un bug réel
mais **non mesuré**, distinct de celui-ci. Il nécessite `get-nonce` en dépendance explicite
(donc `package.json`, voie lourde) et un `setNonce()` appelé en corps de render — jamais
dans un `useEffect`, les effects remontant des enfants vers les parents. À planifier après
mesure, pas avant.

## 8. Definition of DONE

| # | Critère | Preuve |
| --- | ----------------------------------- | -------------------------------------- |
| 1 | CI verte | cf. checks de la PR |
| 2 | Sourcery muet sur le dernier commit | `gh api …/comments` → sortie vide |
| 3 | Threads de review résolus | GraphQL `reviewThreads` → 0 non résolu |
| 4 | Pas de conflit avec `main` | `mergeStateStatus: CLEAN` |
| 5 | Rapport livré | ce fichier |
18 changes: 18 additions & 0 deletions e2e/security-headers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ test.describe('Security headers', () => {
expect(csp).toContain("object-src 'none'");
});

test('style-src allow-lists no third-party style hash', async ({ page }) => {
// Deliberate absence, not an oversight. Allow-listing the hash of the
// <style> sonner injects at runtime would silence its two console
// violations, but allowing a <style> also APPLIES it — and that copy is
// unlayered, so it would outrank the layered `@import` in globals.css and
// every Tailwind utility, painting a white toast in dark mode. The
// stylesheet is served from 'self' instead. Cf. docs/prs/PR-csp-sonner-report.md.
const response = await page.goto('/');
const csp = response?.headers()['content-security-policy'] ?? '';
const styleSrc = csp.split(';').find((d) => d.trim().startsWith('style-src')) ?? '';

expect(styleSrc, 'style-src directive present').toBeTruthy();
expect(styleSrc, 'no hash source may creep into style-src').not.toMatch(/'sha\d{3}-/);
// `'unsafe-hashes'` only concerns inline `style=` attributes and must never
// be needed here — the repo styles exclusively through classes.
expect(styleSrc).not.toContain("'unsafe-hashes'");
});

test('baseline security headers applied', async ({ page }) => {
const response = await page.goto('/');
const h = response?.headers() ?? {};
Expand Down
2 changes: 1 addition & 1 deletion public/llms-full.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ankora.be — Full content export for LLMs

Last generated: 2026-05-28
Last generated: 2026-07-25
Canonical URL: https://ankora.be
License: content available for citation with attribution. Code is proprietary.

Expand Down
30 changes: 30 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
/* Cascade layer order — MUST precede the imports below.
`sonner` sits between `base` and `components` on purpose. Its stylesheet
ships UNLAYERED, and unlayered CSS beats layered CSS whatever the
specificity, so importing it as-is would override the Tailwind utilities in
`@layer utilities` that style our toasts (`bg-card`, `border-border`,
`border-danger`… in `src/components/ui/toast.tsx`) — in both themes.
Pulling it into a named layer declared BEFORE `utilities` gives our
utilities the last word while keeping sonner's positioning rules
(`[data-sonner-toaster]{position:fixed}`) intact. */
@layer theme, base, sonner, components, utilities;

@import 'tailwindcss';

/* Toast styling served as a `'self'` stylesheet instead of relying on the
runtime `<style>` that sonner injects at module evaluation time. That
injection is blocked by our strict CSP, which is why the toasts had no
positioning at all in production (`[data-sonner-toaster]` computed to
`position: static` on the live site, 2026-07-25).

We deliberately do NOT allow-list the two SHA-256 hashes of that runtime
`<style>` in `style-src`, even though it would silence the two console
violations it emits on every page. Allowing a `<style>` also APPLIES it, and
the injected copy is unlayered — it would therefore outrank this layered
import and every Tailwind utility, undoing the whole point of the layer
above. Concretely, sonner defaults to `theme: 'light'`, so its
`--normal-bg: #fff` would win over `bg-card` and paint a white toast in dark
mode. Two cosmetic console warnings are the cheaper defect. It also keeps a
third-party stylesheet from executing at the top of our cascade if a future
sonner release were ever compromised. Cf.
`docs/prs/PR-csp-sonner-report.md`. */
@import 'sonner/dist/styles.css' layer(sonner);

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

/* ---------- Variable fonts — self-hosted (WOFF2 only) ----------
Expand Down
Loading