@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.
@@ -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
+ }