@zevra/support 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/erreurs.ts ADDED
@@ -0,0 +1,199 @@
1
+ // ============================================================================
2
+ // Les échecs, typés. PUR.
3
+ // ============================================================================
4
+ //
5
+ // Miroir de `src/lib/reponse-api.ts` côté Support : la liste des codes y est
6
+ // FERMÉE, et ce fichier est la raison pour laquelle elle l'est. Un code que le
7
+ // SDK ne connaît pas tombe dans le `catch` générique de l'app cliente, qui
8
+ // affiche « erreur inconnue » sur un 422 parfaitement explicite.
9
+ //
10
+ // Trois codes de PLUS ici, qui ne viennent jamais du fil : `reseau`, `delai`
11
+ // et `configuration`. Les confondre avec `interne` (« le serveur a un
12
+ // problème ») enverrait chercher une panne chez Support alors que le DNS de
13
+ // l'appelant ne résout pas, ou que personne n'a créé `SUPPORT_API_KEY`.
14
+
15
+ /** Ce que l'API peut répondre — recopié de `STATUT_PAR_CODE` de Support. */
16
+ export const CODES_DU_FIL = [
17
+ 'cle_invalide',
18
+ 'app_inactive',
19
+ 'introuvable',
20
+ 'validation',
21
+ 'conflit',
22
+ 'limite',
23
+ // ⚠️ Ajouté à l'intégration, en même temps que la ligne `non_configure: 503`
24
+ // de `src/lib/reponse-api.ts`. Trois routes le rendent déjà (`POST /pieces`
25
+ // sans S3, `/api/admin/*` sans clé, `/api/cron/*` sans `CRON_SECRET`) : sans
26
+ // lui ici, une réponse parfaitement légitime tombait dans le repli `interne`
27
+ // et l'intégrateur lisait « le serveur a un problème » là où il fallait lire
28
+ // « personne n'a encore créé la variable ». Un 503 ne se réessaie pas en
29
+ // boucle : il attend qu'un humain passe dans l'admin.
30
+ 'non_configure',
31
+ 'interne',
32
+ ] as const
33
+
34
+ /** Ce que seul le client peut conclure. Ne vient JAMAIS d'une réponse HTTP. */
35
+ export const CODES_DU_CLIENT = ['reseau', 'delai', 'configuration', 'cle_exposee'] as const
36
+
37
+ export const CODES_ERREUR = [...CODES_DU_FIL, ...CODES_DU_CLIENT] as const
38
+
39
+ export type CodeErreurSupport = (typeof CODES_ERREUR)[number]
40
+
41
+ /** `true` seulement pour un code que l'API a réellement le droit d'écrire. */
42
+ export function estCodeDuFil(valeur: unknown): valeur is (typeof CODES_DU_FIL)[number] {
43
+ return typeof valeur === 'string' && (CODES_DU_FIL as readonly string[]).includes(valeur)
44
+ }
45
+
46
+ /**
47
+ * Le message générique.
48
+ *
49
+ * ⚠️ Il est CONSTANT. Recopier le corps brut d'un 502 dans le message, c'est
50
+ * afficher la page d'erreur HTML d'un proxy dans l'interface d'un utilisateur.
51
+ * Le brut part dans `detail`, borné, pour la journalisation.
52
+ */
53
+ export const MESSAGE_GENERIQUE = "Le support n'a pas pu traiter cette requête."
54
+
55
+ /** Au-delà, ce n'est plus un indice, c'est une page HTML entière dans un log. */
56
+ export const LONGUEUR_DETAIL_MAX = 200
57
+
58
+ export interface DonneesErreurSupport {
59
+ code: CodeErreurSupport
60
+ message: string
61
+ /** Rempli seulement sur `validation` : un message par champ fautif. */
62
+ champs?: Record<string, string>
63
+ /** Ce qu'on a vraiment reçu, tronqué. Pour le journal, pas pour l'écran. */
64
+ detail?: string
65
+ statut?: number
66
+ /** Sur `limite` : l'attente demandée, déjà convertie. */
67
+ retryApresMs?: number
68
+ }
69
+
70
+ export class ErreurSupport extends Error {
71
+ readonly code: CodeErreurSupport
72
+ readonly champs?: Record<string, string>
73
+ readonly detail?: string
74
+ readonly statut?: number
75
+ readonly retryApresMs?: number
76
+
77
+ constructor(donnees: DonneesErreurSupport) {
78
+ super(donnees.message)
79
+ this.name = 'ErreurSupport'
80
+ this.code = donnees.code
81
+ this.champs = donnees.champs
82
+ this.detail = donnees.detail
83
+ this.statut = donnees.statut
84
+ this.retryApresMs = donnees.retryApresMs
85
+ }
86
+ }
87
+
88
+ export function estErreurSupport(valeur: unknown): valeur is ErreurSupport {
89
+ return valeur instanceof ErreurSupport
90
+ }
91
+
92
+ /** Borne un texte brut : ce qui dépasse est remplacé par une ellipse. */
93
+ export function bornerDetail(brut: unknown): string | undefined {
94
+ if (typeof brut !== 'string') {
95
+ if (brut === undefined || brut === null) return undefined
96
+ try {
97
+ return bornerDetail(JSON.stringify(brut))
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+ const compacte = brut.replace(/\s+/g, ' ').trim()
103
+ if (compacte === '') return undefined
104
+ return compacte.length > LONGUEUR_DETAIL_MAX
105
+ ? `${compacte.slice(0, LONGUEUR_DETAIL_MAX)}…`
106
+ : compacte
107
+ }
108
+
109
+ export interface EchecHttp {
110
+ statut: number
111
+ /** Le corps DÉJÀ décodé si c'était du JSON, sinon le texte brut. */
112
+ corps: unknown
113
+ retryApresMs?: number | null
114
+ }
115
+
116
+ /**
117
+ * Traduit une réponse en échec d'un des codes fermés. PUR.
118
+ *
119
+ * ⚠️ Le cas « on ne peut pas conclure » reste distinct de « c'est faux » :
120
+ * un corps illisible, un code inconnu, un statut inattendu donnent `interne`
121
+ * — pas une invention. Et le brut est conservé dans `detail`, sans quoi un
122
+ * incident se résume à « erreur 500 » et personne ne sait chez qui chercher.
123
+ */
124
+ export function interpreterEchec(echec: EchecHttp): DonneesErreurSupport {
125
+ const enveloppe = lireEnveloppe(echec.corps)
126
+ const retryApresMs =
127
+ typeof echec.retryApresMs === 'number' && Number.isFinite(echec.retryApresMs)
128
+ ? echec.retryApresMs
129
+ : undefined
130
+
131
+ if (enveloppe && estCodeDuFil(enveloppe.code)) {
132
+ return {
133
+ code: enveloppe.code,
134
+ message: enveloppe.message ?? MESSAGE_GENERIQUE,
135
+ champs: enveloppe.code === 'validation' ? enveloppe.champs : undefined,
136
+ statut: echec.statut,
137
+ retryApresMs,
138
+ }
139
+ }
140
+
141
+ // Un code hors liste (une version d'API plus récente, un proxy bavard) : on
142
+ // ne le PROPAGE pas sous son nom — l'appelant ne peut pas s'y brancher, et
143
+ // lui donner l'air officiel ferait croire à un contrat qui n'existe pas.
144
+ return {
145
+ code: codeParDefautPourStatut(echec.statut),
146
+ message: MESSAGE_GENERIQUE,
147
+ detail: bornerDetail(echec.corps),
148
+ statut: echec.statut,
149
+ retryApresMs,
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Le repli quand le corps ne dit rien d'exploitable.
155
+ *
156
+ * On déduit du STATUT, qui vient au moins d'une couche HTTP réelle : un 429
157
+ * posé par un proxy reste une limite, et le réessai doit le savoir même si le
158
+ * corps est une page HTML.
159
+ */
160
+ function codeParDefautPourStatut(statut: number): CodeErreurSupport {
161
+ if (statut === 401) return 'cle_invalide'
162
+ if (statut === 403) return 'app_inactive'
163
+ if (statut === 404) return 'introuvable'
164
+ if (statut === 409) return 'conflit'
165
+ if (statut === 422) return 'validation'
166
+ if (statut === 429) return 'limite'
167
+ // ⚠️ 503 SEULEMENT, pas 502 ni 504 : ces deux-là viennent d'un proxy qui n'a
168
+ // pas joint le service, ce qui est bien une panne. Un 503 de Support, lui,
169
+ // est écrit par Support et dit « capacité non branchée ».
170
+ if (statut === 503) return 'non_configure'
171
+ return 'interne'
172
+ }
173
+
174
+ interface EnveloppeErreur {
175
+ code?: unknown
176
+ message?: string
177
+ champs?: Record<string, string>
178
+ }
179
+
180
+ function lireEnveloppe(corps: unknown): EnveloppeErreur | null {
181
+ if (typeof corps !== 'object' || corps === null) return null
182
+ const erreur = (corps as { erreur?: unknown }).erreur
183
+ if (typeof erreur !== 'object' || erreur === null) return null
184
+ const brut = erreur as Record<string, unknown>
185
+ const champs =
186
+ typeof brut.champs === 'object' && brut.champs !== null
187
+ ? (Object.fromEntries(
188
+ Object.entries(brut.champs as Record<string, unknown>).map(([cle, valeur]) => [
189
+ cle,
190
+ String(valeur),
191
+ ]),
192
+ ) as Record<string, string>)
193
+ : undefined
194
+ return {
195
+ code: brut.code,
196
+ message: typeof brut.message === 'string' ? brut.message : undefined,
197
+ champs,
198
+ }
199
+ }
@@ -0,0 +1,75 @@
1
+ // ============================================================================
2
+ // La garde qui rend l'erreur de clé IMPOSSIBLE, pas seulement déconseillée.
3
+ // ============================================================================
4
+ //
5
+ // POURQUOI. La clé `sk_support_<slug>_<32 hex>` authentifie l'APP ENTIÈRE. Qui
6
+ // la détient peut créer une demande au nom de n'importe quelle adresse, relire
7
+ // tous les fils de l'app, et lire le contexte technique que les utilisateurs y
8
+ // ont déposé. Dans un bundle de navigateur, elle est en clair : l'onglet
9
+ // Réseau la montre, `view-source` la montre, et une extension la lit. Elle ne
10
+ // se révoque qu'en la faisant tourner — c'est-à-dire en cassant l'intégration
11
+ // de l'app en production.
12
+ //
13
+ // CE QUI REND L'ERREUR FACILE. Dans une app Next, `src/` mélange serveur et
14
+ // client. Un composant marqué `'use client'` qui importe le client du SDK
15
+ // « pour tester vite fait » emporte la clé dans le bundle SANS AUCUNE ERREUR :
16
+ // `process.env.SUPPORT_API_KEY` y vaut `undefined` (Next ne fige que les
17
+ // `NEXT_PUBLIC_*`), l'appel échoue en 401, on « répare » en renommant la
18
+ // variable `NEXT_PUBLIC_SUPPORT_API_KEY` — et la clé part en production dans
19
+ // le JavaScript de chaque page. Ce chemin-là a été parcouru par de vrais
20
+ // projets ; il ne commence jamais par une mauvaise intention.
21
+ //
22
+ // CE QU'ON FAIT. Le module qui LIT la clé appelle `exigerServeur()`. Dans un
23
+ // navigateur, il jette immédiatement, au chargement, avec la marche à suivre —
24
+ // au lieu d'échouer plus tard, en 401, avec un message qui ne dit rien.
25
+ //
26
+ // ⚠️ `import 'server-only'` (le paquet de Next) ferait la même garde À LA
27
+ // COMPILATION, ce qui est mieux : l'erreur arrive au build, pas à l'exécution.
28
+ // Il n'est PAS employé ici parce que ce paquet ne doit avoir aucune dépendance
29
+ // (il doit s'installer dans une app Node, Bun ou edge qui n'a pas Next). Les
30
+ // apps Next qui le veulent peuvent ajouter la ligne dans LEUR fichier de
31
+ // relais : les deux gardes se complètent, elles ne se remplacent pas.
32
+
33
+ import { ErreurSupport } from './erreurs'
34
+
35
+ /** La portée globale, réduite à ce qu'on observe. Injectable : c'est ce qui
36
+ * rend la décision testable sans monter un navigateur. */
37
+ export interface PorteeObservee {
38
+ window?: unknown
39
+ document?: unknown
40
+ }
41
+
42
+ /**
43
+ * « Sommes-nous dans un navigateur ? » PUR.
44
+ *
45
+ * On exige les DEUX (`window` ET `document`) : un worker de service a un
46
+ * `self` mais pas de `document`, jsdom a les deux, Node n'a ni l'un ni
47
+ * l'autre. Se contenter de `window` refuserait des runtimes serveur légitimes
48
+ * qui en définissent un vide.
49
+ */
50
+ export function estNavigateur(portee: PorteeObservee | undefined | null): boolean {
51
+ if (!portee) return false
52
+ return (
53
+ typeof portee.window === 'object' &&
54
+ portee.window !== null &&
55
+ typeof portee.document === 'object' &&
56
+ portee.document !== null
57
+ )
58
+ }
59
+
60
+ export const MESSAGE_CLE_EXPOSEE =
61
+ "@zevra/support est un client SERVEUR : il détient la clé sk_support_… et " +
62
+ 'ne doit jamais être importé dans du code de navigateur. Depuis un composant ' +
63
+ "client, appelez les routes /api/support/* de votre app (creerRoutesSupport), " +
64
+ 'jamais le support directement.'
65
+
66
+ /**
67
+ * Jette si on est dans un navigateur. Appelée par tout module qui lit la clé.
68
+ *
69
+ * Le défaut lit `globalThis`, ce qui la rend impure — d'où la séparation :
70
+ * la DÉCISION est `estNavigateur`, cette fonction n'est que son bras armé.
71
+ */
72
+ export function exigerServeur(portee: PorteeObservee = globalThis as PorteeObservee): void {
73
+ if (!estNavigateur(portee)) return
74
+ throw new ErreurSupport({ code: 'cle_exposee', message: MESSAGE_CLE_EXPOSEE })
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,117 @@
1
+ // ============================================================================
2
+ // @zevra/support — le client SERVEUR de Zevra Support.
3
+ // ============================================================================
4
+ //
5
+ // ⚠️ CE TONNEAU N'EST PAS IMPORTABLE DEPUIS UN NAVIGATEUR, et c'est délibéré :
6
+ // `creerClientSupport()` appelle `exigerServeur()` dès sa première ligne et
7
+ // jette si `window` et `document` existent. Voir `garde-serveur.ts` pour le
8
+ // raisonnement complet — en deux mots : la clé `sk_support_…` authentifie
9
+ // l'app ENTIÈRE, elle est lisible en clair dans un bundle, et le chemin qui
10
+ // l'y amène ne commence jamais par une mauvaise intention.
11
+ //
12
+ // Ce qu'un composant client doit importer : RIEN d'ici. Il appelle les routes
13
+ // `/api/support/*` que `creerRoutesSupport()` installe dans l'app, ou il pose
14
+ // `<WidgetSupport />` de `@zevra/support-widget`, qui ne fait que ça.
15
+ //
16
+ // ⚠️ Rien de ce qui est exporté ci-dessous ne PREND une clé en paramètre
17
+ // depuis l'extérieur, sauf `creerClientSupport` — qui est aussi le seul à
18
+ // porter la garde. Cette symétrie est le contrat : si un jour une fonction
19
+ // exportée accepte une clé sans passer par le client, elle doit appeler
20
+ // `exigerServeur()` elle-même.
21
+
22
+ export { creerClientSupport } from './client'
23
+ export type {
24
+ ClientSupport,
25
+ EvenementClient,
26
+ OptionsAppel,
27
+ OptionsClientSupport,
28
+ } from './client'
29
+
30
+ export { lireConfiguration, slugDeCle, PREFIXE_CLE } from './configuration'
31
+ export type { EntreeConfiguration, EtatConfiguration } from './configuration'
32
+
33
+ export {
34
+ CODES_DU_CLIENT,
35
+ CODES_DU_FIL,
36
+ CODES_ERREUR,
37
+ ErreurSupport,
38
+ MESSAGE_GENERIQUE,
39
+ estCodeDuFil,
40
+ estErreurSupport,
41
+ interpreterEchec,
42
+ } from './erreurs'
43
+ export type { CodeErreurSupport, DonneesErreurSupport, EchecHttp } from './erreurs'
44
+
45
+ export { estNavigateur, exigerServeur, MESSAGE_CLE_EXPOSEE } from './garde-serveur'
46
+
47
+ export {
48
+ ENTETE_CLE,
49
+ ENTETE_IDEMPOTENCE,
50
+ SEGMENT_API,
51
+ TAILLE_MAX_CORPS_OCTETS,
52
+ normaliserBase,
53
+ octetsUtf8,
54
+ preparerRequete,
55
+ serialiserQuery,
56
+ } from './requete'
57
+ export type { EntreeRequete, RequetePreparee, ValeurQuery } from './requete'
58
+
59
+ export {
60
+ BUDGET_MS_DEFAUT,
61
+ DELAI_BASE_MS,
62
+ DELAI_REQUETE_MS_DEFAUT,
63
+ MAX_TENTATIVES_DEFAUT,
64
+ analyserRetryApres,
65
+ deciderReessai,
66
+ delaiExponentiel,
67
+ } from './reessai'
68
+ export type { DecisionReessai, EntreeDecision, RaisonAbandon, RaisonReessai } from './reessai'
69
+
70
+ export { creerRoutesSupport, filtrerCreation, traduireErreur } from './relais/routes'
71
+ export type {
72
+ ContexteRoute,
73
+ GestionnaireRoute,
74
+ OptionsRoutesSupport,
75
+ RoutesSupport,
76
+ } from './relais/routes'
77
+
78
+ export { estIdentifiantPlausible, resoudreRelais } from './relais/chemins'
79
+ export type { ActionRelais, ResolutionRelais } from './relais/chemins'
80
+
81
+ export {
82
+ CHAMPS_CREATION_ACCEPTES,
83
+ LONGUEUR_TITRE,
84
+ MAX_ERREURS_CONSOLE,
85
+ bornerContexte,
86
+ composerCreation,
87
+ composerDepotPiece,
88
+ composerListe,
89
+ composerMessage,
90
+ normaliserEmail,
91
+ verifierAppartenance,
92
+ } from './relais/corps'
93
+ export type { IdentiteRequerant } from './relais/corps'
94
+
95
+ export { TYPES_DEMANDE } from './types'
96
+ export type {
97
+ AuteurMessage,
98
+ ContexteDemande,
99
+ DemandeCreee,
100
+ DemandeLue,
101
+ DepotPiece,
102
+ EntreeAjouterMessage,
103
+ EntreeCreerDemande,
104
+ EntreeDeposerPiece,
105
+ EntreeListerDemandes,
106
+ MessageAjoute,
107
+ MessageLu,
108
+ PageDemandes,
109
+ PieceLue,
110
+ PrioriteDemande,
111
+ ProgrammeActif,
112
+ Requerant,
113
+ ResumeDemande,
114
+ ScenarioCampagne,
115
+ StatutDemande,
116
+ TypeDemande,
117
+ } from './types'
package/src/reessai.ts ADDED
@@ -0,0 +1,134 @@
1
+ // ============================================================================
2
+ // Réessayer, ou renoncer et le dire. PUR — ni horloge, ni hasard, ni sommeil.
3
+ // ============================================================================
4
+ //
5
+ // L'API v1 limite à 60 requêtes / minute / app et répond 429 avec un
6
+ // `Retry-After` (PLAN.md §4). Un client qui l'ignore reconstruit exactement la
7
+ // rafale qu'on limitait ; un client qui réessaie éternellement fait attendre
8
+ // la personne qui a cliqué sans jamais le lui dire. D'où deux bornes, et une
9
+ // troisième décision : ce qu'on ne réessaie PAS.
10
+
11
+ /** Ce qui se retente : la panne passagère et la limite. */
12
+ export type RaisonReessai = 'limite' | 'panne' | 'reseau'
13
+
14
+ /** Ce qui ne se retente pas. Trois motifs DISTINCTS, exprès. */
15
+ export type RaisonAbandon = 'definitif' | 'tentatives_epuisees' | 'budget_epuise'
16
+
17
+ export type DecisionReessai =
18
+ | { reessayer: true; attendreMs: number; raison: RaisonReessai }
19
+ | { reessayer: false; raison: RaisonAbandon }
20
+
21
+ /** Premier palier d'attente, doublé à chaque tentative. */
22
+ export const DELAI_BASE_MS = 500
23
+
24
+ /** Trois tentatives au total (l'originale + deux reprises). */
25
+ export const MAX_TENTATIVES_DEFAUT = 3
26
+
27
+ /** Budget total, réessais et attentes compris. Au-delà, on rend la main. */
28
+ export const BUDGET_MS_DEFAUT = 25_000
29
+
30
+ /** Délai par requête isolée. Une requête qui ne répond pas n'est pas gratuite. */
31
+ export const DELAI_REQUETE_MS_DEFAUT = 10_000
32
+
33
+ export interface EntreeDecision {
34
+ /** Le statut HTTP, ou `null` quand rien n'est revenu (réseau, délai). */
35
+ statut: number | null
36
+ /** La valeur brute de `Retry-After`, telle que lue dans les en-têtes. */
37
+ retryApres?: string | null
38
+ /** 1 pour la première tentative. */
39
+ tentative: number
40
+ maxTentatives?: number
41
+ /** Millisecondes déjà consommées depuis le premier appel. */
42
+ ecouleMs: number
43
+ budgetMs?: number
44
+ /** Instant de référence pour un `Retry-After` en date HTTP. */
45
+ maintenant: Date
46
+ /** Gigue, dans [0, 1[. Injectée : le hasard n'a pas sa place dans un test. */
47
+ gigue?: number
48
+ }
49
+
50
+ /**
51
+ * Lit `Retry-After`. PUR.
52
+ *
53
+ * Deux formes légales (RFC 9110) : un nombre de secondes, ou une date HTTP.
54
+ *
55
+ * ⚠️ Rend `null` quand la valeur est illisible, et ce `null` compte : rendre
56
+ * `0` pour « je n'ai pas compris » relancerait la requête dans l'instant, sur
57
+ * un serveur qui vient précisément de dire qu'il en recevait trop. « On ne
58
+ * peut pas conclure » doit rester distinct de « attendre zéro ».
59
+ */
60
+ export function analyserRetryApres(
61
+ valeur: string | null | undefined,
62
+ maintenant: Date,
63
+ ): number | null {
64
+ if (valeur === null || valeur === undefined) return null
65
+ const detoure = String(valeur).trim()
66
+ if (detoure === '') return null
67
+
68
+ // Forme « secondes ». `Number('12abc')` vaut NaN, ce qu'on veut : une valeur
69
+ // à moitié numérique est une valeur qu'on n'a pas comprise.
70
+ if (/^\d+$/.test(detoure)) {
71
+ const secondes = Number(detoure)
72
+ return Number.isFinite(secondes) ? secondes * 1000 : null
73
+ }
74
+
75
+ const date = new Date(detoure)
76
+ const instant = date.getTime()
77
+ if (!Number.isFinite(instant)) return null
78
+ // Une date déjà passée vaut « tout de suite », pas une attente négative.
79
+ return Math.max(0, instant - maintenant.getTime())
80
+ }
81
+
82
+ /** Le palier d'attente d'une tentative, gigue comprise. PUR. */
83
+ export function delaiExponentiel(tentative: number, gigue: number): number {
84
+ const palier = DELAI_BASE_MS * 2 ** Math.max(0, tentative - 1)
85
+ // La gigue ne fait qu'ÉTALER : elle ajoute jusqu'à un palier, elle ne
86
+ // raccourcit jamais. Deux clients partis en même temps doivent diverger,
87
+ // pas se retrouver plus tôt.
88
+ const borne = Math.min(Math.max(gigue, 0), 0.999)
89
+ return Math.round(palier * (1 + borne))
90
+ }
91
+
92
+ /**
93
+ * Faut-il réessayer, et après combien de temps ? PUR.
94
+ *
95
+ * Ce qui se réessaie : 429 (limite), 408 (délai côté serveur), 5xx (panne
96
+ * passagère), et l'absence de réponse (`statut: null`). Tout le reste est
97
+ * DÉFINITIF : réessayer un 422 renvoie le même corps invalide trois fois, et
98
+ * réessayer un 401 fait compter trois échecs d'authentification à une clé
99
+ * qu'on pourrait finir par bloquer.
100
+ */
101
+ export function deciderReessai(entree: EntreeDecision): DecisionReessai {
102
+ const maxTentatives = entree.maxTentatives ?? MAX_TENTATIVES_DEFAUT
103
+ const budgetMs = entree.budgetMs ?? BUDGET_MS_DEFAUT
104
+ const statut = entree.statut
105
+
106
+ const raison = raisonDeReessayer(statut)
107
+ if (raison === null) return { reessayer: false, raison: 'definitif' }
108
+
109
+ if (entree.tentative >= maxTentatives) {
110
+ return { reessayer: false, raison: 'tentatives_epuisees' }
111
+ }
112
+
113
+ const demande = analyserRetryApres(entree.retryApres, entree.maintenant)
114
+ const attendreMs =
115
+ demande !== null ? demande : delaiExponentiel(entree.tentative, entree.gigue ?? 0)
116
+
117
+ // ⚠️ On ne RABOTE PAS une attente trop longue : un `Retry-After: 120`
118
+ // ramené à 30 s repartirait avant l'heure et récolterait un 429 de plus.
119
+ // Quand l'attente ne tient pas dans le budget, on renonce en le disant —
120
+ // l'appelant peut alors afficher « réessayez dans deux minutes » plutôt que
121
+ // de faire patienter quelqu'un devant un formulaire figé.
122
+ const restantMs = budgetMs - entree.ecouleMs
123
+ if (attendreMs >= restantMs) return { reessayer: false, raison: 'budget_epuise' }
124
+
125
+ return { reessayer: true, attendreMs, raison }
126
+ }
127
+
128
+ function raisonDeReessayer(statut: number | null): RaisonReessai | null {
129
+ if (statut === null) return 'reseau'
130
+ if (statut === 429) return 'limite'
131
+ if (statut === 408) return 'panne'
132
+ if (statut >= 500 && statut <= 599) return 'panne'
133
+ return null
134
+ }
@@ -0,0 +1,91 @@
1
+ // ============================================================================
2
+ // Le plan des routes `/api/support/*`. PUR.
3
+ // ============================================================================
4
+ //
5
+ // ⚠️ CE FICHIER EST UNE LISTE BLANCHE, ET C'EST TOUT SON INTÉRÊT.
6
+ //
7
+ // La tentation, en écrivant un relais, est de recoller les segments reçus sur
8
+ // la base de l'API et de transmettre : trois lignes, ça marche tout de suite.
9
+ // Ce relais-là donne à n'importe quel visiteur la clé de l'app comme
10
+ // laissez-passer. Un `POST /api/support/../admin/apps` atteint l'endpoint
11
+ // d'administration ; un `GET /api/support/demandes?email=pdg@client.fr` lit le
12
+ // fil de quelqu'un d'autre. Rien n'a l'air cassé : le relais fait exactement
13
+ // ce qu'on lui a demandé.
14
+ //
15
+ // Donc : six actions, nommées une par une. Ce qui n'est pas dans cette table
16
+ // n'existe pas, et la réponse est la même (404) pour une route inconnue que
17
+ // pour une route d'administration — on n'apprend pas au visiteur ce qui existe
18
+ // derrière.
19
+
20
+ /** Les six gestes que le navigateur a le droit de demander. */
21
+ export type ActionRelais =
22
+ | 'creer_demande'
23
+ | 'lister_demandes'
24
+ | 'lire_demande'
25
+ | 'ajouter_message'
26
+ | 'deposer_piece'
27
+ | 'programme_actif'
28
+
29
+ export type ResolutionRelais =
30
+ | { action: ActionRelais; demandeId?: string }
31
+ | { refus: 'chemin_inconnu' }
32
+ | { refus: 'methode_refusee'; permises: readonly string[] }
33
+
34
+ /**
35
+ * Un identifiant de demande doit RESSEMBLER à un uuid.
36
+ *
37
+ * ⚠️ Ce n'est pas un contrôle d'existence, c'est un contrôle de FORME : sans
38
+ * lui, `demandes/..%2F..%2Fadmin` arrive dans une URL concaténée. Et le socle
39
+ * de Support l'exige aussi de son côté — un identifiant mal formé y rend
40
+ * `null`, pas une erreur 500.
41
+ */
42
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
43
+
44
+ export function estIdentifiantPlausible(valeur: string): boolean {
45
+ return UUID.test(valeur)
46
+ }
47
+
48
+ /**
49
+ * Résout un chemin du relais. PUR, total : ne jette jamais.
50
+ *
51
+ * `segments` est ce que Next donne dans `params.chemin` pour une route
52
+ * attrape-tout `app/api/support/[...chemin]/route.ts`, donc DÉJÀ décodé —
53
+ * d'où le contrôle de forme plutôt qu'un simple refus des `..` textuels.
54
+ */
55
+ export function resoudreRelais(
56
+ segments: readonly string[] | undefined,
57
+ methode: string,
58
+ ): ResolutionRelais {
59
+ const chemin = (segments ?? []).filter((s) => s !== '')
60
+ const verbe = String(methode ?? '').toUpperCase()
61
+
62
+ if (chemin.length === 1 && chemin[0] === 'demandes') {
63
+ if (verbe === 'POST') return { action: 'creer_demande' }
64
+ if (verbe === 'GET') return { action: 'lister_demandes' }
65
+ return { refus: 'methode_refusee', permises: ['GET', 'POST'] }
66
+ }
67
+
68
+ if (chemin.length === 2 && chemin[0] === 'demandes') {
69
+ if (!estIdentifiantPlausible(chemin[1])) return { refus: 'chemin_inconnu' }
70
+ if (verbe === 'GET') return { action: 'lire_demande', demandeId: chemin[1] }
71
+ return { refus: 'methode_refusee', permises: ['GET'] }
72
+ }
73
+
74
+ if (chemin.length === 3 && chemin[0] === 'demandes' && chemin[2] === 'messages') {
75
+ if (!estIdentifiantPlausible(chemin[1])) return { refus: 'chemin_inconnu' }
76
+ if (verbe === 'POST') return { action: 'ajouter_message', demandeId: chemin[1] }
77
+ return { refus: 'methode_refusee', permises: ['POST'] }
78
+ }
79
+
80
+ if (chemin.length === 1 && chemin[0] === 'pieces') {
81
+ if (verbe === 'POST') return { action: 'deposer_piece' }
82
+ return { refus: 'methode_refusee', permises: ['POST'] }
83
+ }
84
+
85
+ if (chemin.length === 2 && chemin[0] === 'programmes' && chemin[1] === 'actif') {
86
+ if (verbe === 'GET') return { action: 'programme_actif' }
87
+ return { refus: 'methode_refusee', permises: ['GET'] }
88
+ }
89
+
90
+ return { refus: 'chemin_inconnu' }
91
+ }