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