@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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Ce que le navigateur a le droit de dire — et ce qu'on lui impose. PUR.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ LA RÈGLE, EN UNE PHRASE : **l'identité du requérant vient de la session
|
|
6
|
+
// de l'app hôte, jamais du corps reçu.**
|
|
7
|
+
//
|
|
8
|
+
// Sans elle, le formulaire « Aide » devient un moyen d'écrire une demande au
|
|
9
|
+
// nom de n'importe qui (il suffit de changer l'adresse dans l'onglet Réseau),
|
|
10
|
+
// puis — puisque `GET /demandes?email=` liste par adresse — de LIRE le fil de
|
|
11
|
+
// cette personne, contexte technique compris. Ce n'est pas une faille exotique
|
|
12
|
+
// : c'est le comportement par défaut de tout relais écrit avec un
|
|
13
|
+
// `JSON.parse(await req.text())` transmis tel quel.
|
|
14
|
+
//
|
|
15
|
+
// D'où la forme de ce fichier : une liste blanche de champs, et l'identité
|
|
16
|
+
// recollée par-dessus, en dernier.
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ContexteDemande,
|
|
20
|
+
EntreeAjouterMessage,
|
|
21
|
+
EntreeCreerDemande,
|
|
22
|
+
EntreeDeposerPiece,
|
|
23
|
+
EntreeListerDemandes,
|
|
24
|
+
Requerant,
|
|
25
|
+
StatutDemande,
|
|
26
|
+
TypeDemande,
|
|
27
|
+
} from '../types'
|
|
28
|
+
import { TYPES_DEMANDE } from '../types'
|
|
29
|
+
|
|
30
|
+
/** Qui parle, selon l'app hôte. C'est la SEULE source d'identité du relais. */
|
|
31
|
+
export interface IdentiteRequerant {
|
|
32
|
+
email: string
|
|
33
|
+
nom?: string | null
|
|
34
|
+
idExterne?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Les champs qu'un navigateur peut poser sur une création. Rien d'autre. */
|
|
38
|
+
export const CHAMPS_CREATION_ACCEPTES = [
|
|
39
|
+
'type',
|
|
40
|
+
'titre',
|
|
41
|
+
'description',
|
|
42
|
+
'contexte',
|
|
43
|
+
'pieces',
|
|
44
|
+
'campagne_id',
|
|
45
|
+
'scenario',
|
|
46
|
+
'score',
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
/** Au-delà, ce n'est plus un contexte, c'est un vidage de mémoire. */
|
|
50
|
+
export const MAX_ERREURS_CONSOLE = 20
|
|
51
|
+
export const LONGUEUR_CHAMP_CONTEXTE = 500
|
|
52
|
+
export const LONGUEUR_TITRE = 200
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalise une adresse comme le fait Support (`domaine/validation.ts`).
|
|
56
|
+
*
|
|
57
|
+
* Recopié sciemment : le paquet ne dépend pas de l'app. Idempotent.
|
|
58
|
+
*/
|
|
59
|
+
export function normaliserEmail(brut: unknown): string {
|
|
60
|
+
return typeof brut === 'string' ? brut.trim().toLowerCase() : ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compose le corps de `POST /demandes` à partir de ce que le navigateur a
|
|
65
|
+
* envoyé et de qui l'app dit que c'est. PUR.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ L'ordre compte : on RECOPIE les champs autorisés, puis on POSE
|
|
68
|
+
* `requerant`. Un `{ ...recu, requerant: identite }` aurait le même effet ici,
|
|
69
|
+
* mais laisserait passer tout le reste du corps (`app_id`, `statut`,
|
|
70
|
+
* `priorite`, `assignee_id`…) le jour où l'API v1 accepterait un champ de
|
|
71
|
+
* plus. La liste blanche ne se périme pas dans ce sens-là.
|
|
72
|
+
*/
|
|
73
|
+
export function composerCreation(
|
|
74
|
+
recu: unknown,
|
|
75
|
+
identite: IdentiteRequerant,
|
|
76
|
+
): EntreeCreerDemande {
|
|
77
|
+
const brut = objetOuVide(recu)
|
|
78
|
+
|
|
79
|
+
const requerant: Requerant = {
|
|
80
|
+
email: normaliserEmail(identite.email),
|
|
81
|
+
nom: texteOuNull(identite.nom),
|
|
82
|
+
id_externe: texteOuNull(identite.idExterne),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
// ⚠️ ABSENTS, ILS RESTENT ABSENTS (`null`). Une demande en une phrase
|
|
87
|
+
// n'a ni type ni titre, et c'est Support qui les fait poser par le triage
|
|
88
|
+
// IA. Les remplacer ici par une valeur par défaut les ferait passer pour
|
|
89
|
+
// un CHOIX du requérant, que l'IA n'aurait plus le droit de corriger.
|
|
90
|
+
type: typeOuNull(brut.type),
|
|
91
|
+
titre: titreOuNull(brut.titre),
|
|
92
|
+
description: texteOuVide(brut.description),
|
|
93
|
+
requerant,
|
|
94
|
+
contexte: bornerContexte(brut.contexte),
|
|
95
|
+
pieces: listeIdentifiants(brut.pieces),
|
|
96
|
+
campagne_id: texteOuNull(brut.campagne_id),
|
|
97
|
+
scenario: texteOuNull(brut.scenario),
|
|
98
|
+
score: scoreOuNull(brut.score),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compose les critères de `GET /demandes`. PUR.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ L'`email` de la query string reçue est IGNORÉ, pas recopié : c'est le
|
|
106
|
+
* paramètre par lequel on lirait le fil d'autrui. Le statut, lui, ne dit rien
|
|
107
|
+
* de personne et peut venir du navigateur — après contrôle.
|
|
108
|
+
*/
|
|
109
|
+
export function composerListe(
|
|
110
|
+
requete: URLSearchParams | undefined,
|
|
111
|
+
identite: IdentiteRequerant,
|
|
112
|
+
): EntreeListerDemandes {
|
|
113
|
+
const statuts = (requete?.getAll('statut') ?? []).filter(estStatut)
|
|
114
|
+
const limiteBrute = Number(requete?.get('limite') ?? '')
|
|
115
|
+
return {
|
|
116
|
+
email: normaliserEmail(identite.email),
|
|
117
|
+
statut: statuts.length > 0 ? statuts : undefined,
|
|
118
|
+
limite: Number.isInteger(limiteBrute) && limiteBrute > 0 ? limiteBrute : undefined,
|
|
119
|
+
curseur: requete?.get('curseur') || undefined,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Compose `POST /demandes/:id/messages`. L'adresse vient de l'identité. PUR. */
|
|
124
|
+
export function composerMessage(
|
|
125
|
+
recu: unknown,
|
|
126
|
+
identite: IdentiteRequerant,
|
|
127
|
+
): EntreeAjouterMessage {
|
|
128
|
+
const brut = objetOuVide(recu)
|
|
129
|
+
return {
|
|
130
|
+
corps: texteOuVide(brut.corps),
|
|
131
|
+
requerant_email: normaliserEmail(identite.email),
|
|
132
|
+
pieces: listeIdentifiants(brut.pieces),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Compose `POST /pieces`. Ici il n'y a pas d'identité à imposer. PUR. */
|
|
137
|
+
export function composerDepotPiece(recu: unknown): EntreeDeposerPiece {
|
|
138
|
+
const brut = objetOuVide(recu)
|
|
139
|
+
const octets = Number(brut.octets)
|
|
140
|
+
return {
|
|
141
|
+
nom: borner(texteOuVide(brut.nom), 200),
|
|
142
|
+
type_mime: borner(texteOuVide(brut.type_mime), 100),
|
|
143
|
+
octets: Number.isFinite(octets) && octets > 0 ? Math.floor(octets) : 0,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Cette demande est-elle bien celle de cette personne ? PUR.
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ `GET /api/v1/demandes/:id` rend n'importe quelle demande DE L'APP : la
|
|
151
|
+
* clé suffit, l'adresse n'y entre pas. Le relais, lui, parle au nom d'un
|
|
152
|
+
* visiteur — il doit donc refaire la vérification, sinon un identifiant
|
|
153
|
+
* recopié d'un collègue ouvre son fil.
|
|
154
|
+
*
|
|
155
|
+
* Les trois cas se distinguent :
|
|
156
|
+
* - adresses égales (à la casse près) → `true`
|
|
157
|
+
* - adresses différentes → `false`
|
|
158
|
+
* - une des deux manque → `false` AUSSI, et c'est le point :
|
|
159
|
+
* « on ne sait pas » ne doit pas valoir « d'accord ». Une demande sans
|
|
160
|
+
* adresse de requérant (import, migration, cas non prévu) ne s'ouvre à
|
|
161
|
+
* personne par défaut.
|
|
162
|
+
*/
|
|
163
|
+
export function verifierAppartenance(
|
|
164
|
+
emailDemande: unknown,
|
|
165
|
+
emailIdentite: unknown,
|
|
166
|
+
): boolean {
|
|
167
|
+
const a = normaliserEmail(emailDemande)
|
|
168
|
+
const b = normaliserEmail(emailIdentite)
|
|
169
|
+
if (a === '' || b === '') return false
|
|
170
|
+
return a === b
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ─── Nettoyage du contexte ─────────────────────────────────────────── */
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Borne le contexte relevé par le widget. PUR.
|
|
177
|
+
*
|
|
178
|
+
* Le contexte est le seul champ que le navigateur remplit tout seul, donc le
|
|
179
|
+
* seul dont personne ne surveille la taille : 20 erreurs de console d'une app
|
|
180
|
+
* bavarde suffisent à dépasser les 256 Ko du corps. On borne ici, côté
|
|
181
|
+
* serveur, parce qu'un widget d'une version antérieure ne bornera pas.
|
|
182
|
+
*/
|
|
183
|
+
export function bornerContexte(recu: unknown): ContexteDemande | null {
|
|
184
|
+
if (typeof recu !== 'object' || recu === null) return null
|
|
185
|
+
const brut = recu as Record<string, unknown>
|
|
186
|
+
const contexte: ContexteDemande = {}
|
|
187
|
+
|
|
188
|
+
const texte = (valeur: unknown) => {
|
|
189
|
+
const t = texteOuVide(valeur)
|
|
190
|
+
return t === '' ? undefined : borner(t, LONGUEUR_CHAMP_CONTEXTE)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
contexte.url = texte(brut.url)
|
|
194
|
+
contexte.user_agent = texte(brut.user_agent)
|
|
195
|
+
contexte.version = texte(brut.version)
|
|
196
|
+
contexte.os = texte(brut.os)
|
|
197
|
+
contexte.viewport = texte(brut.viewport)
|
|
198
|
+
contexte.langue = texte(brut.langue)
|
|
199
|
+
|
|
200
|
+
if (Array.isArray(brut.console)) {
|
|
201
|
+
const lignes = brut.console
|
|
202
|
+
.slice(-MAX_ERREURS_CONSOLE)
|
|
203
|
+
.map((ligne) => borner(texteOuVide(ligne), LONGUEUR_CHAMP_CONTEXTE))
|
|
204
|
+
.filter((ligne) => ligne !== '')
|
|
205
|
+
if (lignes.length > 0) contexte.console = lignes
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (typeof brut.extra === 'object' && brut.extra !== null && !Array.isArray(brut.extra)) {
|
|
209
|
+
// `extra` est libre par contrat : on le garde tel quel, mais seulement
|
|
210
|
+
// s'il se sérialise. Un objet circulaire ferait jeter `JSON.stringify` au
|
|
211
|
+
// moment de l'envoi — c'est-à-dire loin d'ici, dans un message
|
|
212
|
+
// incompréhensible.
|
|
213
|
+
try {
|
|
214
|
+
const rendu = JSON.stringify(brut.extra)
|
|
215
|
+
if (rendu !== undefined && rendu.length <= 4000) {
|
|
216
|
+
contexte.extra = JSON.parse(rendu) as Record<string, unknown>
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Un extra illisible n'est pas une raison de perdre le reste du contexte.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const cle of Object.keys(contexte) as (keyof ContexteDemande)[]) {
|
|
224
|
+
if (contexte[cle] === undefined) delete contexte[cle]
|
|
225
|
+
}
|
|
226
|
+
return Object.keys(contexte).length === 0 ? null : contexte
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ─── Petites conversions ───────────────────────────────────────────── */
|
|
230
|
+
|
|
231
|
+
function objetOuVide(valeur: unknown): Record<string, unknown> {
|
|
232
|
+
return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur)
|
|
233
|
+
? (valeur as Record<string, unknown>)
|
|
234
|
+
: {}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function texteOuVide(valeur: unknown): string {
|
|
238
|
+
return typeof valeur === 'string' ? valeur.trim() : ''
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function texteOuNull(valeur: unknown): string | null {
|
|
242
|
+
const texte = texteOuVide(valeur)
|
|
243
|
+
return texte === '' ? null : texte
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function borner(texte: string, longueur: number): string {
|
|
247
|
+
return texte.length <= longueur ? texte : `${texte.slice(0, longueur - 1)}…`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Un type hors vocabulaire retombe sur `question`, jamais sur `bug`.
|
|
252
|
+
*
|
|
253
|
+
* Un faux bug déclenche des priorités et des alertes ; une fausse question
|
|
254
|
+
* attend son tour. En cas de doute, on choisit le geste réversible.
|
|
255
|
+
*/
|
|
256
|
+
function typeOuNull(valeur: unknown): TypeDemande | null {
|
|
257
|
+
return typeof valeur === 'string' && (TYPES_DEMANDE as readonly string[]).includes(valeur)
|
|
258
|
+
? (valeur as TypeDemande)
|
|
259
|
+
: null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function titreOuNull(valeur: unknown): string | null {
|
|
263
|
+
const titre = texteOuVide(valeur).trim()
|
|
264
|
+
return titre === '' ? null : borner(titre, LONGUEUR_TITRE)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const STATUTS: readonly string[] = [
|
|
268
|
+
'nouvelle',
|
|
269
|
+
'ouverte',
|
|
270
|
+
'en_attente_requerant',
|
|
271
|
+
'resolue',
|
|
272
|
+
'fermee',
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
function estStatut(valeur: string): valeur is StatutDemande {
|
|
276
|
+
return STATUTS.includes(valeur)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function listeIdentifiants(valeur: unknown): string[] | undefined {
|
|
280
|
+
if (!Array.isArray(valeur)) return undefined
|
|
281
|
+
const ids = valeur.filter(
|
|
282
|
+
(v): v is string => typeof v === 'string' && /^[0-9a-z-]{1,64}$/i.test(v),
|
|
283
|
+
)
|
|
284
|
+
return ids.length > 0 ? ids.slice(0, 10) : undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scoreOuNull(valeur: unknown): number | null {
|
|
288
|
+
const n = Number(valeur)
|
|
289
|
+
if (!Number.isFinite(n)) return null
|
|
290
|
+
const entier = Math.round(n)
|
|
291
|
+
return entier >= 1 && entier <= 5 ? entier : null
|
|
292
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// `creerRoutesSupport()` — le relais qui détient la clé. IMPUR.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Une app cliente pose UN fichier :
|
|
6
|
+
//
|
|
7
|
+
// // app/api/support/[...chemin]/route.ts
|
|
8
|
+
// import { creerRoutesSupport } from '@zevra/support'
|
|
9
|
+
// import { session } from '@/lib/auth'
|
|
10
|
+
//
|
|
11
|
+
// export const { GET, POST } = creerRoutesSupport({
|
|
12
|
+
// identifier: async () => {
|
|
13
|
+
// const s = await session()
|
|
14
|
+
// return s ? { email: s.user.email, nom: s.user.name, idExterne: s.user.id } : null
|
|
15
|
+
// },
|
|
16
|
+
// })
|
|
17
|
+
//
|
|
18
|
+
// ⚠️ Ce fichier tourne au SERVEUR. C'est le seul endroit de l'app cliente où
|
|
19
|
+
// la clé `sk_support_…` existe. Le widget, lui, n'appelle que `/api/support/*`
|
|
20
|
+
// et ne connaît aucune clé : c'est toute l'architecture du lot, et c'est ce
|
|
21
|
+
// qui rend l'erreur impossible plutôt que déconseillée.
|
|
22
|
+
//
|
|
23
|
+
// Les décisions vivent dans `chemins.ts` (quelles routes existent) et
|
|
24
|
+
// `corps.ts` (quels champs sont acceptés, qui est le requérant). Ici, on ne
|
|
25
|
+
// fait que les appliquer.
|
|
26
|
+
|
|
27
|
+
import { creerClientSupport, type ClientSupport, type OptionsClientSupport } from '../client'
|
|
28
|
+
import { ErreurSupport, estErreurSupport, MESSAGE_GENERIQUE } from '../erreurs'
|
|
29
|
+
import type { DemandeCreee, DemandeLue } from '../types'
|
|
30
|
+
import { resoudreRelais, type ActionRelais } from './chemins'
|
|
31
|
+
import {
|
|
32
|
+
composerCreation,
|
|
33
|
+
composerDepotPiece,
|
|
34
|
+
composerListe,
|
|
35
|
+
composerMessage,
|
|
36
|
+
normaliserEmail,
|
|
37
|
+
verifierAppartenance,
|
|
38
|
+
type IdentiteRequerant,
|
|
39
|
+
} from './corps'
|
|
40
|
+
|
|
41
|
+
/** Ce que Next passe en second argument d'un gestionnaire attrape-tout. */
|
|
42
|
+
export interface ContexteRoute {
|
|
43
|
+
params: { chemin?: string[] } | Promise<{ chemin?: string[] }>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type GestionnaireRoute = (
|
|
47
|
+
requete: Request,
|
|
48
|
+
contexte: ContexteRoute,
|
|
49
|
+
) => Promise<Response>
|
|
50
|
+
|
|
51
|
+
export interface OptionsRoutesSupport extends OptionsClientSupport {
|
|
52
|
+
/**
|
|
53
|
+
* Qui est en train de parler, selon VOTRE session. `null` ⇒ 401.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ C'est la pièce que vous seul pouvez écrire, et la seule qui compte.
|
|
56
|
+
* La faire lire dans le corps de la requête (« l'email est dans le
|
|
57
|
+
* formulaire ») redonne au navigateur le droit de se déclarer qui il veut.
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ `email` doit porter une VRAIE adresse. Une chaîne vide ou faite
|
|
60
|
+
* d'espaces est traitée comme une absence d'identité (401) : la liste des
|
|
61
|
+
* demandes se fait par adresse, et une adresse vide ne dit pas « personne »,
|
|
62
|
+
* elle efface le filtre — l'API rendrait toutes les demandes de l'app.
|
|
63
|
+
*/
|
|
64
|
+
identifier: (requete: Request) => Promise<IdentiteRequerant | null> | IdentiteRequerant | null
|
|
65
|
+
/** Client déjà construit (tests, client partagé). Sinon il en crée un. */
|
|
66
|
+
client?: ClientSupport
|
|
67
|
+
/**
|
|
68
|
+
* Rendre `url_portail` au navigateur à la création. Défaut : NON.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ C'est un lien magique : qui l'a lit le fil sans se connecter. Le
|
|
71
|
+
* navigateur n'en a pas besoin (il a l'onglet « Mes demandes »), et le
|
|
72
|
+
* laisser passer le ferait atterrir dans l'historique, dans les journaux du
|
|
73
|
+
* proxy et dans tout outil de mesure branché sur les réponses réseau.
|
|
74
|
+
* À n'activer que si votre app affiche elle-même « suivre ma demande ».
|
|
75
|
+
*/
|
|
76
|
+
revelerUrlPortail?: boolean
|
|
77
|
+
/**
|
|
78
|
+
* Branchez votre journal sur les échecs DU RELAIS. Reçoit le chemin et le
|
|
79
|
+
* statut rendu, jamais le corps de la demande.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ Nom distinct de `journaliser`, hérité de `OptionsClientSupport`, et ce
|
|
82
|
+
* n'est pas un doublon : il y a deux couches et deux journaux. `journaliser`
|
|
83
|
+
* voit les appels SORTANTS vers Support (tentatives, réessais, durées) ;
|
|
84
|
+
* `journaliserRelais` voit ce que le NAVIGATEUR a reçu. Une même panne s'y
|
|
85
|
+
* lit deux fois — un 429 côté sortant, un 429 côté entrant — et c'est cette
|
|
86
|
+
* paire qui dit si le réessai a servi.
|
|
87
|
+
*/
|
|
88
|
+
journaliserRelais?: (evenement: {
|
|
89
|
+
chemin: string
|
|
90
|
+
statut: number
|
|
91
|
+
message?: string
|
|
92
|
+
}) => void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface RoutesSupport {
|
|
96
|
+
GET: GestionnaireRoute
|
|
97
|
+
POST: GestionnaireRoute
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function creerRoutesSupport(options: OptionsRoutesSupport): RoutesSupport {
|
|
101
|
+
// Créé une fois, à l'import du module de route : `creerClientSupport` ne
|
|
102
|
+
// jette pas quand la configuration manque (voir `configuration.ts`), donc
|
|
103
|
+
// une app non encore liée au Support démarre quand même — ses routes
|
|
104
|
+
// répondent 503 avec la phrase à corriger.
|
|
105
|
+
const client = options.client ?? creerClientSupport(options)
|
|
106
|
+
|
|
107
|
+
const gestionnaire: GestionnaireRoute = async (requete, contexte) => {
|
|
108
|
+
const params = await contexte?.params
|
|
109
|
+
const resolution = resoudreRelais(params?.chemin, requete.method)
|
|
110
|
+
|
|
111
|
+
if ('refus' in resolution) {
|
|
112
|
+
if (resolution.refus === 'methode_refusee') {
|
|
113
|
+
return reponseErreur(405, 'introuvable', 'Cette méthode ne vaut pas ici.', {
|
|
114
|
+
Allow: resolution.permises.join(', '),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
// Même réponse pour « n'existe pas » et « existe mais pas pour vous » :
|
|
118
|
+
// un 403 distinct apprendrait au visiteur ce qu'il y a derrière.
|
|
119
|
+
return reponseErreur(404, 'introuvable', "Cette route du relais n'existe pas.")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!client.etat.pret) {
|
|
123
|
+
return reponseErreur(
|
|
124
|
+
503,
|
|
125
|
+
'interne',
|
|
126
|
+
`Le support n'est pas branché sur cette app. ${client.etat.problemes.join(' ')}`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let identite: IdentiteRequerant | null
|
|
131
|
+
try {
|
|
132
|
+
identite = (await options.identifier(requete)) ?? null
|
|
133
|
+
} catch {
|
|
134
|
+
// Une session illisible n'est pas une identité : on refuse, on ne
|
|
135
|
+
// suppose pas. Le contraire ouvrirait le relais à chaque panne de la
|
|
136
|
+
// couche d'authentification de l'app hôte.
|
|
137
|
+
identite = null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ⚠️ `normaliserEmail` ET PAS `!identite.email`, et la nuance n'est pas
|
|
141
|
+
// cosmétique : une adresse faite d'espaces passe le test de vérité de
|
|
142
|
+
// JavaScript. Elle traverse alors `composerListe`, devient `''`, et
|
|
143
|
+
// `serialiserQuery` OMET les valeurs vides — la requête part sans filtre
|
|
144
|
+
// d'adresse, et `GET /api/v1/demandes` rend TOUTES les demandes de l'app à
|
|
145
|
+
// ce navigateur. Une chaîne d'espaces dans une session est un cas de
|
|
146
|
+
// bordure, pas un cas de laboratoire (import, compte de service, champ
|
|
147
|
+
// rempli à la main) ; et rien ne le signalerait, la réponse étant une
|
|
148
|
+
// liste parfaitement valide.
|
|
149
|
+
const adresse = normaliserEmail(identite?.email)
|
|
150
|
+
if (!identite || adresse === '') {
|
|
151
|
+
return reponseErreur(
|
|
152
|
+
401,
|
|
153
|
+
'cle_invalide',
|
|
154
|
+
'Connectez-vous pour utiliser le support depuis cette app.',
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const donnees = await executer(resolution.action, resolution.demandeId, requete, identite, {
|
|
160
|
+
client,
|
|
161
|
+
revelerUrlPortail: options.revelerUrlPortail === true,
|
|
162
|
+
})
|
|
163
|
+
return reponseOk(donnees, resolution.action === 'creer_demande' ? 201 : 200)
|
|
164
|
+
} catch (erreur) {
|
|
165
|
+
const { statut, corps, entetes } = traduireErreur(erreur)
|
|
166
|
+
options.journaliserRelais?.({
|
|
167
|
+
chemin: (params?.chemin ?? []).join('/'),
|
|
168
|
+
statut,
|
|
169
|
+
// `detail` ne part JAMAIS vers le navigateur (voir `traduireErreur`) ;
|
|
170
|
+
// il n'existe que pour le journal, et c'est là qu'il doit atterrir.
|
|
171
|
+
message: estErreurSupport(erreur)
|
|
172
|
+
? `${erreur.code}: ${erreur.message}${erreur.detail ? ` — ${erreur.detail}` : ''}`
|
|
173
|
+
: undefined,
|
|
174
|
+
})
|
|
175
|
+
return reponse(statut, corps, entetes)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { GET: gestionnaire, POST: gestionnaire }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ─── L'aiguillage ──────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
async function executer(
|
|
185
|
+
action: ActionRelais,
|
|
186
|
+
demandeId: string | undefined,
|
|
187
|
+
requete: Request,
|
|
188
|
+
identite: IdentiteRequerant,
|
|
189
|
+
contexte: { client: ClientSupport; revelerUrlPortail: boolean },
|
|
190
|
+
): Promise<unknown> {
|
|
191
|
+
const { client } = contexte
|
|
192
|
+
|
|
193
|
+
if (action === 'creer_demande') {
|
|
194
|
+
const recu = await lireJson(requete)
|
|
195
|
+
const creee = await client.creerDemande(composerCreation(recu, identite))
|
|
196
|
+
return filtrerCreation(creee, contexte.revelerUrlPortail)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (action === 'lister_demandes') {
|
|
200
|
+
const url = new URL(requete.url)
|
|
201
|
+
return client.listerDemandes(composerListe(url.searchParams, identite))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (action === 'lire_demande') {
|
|
205
|
+
const lue = await client.lireDemande(demandeId as string)
|
|
206
|
+
exigerAppartenance(lue, identite)
|
|
207
|
+
return lue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (action === 'ajouter_message') {
|
|
211
|
+
// Même garde que la lecture, et pour la même raison : sans elle, un
|
|
212
|
+
// identifiant recopié permet d'écrire dans le fil d'autrui — que le
|
|
213
|
+
// requérant recevra par mail, signé de son propre nom.
|
|
214
|
+
const lue = await client.lireDemande(demandeId as string)
|
|
215
|
+
exigerAppartenance(lue, identite)
|
|
216
|
+
const recu = await lireJson(requete)
|
|
217
|
+
return client.ajouterMessage(demandeId as string, composerMessage(recu, identite))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (action === 'deposer_piece') {
|
|
221
|
+
const recu = await lireJson(requete)
|
|
222
|
+
// Le fichier lui-même ne passe PAS par ici : l'URL présignée rendue part
|
|
223
|
+
// directement vers S3 depuis le navigateur. Relayer 20 Mo ferait tenir la
|
|
224
|
+
// mémoire du serveur de l'app hôte pour un fichier qui ne l'intéresse pas.
|
|
225
|
+
return client.demanderDepotPiece(composerDepotPiece(recu))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return client.programmeActif()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Retire `url_portail` sauf demande explicite. PUR.
|
|
233
|
+
*
|
|
234
|
+
* Exporté pour être testé : c'est une décision, pas un détail de plomberie.
|
|
235
|
+
*/
|
|
236
|
+
export function filtrerCreation(creee: DemandeCreee, reveler: boolean): Partial<DemandeCreee> {
|
|
237
|
+
if (reveler) return creee
|
|
238
|
+
const { url_portail: _url_portail, ...reste } = creee
|
|
239
|
+
return reste
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Refuse un fil qui n'est pas celui de la personne connectée.
|
|
244
|
+
*
|
|
245
|
+
* ⚠️ La réponse est la MÊME (404) dans les deux cas de refus : confirmer
|
|
246
|
+
* l'existence d'une demande qu'on n'a pas le droit de lire, c'est déjà une
|
|
247
|
+
* information (elle existe, elle est de cette app, quelqu'un d'autre l'a
|
|
248
|
+
* écrite).
|
|
249
|
+
*
|
|
250
|
+
* ⚠️ Mais les deux cas ne se journalisent pas pareil, et c'est le point de
|
|
251
|
+
* cette fonction. « L'adresse ne correspond pas » est un refus NORMAL, celui
|
|
252
|
+
* pour lequel la garde existe. « La réponse de l'API v1 ne porte pas
|
|
253
|
+
* `requerant_email` » est une rupture de contrat : la garde refuse alors
|
|
254
|
+
* TOUT LE MONDE, l'onglet « Mes demandes » liste des demandes qu'aucun clic
|
|
255
|
+
* n'ouvre, et rien dans la réponse HTTP ne distingue cette panne totale d'un
|
|
256
|
+
* fil qui appartient à quelqu'un d'autre. Le `detail` — qui ne sort jamais
|
|
257
|
+
* vers le navigateur, seulement vers `journaliserRelais` — est ce qui évite
|
|
258
|
+
* de chercher pendant une journée une garde qui fonctionne trop bien.
|
|
259
|
+
*/
|
|
260
|
+
function exigerAppartenance(lue: DemandeLue, identite: IdentiteRequerant): void {
|
|
261
|
+
const email = lue?.demande?.requerant_email
|
|
262
|
+
if (verifierAppartenance(email, identite.email)) return
|
|
263
|
+
const absent = typeof email !== 'string' || email.trim() === ''
|
|
264
|
+
throw new ErreurSupport({
|
|
265
|
+
code: 'introuvable',
|
|
266
|
+
message: "Cette demande n'existe pas.",
|
|
267
|
+
detail: absent
|
|
268
|
+
? "la réponse de GET /demandes/:id ne porte pas requerant_email : la garde d'appartenance refuse alors TOUTES les lectures, y compris légitimes"
|
|
269
|
+
: undefined,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ─── Réponses ──────────────────────────────────────────────────────── */
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* ⚠️ `Cache-Control: no-store` sur TOUTES les réponses.
|
|
277
|
+
*
|
|
278
|
+
* `/api/support/demandes` rend la liste d'UNE personne à une URL qui ne
|
|
279
|
+
* contient pas son nom. Un cache partagé (CDN, proxy d'entreprise, le cache
|
|
280
|
+
* de route de Next lui-même) qui la garderait trente secondes servirait le fil
|
|
281
|
+
* du premier visiteur au deuxième. Rien ne le signalerait : les deux réponses
|
|
282
|
+
* sont valides, c'est leur destinataire qui est faux.
|
|
283
|
+
*/
|
|
284
|
+
const ENTETES_BASE: Record<string, string> = {
|
|
285
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
286
|
+
'Cache-Control': 'no-store',
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function reponse(statut: number, corps: unknown, entetes: Record<string, string> = {}): Response {
|
|
290
|
+
return new Response(JSON.stringify(corps), {
|
|
291
|
+
status: statut,
|
|
292
|
+
headers: { ...ENTETES_BASE, ...entetes },
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function reponseOk(donnees: unknown, statut: number): Response {
|
|
297
|
+
return reponse(statut, donnees)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function reponseErreur(
|
|
301
|
+
statut: number,
|
|
302
|
+
code: string,
|
|
303
|
+
message: string,
|
|
304
|
+
entetes: Record<string, string> = {},
|
|
305
|
+
): Response {
|
|
306
|
+
return reponse(statut, { erreur: { code, message } }, entetes)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Traduit une erreur du client en réponse HTTP, sans jamais fuir de détail.
|
|
311
|
+
*
|
|
312
|
+
* ⚠️ Le message d'une erreur inconnue n'est PAS recopié : il peut contenir
|
|
313
|
+
* l'URL complète appelée, donc l'hôte de Support et parfois la clé si un
|
|
314
|
+
* intermédiaire l'a mise dans une trace. Le générique suffit ; le détail part
|
|
315
|
+
* dans `journaliser`. Même raison pour `detail` d'une `ErreurSupport` : il est
|
|
316
|
+
* écrit pour le journal, jamais pour l'écran, et il n'entre donc pas dans le
|
|
317
|
+
* corps rendu.
|
|
318
|
+
*
|
|
319
|
+
* ⚠️ `Retry-After` est REPOSÉ sur un 429. Support l'envoie, le client le lit
|
|
320
|
+
* pour son propre réessai — et sans cette ligne, il s'arrêtait là : le
|
|
321
|
+
* navigateur recevait une limite sans savoir quand revenir, et c'est
|
|
322
|
+
* exactement ce qui reconstruit la rafale qu'on limitait. Un en-tête perdu
|
|
323
|
+
* dans un relais ne se voit dans aucun test qui ne regarde que le corps.
|
|
324
|
+
*/
|
|
325
|
+
export function traduireErreur(erreur: unknown): {
|
|
326
|
+
statut: number
|
|
327
|
+
corps: unknown
|
|
328
|
+
entetes?: Record<string, string>
|
|
329
|
+
} {
|
|
330
|
+
if (estErreurSupport(erreur)) {
|
|
331
|
+
const statut = STATUT_PAR_CODE[erreur.code] ?? 500
|
|
332
|
+
const secondes =
|
|
333
|
+
typeof erreur.retryApresMs === 'number' && Number.isFinite(erreur.retryApresMs)
|
|
334
|
+
? Math.max(1, Math.ceil(erreur.retryApresMs / 1000))
|
|
335
|
+
: null
|
|
336
|
+
return {
|
|
337
|
+
statut,
|
|
338
|
+
corps: {
|
|
339
|
+
erreur: {
|
|
340
|
+
code: erreur.code,
|
|
341
|
+
message: erreur.message,
|
|
342
|
+
...(erreur.champs ? { champs: erreur.champs } : {}),
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
...(statut === 429 && secondes !== null
|
|
346
|
+
? { entetes: { 'Retry-After': String(secondes) } }
|
|
347
|
+
: {}),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { statut: 500, corps: { erreur: { code: 'interne', message: MESSAGE_GENERIQUE } } }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Miroir de `STATUT_PAR_CODE` de Support, plus les codes du client. */
|
|
354
|
+
const STATUT_PAR_CODE: Record<string, number> = {
|
|
355
|
+
cle_invalide: 401,
|
|
356
|
+
app_inactive: 403,
|
|
357
|
+
introuvable: 404,
|
|
358
|
+
conflit: 409,
|
|
359
|
+
validation: 422,
|
|
360
|
+
limite: 429,
|
|
361
|
+
interne: 500,
|
|
362
|
+
// Côté relais, une panne de configuration ou de réseau est une
|
|
363
|
+
// indisponibilité de SERVICE, pas une faute du navigateur : 503 invite à
|
|
364
|
+
// réessayer, 500 laisse croire à un bug de la requête.
|
|
365
|
+
configuration: 503,
|
|
366
|
+
reseau: 503,
|
|
367
|
+
delai: 504,
|
|
368
|
+
cle_exposee: 500,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function lireJson(requete: Request): Promise<unknown> {
|
|
372
|
+
try {
|
|
373
|
+
return await requete.json()
|
|
374
|
+
} catch {
|
|
375
|
+
// Un corps illisible donne un objet vide, que la validation de Support
|
|
376
|
+
// refusera en nommant les champs manquants. C'est un meilleur message que
|
|
377
|
+
// « Unexpected token < in JSON at position 0 ».
|
|
378
|
+
return {}
|
|
379
|
+
}
|
|
380
|
+
}
|