@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/README.md +65 -0
- package/package.json +19 -0
- package/src/client.ts +354 -0
- package/src/configuration.ts +106 -0
- package/src/erreurs.ts +199 -0
- package/src/garde-serveur.ts +75 -0
- package/src/index.ts +117 -0
- package/src/reessai.ts +134 -0
- package/src/relais/chemins.ts +91 -0
- package/src/relais/corps.ts +292 -0
- package/src/relais/routes.ts +380 -0
- package/src/requete.ts +146 -0
- package/src/types.ts +221 -0
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
|
+
}
|