@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/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @zevra/support — le client SERVEUR de Zevra Support
|
|
2
|
+
|
|
3
|
+
Client typé de l'API v1 (`PLAN.md` §4), **sans aucune dépendance**, et le
|
|
4
|
+
relais `/api/support/*` que le navigateur appelle à sa place.
|
|
5
|
+
|
|
6
|
+
> **La clé `sk_support_…` ne va jamais dans un navigateur.**
|
|
7
|
+
> `creerClientSupport()` jette si `window` et `document` existent. Ce n'est pas
|
|
8
|
+
> une précaution : la clé authentifie l'app entière, elle est lisible en clair
|
|
9
|
+
> dans un bundle, et le chemin qui l'y amène ne commence jamais par une
|
|
10
|
+
> mauvaise intention (voir `src/garde-serveur.ts`, qui le raconte en entier).
|
|
11
|
+
|
|
12
|
+
Le guide d'intégration complet — installation, exemple de bout en bout, pièges
|
|
13
|
+
— vit dans **`docs/integration.md`** du dépôt et sur
|
|
14
|
+
**https://support.zevra.tech/integration**.
|
|
15
|
+
|
|
16
|
+
## En trente secondes
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// app/api/support/[...chemin]/route.ts — le relais, côté serveur
|
|
20
|
+
import { creerRoutesSupport } from '@zevra/support'
|
|
21
|
+
|
|
22
|
+
export const { GET, POST } = creerRoutesSupport({
|
|
23
|
+
identifier: async (requete) => {
|
|
24
|
+
const session = await maSession(requete)
|
|
25
|
+
return session ? { email: session.email, idExterne: session.id } : null
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// n'importe où au serveur
|
|
32
|
+
import { creerClientSupport } from '@zevra/support'
|
|
33
|
+
const client = creerClientSupport() // lit SUPPORT_URL / SUPPORT_API_KEY / SUPPORT_APP
|
|
34
|
+
await client.creerDemande({ type: 'bug', titre, description, requerant: { email } })
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Ce qu'il y a dedans
|
|
38
|
+
|
|
39
|
+
| Module | Rôle | Pur ? |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `garde-serveur.ts` | refuse le chargement dans un navigateur | la décision l'est |
|
|
42
|
+
| `configuration.ts` | ce qui manque, et comment le dire | oui |
|
|
43
|
+
| `requete.ts` | URL, en-têtes, corps, borne des 256 Ko | oui |
|
|
44
|
+
| `reessai.ts` | 429 / 5xx, `Retry-After`, budget de temps | oui |
|
|
45
|
+
| `erreurs.ts` | la liste FERMÉE des codes | oui |
|
|
46
|
+
| `client.ts` | la boucle d'appel | non (réseau) |
|
|
47
|
+
| `relais/chemins.ts` | la liste blanche des six routes | oui |
|
|
48
|
+
| `relais/corps.ts` | l'identité imposée, les champs acceptés | oui |
|
|
49
|
+
| `relais/routes.ts` | les gestionnaires Next | non |
|
|
50
|
+
|
|
51
|
+
## Tests
|
|
52
|
+
|
|
53
|
+
Le paquet a sa propre chaîne — la configuration vitest de la racine ne ramasse
|
|
54
|
+
que les tests de l'app. Depuis la racine du dépôt :
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx vitest run --config packages/sdk/vitest.config.ts
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Ce qui est couvert : la clé dans `X-Support-Key` et jamais dans
|
|
61
|
+
`Authorization`, la borne de corps comptée en octets UTF-8, le `Retry-After`
|
|
62
|
+
illisible qui rend `null` et non `0`, l'attente qui ne tient pas dans le budget,
|
|
63
|
+
la liste blanche face à `/admin/apps` et aux segments de remontée, l'identité du
|
|
64
|
+
corps reçu qui est ignorée, le fil d'autrui qui rend 404, `url_portail` retiré,
|
|
65
|
+
et `Cache-Control: no-store` sur toutes les réponses.
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zevra/support",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Client serveur et relais /api/support de Zevra Support.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Le client de l'API v1. IMPUR — c'est lui qui parle au réseau.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Tout ce qu'il DÉCIDE est ailleurs (`requete.ts`, `reessai.ts`, `erreurs.ts`,
|
|
6
|
+
// `configuration.ts`) ; ici on ne fait qu'appeler, attendre et transmettre.
|
|
7
|
+
// C'est ce qui permet de tester le comportement sans monter un serveur.
|
|
8
|
+
|
|
9
|
+
import { lireConfiguration, type EtatConfiguration } from './configuration'
|
|
10
|
+
import { ErreurSupport, interpreterEchec } from './erreurs'
|
|
11
|
+
import { exigerServeur } from './garde-serveur'
|
|
12
|
+
import {
|
|
13
|
+
preparerRequete,
|
|
14
|
+
type EntreeRequete,
|
|
15
|
+
type ValeurQuery,
|
|
16
|
+
} from './requete'
|
|
17
|
+
import {
|
|
18
|
+
BUDGET_MS_DEFAUT,
|
|
19
|
+
DELAI_REQUETE_MS_DEFAUT,
|
|
20
|
+
MAX_TENTATIVES_DEFAUT,
|
|
21
|
+
analyserRetryApres,
|
|
22
|
+
deciderReessai,
|
|
23
|
+
} from './reessai'
|
|
24
|
+
import type {
|
|
25
|
+
DemandeCreee,
|
|
26
|
+
DemandeLue,
|
|
27
|
+
DepotPiece,
|
|
28
|
+
EntreeAjouterMessage,
|
|
29
|
+
EntreeCreerDemande,
|
|
30
|
+
EntreeDeposerPiece,
|
|
31
|
+
EntreeListerDemandes,
|
|
32
|
+
MessageAjoute,
|
|
33
|
+
PageDemandes,
|
|
34
|
+
ProgrammeActif,
|
|
35
|
+
} from './types'
|
|
36
|
+
|
|
37
|
+
export interface OptionsClientSupport {
|
|
38
|
+
/** Défaut : `SUPPORT_URL`. */
|
|
39
|
+
base?: string | null
|
|
40
|
+
/** Défaut : `SUPPORT_API_KEY`. NE DOIT JAMAIS venir d'un `NEXT_PUBLIC_*`. */
|
|
41
|
+
cle?: string | null
|
|
42
|
+
/** Défaut : `SUPPORT_APP`. Sert à repérer une clé recopiée d'une autre app. */
|
|
43
|
+
app?: string | null
|
|
44
|
+
/** Injectable pour les tests et pour un runtime sans `fetch` global. */
|
|
45
|
+
fetch?: typeof fetch
|
|
46
|
+
/** Délai d'une requête isolée. */
|
|
47
|
+
delaiMs?: number
|
|
48
|
+
/** Budget total, réessais et attentes compris. */
|
|
49
|
+
budgetMs?: number
|
|
50
|
+
maxTentatives?: number
|
|
51
|
+
/** Injectables : sans eux, un test de réessai dure réellement 500 ms. */
|
|
52
|
+
attendre?: (ms: number) => Promise<void>
|
|
53
|
+
horloge?: () => number
|
|
54
|
+
alea?: () => number
|
|
55
|
+
/** Appelé à chaque échec, avant réessai. Pour brancher VOTRE journal. */
|
|
56
|
+
journaliser?: (evenement: EvenementClient) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EvenementClient {
|
|
60
|
+
chemin: string
|
|
61
|
+
methode: string
|
|
62
|
+
statut: number | null
|
|
63
|
+
tentative: number
|
|
64
|
+
reessai: boolean
|
|
65
|
+
dureeMs: number
|
|
66
|
+
message?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ClientSupport {
|
|
70
|
+
/** L'état de la configuration : `pret`, et les phrases à afficher sinon. */
|
|
71
|
+
readonly etat: EtatConfiguration
|
|
72
|
+
creerDemande(entree: EntreeCreerDemande, options?: OptionsAppel): Promise<DemandeCreee>
|
|
73
|
+
listerDemandes(entree: EntreeListerDemandes): Promise<PageDemandes>
|
|
74
|
+
lireDemande(id: string): Promise<DemandeLue>
|
|
75
|
+
ajouterMessage(
|
|
76
|
+
demandeId: string,
|
|
77
|
+
entree: EntreeAjouterMessage,
|
|
78
|
+
options?: OptionsAppel,
|
|
79
|
+
): Promise<MessageAjoute>
|
|
80
|
+
demanderDepotPiece(entree: EntreeDeposerPiece): Promise<DepotPiece>
|
|
81
|
+
programmeActif(): Promise<ProgrammeActif>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface OptionsAppel {
|
|
85
|
+
/** `Idempotency-Key` : même clé + même app sous 24 h ⇒ même réponse. */
|
|
86
|
+
cleIdempotence?: string | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Crée le client. NE JETTE JAMAIS — sauf dans un navigateur (garde de clé).
|
|
91
|
+
*
|
|
92
|
+
* Il se crée même mal configuré : `client.etat.pret` dit si les appels
|
|
93
|
+
* peuvent aboutir, et chaque appel échoue alors en `configuration` avec la
|
|
94
|
+
* phrase exacte à corriger. Voir `configuration.ts` pour le pourquoi.
|
|
95
|
+
*/
|
|
96
|
+
export function creerClientSupport(options: OptionsClientSupport = {}): ClientSupport {
|
|
97
|
+
// ⚠️ Première ligne exécutée du chemin qui lit la clé. Dans un navigateur,
|
|
98
|
+
// on s'arrête ici, au chargement, avec la marche à suivre — et pas plus tard
|
|
99
|
+
// sur un 401 qui ferait chercher au mauvais endroit.
|
|
100
|
+
exigerServeur()
|
|
101
|
+
|
|
102
|
+
const env = lireEnvironnement()
|
|
103
|
+
const base = options.base ?? env.SUPPORT_URL ?? ''
|
|
104
|
+
const cle = options.cle ?? env.SUPPORT_API_KEY ?? ''
|
|
105
|
+
const app = options.app ?? env.SUPPORT_APP ?? ''
|
|
106
|
+
const etat = lireConfiguration({ base, cle, app })
|
|
107
|
+
|
|
108
|
+
const appelFetch = options.fetch ?? globalThis.fetch
|
|
109
|
+
const delaiMs = options.delaiMs ?? DELAI_REQUETE_MS_DEFAUT
|
|
110
|
+
const budgetMs = options.budgetMs ?? BUDGET_MS_DEFAUT
|
|
111
|
+
const maxTentatives = options.maxTentatives ?? MAX_TENTATIVES_DEFAUT
|
|
112
|
+
const horloge = options.horloge ?? (() => Date.now())
|
|
113
|
+
const alea = options.alea ?? Math.random
|
|
114
|
+
const attendre =
|
|
115
|
+
options.attendre ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
|
116
|
+
|
|
117
|
+
async function appeler<T>(
|
|
118
|
+
methode: 'GET' | 'POST',
|
|
119
|
+
chemin: string,
|
|
120
|
+
extra: { requete?: Record<string, ValeurQuery>; corps?: unknown; cleIdempotence?: string | null } = {},
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
if (!etat.pret) {
|
|
123
|
+
throw new ErreurSupport({
|
|
124
|
+
code: 'configuration',
|
|
125
|
+
message: `Support n'est pas branché sur cette app. ${etat.problemes.join(' ')}`,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
if (typeof appelFetch !== 'function') {
|
|
129
|
+
throw new ErreurSupport({
|
|
130
|
+
code: 'configuration',
|
|
131
|
+
message:
|
|
132
|
+
"Aucun `fetch` disponible dans ce runtime : passez-en un à creerClientSupport({ fetch }).",
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const entree: EntreeRequete = {
|
|
137
|
+
base,
|
|
138
|
+
cle,
|
|
139
|
+
methode,
|
|
140
|
+
chemin,
|
|
141
|
+
requete: extra.requete,
|
|
142
|
+
corps: extra.corps,
|
|
143
|
+
cleIdempotence: extra.cleIdempotence,
|
|
144
|
+
}
|
|
145
|
+
// Préparée UNE fois, hors de la boucle : elle ne dépend pas de la
|
|
146
|
+
// tentative, et la refaire ferait payer la sérialisation du corps à chaque
|
|
147
|
+
// réessai — sur un contexte de 200 Ko, ce n'est pas gratuit.
|
|
148
|
+
const preparee = preparerRequete(entree)
|
|
149
|
+
|
|
150
|
+
const debut = horloge()
|
|
151
|
+
let tentative = 1
|
|
152
|
+
|
|
153
|
+
for (;;) {
|
|
154
|
+
const resultat = await tenter(appelFetch, preparee, delaiMs)
|
|
155
|
+
const dureeMs = horloge() - debut
|
|
156
|
+
|
|
157
|
+
if (resultat.genre === 'reponse' && resultat.ok) {
|
|
158
|
+
options.journaliser?.({
|
|
159
|
+
chemin,
|
|
160
|
+
methode,
|
|
161
|
+
statut: resultat.statut,
|
|
162
|
+
tentative,
|
|
163
|
+
reessai: false,
|
|
164
|
+
dureeMs,
|
|
165
|
+
})
|
|
166
|
+
return resultat.donnees as T
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const statut = resultat.genre === 'reponse' ? resultat.statut : null
|
|
170
|
+
const retryApres = resultat.genre === 'reponse' ? resultat.retryApres : null
|
|
171
|
+
const decision = deciderReessai({
|
|
172
|
+
statut,
|
|
173
|
+
retryApres,
|
|
174
|
+
tentative,
|
|
175
|
+
maxTentatives,
|
|
176
|
+
ecouleMs: dureeMs,
|
|
177
|
+
budgetMs,
|
|
178
|
+
maintenant: new Date(horloge()),
|
|
179
|
+
gigue: alea(),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
options.journaliser?.({
|
|
183
|
+
chemin,
|
|
184
|
+
methode,
|
|
185
|
+
statut,
|
|
186
|
+
tentative,
|
|
187
|
+
reessai: decision.reessayer,
|
|
188
|
+
dureeMs,
|
|
189
|
+
message: resultat.genre === 'echec' ? resultat.message : undefined,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if (!decision.reessayer) {
|
|
193
|
+
throw composerErreur(resultat, statut, retryApres, decision.raison, horloge)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await attendre(decision.attendreMs)
|
|
197
|
+
tentative += 1
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
etat,
|
|
203
|
+
creerDemande: (entree, opts) =>
|
|
204
|
+
appeler<DemandeCreee>('POST', 'demandes', {
|
|
205
|
+
corps: entree,
|
|
206
|
+
cleIdempotence: opts?.cleIdempotence,
|
|
207
|
+
}),
|
|
208
|
+
// ⚠️ `async` et pas une simple flèche : la garde ci-dessous JETTE, et la
|
|
209
|
+
// signature promet une promesse. Un `throw` synchrone dans une méthode
|
|
210
|
+
// déclarée `Promise<…>` remonte hors de tout `.catch()` — l'appelant qui a
|
|
211
|
+
// écrit le branchement correct se prend quand même une exception non
|
|
212
|
+
// rattrapée, et, dans un gestionnaire de route, un 500 au lieu de son
|
|
213
|
+
// message.
|
|
214
|
+
listerDemandes: async (entree) => {
|
|
215
|
+
// ⚠️ LA CEINTURE DE `?email=`. `serialiserQuery` omet les valeurs vides
|
|
216
|
+
// — c'est ce qu'il faut pour un statut au repos, et c'est exactement ce
|
|
217
|
+
// qu'il ne faut pas ici : une adresse vide ne produit pas « les demandes
|
|
218
|
+
// de personne », elle produit une requête SANS filtre, et l'API rend
|
|
219
|
+
// alors toutes les demandes de l'app. Servie par le relais, c'est la
|
|
220
|
+
// boîte entière dans le navigateur d'un visiteur, sous la forme d'une
|
|
221
|
+
// réponse parfaitement valide que rien ne signale.
|
|
222
|
+
//
|
|
223
|
+
// On refuse donc AVANT le réseau. « On ne sait pas de qui » ne vaut pas
|
|
224
|
+
// « de tout le monde ».
|
|
225
|
+
const email = String(entree.email ?? '').trim()
|
|
226
|
+
if (email === '') {
|
|
227
|
+
throw new ErreurSupport({
|
|
228
|
+
code: 'validation',
|
|
229
|
+
message:
|
|
230
|
+
"Lister des demandes exige l'adresse du requérant : sans elle, l'API rendrait toutes les demandes de l'app.",
|
|
231
|
+
champs: { email: 'requise' },
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
return appeler<PageDemandes>('GET', 'demandes', {
|
|
235
|
+
requete: {
|
|
236
|
+
email,
|
|
237
|
+
statut: entree.statut as ValeurQuery,
|
|
238
|
+
limite: entree.limite,
|
|
239
|
+
curseur: entree.curseur,
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
},
|
|
243
|
+
lireDemande: (id) => appeler<DemandeLue>('GET', `demandes/${encodeURIComponent(id)}`),
|
|
244
|
+
ajouterMessage: (demandeId, entree, opts) =>
|
|
245
|
+
appeler<MessageAjoute>('POST', `demandes/${encodeURIComponent(demandeId)}/messages`, {
|
|
246
|
+
corps: entree,
|
|
247
|
+
cleIdempotence: opts?.cleIdempotence,
|
|
248
|
+
}),
|
|
249
|
+
demanderDepotPiece: (entree) => appeler<DepotPiece>('POST', 'pieces', { corps: entree }),
|
|
250
|
+
programmeActif: () => appeler<ProgrammeActif>('GET', 'programmes/actif'),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ─── Le détail mécanique ───────────────────────────────────────────── */
|
|
255
|
+
|
|
256
|
+
type Resultat =
|
|
257
|
+
| { genre: 'reponse'; ok: boolean; statut: number; donnees: unknown; retryApres: string | null }
|
|
258
|
+
| { genre: 'echec'; message: string; delai: boolean }
|
|
259
|
+
|
|
260
|
+
async function tenter(
|
|
261
|
+
appelFetch: typeof fetch,
|
|
262
|
+
preparee: ReturnType<typeof preparerRequete>,
|
|
263
|
+
delaiMs: number,
|
|
264
|
+
): Promise<Resultat> {
|
|
265
|
+
// ⚠️ `AbortSignal.timeout` plutôt qu'une course de promesses : sans
|
|
266
|
+
// annulation, la requête abandonnée continue de tenir une connexion. Sur un
|
|
267
|
+
// support momentanément lent, l'app cliente épuise son pool et tombe pour
|
|
268
|
+
// une raison qui n'a plus rien à voir avec le support.
|
|
269
|
+
const signal =
|
|
270
|
+
typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
271
|
+
? AbortSignal.timeout(delaiMs)
|
|
272
|
+
: undefined
|
|
273
|
+
|
|
274
|
+
let reponse: Response
|
|
275
|
+
try {
|
|
276
|
+
reponse = await appelFetch(preparee.url, {
|
|
277
|
+
method: preparee.methode,
|
|
278
|
+
headers: preparee.entetes,
|
|
279
|
+
body: preparee.corps,
|
|
280
|
+
signal,
|
|
281
|
+
})
|
|
282
|
+
} catch (erreur) {
|
|
283
|
+
const message = erreur instanceof Error ? erreur.message : String(erreur)
|
|
284
|
+
const delai =
|
|
285
|
+
erreur instanceof Error && (erreur.name === 'TimeoutError' || erreur.name === 'AbortError')
|
|
286
|
+
return { genre: 'echec', message, delai }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const retryApres = reponse.headers?.get?.('Retry-After') ?? null
|
|
290
|
+
const donnees = await lireCorps(reponse)
|
|
291
|
+
return { genre: 'reponse', ok: reponse.ok, statut: reponse.status, donnees, retryApres }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Lit le corps SANS jamais jeter.
|
|
296
|
+
*
|
|
297
|
+
* Un 502 rendu par un proxy est du HTML ; un 204 n'a pas de corps. Dans les
|
|
298
|
+
* deux cas, `response.json()` jette, et l'exception remplacerait l'erreur
|
|
299
|
+
* réelle (« 502 ») par une erreur de syntaxe JSON — on chercherait alors un
|
|
300
|
+
* bug de sérialisation qui n'existe pas.
|
|
301
|
+
*/
|
|
302
|
+
async function lireCorps(reponse: Response): Promise<unknown> {
|
|
303
|
+
let texte: string
|
|
304
|
+
try {
|
|
305
|
+
texte = await reponse.text()
|
|
306
|
+
} catch {
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
if (texte.trim() === '') return null
|
|
310
|
+
try {
|
|
311
|
+
return JSON.parse(texte)
|
|
312
|
+
} catch {
|
|
313
|
+
return texte
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function composerErreur(
|
|
318
|
+
resultat: Resultat,
|
|
319
|
+
statut: number | null,
|
|
320
|
+
retryApres: string | null,
|
|
321
|
+
raisonAbandon: string,
|
|
322
|
+
horloge: () => number,
|
|
323
|
+
): ErreurSupport {
|
|
324
|
+
if (resultat.genre === 'echec') {
|
|
325
|
+
return new ErreurSupport({
|
|
326
|
+
code: resultat.delai ? 'delai' : 'reseau',
|
|
327
|
+
message: resultat.delai
|
|
328
|
+
? "Le support n'a pas répondu dans le délai imparti."
|
|
329
|
+
: `Le support est injoignable : ${resultat.message}`,
|
|
330
|
+
detail: raisonAbandon,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
const donnees = interpreterEchec({
|
|
334
|
+
statut: resultat.statut,
|
|
335
|
+
corps: resultat.donnees,
|
|
336
|
+
retryApresMs: analyserRetryApres(retryApres, new Date(horloge())),
|
|
337
|
+
})
|
|
338
|
+
return new ErreurSupport({ ...donnees, detail: donnees.detail ?? raisonAbandon })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Lit l'environnement sans supposer que `process` existe.
|
|
343
|
+
*
|
|
344
|
+
* ⚠️ L'indirection par `globalThis` n'est pas du style : ce paquet doit
|
|
345
|
+
* s'installer dans une app qui tourne sur un runtime edge, où `process` peut
|
|
346
|
+
* ne pas être défini. Écrit `process.env.SUPPORT_URL` en clair, le module
|
|
347
|
+
* lèverait un `ReferenceError` à l'import — donc au démarrage, donc avant
|
|
348
|
+
* qu'aucune requête n'ait lieu, pour une capacité qui n'était peut-être même
|
|
349
|
+
* pas utilisée sur ce runtime.
|
|
350
|
+
*/
|
|
351
|
+
function lireEnvironnement(): Record<string, string | undefined> {
|
|
352
|
+
const global = globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
353
|
+
return global.process?.env ?? {}
|
|
354
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Ce que cette app sait faire avec Support — et ce qui lui manque. PUR.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Trois variables sont posées par l'admin Zevra lors de la liaison (PLAN.md
|
|
6
|
+
// §6) : `SUPPORT_URL`, `SUPPORT_API_KEY`, `SUPPORT_APP`. Tant qu'elles ne sont
|
|
7
|
+
// pas là, la capacité est ÉTEINTE — elle ne jette pas.
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ C'est la règle du dépôt Support, et elle vaut encore plus chez l'app
|
|
10
|
+
// cliente : un `throw` à la création du client se déclenche à l'import du
|
|
11
|
+
// module, donc au démarrage du serveur, donc AVANT la première requête. Une
|
|
12
|
+
// app entière refuserait de démarrer parce que le bouton « Aide » n'est pas
|
|
13
|
+
// branché. Ici : le client se crée toujours, il annonce ce qui lui manque, et
|
|
14
|
+
// chaque appel échoue proprement en `configuration` avec la phrase à lire.
|
|
15
|
+
|
|
16
|
+
/** Le préfixe de toute clé de Support (`src/lib/cles/format.ts`). */
|
|
17
|
+
export const PREFIXE_CLE = 'sk_support_'
|
|
18
|
+
|
|
19
|
+
export interface EntreeConfiguration {
|
|
20
|
+
base?: string | null
|
|
21
|
+
cle?: string | null
|
|
22
|
+
app?: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EtatConfiguration {
|
|
26
|
+
pret: boolean
|
|
27
|
+
/** Une phrase par problème, prête à afficher. Vide quand tout va bien. */
|
|
28
|
+
problemes: string[]
|
|
29
|
+
/** Le slug LU dans la clé, quand elle est bien formée. */
|
|
30
|
+
slugDeLaCle: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extrait le slug d'une clé `sk_support_<slug>_<32 hex>`. PUR, total.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ Ceci ne VÉRIFIE rien : la vérification exige le sha256 stocké côté
|
|
37
|
+
* Support. On lit seulement une étiquette, pour pouvoir dire « cette clé est
|
|
38
|
+
* celle de lexform, pas de cockpit » — la confusion la plus banale quand on
|
|
39
|
+
* copie des variables d'une app à l'autre, et celle qui coûte le plus cher à
|
|
40
|
+
* diagnostiquer : Support répond 401 « clé invalide », ce qui est vrai et ne
|
|
41
|
+
* dit rien.
|
|
42
|
+
*
|
|
43
|
+
* Le slug peut contenir des tirets (`lexform-beta`) ; l'aléatoire, non. On
|
|
44
|
+
* découpe donc sur le DERNIER souligné, pas sur le premier.
|
|
45
|
+
*/
|
|
46
|
+
export function slugDeCle(cle: string | null | undefined): string | null {
|
|
47
|
+
if (typeof cle !== 'string') return null
|
|
48
|
+
const detoure = cle.trim()
|
|
49
|
+
if (!detoure.startsWith(PREFIXE_CLE)) return null
|
|
50
|
+
const reste = detoure.slice(PREFIXE_CLE.length)
|
|
51
|
+
const dernierSouligne = reste.lastIndexOf('_')
|
|
52
|
+
if (dernierSouligne <= 0) return null
|
|
53
|
+
const slug = reste.slice(0, dernierSouligne)
|
|
54
|
+
const aleatoire = reste.slice(dernierSouligne + 1)
|
|
55
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null
|
|
56
|
+
if (!/^[0-9a-f]{32}$/.test(aleatoire)) return null
|
|
57
|
+
return slug
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Dresse l'état de la configuration. PUR.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ Un slug qui ne correspond pas à `SUPPORT_APP` est un AVERTISSEMENT, pas
|
|
64
|
+
* un refus : c'est presque toujours une erreur de copie, mais pas toujours —
|
|
65
|
+
* une app peut légitimement écrire au support d'une autre pendant une
|
|
66
|
+
* migration. Refuser sur cette base transformerait une gêne en panne.
|
|
67
|
+
*/
|
|
68
|
+
export function lireConfiguration(entree: EntreeConfiguration): EtatConfiguration {
|
|
69
|
+
const problemes: string[] = []
|
|
70
|
+
const base = (entree.base ?? '').trim()
|
|
71
|
+
const cle = (entree.cle ?? '').trim()
|
|
72
|
+
const app = (entree.app ?? '').trim()
|
|
73
|
+
|
|
74
|
+
if (base === '') {
|
|
75
|
+
problemes.push(
|
|
76
|
+
"SUPPORT_URL n'est pas définie : l'admin Zevra la pose en même temps que la clé, à la liaison de l'app au Support.",
|
|
77
|
+
)
|
|
78
|
+
} else if (!/^https?:\/\//i.test(base)) {
|
|
79
|
+
problemes.push(
|
|
80
|
+
`SUPPORT_URL doit commencer par http:// ou https:// (lu : « ${base} »).`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const slug = slugDeCle(cle)
|
|
85
|
+
if (cle === '') {
|
|
86
|
+
problemes.push(
|
|
87
|
+
"SUPPORT_API_KEY n'est pas définie : demandez la liaison au Support depuis l'admin Zevra.",
|
|
88
|
+
)
|
|
89
|
+
} else if (slug === null) {
|
|
90
|
+
problemes.push(
|
|
91
|
+
`SUPPORT_API_KEY ne ressemble pas à une clé de Support (attendu : ${PREFIXE_CLE}<app>_<32 caractères>). Une clé tronquée à la copie donne exactement cette forme.`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `pret` se fige AVANT l'avertissement de slug : ce qui suit est une
|
|
96
|
+
// discordance à signaler, pas un empêchement d'appeler.
|
|
97
|
+
const pret = problemes.length === 0
|
|
98
|
+
|
|
99
|
+
if (app !== '' && slug !== null && slug !== app) {
|
|
100
|
+
problemes.push(
|
|
101
|
+
`SUPPORT_APP vaut « ${app} » mais la clé est celle de « ${slug} ». Les demandes seront classées sous ${slug}.`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { pret, problemes, slugDeLaCle: slug }
|
|
106
|
+
}
|