@zevra/retours
v0.3.0
Published
Retours des utilisateurs d'une app Zevra : bouton, page d'aide et relais /api/retours vers l'admin.
Readme
@zevra/retours — les retours des utilisateurs d'une app Zevra
Un bouton (« Aide ») qui ouvre un tiroir à deux onglets, Signaler et
Mes retours, la même chose dans une page d'aide, et le relais serveur
/api/retours/* qui parle à l'admin Zevra.
L'app ne connaît que l'admin. C'est l'admin qui choisit, à l'arrivée de chaque retour, où il part : au guichet Support pour une app en exploitation, au board Projet de la mission dans le cockpit pour une app en réalisation. Rien dans ce paquet ne le sait, et rien ne l'affiche.
La clé ne va jamais dans un navigateur. Le relais (
@zevra/retours/serveur) la détient et refuse de se charger dans un navigateur ; les composants (@zevra/retours) n'appellent que/api/retours/*sur le domaine de l'app.
Contrat complet : docs/retours.md du dépôt zevra-admin.
Installation
pnpm add @zevra/retoursLe paquet est publié en source TypeScript (comme @zevra/support-widget).
Dans next.config.ts :
const config: NextConfig = {
transpilePackages: ['@zevra/retours'],
}⚠️ Sans cette ligne, le build échoue : Next ne compile pas le TypeScript d'un
paquet de node_modules de lui-même.
Variables
Posées par l'admin dans les pods de l'app quand on allume les retours pour un environnement (Espace client → Retours). Rien à créer à la main.
| Variable | Valeur |
|---|---|
| RETOURS_URL | l'URL interne de l'admin (http://zevra-admin.zevra-admin.svc.cluster.local) |
| RETOURS_API_KEY | zr_<env>_…, propre à l'app et à l'environnement |
| RETOURS_APP | le slug de l'app |
Le relais est actif quand les trois variables sont présentes (non
vides) ; sinon GET /api/retours/etat répond { actif: false },
retoursActifs() rend false, et le bouton ne s'affiche pas. RETOURS_APP
compte autant que les deux autres : le slug fait partie du chemin de chaque
appel, et un pod qui aurait l'URL et la clé sans lui (pose interrompue)
afficherait un bouton dont chaque envoi échoue. Éteindre un environnement depuis l'admin retire les
variables et relance le pod : le bouton disparaît.
La pose, en trois fichiers
1. Le relais — app/api/retours/[...chemin]/route.ts
import { creerRoutesRetours } from '@zevra/retours/serveur'
import { getSession } from '@/lib/session'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const routes = creerRoutesRetours({
// ⚠️ L'identité vient de VOTRE session, jamais du corps de la requête —
// et l'adresse rendue doit être VÉRIFIÉE par l'app (voir plus bas).
identifier: async () => {
const session = await getSession()
const utilisateur = session?.user
if (!utilisateur?.email || !utilisateur.emailVerified) return null
return { email: utilisateur.email, nom: utilisateur.name ?? null, idExterne: utilisateur.id ?? null }
},
// Facultatif : vos échecs (non configuré, admin injoignable, refus…).
journaliser: (e) => console.warn('[retours/relais]', e),
})
export const GET = routes.GET
export const POST = routes.POSTCes deux lignes compilent telles quelles sous next build, Next 15 comme
Next 16, turbopack comme --webpack : le second argument des gestionnaires
est déclaré { params: Promise<{ chemin?: string[] }> }, la seule forme que
les deux contrôles de types de Next acceptent. ⚠️ Gardez le nom du dossier,
[...chemin] : le nom du segment fait partie de ce type.
⚠️ identifier doit rendre une adresse VÉRIFIÉE par l'app (lien de
confirmation, SSO, invitation). Le relais écrit et lit les retours AU NOM de
cette adresse : une app qui ouvre ses comptes sans vérifier l'adresse laisse
n'importe qui s'inscrire sous celle d'un autre — un client, un membre de
l'équipe — puis écrire en son nom, et lire ses retours et ses échanges avec
l'équipe dans « Mes retours ». Une session dont l'adresse n'est pas vérifiée
rend null (401 non_connecte) ; c'est à l'app de le décider, le paquet ne
voit que ce que identifier lui rend.
| Chemin | Méthode | Ce qui se passe |
|---|---|---|
| etat | GET | { actif } d'après les trois variables, sans identité, sans appel à l'admin |
| retours | POST | crée un retour ; auteur = la session ; Idempotency-Key relayée |
| retours | GET | les retours de la personne connectée (cet environnement seulement) |
| retours/<uuid> | GET | un retour et son fil |
| retours/<uuid>/messages | POST | répondre dans le fil (4000 caractères au plus) ; Idempotency-Key relayée |
Réponses : celles de l'admin, reprojetées sur les champs du contrat ; ses
erreurs passent telles quelles ({ erreur: { code, message, champs? } },
Retry-After compris). Toutes les réponses portent Cache-Control: no-store.
Les refus propres au relais :
| Code | Statut | Quand |
|---|---|---|
| non_connecte | 401 | identifier rend null (ou jette) |
| origine_refusee | 403 | POST dont Sec-Fetch-Site n'est pas same-origin (site frère, autre origine) |
| introuvable | 404 | chemin inconnu du relais |
| methode_refusee | 405 | mauvaise méthode pour ce chemin (Allow donné) |
| format_refuse | 415 | POST dont le corps n'est pas annoncé Content-Type: application/json |
| validation | 422 | Idempotency-Key mal formée ; corps de création de plus de 21 Mio (pieces) ; réponse vide ou de plus de 4000 caractères, ou corps de réponse de plus de 64 Kio (corps) |
| non_configure | 503 | variables absentes ou mal formées |
| admin_injoignable | 503 | l'admin ne répond pas (réseau) |
| delai | 504 | délai dépassé : 90 s pour une création, 10 s sinon |
| interne | statut de l’admin, ou 502 | réponse de l’admin hors contrat (corps illisible : son statut est gardé ; succès de forme inattendue : 502) |
Le corps d'un POST est lu en flux et coupé à la borne, Content-Length ou
pas : un envoi par morceaux de plusieurs Go n'est jamais lu en entier avant le
refus (mesuré : 200 Mo envoyés, ~23 Mo lus avant la 422).
⚠️ Un POST doit venir de la page de l'app, en JSON. Les cookies de session
sont en SameSite=Lax, ce qui n'arrête pas un site frère : toute autre app
sous *.zevra.tech pourrait, par un simple <form enctype="text/plain">,
poster un retour au nom de la personne connectée. Le relais refuse donc tout
POST dont Sec-Fetch-Site n'est pas same-origin, ou dont le Content-Type
n'est pas application/json (qu'un formulaire ne sait pas envoyer). Les
composants du paquet le font déjà ; un client écrit à la main doit faire de
même. Il n'y a pas de comparaison Origin/Host : derrière un proxy qui
réécrit Host, elle refuserait les envois légitimes.
⚠️ Si l'app a un proxy (src/proxy.ts) ou une liste de chemins ouverts sans
abonnement, /api/retours doit y figurer comme /api/support — sinon le
bouton disparaît le jour où l'interrupteur s'allume.
2. Le bouton — dans le layout des pages connectées
BoutonRetours est un composant client : un layout serveur le pose
directement, avec des props sérialisables.
// app/(app)/layout.tsx — composant serveur, sous la session
import { BoutonRetours } from '@zevra/retours'
import { retoursActifs } from '@zevra/retours/serveur'
// … dans le rendu :
<BoutonRetours
// Décidé au serveur : pas d'attente de `GET /api/retours/etat`. Sans cette
// prop, le bouton pose la question lui-même au montage.
actif={retoursActifs()}
libelle="Aide"
position="bas-droite"
discret
version={process.env.NEXT_PUBLIC_VERSION}
/>⚠️ actif={retoursActifs()} seulement dans un layout rendu à la requête
(il lit la session : c'est le cas d'un layout de pages connectées). Dans un
rendu statique, la valeur serait figée AU BUILD — où aucune variable
RETOURS_* n'existe — et le bouton ne s'afficherait jamais. Dans le doute,
omettez la prop : le bouton demande au relais.
⚠️ contexteSupplementaire, onOuvrir et onEnvoye sont des fonctions : elles
ne traversent pas la frontière serveur → client. Pour les passer, enveloppez le
bouton dans un petit composant 'use client' de l'app.
Props de BoutonRetours : base (défaut /api/retours), libelle, titre,
position (bas-droite | bas-gauche | haut-droite | haut-gauche |
haut-centre | bas-centre), decalage, discret, marque, version,
captureOfferte (défaut oui : le clic FIGE l'écran, voir plus bas), contexteSupplementaire, onOuvrir,
onEnvoye(reference), actif.
PageRetours pose le même contenu dans une page (menu du compte → Aide) :
écrire, suivre, lire le fil, répondre. Props : base, titre, version,
captureOfferte (défaut non : sur la page d'aide, « toute la page » serait
une capture de la page d'aide), contexteSupplementaire, onEnvoye, actif.
Les deux interrogent GET {base}/etat au montage et ne rendent rien tant
que le relais n'a pas répondu actif: true — sauf si la prop actif est
fournie, qui tranche dans les deux sens.
Ce que les composants garantissent, et que l'app n'a pas à refaire :
- Rendus au serveur sans erreur d'hydratation (
actifforcé) : rien de ce qui dépend dewindowou dedocumentn'est lu pendant le rendu ; le contexte technique est relevé à l'envoi (et à l'ouverture du repli « Ce qui sera joint »). - La feuille de style arrive avec le rendu, pas après : une balise
<style href precedence>que React 19 monte dans le<head>(dès le HTML du serveur) et ne pose qu'une fois, même avec le bouton ET la page sur l'écran. Sous React 18, elle se rend en place, une par composant. - Le brouillon survit à un changement d'onglet et à la fermeture du tiroir (texte, capture, envoi en cours ou en échec avec sa clé d'idempotence) ; il n'est vidé que par un envoi réussi.
- Une capture en cours se voit (« Capture en cours… ») : ses boutons et « Envoyer » sont désactivés le temps qu'elle finisse.
- Le clic sur le bouton fige l'écran (≥ 0.3.0, #415) : il n'atteint pas
l'app (écouteur de la fenêtre en capture : ni
pointerdown, nimousedown, ni focus), donc une liste déroulante ouverte reste ouverte, et la fenêtre visible — défilement compris — est photographiée avant que le tiroir ne prenne le focus. L'image est jointe d'office (Retirer, Recadrer sur l'image figée). ⚠️ Une liste NATIVE (<select>) est dessinée par le système : aucune capture web ne la voit ouverte, et le navigateur la ferme avant que la page ne reçoive le clic — c'est la zone de dépôt qui y répond. - Des images à la main : glissées dans le tiroir, collées dans le champ (⌘ V) ou choisies. Trois images au plus par retour (borne de l'admin) ; une image trop lourde ou hors PNG/JPEG/WebP est redessinée en JPEG (2560 px au plus) ; le nom du fichier ne part pas.
- Le tiroir est une vraie modale au clavier : Tab et Maj+Tab restent dans le panneau, Échap ferme, et le focus revient à l'élément qui l'avait à l'ouverture (le bouton, à défaut).
3. Le tampon d'erreurs — le plus tôt possible
'use client'
import { useEffect } from 'react'
import { installerTamponErreurs } from '@zevra/retours'
export function TamponErreurs() {
useEffect(() => installerTamponErreurs(), [])
return null
}À poser dans la coquille de l'app (layout racine), pas au montage du bouton : posé au montage, il manque les erreurs du démarrage — celles qu'on signale. Le bouton le repose en filet de sécurité ; la pose est idempotente.
Pendant une capture d'écran, le tampon cesse d'enregistrer console.error et
console.warn : html-to-image y écrit ses propres échecs (feuilles de style
d'une autre origine, polices), qui partaient avec le retour et chassaient les
vraies erreurs du tampon (vingt lignes, circulaire). La console de l'app les
reçoit toujours ; les exceptions et rejets non rattrapés restent enregistrés.
Ce qui part avec un retour
- Une phrase (3 à 5000 caractères) — le titre est posé par l'admin.
- Jusqu'à trois images : l'écran figé au clic, une zone tracée ou la page
entière (
html-to-image, chargé au clic), des fichiers glissés ou collés — 3 Mo au plus chacune, envoyées en base64 dans le corps : pas de dépôt présigné, pas de règle CORS à poser. - Le contexte technique, relevé à l'envoi : URL nettoyée (les valeurs
des paramètres sont retirées, leurs noms gardés :
?jeton=abc&page=2devient?jeton&page; le fragment part entièrement), navigateur, système, taille de fenêtre, langue, version, les 20 dernières erreurs de console, et ce quecontexteSupplementaireajoute (20 clés au plus, nommées de 1 à 100 caractères, valeurs scalaires de 500 caractères au plus ; une clé hors bornes est écartée, pas coupée). - L'auteur, tiré de la session par
identifier: l'adresse, le nom (ramené à 200 caractères) et l'identifiant externe (omis s'il dépasse 200 caractères — un identifiant coupé désignerait quelqu'un d'autre ; l'adresse reste l'identité). Au-delà de ces bornes, l'admin refuserait TOUT le retour en 422, avec un message que la personne ne peut pas corriger. - Une
Idempotency-Keypar envoi : réessayer le même envoi (même texte, même capture) rejoue la même clé et le même corps, et l'admin rend le retour déjà créé au lieu d'en faire un second. Une réponse dans le fil (4000 caractères au plus — le cockpit coupe au-delà) porte aussi la sienne, la même au réessai : l'admin la rejoue au guichet au lieu de doubler le message (le cockpit n'en a pas encore : un réessai après un délai dépassé peut y doubler une réponse).
⚠️ Pour une app en réalisation, le contexte finit dans une tâche que le client voit dans son board. N'y mettez rien qu'il n'ait vu lui-même.
Le lien vers l'espace client (0.2.0)
« Mes retours » affiche « Suivre dans votre espace client ↗ », et le fil
d'un retour arrivé au board Projet « Voir dans votre espace client ↗ »
(sa tâche, ?tache=BSP-12), dans un nouvel onglet. Rien à poser dans l'app :
l'admin Zevra n'offre ces liens qu'à une adresse qui peut ouvrir l'espace
(compte lié à l'app, ou équipe Zevra, portail activé) ; pour les autres, rien
ne s'affiche. Le relais revérifie la forme (/espace/<RETOURS_APP> en https)
avant de laisser passer quoi que ce soit.
Ce qu'il ne faut pas faire
- ⛔ Aucune variable
NEXT_PUBLIC_RETOURS_*. La clé dans un bundle se lit dans l'onglet Réseau de n'importe quel visiteur. Si un appel échoue en 401 « pour tester », la réponse n'est jamais de renommer la variable. - ⛔ Ne lisez pas l'identité dans le corps de la requête (« l'adresse est
dans le formulaire »). Le relais ignore
auteuretemailreçus du navigateur ; votreidentifierne doit lire que la session. - ⛔ Ne recopiez pas les retours dans la base de l'app. L'admin est l'index (et le guichet ou le cockpit, la source) ; une copie locale divergerait au premier changement de statut et afficherait une histoire que l'équipe ne voit pas.
- ⛔ N'importez pas
@zevra/retours/serveurdans un composant client : il jette au chargement, exprès. - ⛔ Ne mettez pas
RETOURS_URLsuradmin.zevra.tech: depuis un pod, l'URL publique fait une boucle par le répartiteur (hairpin) qui échoue en silence.
Tests
Le paquet a sa propre chaîne (la racine de zevra-admin exclut packages/) :
cd packages/retours
pnpm install
pnpm typecheck && pnpm testCe qui est couvert : chaque chemin du relais et la liste blanche, la méthode
refusée, l'identité absente (401), le relais non configuré (503), actif qui
exige les trois variables (et GET etat d'accord avec retoursActifs()), le
POST d'un site frère refusé (403 sur Sec-Fetch-Site, 415 sur un corps non
JSON), l'auteur du corps ignoré, l'auteur ramené aux bornes de l'admin, la clé
dans x-retours-key et jamais dans Authorization, Idempotency-Key relayée
sur la création ET sur la réponse dans le fil (jamais sur une lecture), la
réponse bornée à 4000 caractères, le corps lu en flux et coupé à la borne
(envoi par morceaux sans Content-Length compris), Retry-After reposé, les
délais de 90 s et 10 s, no-store partout, la reprojection qui retire tout ce
qui n'est pas au contrat ; les types exportés face aux deux contrôles de
next build (types-next.test.ts, vérifié par pnpm typecheck) ;
retoursActifs ; la validation de saisie aux bornes du contrat ; le nettoyage
d'URL et les bornes du contexte (noms de clés d'extra compris) ; la
géométrie de la zone ; le tampon d'erreurs et sa suspension pendant la
capture ; le décodage de capture ; le rejeu à l'identique d'un envoi et d'une
réponse ; le brouillon (brouillon.ts) et le focus du tiroir (focus.ts) ;
la feuille de style (préfixe zvr-, un repli par token).
Les composants sont aussi testés rendus : au serveur, sous node
(rendu-serveur.test.tsx : même HTML avec et sans window, feuille posée une
fois, dans le <head>), et montés dans un document happy-dom
(composants.test.tsx : brouillon gardé à travers les onglets et le tiroir,
vidé après un envoi réussi seulement ; capture en cours ; piège de focus et
retour du focus ; feuille unique).
Publication
Workflow .github/workflows/retours-paquet.yml de zevra-admin : contrôles à
chaque push ou PR qui touche packages/retours/** ; publication sur npm sur
une étiquette retours-v<version> (qui doit être celle du package.json) ou
par lancement manuel avec « publier » coché.
