@mostajs/recette 0.1.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/CHANGELOG.md +53 -0
- package/README.md +173 -0
- package/llms.txt +91 -0
- package/package.json +93 -0
- package/src/index.js +14 -0
- package/src/memory-repo.js +17 -0
- package/src/recette.js +489 -0
- package/src/register.js +55 -0
- package/src/sceau.js +98 -0
- package/src/schemas.js +159 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Journal des versions — @mostajs/recette
|
|
2
|
+
|
|
3
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
|
|
5
|
+
## 0.1.0 — 2026-09-10
|
|
6
|
+
|
|
7
|
+
Première version. Le moteur de la recette applicative, extrait du besoin de **déléguer la recette
|
|
8
|
+
au client** sans lui donner les outils du développeur.
|
|
9
|
+
|
|
10
|
+
### Ce qu'il porte
|
|
11
|
+
|
|
12
|
+
- **Sept entités** : `Campagne` (sur une `Release` QaTrax **référencée**), `Point`, `Feuillet`,
|
|
13
|
+
`Releve`, `Anomalie`, `Dossier`, `Certificat`.
|
|
14
|
+
- **Le parcours en six étapes**, sans saut de cran, avec historique immuable des transitions.
|
|
15
|
+
- **La trame** : `trame.synchroniser()` la tire de QaTrax par un port, `trame.importer()` la lit —
|
|
16
|
+
plan `mostajs-devtest/1`, le format que `@mostajs/qa-engine` sait déjà exporter. Enveloppe
|
|
17
|
+
`mostajs-recette-trame/1`, signature vérifiée, **import idempotent par `ref`**.
|
|
18
|
+
- **La sélection est dans ValiTrax** : la trame arrive entière, on en retient une partie. C'est le
|
|
19
|
+
responsable de recette qui sait ce que son client doit éprouver, pas le certificateur.
|
|
20
|
+
- **Le composeur rend la structure du manifeste des feuillets PDF** —
|
|
21
|
+
`{ ref, libelle, attendu, options: ['ok','ko','er'] }` : papier, écran et moteur parlent une
|
|
22
|
+
seule langue.
|
|
23
|
+
- **L'anomalie n'est pas un bug** : deux gravités coexistent, celle que le client propose et celle
|
|
24
|
+
que l'éditeur qualifie. La promotion garde le lien vers QaTrax sans créer le bug.
|
|
25
|
+
- **Le dossier figé porte une empreinte SHA-256**, et le certificat reçu doit s'y adosser.
|
|
26
|
+
- **Le feu d'exploitation dérive du certificat** : il ne se coche pas.
|
|
27
|
+
- **`@mostajs/recette/sceau`** : signature Ed25519 et enveloppe X25519 par `@mostajs/crypto-box`,
|
|
28
|
+
sur un chemin d'import à part — une app qui n'exporte rien n'installe aucune cryptographie.
|
|
29
|
+
- 13 essais (`@mostajs/mjs-unit`), tous portant sur ce que le moteur **refuse**.
|
|
30
|
+
|
|
31
|
+
### Trois décisions qui ont façonné le module
|
|
32
|
+
|
|
33
|
+
**① Un transport ASYMÉTRIQUE.** L'aller — la trame — relie deux de nos serveurs : il est
|
|
34
|
+
**automatique**, tiré par un port injecté. Le retour — le dossier — **passe par le client**, qui
|
|
35
|
+
valide et rend : il voyage donc comme un **fichier signé**, à la main. Ce n'est pas un pis-aller,
|
|
36
|
+
c'est ce qui permet à un client sans accès vers nos serveurs de rendre sa recette. Et la signature
|
|
37
|
+
vaut des deux côtés : un canal direct dit d'où vient la connexion, pas ce que le document contient.
|
|
38
|
+
|
|
39
|
+
**② L'import n'écrase jamais l'enrichissement.** Une trame se réimporte à chaque version
|
|
40
|
+
candidate. Si l'import régénérait les points, chaque réimport effacerait la formulation écrite
|
|
41
|
+
pour le client, les rôles et l'ordre — **sans lever d'erreur**, et l'on ne s'en apercevrait qu'en
|
|
42
|
+
distribuant les feuillets. Même garde, et même raison, que `seedRbac` dans LabTrax.
|
|
43
|
+
|
|
44
|
+
**③ Signer et sceller sont deux actes distincts.** On signe avec sa clé PRIVÉE (prouver
|
|
45
|
+
l'origine) ; on scelle avec la clé PUBLIQUE du destinataire (fermer le pli). L'ordre imposé est
|
|
46
|
+
**signer puis sceller** — l'inverse signerait un chiffré, et l'on prouverait avoir transmis une
|
|
47
|
+
boîte, pas ce qu'elle contient.
|
|
48
|
+
|
|
49
|
+
### Origine
|
|
50
|
+
|
|
51
|
+
La recette LabTrax du 2026-09-09 : 13 feuillets, 25 exemplaires, près de trois cents observations
|
|
52
|
+
qui vivaient sur du papier et nulle part ailleurs — pendant que 91 essais automatisés étaient au
|
|
53
|
+
vert avec deux écrans cassés qu'aucun d'eux n'atteignait.
|
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# @mostajs/recette — validation & recette applicative
|
|
2
|
+
|
|
3
|
+
**Auteur** : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
4
|
+
**Niveau** : N3 métier · **Forme** : moteur · **Dépend de** : aucun autre N3
|
|
5
|
+
|
|
6
|
+
Le moteur de la **recette** : une trame reçue du certificateur, des points enrichis pour le client,
|
|
7
|
+
des feuillets composés, des relevés `ok`/`ko`/`er`, des anomalies déclarées puis qualifiées, un
|
|
8
|
+
dossier figé et **exporté signé** — puis un certificat reçu, vérifié, et un feu d'exploitation qui
|
|
9
|
+
en **dérive**.
|
|
10
|
+
|
|
11
|
+
Il fait tourner **ValiTrax** (`valitrax.mostajs.dev`), par lequel un éditeur délègue la recette à
|
|
12
|
+
son client ou à son équipe.
|
|
13
|
+
|
|
14
|
+
## La chaîne, et son transport
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
DEV → QaTrax ═══ trame signée ═══════════════▶ ValiTrax ───▶ le CLIENT
|
|
18
|
+
AUTOMATIQUE (deux de nos serveurs) │ lit, coche
|
|
19
|
+
QaTrax ◀── dossier figé, signé (et scellé) ────┘
|
|
20
|
+
│ À LA MAIN — le document passe par le client
|
|
21
|
+
│ vérifie · valide le cycle · scelle le PV
|
|
22
|
+
└──────── certificat signé + le feu ────────▶ ValiTrax → l'expose au client → PROD
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Le transport est ASYMÉTRIQUE, et c'est voulu.**
|
|
26
|
+
|
|
27
|
+
**L'aller est automatique** : la trame relie deux de vos serveurs, rien n'y justifie une main.
|
|
28
|
+
`trame.synchroniser()` la tire par un port injecté — le moteur ne connaît ni URL ni jeton.
|
|
29
|
+
|
|
30
|
+
**Le retour est humain**, parce que le document **passe par le client** : c'est lui qui valide, et
|
|
31
|
+
c'est lui qui rend. Ce n'est pas un pis-aller — c'est ce qui permet à un client sans accès vers
|
|
32
|
+
vos serveurs de rendre sa recette.
|
|
33
|
+
|
|
34
|
+
**La signature vaut dans les deux sens**, y compris sur le canal automatique : un canal direct dit
|
|
35
|
+
d'où vient la *connexion*, pas ce que le *document* contient. Une mauvaise URL injecterait une
|
|
36
|
+
trame étrangère sans bruit, et le client éprouverait autre chose que le spécifié — sans pouvoir
|
|
37
|
+
s'en apercevoir, faute de référentiel.
|
|
38
|
+
|
|
39
|
+
**QaTrax FAIT le PV. ValiTrax l'EXPOSE.** Le client ne va jamais sur QaTrax : c'est un outil de
|
|
40
|
+
développement, et l'écarter est la raison d'être de ValiTrax.
|
|
41
|
+
|
|
42
|
+
## Ce qu'il ne fait pas
|
|
43
|
+
|
|
44
|
+
| il ne fait pas | qui le fait |
|
|
45
|
+
|---|---|
|
|
46
|
+
| tests, exécutions, bugs, version candidate | `@mostajs/qa-engine` — **référencés**, jamais recopiés |
|
|
47
|
+
| produire les formulaires du client | ce module — QaTrax n'envoie que la **trame** |
|
|
48
|
+
| émettre le certificat | le certificateur (QaTrax) — ce module le **reçoit** et le vérifie |
|
|
49
|
+
| implémenter la cryptographie | `@mostajs/crypto-box` — Ed25519, enveloppe X25519 |
|
|
50
|
+
| sceller un PV collégial · le rendre vérifiable par QR | `@mostajs/deliberation` · `@mostajs/certificates` (côté certificateur) |
|
|
51
|
+
| fils, mentions · convocations | `@mostajs/collab` · `@mostajs/notifications` |
|
|
52
|
+
| engendrer les PDF des feuillets | l'outillage de l'application |
|
|
53
|
+
| lire une feuille photographiée | `@mostajs/ocr` |
|
|
54
|
+
|
|
55
|
+
## Le point de recette — deux moitiés, une frontière
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
côté TRAME (vient de QaTrax) côté ENRICHISSEMENT (appartient à ValiTrax)
|
|
59
|
+
ref · specRef · titreTrame libelle · attendu · role · ordre · langue · retenu
|
|
60
|
+
priorite · etapesTrame ─────────────────────────────────────────────────
|
|
61
|
+
réécrit à chaque import L'IMPORT N'Y TOUCHE JAMAIS
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> « Vérifier que `POST /dossiers` refuse un livrable non accepté » est un **titre de trame**.
|
|
65
|
+
> « Essayez de programmer une soutenance sur un dossier dont un livrable manque — le refus doit
|
|
66
|
+
> dire lequel » est un **libellé de recette**.
|
|
67
|
+
|
|
68
|
+
Même exigence, deux lecteurs, deux textes. Une trame se réimporte à chaque version candidate ; si
|
|
69
|
+
l'import régénérait les points, chaque réimport effacerait la formulation écrite pour le client,
|
|
70
|
+
les rôles et l'ordre — et l'on ne s'en apercevrait qu'en distribuant les feuillets.
|
|
71
|
+
|
|
72
|
+
**La sélection se fait dans ValiTrax**, pas chez QaTrax : la trame arrive entière, `points.retenir()`
|
|
73
|
+
en garde une partie. C'est le responsable de recette qui sait ce que SON client doit éprouver.
|
|
74
|
+
|
|
75
|
+
## Le client n'est pas une machine : il lit, il coche
|
|
76
|
+
|
|
77
|
+
`feuillets.composer()` rend **exactement la structure du manifeste** des feuillets PDF :
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
{ genre: 'action', ref: 'T-APP-9', libelle: 'Programmez la soutenance',
|
|
81
|
+
attendu: 'le refus nomme le livrable', specRef: 'SPEC-APP-09',
|
|
82
|
+
enrichi: true, options: ['ok', 'ko', 'er'] }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Papier, écran et moteur parlent **une seule langue**. Un troisième format, et l'on retomberait
|
|
86
|
+
dans la divination : quel rectangle porte quelle réponse ?
|
|
87
|
+
|
|
88
|
+
`enrichi` dit si le client lira du français de métier ou un titre de cas de test. Un point non
|
|
89
|
+
enrichi reste lisible — mais cela se voit.
|
|
90
|
+
|
|
91
|
+
## Trois valeurs, plus l'absence
|
|
92
|
+
|
|
93
|
+
| | ce que ça dit | ce qu'on corrige |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `ok` | conforme | rien |
|
|
96
|
+
| `ko` | le logiciel marche, mais **autrement** que le plan l'annonce | le **document** |
|
|
97
|
+
| `er` | exception, panne | le **code** |
|
|
98
|
+
| *rien* | non joué — l'étape précédente bloquait | l'ordonnancement de la séance |
|
|
99
|
+
|
|
100
|
+
`ko` et `er` n'ont ni le même destinataire ni la même urgence.
|
|
101
|
+
|
|
102
|
+
## Usage
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
import { createRecette, createMemoryRepositories } from '@mostajs/recette';
|
|
106
|
+
import { creerSceau } from '@mostajs/recette/sceau'; // chemin à part : lui seul importe crypto-box
|
|
107
|
+
|
|
108
|
+
const recette = createRecette({
|
|
109
|
+
repositories,
|
|
110
|
+
ports: {
|
|
111
|
+
trameSource: { lire: ({ projet }) => fetch(`${QATRAX}/trame?p=${projet}`).then((r) => r.json()) },
|
|
112
|
+
sceau: creerSceau({
|
|
113
|
+
signature: { privee: process.env.VALITRAX_CLE_PRIVEE, nom: 'valitrax' }, // NOTRE identité
|
|
114
|
+
destinataire: { publique: process.env.QATRAX_CLE_RECEPTION }, // sceller pour eux
|
|
115
|
+
certificateur: { publique: process.env.QATRAX_CLE_SIGNATURE }, // vérifier leur PV
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const c = await recette.campagnes.ouvrir({ releaseRef: 'qatrax:release:42', instanceUrl: 'https://banc…' });
|
|
121
|
+
await recette.trame.synchroniser(c.id, { projet: 'labtrax' }); // tirée de QaTrax, vérifiée, idempotente
|
|
122
|
+
await recette.points.enrichir(pointId, { libelle: '…', attendu: '…', role: 'S' });
|
|
123
|
+
await recette.points.retenir(c.id, ['T-APP-9', 'T-APP-13']);
|
|
124
|
+
await recette.campagnes.avancer(c.id, 'verification');
|
|
125
|
+
const { feuillet, cases } = await recette.feuillets.composer({ campagneId: c.id, role: 'S' });
|
|
126
|
+
// … consigner · rendre · figer · exporter · recevoir le certificat · autoriser l'exploitation
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Signer et sceller ne sont pas la même chose
|
|
130
|
+
|
|
131
|
+
| | avec quelle clé | ce que ça prouve |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| **signer** | **notre privée** (Ed25519) | l'**origine** — un document signé se lit par tous |
|
|
134
|
+
| **sceller** | la **publique du destinataire** (X25519) | le **pli fermé** — lui seul ouvre, rien n'est authentifié |
|
|
135
|
+
|
|
136
|
+
L'ordre est **signer PUIS sceller**. Sceller d'abord signerait un chiffré : on prouverait avoir
|
|
137
|
+
transmis une boîte, pas ce qu'elle contient.
|
|
138
|
+
|
|
139
|
+
Les verbes du sceau **n'existent que si leur clé existe**. Une clé manquante se voit donc au
|
|
140
|
+
démarrage, et non à la fin de la recette quand tout le monde attend le document.
|
|
141
|
+
|
|
142
|
+
## Les gardes — c'est par elles qu'il faut juger ce module
|
|
143
|
+
|
|
144
|
+
1. **Une recette ne se joue pas sur la production** : l'URL est refusée.
|
|
145
|
+
2. **Le parcours ne saute pas de cran** — `candidate → verification → recette → validation → figee
|
|
146
|
+
→ certifiee → exploitation`.
|
|
147
|
+
3. **La trame est vérifiée** : enveloppe `mostajs-recette-trame/1`, plan `mostajs-devtest/1`,
|
|
148
|
+
signature valide — même sur le canal automatique, sinon refus.
|
|
149
|
+
4. **L'import n'écrase jamais l'enrichissement**, et dit combien il en a préservé.
|
|
150
|
+
5. **On n'enrichit pas par-dessus la trame** : `titreTrame`, `specRef` et consorts sont refusés.
|
|
151
|
+
6. **On ne consigne que sur un feuillet REMIS**, et un `er` porte son **heure**.
|
|
152
|
+
7. **Qualifier n'efface pas la gravité annoncée par le client** — le désaccord reste lisible.
|
|
153
|
+
8. **Écarter une anomalie exige un motif.**
|
|
154
|
+
9. **Le figement refuse et NOMME ce qui manque** : feuillets non rendus, anomalies non qualifiées,
|
|
155
|
+
bloquantes ni vérifiées ni écartées.
|
|
156
|
+
10. **Un export non signé est refusé** : il ne prouverait rien, et c'est tout ce qu'on lui demande.
|
|
157
|
+
11. **Le certificat doit citer l'EMPREINTE du dossier**, et sa **signature est vérifiée**.
|
|
158
|
+
12. **Le feu dérive du certificat** : pas de certificat, verdict `refuse`, ou certificat sans le
|
|
159
|
+
feu — l'exploitation reste fermée.
|
|
160
|
+
|
|
161
|
+
## L'empreinte décide de tout
|
|
162
|
+
|
|
163
|
+
Le dossier figé porte un **SHA-256 de son contenu**, et le certificat reçu doit la citer. Sans
|
|
164
|
+
elle, un certificat porterait sur « la recette » — c'est-à-dire sur rien de vérifiable. Avec elle,
|
|
165
|
+
il porte sur **ce dossier-là**, et le client peut le prouver seul.
|
|
166
|
+
|
|
167
|
+
## Essais
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm test # 13 essais — ils éprouvent ce que le moteur REFUSE
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Un moteur de recette qui accepte tout ne sert à rien : c'est le document Word qu'il remplace.
|
package/llms.txt
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @mostajs/recette — fiche
|
|
2
|
+
# Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
QUESTION À LAQUELLE IL RÉPOND
|
|
5
|
+
Qui a éprouvé cette version candidate, qu'a-t-il constaté, et sur quel acte repose
|
|
6
|
+
l'autorisation de la mettre en production ?
|
|
7
|
+
|
|
8
|
+
NIVEAU N3 · métier · forme : moteur · dépend de : aucun autre N3
|
|
9
|
+
@mostajs/qa-engine n'est ni importé ni appelé : ce qui circule est un FICHIER SIGNÉ.
|
|
10
|
+
@mostajs/crypto-box n'est importé que par `@mostajs/recette/sceau`, chemin à part.
|
|
11
|
+
|
|
12
|
+
ENTITÉS (7) Campagne · Point · Feuillet · Releve · Anomalie · Dossier · Certificat
|
|
13
|
+
VERBES ouvrir · synchroniser · importer · enrichir · retenir · composer · remettre · consigner ·
|
|
14
|
+
declarer · qualifier · figer · exporter · recevoir · autoriser
|
|
15
|
+
|
|
16
|
+
PARCOURS candidate → verification → recette → validation → figee → certifiee → exploitation
|
|
17
|
+
(`abandonnee` : seule sortie latérale ; fermée une fois l'exploitation autorisée —
|
|
18
|
+
le logiciel est alors en production, « abandonner » n'y veut plus rien dire)
|
|
19
|
+
|
|
20
|
+
LA CHAÎNE — TRANSPORT ASYMÉTRIQUE
|
|
21
|
+
ALLER QaTrax ==trame signée==> ValiTrax : AUTOMATIQUE (deux de nos serveurs, port injecté)
|
|
22
|
+
puis ValiTrax --feuillets--> le CLIENT : il lit, il coche
|
|
23
|
+
RETOUR ValiTrax --dossier figé signé--> QaTrax : À LA MAIN (le document passe par le client)
|
|
24
|
+
puis QaTrax --certificat signé + le FEU--> ValiTrax --l'EXPOSE--> le client
|
|
25
|
+
La signature vaut DANS LES DEUX SENS : un canal direct dit d'où vient la connexion, pas ce que
|
|
26
|
+
le document contient. Une mauvaise URL injecterait une trame étrangère sans bruit.
|
|
27
|
+
QaTrax FAIT le PV, ValiTrax l'EXPOSE : le client ne va jamais sur un outil de développement.
|
|
28
|
+
|
|
29
|
+
LE POINT DE RECETTE — DEUX MOITIÉS
|
|
30
|
+
TRAME (de QaTrax, réécrite à l'import) : ref · specRef · titreTrame · priorite · etapesTrame
|
|
31
|
+
ENRICHISSEMENT (à ValiTrax, JAMAIS écrasé) : libelle · attendu · role · ordre · langue · retenu
|
|
32
|
+
« Vérifier que POST /dossiers refuse un livrable non accepté » = titre de trame.
|
|
33
|
+
« Essayez de programmer une soutenance sur un dossier dont un livrable manque » = libellé de
|
|
34
|
+
recette. Même exigence, deux lecteurs, deux textes.
|
|
35
|
+
LA SÉLECTION EST DANS VALITRAX : la trame arrive entière, on en retient une partie.
|
|
36
|
+
|
|
37
|
+
LE CLIENT N'EST PAS UNE MACHINE : IL LIT, IL COCHE
|
|
38
|
+
`feuillets.composer()` rend la structure EXACTE du manifeste des feuillets PDF :
|
|
39
|
+
{ genre:'action', ref, libelle, attendu, specRef, enrichi, options:['ok','ko','er'] }
|
|
40
|
+
Papier, écran et moteur parlent une seule langue — un troisième format rouvrirait la divination.
|
|
41
|
+
|
|
42
|
+
TROIS VALEURS, PLUS L'ABSENCE
|
|
43
|
+
ok = conforme · ko = marche AUTREMENT que le plan (corriger le DOCUMENT) ·
|
|
44
|
+
er = panne (corriger le CODE) · rien = non joué
|
|
45
|
+
|
|
46
|
+
CE QU'IL NE FAIT PAS, ET QUI LE FAIT
|
|
47
|
+
tests / bugs / Release ............ @mostajs/qa-engine (RÉFÉRENCÉS, jamais recopiés)
|
|
48
|
+
produire les formulaires .......... ce module — QaTrax n'envoie que la trame
|
|
49
|
+
émettre le certificat ............. le certificateur — ce module le REÇOIT et le VÉRIFIE
|
|
50
|
+
cryptographie ..................... @mostajs/crypto-box (Ed25519, enveloppe X25519)
|
|
51
|
+
PV collégial scellé / QR .......... @mostajs/deliberation / @mostajs/certificates
|
|
52
|
+
fils, mentions / convocations ..... @mostajs/collab / @mostajs/notifications
|
|
53
|
+
PDF des feuillets ................. l'outillage de l'application
|
|
54
|
+
feuille photographiée ............. @mostajs/ocr
|
|
55
|
+
|
|
56
|
+
SIGNER ≠ SCELLER
|
|
57
|
+
signer = clé PRIVÉE de l'émetteur → prouve l'ORIGINE (le document reste lisible par tous)
|
|
58
|
+
sceller = clé PUBLIQUE du destinataire → ferme le PLI (lui seul ouvre ; rien n'est authentifié)
|
|
59
|
+
Ordre imposé : signer PUIS sceller. L'inverse signerait un chiffré — on prouverait avoir
|
|
60
|
+
transmis une boîte, pas ce qu'elle contient.
|
|
61
|
+
Les verbes du sceau n'existent que si leur clé existe : une clé manquante se voit au démarrage,
|
|
62
|
+
pas à la fin de la recette quand tout le monde attend le document.
|
|
63
|
+
|
|
64
|
+
LES GARDES — juger le module par elles
|
|
65
|
+
· l'URL de production est refusée
|
|
66
|
+
· le parcours ne saute pas de cran, et ne recule jamais
|
|
67
|
+
· trame : enveloppe, format ET signature vérifiés — même tirée automatiquement
|
|
68
|
+
· L'IMPORT N'ÉCRASE JAMAIS L'ENRICHISSEMENT, et dit combien il en a préservé
|
|
69
|
+
· on n'enrichit pas par-dessus la trame (titreTrame, specRef… refusés)
|
|
70
|
+
· on ne consigne que sur un feuillet REMIS ; un `er` sans HEURE est refusé
|
|
71
|
+
· qualifier n'efface pas la gravité annoncée par le client
|
|
72
|
+
· écarter EXIGE un motif
|
|
73
|
+
· le figement refuse et NOMME ce qui manque
|
|
74
|
+
· un export NON SIGNÉ est refusé
|
|
75
|
+
· le certificat doit citer l'EMPREINTE du dossier, et sa signature est vérifiée
|
|
76
|
+
· l'exploitation ne s'autorise que sur un certificat portant le feu
|
|
77
|
+
|
|
78
|
+
L'EMPREINTE DÉCIDE DE TOUT
|
|
79
|
+
SHA-256 du contenu figé. Le certificat doit la citer. Sans elle il porterait sur « la recette »,
|
|
80
|
+
c'est-à-dire sur rien de vérifiable.
|
|
81
|
+
|
|
82
|
+
PIÈGE CONNU
|
|
83
|
+
Réimporter une trame est le geste le plus fréquent de la vie d'une campagne. Toute évolution qui
|
|
84
|
+
ferait écrire l'import sur le côté enrichissement détruirait le travail humain SANS ERREUR — on
|
|
85
|
+
ne s'en apercevrait qu'en distribuant les feuillets. `CHAMPS_TRAME` est la liste blanche ; elle
|
|
86
|
+
se lit par `trame.champsEcrasables()`.
|
|
87
|
+
|
|
88
|
+
USAGE createRecette({ repositories, ports: { sceau, trameSource, audit }, clock, numeroteur })
|
|
89
|
+
creerSceau({ signature, destinataire, certificateur }) ← @mostajs/recette/sceau
|
|
90
|
+
engendrerClefs() ← une fois, à la mise en service ; les clés vont au coffre
|
|
91
|
+
ESSAIS npm test — 13 essais, tous sur ce que le moteur REFUSE
|
package/package.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mostajs/recette",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Validation & recette applicative : trame `mostajs-devtest/1` reçue signée du certificateur, points enrichis POUR LE CLIENT (l'import n'écrase jamais l'enrichissement), feuillets composés à l'image du manifeste PDF (ok/ko/er), anomalies déclarées puis qualifiées, dossier figé et EXPORTÉ SIGNÉ — aucun appel réseau —, certificat reçu et vérifié, feu d'exploitation DÉRIVÉ du certificat. Powers ValiTrax (valitrax.mostajs.dev).",
|
|
5
|
+
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
6
|
+
"license": "AGPL-3.0-or-later",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./schemas": "./src/schemas.js",
|
|
12
|
+
"./register": "./src/register.js",
|
|
13
|
+
"./sceau": "./src/sceau.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"llms.txt",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "bash test-scripts/run-tests.sh"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mostajs",
|
|
26
|
+
"recette",
|
|
27
|
+
"validation",
|
|
28
|
+
"uat",
|
|
29
|
+
"qa",
|
|
30
|
+
"pv",
|
|
31
|
+
"certificat"
|
|
32
|
+
],
|
|
33
|
+
"mostajs": {
|
|
34
|
+
"niveau": "N3",
|
|
35
|
+
"nature": "metier",
|
|
36
|
+
"reutilisabilite": "mono-domaine",
|
|
37
|
+
"depend_de": [],
|
|
38
|
+
"question": "Qui a éprouvé cette version candidate, qu'a-t-il constaté, et sur quel acte repose l'autorisation de la mettre en production ?",
|
|
39
|
+
"forme": "moteur",
|
|
40
|
+
"entites": [
|
|
41
|
+
"Campagne",
|
|
42
|
+
"Point",
|
|
43
|
+
"Feuillet",
|
|
44
|
+
"Releve",
|
|
45
|
+
"Anomalie",
|
|
46
|
+
"Dossier",
|
|
47
|
+
"Certificat"
|
|
48
|
+
],
|
|
49
|
+
"verbes": [
|
|
50
|
+
"ouvrir",
|
|
51
|
+
"importer",
|
|
52
|
+
"enrichir",
|
|
53
|
+
"retenir",
|
|
54
|
+
"composer",
|
|
55
|
+
"consigner",
|
|
56
|
+
"qualifier",
|
|
57
|
+
"figer",
|
|
58
|
+
"exporter",
|
|
59
|
+
"recevoir",
|
|
60
|
+
"autoriser"
|
|
61
|
+
],
|
|
62
|
+
"ne_fait_pas": {
|
|
63
|
+
"gérer les tests, les exécutions et les bugs": "@mostajs/qa-engine",
|
|
64
|
+
"modéliser la version candidate": "@mostajs/qa-engine (Release) — référencée, jamais copiée",
|
|
65
|
+
"émettre le certificat": "l'autorité de certification (QaTrax) — ce module le REÇOIT et refuse ce qui ne s'y adosse pas",
|
|
66
|
+
"sceller un procès-verbal collégial": "@mostajs/deliberation",
|
|
67
|
+
"rendre un acte vérifiable par QR": "@mostajs/certificates",
|
|
68
|
+
"porter les fils de discussion et mentions": "@mostajs/collab",
|
|
69
|
+
"convoquer et relancer": "@mostajs/notifications",
|
|
70
|
+
"engendrer les feuillets PDF et leur manifeste": "l'outillage de l'application (voir ValiTrax)",
|
|
71
|
+
"lire une feuille papier photographiée": "@mostajs/ocr",
|
|
72
|
+
"implémenter la cryptographie": "@mostajs/crypto-box — Ed25519 pour signer, enveloppe X25519 pour sceller",
|
|
73
|
+
"déposer le dossier chez le certificateur": "personne : l'export est un FICHIER que le client ou notre service transporte",
|
|
74
|
+
"produire les formulaires du client": "ce module — QaTrax n'envoie que la TRAME (mostajs-devtest/1) ; la formulation appartient à la recette"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"peerDependencies": {
|
|
78
|
+
"@mostajs/mjs-unit": ">=0.4.0",
|
|
79
|
+
"@mostajs/crypto-box": ">=0.3.0"
|
|
80
|
+
},
|
|
81
|
+
"peerDependenciesMeta": {
|
|
82
|
+
"@mostajs/mjs-unit": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"@mostajs/crypto-box": {
|
|
86
|
+
"optional": true
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"devDependencies": {
|
|
90
|
+
"@mostajs/crypto-box": "file:../../mosta-kernel-stack/mosta-crypto-box",
|
|
91
|
+
"@mostajs/mjs-unit": "^0.6.0"
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @mostajs/recette — point d'entrée (produit : ValiTrax). Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
//
|
|
3
|
+
// N3 métier, et il ne dépend d'AUCUN autre N3. Son voisin naturel — `@mostajs/qa-engine`, qui
|
|
4
|
+
// possède les tests, les bugs et les versions — est consommé par PORT injecté depuis l'app, et
|
|
5
|
+
// seulement pour DÉPOSER un dossier. Cela évite le couplage latéral et garde le moteur éprouvable
|
|
6
|
+
// seul, sans QaTrax en face.
|
|
7
|
+
// `./sceau` n'est PAS ré-exporté ici : il importe `@mostajs/crypto-box`, et une app qui
|
|
8
|
+
// n'exporte aucun dossier n'a pas à installer de cryptographie. Il s'obtient par le chemin
|
|
9
|
+
// dédié : `import { creerSceau } from '@mostajs/recette/sceau'`.
|
|
10
|
+
export { createRecette } from './recette.js';
|
|
11
|
+
export { createMemoryRepositories } from './memory-repo.js';
|
|
12
|
+
export {
|
|
13
|
+
CampagneSchema, PointSchema, FeuilletSchema, ReleveSchema, AnomalieSchema, DossierSchema, CertificatSchema, RECETTE_SCHEMAS,
|
|
14
|
+
} from './schemas.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @mostajs/recette — dépôts en mémoire pour essais et exemples. Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
//
|
|
3
|
+
// Ils ne servent PAS en exploitation : une app câble @mostajs/orm et ses dix-huit pilotes. Ils
|
|
4
|
+
// existent pour que le moteur s'éprouve SEUL, sans base — c'est ce qui rend ses gardes testables.
|
|
5
|
+
function coll() {
|
|
6
|
+
const m = new Map();
|
|
7
|
+
return {
|
|
8
|
+
async create(d) { const id = d.id || globalThis.crypto.randomUUID(); const now = new Date(); const r = { id, createdAt: now, updatedAt: now, ...d }; m.set(id, r); return { ...r }; },
|
|
9
|
+
async findById(id) { const r = m.get(id); return r ? { ...r } : null; },
|
|
10
|
+
async update(id, p) { const r = m.get(id); if (!r) return null; const x = { ...r, ...p, updatedAt: new Date() }; m.set(id, x); return { ...x }; },
|
|
11
|
+
async find(f = () => true) { return [...m.values()].filter(f).map((r) => ({ ...r })); },
|
|
12
|
+
async remove(id) { return m.delete(id); },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function createMemoryRepositories() {
|
|
16
|
+
return { campagnes: coll(), points: coll(), feuillets: coll(), releves: coll(), anomalies: coll(), dossiers: coll(), certificats: coll() };
|
|
17
|
+
}
|
package/src/recette.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// @mostajs/recette — le moteur. Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
2
|
+
//
|
|
3
|
+
// CE QUE CE MODULE POSSÈDE, ET CE QU'IL NE POSSÈDE PAS.
|
|
4
|
+
//
|
|
5
|
+
// Il possède la RECETTE : la campagne, le feuillet remis à quelqu'un, le relevé d'une
|
|
6
|
+
// observation, l'anomalie déclarée, le dossier figé, et le certificat reçu.
|
|
7
|
+
//
|
|
8
|
+
// Il ne possède NI les tests NI les bugs NI la version : ils vivent chez `@mostajs/qa-engine`.
|
|
9
|
+
// `releaseRef` et `bugRef` sont des références OPAQUES. Deux modules qui modélisent la même chose
|
|
10
|
+
// divergent, et l'on découvre la divergence le jour d'un litige.
|
|
11
|
+
//
|
|
12
|
+
// LE SENS DE LA CHAÎNE, ET SON TRANSPORT — ASYMÉTRIQUE, ET C'EST VOULU.
|
|
13
|
+
//
|
|
14
|
+
// ALLER (QaTrax → ValiTrax) : AUTOMATIQUE. La trame relie deux de NOS serveurs ; rien n'y
|
|
15
|
+
// justifie une main humaine. `trame.synchroniser()` la tire par un port, la vérifie, l'importe.
|
|
16
|
+
//
|
|
17
|
+
// RETOUR (ValiTrax → QaTrax) : HUMAIN, parce que le document PASSE PAR LE CLIENT — c'est lui qui
|
|
18
|
+
// valide, et c'est lui qui rend. `dossiers.exporter()` produit un fichier signé, que le client ou
|
|
19
|
+
// notre service transporte et dépose. Ce n'est pas un pis-aller : c'est ce qui permet à un client
|
|
20
|
+
// sans accès vers nos serveurs de rendre sa recette.
|
|
21
|
+
//
|
|
22
|
+
// LA SIGNATURE VAUT DANS LES DEUX SENS, y compris sur le canal automatique : une mauvaise URL ou
|
|
23
|
+
// un réseau compromis injecterait une trame étrangère SANS BRUIT, et le client éprouverait autre
|
|
24
|
+
// chose que ce qui a été spécifié — sans pouvoir s'en rendre compte, faute de référentiel.
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
/** Les six étapes, dans l'ordre. `abandonnee` est la seule sortie latérale, depuis n'importe où. */
|
|
28
|
+
const PARCOURS = ['candidate', 'verification', 'recette', 'validation', 'figee', 'certifiee', 'exploitation'];
|
|
29
|
+
const rang = (e) => PARCOURS.indexOf(e);
|
|
30
|
+
|
|
31
|
+
const need = (v, msg) => { if (v === undefined || v === null || v === '') throw new Error(msg); return v; };
|
|
32
|
+
const asDate = (d) => (d ? new Date(d) : null);
|
|
33
|
+
|
|
34
|
+
export function createRecette({ repositories, ports = {}, clock = () => new Date(), numeroteur } = {}) {
|
|
35
|
+
const repo = need(repositories, 'repositories requis');
|
|
36
|
+
for (const c of ['campagnes', 'points', 'feuillets', 'releves', 'anomalies', 'dossiers', 'certificats']) {
|
|
37
|
+
need(repo[c], `dépôt « ${c} » requis`);
|
|
38
|
+
}
|
|
39
|
+
const audit = ports.audit || null;
|
|
40
|
+
const trace = async (evt, par, data) => { try { await audit?.log?.(evt, { par, ...data }); } catch { /* l'audit n'interrompt jamais un acte */ } };
|
|
41
|
+
// Le numéro est LISIBLE et se cite dans un courriel : `REC-2026-0007`. Injectable, parce qu'un
|
|
42
|
+
// établissement a le droit d'avoir sa propre forme.
|
|
43
|
+
const numero = numeroteur || (async (prefixe, n) => `${prefixe}-${new Date().getFullYear()}-${String(n + 1).padStart(4, '0')}`);
|
|
44
|
+
|
|
45
|
+
async function getCampagne(id) {
|
|
46
|
+
return need(await repo.campagnes.findById(id), `campagne introuvable : ${id}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Une transition n'AVANCE jamais de plus d'un cran, et ne recule jamais. */
|
|
50
|
+
async function avancer(campagne, vers, { by = null, reason = '' } = {}) {
|
|
51
|
+
if (vers === 'abandonnee') {
|
|
52
|
+
// On abandonne de partout, sauf une fois l'exploitation autorisée : à ce moment le logiciel
|
|
53
|
+
// est en production, et « abandonner la recette » ne veut plus rien dire.
|
|
54
|
+
if (campagne.state === 'exploitation') throw new Error("l'exploitation est autorisée : la campagne ne s'abandonne plus");
|
|
55
|
+
} else {
|
|
56
|
+
const de = rang(campagne.state), a = rang(vers);
|
|
57
|
+
if (a < 0) throw new Error(`étape inconnue : ${vers}`);
|
|
58
|
+
if (de < 0) throw new Error(`la campagne ${campagne.no || campagne.id} est ${campagne.state} — aucune transition possible`);
|
|
59
|
+
if (a !== de + 1) throw new Error(`transition illégale : ${campagne.state} → ${vers} (attendu : ${PARCOURS[de + 1] || 'aucune'})`);
|
|
60
|
+
}
|
|
61
|
+
const at = clock();
|
|
62
|
+
const out = await repo.campagnes.update(campagne.id, {
|
|
63
|
+
state: vers,
|
|
64
|
+
stateHistory: [...(campagne.stateHistory || []), { from: campagne.state, to: vers, at, by, reason }],
|
|
65
|
+
...(vers === 'exploitation' || vers === 'abandonnee' ? { closeLe: at } : {}),
|
|
66
|
+
});
|
|
67
|
+
await trace('recette.campagne.transition', by, { campagneId: campagne.id, from: campagne.state, to: vers });
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const campagnes = {
|
|
72
|
+
/** Ouvre la recette d'une version candidate. La version vient de QaTrax : on la RÉFÉRENCE. */
|
|
73
|
+
async ouvrir({ releaseRef, projet = '', perimetre = '', instanceUrl = '', tenantId = null, by = null } = {}) {
|
|
74
|
+
need(releaseRef, 'releaseRef requis — la version candidate vit chez QaTrax');
|
|
75
|
+
// L'INSTANCE ÉPROUVÉE N'EST PAS LA PRODUCTION, et c'est une garde, pas une consigne : une
|
|
76
|
+
// recette jouée sur la production écrit de fausses données dans le système réel du client.
|
|
77
|
+
if (/^https?:\/\/(www\.)?prod/i.test(instanceUrl)) throw new Error('une recette ne se joue pas sur la production');
|
|
78
|
+
const n = (await repo.campagnes.find(() => true)).length;
|
|
79
|
+
const c = await repo.campagnes.create({
|
|
80
|
+
no: await numero('REC', n), releaseRef, projet, perimetre, instanceUrl, tenantId,
|
|
81
|
+
state: 'candidate', stateHistory: [], ouverteLe: clock(),
|
|
82
|
+
});
|
|
83
|
+
await trace('recette.campagne.ouvrir', by, { campagneId: c.id, releaseRef });
|
|
84
|
+
return c;
|
|
85
|
+
},
|
|
86
|
+
get: getCampagne,
|
|
87
|
+
list: (filtre = () => true) => repo.campagnes.find(filtre),
|
|
88
|
+
avancer: async (id, vers, opts) => avancer(await getCampagne(id), vers, opts),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Les champs que l'import a le droit d'écrire. Tout le reste appartient à ValiTrax. */
|
|
92
|
+
const CHAMPS_TRAME = ['specRef', 'titreTrame', 'priorite', 'typeTrame', 'etapesTrame'];
|
|
93
|
+
|
|
94
|
+
const trame = {
|
|
95
|
+
/**
|
|
96
|
+
* IMPORTER LA TRAME — le `mostajs-devtest/1` que QaTrax exporte.
|
|
97
|
+
*
|
|
98
|
+
* SIGNÉE, comme tout ce qui traverse : une trame altérée en chemin ferait éprouver autre
|
|
99
|
+
* chose que ce qui a été spécifié, et personne ne s'en apercevrait.
|
|
100
|
+
*
|
|
101
|
+
* IDEMPOTENT PAR `ref`, et — c'est là que tout se joue — **l'enrichissement n'est jamais
|
|
102
|
+
* écrasé**. Une trame se réimporte à chaque version candidate ; si l'import régénérait les
|
|
103
|
+
* points, chaque réimport effacerait la formulation écrite pour le client, les rôles et
|
|
104
|
+
* l'ordre. On ne s'en apercevrait qu'en distribuant les feuillets.
|
|
105
|
+
*/
|
|
106
|
+
async importer(campagneId, document, { by = null, signature = null } = {}) {
|
|
107
|
+
const c = await getCampagne(campagneId);
|
|
108
|
+
if (rang(c.state) > rang('recette')) throw new Error(`la trame ne s'importe plus à l'étape « ${c.state} »`);
|
|
109
|
+
|
|
110
|
+
const plan = typeof document === 'string' ? JSON.parse(document) : document;
|
|
111
|
+
if (plan?.plan !== 'mostajs-devtest/1') throw new Error(`format inconnu : ${plan?.plan || '(absent)'} — attendu mostajs-devtest/1`);
|
|
112
|
+
// La signature de la trame se vérifie comme celle du PV. Même clé publique, même hors ligne.
|
|
113
|
+
if (signature) {
|
|
114
|
+
if (!ports.sceau?.verifier) throw new Error('trame signée mais aucune clé publique configurée pour la vérifier');
|
|
115
|
+
if (!(await ports.sceau.verifier(typeof document === 'string' ? document : JSON.stringify(plan), signature))) {
|
|
116
|
+
throw new Error('signature de la trame INVALIDE — import refusé');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const existants = new Map((await repo.points.find((p) => p.campagneId === campagneId)).map((p) => [p.ref, p]));
|
|
121
|
+
const bilan = { crees: 0, majTrame: 0, enrichissementsPreserves: 0 };
|
|
122
|
+
for (const t of plan.tests || []) {
|
|
123
|
+
if (!t.ref) continue;
|
|
124
|
+
const cote = {
|
|
125
|
+
specRef: t.specRef || null, titreTrame: t.title || '', priorite: t.priority || 'medium',
|
|
126
|
+
typeTrame: t.type || null, etapesTrame: t.steps || [], importeLe: clock(),
|
|
127
|
+
};
|
|
128
|
+
const deja = existants.get(t.ref);
|
|
129
|
+
if (!deja) { await repo.points.create({ campagneId, ref: t.ref, ...cote }); bilan.crees += 1; continue; }
|
|
130
|
+
// On n'écrit QUE les champs de trame. `libelle`, `attendu`, `role`, `ordre`, `langue`
|
|
131
|
+
// et `retenu` ne figurent pas dans `cote` — et c'est tout l'objet de cette ligne.
|
|
132
|
+
await repo.points.update(deja.id, cote);
|
|
133
|
+
bilan.majTrame += 1;
|
|
134
|
+
if (deja.libelle || deja.role) bilan.enrichissementsPreserves += 1;
|
|
135
|
+
}
|
|
136
|
+
await trace('recette.trame.importer', by, { campagneId, ...bilan });
|
|
137
|
+
return bilan;
|
|
138
|
+
},
|
|
139
|
+
/**
|
|
140
|
+
* SYNCHRONISER — tirer la trame de QaTrax, automatiquement.
|
|
141
|
+
*
|
|
142
|
+
* C'est le SEUL sens automatisé de la chaîne. Le port est injecté : le moteur ne connaît ni
|
|
143
|
+
* URL, ni jeton, ni transport — l'app câble `GET /trame?p=<projet>` et rien ne remonte ici.
|
|
144
|
+
*
|
|
145
|
+
* La vérification de signature n'est PAS allégée parce que le canal est direct. Un canal
|
|
146
|
+
* direct dit d'où VIENT la connexion, pas ce que le document CONTIENT.
|
|
147
|
+
*/
|
|
148
|
+
async synchroniser(campagneId, { projet, by = null } = {}) {
|
|
149
|
+
const source = ports.trameSource;
|
|
150
|
+
if (!source?.lire) throw new Error("aucune source de trame configurée : rien à synchroniser");
|
|
151
|
+
const doc = await source.lire({ projet: need(projet, 'projet requis') });
|
|
152
|
+
const enveloppe = typeof doc === 'string' ? JSON.parse(doc) : doc;
|
|
153
|
+
if (enveloppe?.type !== 'mostajs-recette-trame/1') {
|
|
154
|
+
throw new Error(`enveloppe inconnue : ${enveloppe?.type || '(absente)'} — attendu mostajs-recette-trame/1`);
|
|
155
|
+
}
|
|
156
|
+
// `enveloppe.plan` est le TEXTE exact qui a été signé, pas un objet reconstruit : deux
|
|
157
|
+
// sérialisations peuvent différer d'un espace, et la vérification échouerait alors sur un
|
|
158
|
+
// document intact — le pire des refus, celui qu'on met des heures à comprendre.
|
|
159
|
+
const bilan = await trame.importer(campagneId, enveloppe.plan, { signature: enveloppe.signature, by });
|
|
160
|
+
await trace('recette.trame.synchroniser', by, { campagneId, projet, ...bilan });
|
|
161
|
+
return { ...bilan, emisLe: enveloppe.emisLe || null };
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/** Ce que l'import a le droit de toucher — rendu, pour qu'on n'ait pas à le deviner. */
|
|
165
|
+
champsEcrasables: () => [...CHAMPS_TRAME],
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const points = {
|
|
169
|
+
/** L'ENRICHISSEMENT : écrire le point POUR LE CLIENT. Refuse de toucher au côté trame. */
|
|
170
|
+
async enrichir(pointId, patch = {}, { by = null } = {}) {
|
|
171
|
+
const p = need(await repo.points.findById(pointId), `point introuvable : ${pointId}`);
|
|
172
|
+
const interdits = Object.keys(patch).filter((k) => CHAMPS_TRAME.includes(k) || k === 'ref' || k === 'campagneId');
|
|
173
|
+
if (interdits.length) throw new Error(`ces champs viennent de la trame et ne s'enrichissent pas : ${interdits.join(', ')}`);
|
|
174
|
+
await trace('recette.point.enrichir', by, { pointId });
|
|
175
|
+
return repo.points.update(p.id, patch);
|
|
176
|
+
},
|
|
177
|
+
/** LA SÉLECTION — dans ValiTrax, jamais chez QaTrax. */
|
|
178
|
+
async retenir(campagneId, refs, { retenu = true, by = null } = {}) {
|
|
179
|
+
const voulus = new Set(refs);
|
|
180
|
+
const ps = await repo.points.find((p) => p.campagneId === campagneId && voulus.has(p.ref));
|
|
181
|
+
const absents = [...voulus].filter((r) => !ps.some((p) => p.ref === r));
|
|
182
|
+
if (absents.length) throw new Error(`références absentes de la trame : ${absents.join(', ')}`);
|
|
183
|
+
for (const p of ps) await repo.points.update(p.id, { retenu });
|
|
184
|
+
await trace('recette.point.retenir', by, { campagneId, n: ps.length, retenu });
|
|
185
|
+
return ps.length;
|
|
186
|
+
},
|
|
187
|
+
of: (campagneId) => repo.points.find((p) => p.campagneId === campagneId),
|
|
188
|
+
retenus: (campagneId) => repo.points.find((p) => p.campagneId === campagneId && p.retenu),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const feuillets = {
|
|
192
|
+
/** Remettre un feuillet à quelqu'un. C'est l'acte qui autorise ses relevés. */
|
|
193
|
+
async remettre({ campagneId, role, destinataireId = null, destinataire = '', langue = 'fr', planVersion = '', by = null } = {}) {
|
|
194
|
+
const c = await getCampagne(need(campagneId, 'campagneId requis'));
|
|
195
|
+
if (!['verification', 'recette'].includes(c.state)) {
|
|
196
|
+
throw new Error(`on ne remet pas de feuillet à l'étape « ${c.state} » (attendu : verification ou recette)`);
|
|
197
|
+
}
|
|
198
|
+
const f = await repo.feuillets.create({
|
|
199
|
+
campagneId, role: need(role, 'role requis'), destinataireId, destinataire, langue, planVersion,
|
|
200
|
+
state: 'remis', remisLe: clock(),
|
|
201
|
+
});
|
|
202
|
+
await trace('recette.feuillet.remettre', by, { feuilletId: f.id, campagneId, role });
|
|
203
|
+
return f;
|
|
204
|
+
},
|
|
205
|
+
/** Le testeur rend son feuillet : plus aucun relevé n'y entre. */
|
|
206
|
+
async rendre(feuilletId, { by = null } = {}) {
|
|
207
|
+
const f = need(await repo.feuillets.findById(feuilletId), `feuillet introuvable : ${feuilletId}`);
|
|
208
|
+
if (f.state !== 'remis') throw new Error(`le feuillet est déjà « ${f.state} » — il ne se rend pas deux fois`);
|
|
209
|
+
const out = await repo.feuillets.update(f.id, { state: 'rendu', renduLe: clock() });
|
|
210
|
+
await trace('recette.feuillet.rendre', by, { feuilletId });
|
|
211
|
+
return out;
|
|
212
|
+
},
|
|
213
|
+
depouiller: async (feuilletId, { by = null } = {}) => {
|
|
214
|
+
const f = need(await repo.feuillets.findById(feuilletId), `feuillet introuvable : ${feuilletId}`);
|
|
215
|
+
if (f.state !== 'rendu') throw new Error(`on ne dépouille qu'un feuillet rendu (celui-ci est « ${f.state} »)`);
|
|
216
|
+
await trace('recette.feuillet.depouiller', by, { feuilletId });
|
|
217
|
+
return repo.feuillets.update(f.id, { state: 'depouille' });
|
|
218
|
+
},
|
|
219
|
+
of: (campagneId) => repo.feuillets.find((f) => f.campagneId === campagneId),
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* COMPOSER un feuillet — et rendre ce que le CLIENT va lire.
|
|
223
|
+
*
|
|
224
|
+
* LE CLIENT N'EST PAS UNE MACHINE : il lit, et il coche. La sortie a donc exactement la forme
|
|
225
|
+
* du manifeste des feuillets PDF — `{ ref, libelle, attendu, options: ['ok','ko','er'] }` —
|
|
226
|
+
* pour que le papier, l'écran et le moteur parlent UNE langue. Un troisième format ici, et
|
|
227
|
+
* l'on retomberait dans la divination : quel rectangle porte quelle réponse ?
|
|
228
|
+
*
|
|
229
|
+
* Le libellé rendu est l'ENRICHISSEMENT s'il existe, à défaut la matière de la trame. Un
|
|
230
|
+
* point non enrichi reste lisible — mais il parle le langage du développeur, et cela se voit.
|
|
231
|
+
*/
|
|
232
|
+
async composer({ campagneId, role, langue = 'fr', destinataire = '', destinataireId = null, planVersion = '', by = null } = {}) {
|
|
233
|
+
const retenus = (await points.retenus(campagneId))
|
|
234
|
+
.filter((p) => !p.role || p.role === role)
|
|
235
|
+
.sort((a, b) => (a.ordre - b.ordre) || String(a.ref).localeCompare(String(b.ref)));
|
|
236
|
+
if (!retenus.length) throw new Error(`aucun point retenu pour le rôle « ${role} » — rien à composer`);
|
|
237
|
+
const f = await feuillets.remettre({ campagneId, role, destinataire, destinataireId, langue, planVersion, by });
|
|
238
|
+
return {
|
|
239
|
+
feuillet: f,
|
|
240
|
+
cases: retenus.map((p) => ({
|
|
241
|
+
genre: 'action',
|
|
242
|
+
ref: p.ref,
|
|
243
|
+
libelle: p.libelle || p.titreTrame,
|
|
244
|
+
attendu: p.attendu || (p.etapesTrame || []).map((e) => e.expected).filter(Boolean).join(' · '),
|
|
245
|
+
specRef: p.specRef, // la couverture reste traçable jusqu'à l'exigence
|
|
246
|
+
enrichi: Boolean(p.libelle), // dit si le client lira du français de métier
|
|
247
|
+
options: ['ok', 'ko', 'er'],
|
|
248
|
+
})),
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const releves = {
|
|
254
|
+
/**
|
|
255
|
+
* Consigner une observation.
|
|
256
|
+
*
|
|
257
|
+
* GARDE : le feuillet doit être REMIS et pas encore rendu. Consigner sur un feuillet jamais
|
|
258
|
+
* distribué, ou déjà rendu, produit une observation que personne n'a faite — et c'est
|
|
259
|
+
* précisément ce qu'un procès-verbal ne doit pas contenir.
|
|
260
|
+
*/
|
|
261
|
+
async consigner({ feuilletId, ref, issue, at = null, texte = '', source = 'formulaire', confiance = 1, by = null } = {}) {
|
|
262
|
+
const f = need(await repo.feuillets.findById(need(feuilletId, 'feuilletId requis')), `feuillet introuvable : ${feuilletId}`);
|
|
263
|
+
if (f.state !== 'remis') throw new Error(`le feuillet est « ${f.state} » : on n'y consigne plus rien`);
|
|
264
|
+
if (!['ok', 'ko', 'er'].includes(issue)) throw new Error(`issue inconnue : ${issue} (attendu : ok, ko ou er)`);
|
|
265
|
+
// L'HEURE d'un `er` n'est pas décorative : c'est elle qui permet de retrouver la trace côté
|
|
266
|
+
// serveur. Un incident sans heure est une impression.
|
|
267
|
+
if (issue === 'er' && !at) throw new Error("un `er` se consigne avec son HEURE — c'est elle qui retrouve la trace serveur");
|
|
268
|
+
const r = await repo.releves.create({
|
|
269
|
+
feuilletId, campagneId: f.campagneId, ref: need(ref, 'ref requis'), issue,
|
|
270
|
+
at: asDate(at) || clock(), texte, source, confiance,
|
|
271
|
+
});
|
|
272
|
+
await trace('recette.releve.consigner', by, { releveId: r.id, feuilletId, ref, issue });
|
|
273
|
+
return r;
|
|
274
|
+
},
|
|
275
|
+
of: (campagneId) => repo.releves.find((r) => r.campagneId === campagneId),
|
|
276
|
+
duFeuillet: (feuilletId) => repo.releves.find((r) => r.feuilletId === feuilletId),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const anomalies = {
|
|
280
|
+
/** Ce que le client DÉCLARE. Ce n'est pas encore un bug. */
|
|
281
|
+
async declarer({ campagneId, titre, description = '', declareePar, graviteProposee = null, feuilletId = null, releveRef = null } = {}) {
|
|
282
|
+
const c = await getCampagne(need(campagneId, 'campagneId requis'));
|
|
283
|
+
if (!['verification', 'recette', 'validation'].includes(c.state)) {
|
|
284
|
+
throw new Error(`on ne déclare pas d'anomalie à l'étape « ${c.state} »`);
|
|
285
|
+
}
|
|
286
|
+
const a = await repo.anomalies.create({
|
|
287
|
+
campagneId, titre: need(titre, 'titre requis'), description,
|
|
288
|
+
declareePar: need(declareePar, 'declareePar requis'), graviteProposee,
|
|
289
|
+
feuilletId, releveRef, state: 'declaree',
|
|
290
|
+
});
|
|
291
|
+
await trace('recette.anomalie.declarer', declareePar, { anomalieId: a.id, campagneId });
|
|
292
|
+
return a;
|
|
293
|
+
},
|
|
294
|
+
/**
|
|
295
|
+
* QUALIFIER — l'acte de l'éditeur, qui n'efface pas celui du client.
|
|
296
|
+
*
|
|
297
|
+
* `graviteProposee` reste. Effacer la gravité annoncée par le client, c'est effacer le
|
|
298
|
+
* désaccord — et le désaccord est ce qu'un procès-verbal doit pouvoir montrer.
|
|
299
|
+
*/
|
|
300
|
+
async qualifier(anomalieId, { gravite, bugRef = null, by = null } = {}) {
|
|
301
|
+
const a = need(await repo.anomalies.findById(anomalieId), `anomalie introuvable : ${anomalieId}`);
|
|
302
|
+
if (!['bloquante', 'majeure', 'mineure'].includes(gravite)) throw new Error(`gravité inconnue : ${gravite}`);
|
|
303
|
+
const out = await repo.anomalies.update(a.id, { graviteQualifiee: gravite, bugRef, state: 'qualifiee', qualifieePar: by, qualifieeLe: clock() });
|
|
304
|
+
await trace('recette.anomalie.qualifier', by, { anomalieId, gravite, bugRef });
|
|
305
|
+
return out;
|
|
306
|
+
},
|
|
307
|
+
/** Écarter : ce n'est pas un défaut. Le MOTIF est exigé — « ce n'est pas un bug » sans raison
|
|
308
|
+
* laisse le client sans réponse, et c'est ainsi qu'une recette se termine mal. */
|
|
309
|
+
async ecarter(anomalieId, { motif, by = null } = {}) {
|
|
310
|
+
const a = need(await repo.anomalies.findById(anomalieId), `anomalie introuvable : ${anomalieId}`);
|
|
311
|
+
need(motif, "un motif est requis pour écarter une anomalie");
|
|
312
|
+
const out = await repo.anomalies.update(a.id, { state: 'ecartee', motifEcart: motif, qualifieePar: by, qualifieeLe: clock() });
|
|
313
|
+
await trace('recette.anomalie.ecarter', by, { anomalieId, motif });
|
|
314
|
+
return out;
|
|
315
|
+
},
|
|
316
|
+
/** Le lien vers le bug QaTrax. Ce module ne CRÉE pas le bug : il en garde la référence. */
|
|
317
|
+
async promouvoir(anomalieId, bugRef, { by = null } = {}) {
|
|
318
|
+
const a = need(await repo.anomalies.findById(anomalieId), `anomalie introuvable : ${anomalieId}`);
|
|
319
|
+
if (a.state === 'declaree') throw new Error("une anomalie se qualifie avant d'être promue en bug");
|
|
320
|
+
await trace('recette.anomalie.promouvoir', by, { anomalieId, bugRef });
|
|
321
|
+
return repo.anomalies.update(a.id, { bugRef: need(bugRef, 'bugRef requis') });
|
|
322
|
+
},
|
|
323
|
+
corriger: (anomalieId, { by = null } = {}) => repo.anomalies.update(anomalieId, { state: 'corrigee' }).then(async (r) => { await trace('recette.anomalie.corriger', by, { anomalieId }); return r; }),
|
|
324
|
+
verifier: (anomalieId, { by = null } = {}) => repo.anomalies.update(anomalieId, { state: 'verifiee' }).then(async (r) => { await trace('recette.anomalie.verifier', by, { anomalieId }); return r; }),
|
|
325
|
+
of: (campagneId) => repo.anomalies.find((a) => a.campagneId === campagneId),
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Ce qui EMPÊCHE de figer le dossier — rendu en clair, jamais en booléen. */
|
|
329
|
+
async function obstaclesAuDepot(campagneId) {
|
|
330
|
+
const out = [];
|
|
331
|
+
const fs = await feuillets.of(campagneId);
|
|
332
|
+
const enAttente = fs.filter((f) => f.state === 'remis');
|
|
333
|
+
if (enAttente.length) out.push(`${enAttente.length} feuillet(s) non rendu(s) : ${enAttente.map((f) => f.role).join(', ')}`);
|
|
334
|
+
const as = await anomalies.of(campagneId);
|
|
335
|
+
const nonQualifiees = as.filter((a) => a.state === 'declaree');
|
|
336
|
+
if (nonQualifiees.length) out.push(`${nonQualifiees.length} anomalie(s) déclarée(s) et non qualifiée(s)`);
|
|
337
|
+
// Une BLOQUANTE ne se dépose pas : soit elle est corrigée ET revérifiée, soit le client a
|
|
338
|
+
// accepté de l'écarter. Déposer un dossier portant une bloquante ouverte, c'est demander un
|
|
339
|
+
// certificat sur un logiciel qu'on sait inutilisable.
|
|
340
|
+
const bloquantes = as.filter((a) => a.graviteQualifiee === 'bloquante' && !['verifiee', 'ecartee'].includes(a.state));
|
|
341
|
+
if (bloquantes.length) out.push(`${bloquantes.length} anomalie(s) BLOQUANTE(s) ni vérifiée(s) ni écartée(s)`);
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const dossiers = {
|
|
346
|
+
obstacles: obstaclesAuDepot,
|
|
347
|
+
/**
|
|
348
|
+
* FIGER le dossier de recette.
|
|
349
|
+
*
|
|
350
|
+
* L'EMPREINTE est ce qui rend le certificat opposable : elle dit sur QUOI il porte. Sans
|
|
351
|
+
* elle, un certificat porterait sur « la recette », c'est-à-dire sur rien de vérifiable.
|
|
352
|
+
*/
|
|
353
|
+
async figer(campagneId, { by = null, force = false } = {}) {
|
|
354
|
+
const c = await getCampagne(campagneId);
|
|
355
|
+
if (c.state !== 'validation') throw new Error(`le dossier se fige depuis « validation » (la campagne est « ${c.state} »)`);
|
|
356
|
+
const obstacles = await obstaclesAuDepot(campagneId);
|
|
357
|
+
if (obstacles.length && !force) throw new Error(`figement refusé — ${obstacles.join(' · ')}`);
|
|
358
|
+
|
|
359
|
+
const fs = await feuillets.of(campagneId);
|
|
360
|
+
const rs = await releves.of(campagneId);
|
|
361
|
+
const as = await anomalies.of(campagneId);
|
|
362
|
+
const contenu = {
|
|
363
|
+
campagne: { no: c.no, releaseRef: c.releaseRef, projet: c.projet, perimetre: c.perimetre, instanceUrl: c.instanceUrl },
|
|
364
|
+
feuillets: fs.map((f) => ({ role: f.role, langue: f.langue, state: f.state, planVersion: f.planVersion })),
|
|
365
|
+
releves: rs.map((r) => ({ ref: r.ref, issue: r.issue, at: r.at, source: r.source, confiance: r.confiance, texte: r.texte })),
|
|
366
|
+
anomalies: as.map((a) => ({ titre: a.titre, graviteProposee: a.graviteProposee, graviteQualifiee: a.graviteQualifiee, state: a.state, bugRef: a.bugRef, motifEcart: a.motifEcart })),
|
|
367
|
+
compteurs: { ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length },
|
|
368
|
+
obstaclesAuFigement: obstacles, // consignés même en `force` : un dossier dit ce qu'il vaut
|
|
369
|
+
};
|
|
370
|
+
const empreinte = createHash('sha256').update(JSON.stringify(contenu)).digest('hex');
|
|
371
|
+
const n = (await repo.dossiers.find(() => true)).length;
|
|
372
|
+
const d = await repo.dossiers.create({ campagneId, no: await numero('DOS-REC', n), figeLe: clock(), figePar: by, empreinte, contenu });
|
|
373
|
+
await avancer(c, 'figee', { by, reason: `dossier ${d.no}` });
|
|
374
|
+
await trace('recette.dossier.figer', by, { campagneId, dossierId: d.id, empreinte });
|
|
375
|
+
return repo.dossiers.findById(d.id);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* EXPORTER — le document qui quitte ValiTrax, et le seul.
|
|
380
|
+
*
|
|
381
|
+
* Il est SIGNÉ (clé privée de l'instance : il dit d'où il vient) et, si un destinataire est
|
|
382
|
+
* configuré, SCELLÉ pour lui (sa clé publique : lui seul l'ouvre). Signer et sceller ne sont
|
|
383
|
+
* pas la même chose et ne se remplacent pas — l'un prouve l'origine, l'autre ferme le pli.
|
|
384
|
+
*
|
|
385
|
+
* Un export NON SIGNÉ est refusé : il ne prouverait rien, et c'est pourtant tout ce qu'on lui
|
|
386
|
+
* demande. Le transport, lui, ne regarde pas ce module : courriel, clé USB, dépôt manuel.
|
|
387
|
+
*/
|
|
388
|
+
async exporter(campagneId, { by = null, sceller = false } = {}) {
|
|
389
|
+
const c = await getCampagne(campagneId);
|
|
390
|
+
if (rang(c.state) < rang('figee')) throw new Error(`rien à exporter : la campagne est « ${c.state} », le dossier n'est pas figé`);
|
|
391
|
+
const d = need(await dossiers.of(campagneId), 'aucun dossier figé pour cette campagne');
|
|
392
|
+
const sceau = ports.sceau;
|
|
393
|
+
if (!sceau?.signer) throw new Error("aucun sceau configuré : un export non signé ne prouve rien, il est refusé");
|
|
394
|
+
|
|
395
|
+
const enveloppe = {
|
|
396
|
+
type: 'mostajs-recette-dossier/1',
|
|
397
|
+
emisLe: clock().toISOString(),
|
|
398
|
+
campagne: { no: c.no, releaseRef: c.releaseRef },
|
|
399
|
+
dossier: { no: d.no, empreinte: d.empreinte, figeLe: d.figeLe },
|
|
400
|
+
contenu: d.contenu,
|
|
401
|
+
};
|
|
402
|
+
const charge = JSON.stringify(enveloppe);
|
|
403
|
+
const signature = await sceau.signer(charge);
|
|
404
|
+
let corps = { enveloppe, signature };
|
|
405
|
+
if (sceller) {
|
|
406
|
+
if (!sceau.sceller) throw new Error("scellement demandé mais aucun destinataire configuré (clé publique du certificateur)");
|
|
407
|
+
corps = { type: 'mostajs-recette-scelle/1', scelle: await sceau.sceller(JSON.stringify(corps)) };
|
|
408
|
+
}
|
|
409
|
+
await repo.dossiers.update(d.id, { signature, scelle: Boolean(sceller), exporteLe: clock(), exportePar: by });
|
|
410
|
+
await trace('recette.dossier.exporter', by, { campagneId, dossierId: d.id, scelle: Boolean(sceller) });
|
|
411
|
+
return corps;
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/** L'accusé rendu par QaTrax APRÈS dépôt manuel — enregistré, jamais supposé. */
|
|
415
|
+
accuser: async (campagneId, depotRef) => {
|
|
416
|
+
const d = need(await dossiers.of(campagneId), 'aucun dossier pour cette campagne');
|
|
417
|
+
return repo.dossiers.update(d.id, { depotRef: need(depotRef, 'depotRef requis') });
|
|
418
|
+
},
|
|
419
|
+
of: async (campagneId) => (await repo.dossiers.find((x) => x.campagneId === campagneId))[0] || null,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const certificats = {
|
|
423
|
+
/**
|
|
424
|
+
* RECEVOIR le certificat émis par QaTrax. Ce module ne signe pas — il vérifie l'adossement.
|
|
425
|
+
*
|
|
426
|
+
* L'empreinte doit correspondre au dossier déposé : un certificat qui porte sur un autre
|
|
427
|
+
* contenu que celui qu'on a soumis n'est pas un certificat, c'est une homonymie.
|
|
428
|
+
*/
|
|
429
|
+
async recevoir(campagneId, { no, verdict, empreinteDossier, signature, reserves = [], emisPar = '', emisLe = null, verificationUrl = '', documentRef = null, goExploitation = false, by = null } = {}) {
|
|
430
|
+
const c = await getCampagne(campagneId);
|
|
431
|
+
if (c.state !== 'figee') throw new Error(`un certificat se reçoit sur une campagne figée (celle-ci est « ${c.state} »)`);
|
|
432
|
+
const d = need(await dossiers.of(campagneId), 'aucun dossier déposé pour cette campagne');
|
|
433
|
+
if (empreinteDossier !== d.empreinte) {
|
|
434
|
+
throw new Error(`le certificat porte sur une autre empreinte que le dossier déposé — reçu ${String(empreinteDossier).slice(0, 12)}…, attendu ${d.empreinte.slice(0, 12)}…`);
|
|
435
|
+
}
|
|
436
|
+
if (!['accepte', 'accepte-avec-reserves', 'refuse'].includes(verdict)) throw new Error(`verdict inconnu : ${verdict}`);
|
|
437
|
+
// LA SIGNATURE DU PV SE VÉRIFIE, elle ne se croit pas. Un certificat non vérifié vaut ce
|
|
438
|
+
// que vaut le courriel qui l'apportait — c'est-à-dire rien, le jour où quelqu'un conteste.
|
|
439
|
+
// Même construction que la licence signée de LabTrax : clé publique du tiers, hors ligne.
|
|
440
|
+
need(signature, "un certificat NON SIGNÉ n'est pas un certificat");
|
|
441
|
+
if (!ports.sceau?.verifier) throw new Error("aucune clé publique du certificateur configurée : la signature du PV ne peut pas être vérifiée");
|
|
442
|
+
const atteste = JSON.stringify({ no, campagne: c.no, empreinteDossier, verdict, goExploitation: Boolean(goExploitation) });
|
|
443
|
+
if (!(await ports.sceau.verifier(atteste, signature))) throw new Error(`signature du certificat ${no} INVALIDE — le document est refusé`);
|
|
444
|
+
const cert = await repo.certificats.create({
|
|
445
|
+
campagneId, no: need(no, 'no du certificat requis'), empreinteDossier, verdict, reserves,
|
|
446
|
+
emisPar, emisLe: asDate(emisLe) || clock(), verificationUrl, documentRef, signature,
|
|
447
|
+
goExploitation: Boolean(goExploitation), goLe: goExploitation ? clock() : null,
|
|
448
|
+
});
|
|
449
|
+
await avancer(c, 'certifiee', { by, reason: `certificat ${cert.no} — ${verdict}` });
|
|
450
|
+
await trace('recette.certificat.recevoir', by, { campagneId, no: cert.no, verdict });
|
|
451
|
+
return cert;
|
|
452
|
+
},
|
|
453
|
+
of: async (campagneId) => (await repo.certificats.find((x) => x.campagneId === campagneId))[0] || null,
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* LE FEU — il DÉRIVE du certificat, il ne se coche pas.
|
|
458
|
+
*
|
|
459
|
+
* Un « go » qui s'attribue sans certificat ne s'oppose à personne six mois plus tard, quand la
|
|
460
|
+
* question devient « qui avait validé ça ? ». C'est la raison d'être de tout ce qui précède.
|
|
461
|
+
*/
|
|
462
|
+
async function autoriserExploitation(campagneId, { by = null, dateEffet = null } = {}) {
|
|
463
|
+
const c = await getCampagne(campagneId);
|
|
464
|
+
const cert = await certificats.of(campagneId);
|
|
465
|
+
if (!cert) throw new Error("aucun certificat reçu : l'exploitation ne s'autorise pas sans lui");
|
|
466
|
+
if (cert.verdict === 'refuse') throw new Error(`le certificat ${cert.no} REFUSE la version — l'exploitation est fermée`);
|
|
467
|
+
if (!cert.goExploitation) throw new Error(`le certificat ${cert.no} ne porte pas le feu vers l'exploitation`);
|
|
468
|
+
const out = await avancer(c, 'exploitation', { by, reason: `certificat ${cert.no}${dateEffet ? ` · effet ${new Date(dateEffet).toISOString()}` : ''}` });
|
|
469
|
+
await trace('recette.exploitation.autoriser', by, { campagneId, certificat: cert.no });
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** L'état d'une campagne, en une lecture : ce qui manque, et ce qui bloque. */
|
|
474
|
+
async function etat(campagneId) {
|
|
475
|
+
const c = await getCampagne(campagneId);
|
|
476
|
+
const fs = await feuillets.of(campagneId);
|
|
477
|
+
const rs = await releves.of(campagneId);
|
|
478
|
+
const as = await anomalies.of(campagneId);
|
|
479
|
+
return {
|
|
480
|
+
campagne: { no: c.no, state: c.state, releaseRef: c.releaseRef },
|
|
481
|
+
feuillets: { remis: fs.length, rendus: fs.filter((f) => f.state !== 'remis').length },
|
|
482
|
+
releves: { ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length },
|
|
483
|
+
anomalies: { declarees: as.filter((a) => a.state === 'declaree').length, bloquantes: as.filter((a) => a.graviteQualifiee === 'bloquante').length, total: as.length },
|
|
484
|
+
obstacles: await obstaclesAuDepot(campagneId),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { campagnes, trame, points, feuillets, releves, anomalies, dossiers, certificats, autoriserExploitation, etat, PARCOURS };
|
|
489
|
+
}
|
package/src/register.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @mostajs/recette — enregistrement runtime (contrat @mostajs/module-contract).
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// Le module CONTRIBUE ses schémas par le CONTRAT ; `@mostajs/socle` les agrège avec ceux des
|
|
5
|
+
// autres modules câblés. Une app n'a donc pas de liste de schémas à tenir — liste qui, le jour où
|
|
6
|
+
// ce module gagne une entité, ne le saurait pas : la table ne serait jamais créée, EN SILENCE.
|
|
7
|
+
import { RECETTE_SCHEMAS } from './schemas.js';
|
|
8
|
+
|
|
9
|
+
export function register(registry) {
|
|
10
|
+
registry.register({
|
|
11
|
+
manifest: {
|
|
12
|
+
name: 'recette',
|
|
13
|
+
package: '@mostajs/recette',
|
|
14
|
+
// À TENIR ÉGALE à `package.json` — un essai l'épingle.
|
|
15
|
+
version: '0.1.0',
|
|
16
|
+
type: 'business',
|
|
17
|
+
priority: 100,
|
|
18
|
+
displayName: 'Recette (ValiTrax)',
|
|
19
|
+
description: "Validation & recette applicative : campagne sur une version candidate, feuillets remis, relevés, anomalies déclarées puis qualifiées, dossier figé déposé, certificat reçu et feu d'exploitation dérivé.",
|
|
20
|
+
icon: 'ClipboardCheck',
|
|
21
|
+
register: './src/register.js',
|
|
22
|
+
},
|
|
23
|
+
// Les PERMISSIONS sont nommées PAR LE MODULE : sans cela, chaque app réinventerait un
|
|
24
|
+
// vocabulaire, et deux apps le composant n'auraient aucun nom en commun. L'app agrège et
|
|
25
|
+
// n'a plus qu'à définir ses RÔLES — le seul étage légitimement propre à une organisation.
|
|
26
|
+
// FORME IMPOSÉE PAR LE CONTRAT : `{ permissions, definitions, categories }`, pas un tableau.
|
|
27
|
+
permissions: {
|
|
28
|
+
permissions: {
|
|
29
|
+
'recette:campagne:ouvrir': 'Ouvrir une campagne de recette',
|
|
30
|
+
'recette:campagne:lire': 'Consulter une campagne',
|
|
31
|
+
'recette:campagne:conduire': 'Faire avancer une campagne',
|
|
32
|
+
'recette:feuillet:remettre': 'Remettre un feuillet à un testeur',
|
|
33
|
+
'recette:releve:consigner': 'Consigner une observation',
|
|
34
|
+
'recette:anomalie:declarer': 'Déclarer une anomalie',
|
|
35
|
+
'recette:anomalie:qualifier': 'Qualifier ou écarter une anomalie',
|
|
36
|
+
'recette:dossier:deposer': 'Figer et déposer le dossier de recette',
|
|
37
|
+
'recette:exploitation:autoriser': "Autoriser l'exploitation",
|
|
38
|
+
},
|
|
39
|
+
definitions: {
|
|
40
|
+
'recette:anomalie:declarer': {
|
|
41
|
+
label: 'Déclarer une anomalie',
|
|
42
|
+
// Le CLIENT la porte. C'est la permission qui rend la délégation réelle : sans elle,
|
|
43
|
+
// « déléguer la recette » se réduit à envoyer un document.
|
|
44
|
+
note: "Portée par le client. Une anomalie déclarée n'est pas un bug : elle le devient après qualification.",
|
|
45
|
+
},
|
|
46
|
+
'recette:exploitation:autoriser': {
|
|
47
|
+
label: "Autoriser l'exploitation",
|
|
48
|
+
note: "Ne s'exerce QUE sur un certificat reçu portant le feu. Le go dérive du certificat, il ne se coche pas.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
categories: { recette: 'Recette & validation' },
|
|
52
|
+
},
|
|
53
|
+
schemas: RECETTE_SCHEMAS.map((schema) => ({ name: schema.name, schema })),
|
|
54
|
+
});
|
|
55
|
+
}
|
package/src/sceau.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @mostajs/recette — le SCEAU : signer ce qui part, vérifier ce qui revient.
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// POURQUOI CE FICHIER EST À PART, ET EXPOSÉ EN `@mostajs/recette/sceau`.
|
|
5
|
+
// Le moteur (`.`) ne connaît AUCUNE cryptographie : il reçoit un port `sceau` à trois verbes et
|
|
6
|
+
// refuse d'exporter sans lui. Ici vit le câblage canonique sur `@mostajs/crypto-box` — la seule
|
|
7
|
+
// maison des primitives de l'écosystème (N0). Deux conséquences :
|
|
8
|
+
// · une app qui n'exporte pas n'a aucune dépendance cryptographique à installer ;
|
|
9
|
+
// · le FORMAT de la signature est le même partout, parce qu'il n'est écrit qu'ici.
|
|
10
|
+
//
|
|
11
|
+
// ─── SIGNER ET SCELLER NE SONT PAS LA MÊME CHOSE ─────────────────────────────────────────────
|
|
12
|
+
// On SIGNE avec sa clé PRIVÉE ; le destinataire vérifie avec la clé PUBLIQUE correspondante.
|
|
13
|
+
// Cela prouve l'ORIGINE, et rien d'autre : un document signé se lit par tout le monde.
|
|
14
|
+
// On SCELLE avec la clé PUBLIQUE du destinataire ; lui seul ouvre, avec sa privée.
|
|
15
|
+
// Cela ferme le PLI, et rien d'autre : un document scellé n'est pas authentifié pour autant.
|
|
16
|
+
//
|
|
17
|
+
// L'usage attendu ici les enchaîne, dans cet ordre : **signer PUIS sceller**. Sceller d'abord
|
|
18
|
+
// signerait un chiffré — on prouverait qu'on a bien transmis une boîte, pas ce qu'elle contient.
|
|
19
|
+
//
|
|
20
|
+
// La formule « signé avec la clé publique fournie par le certificateur » décrit en réalité le
|
|
21
|
+
// SCELLEMENT : cette clé sert à fermer le pli à son intention, pas à prouver notre identité.
|
|
22
|
+
// C'est `destinataire.publique` ci-dessous. Notre identité, elle, vient de `signature.privee`.
|
|
23
|
+
import { sign, verifySignature, seal, generateSigningKeyPair, generateRecipientKeyPair } from '@mostajs/crypto-box';
|
|
24
|
+
|
|
25
|
+
const ALG_SIGNATURE = 'Ed25519';
|
|
26
|
+
|
|
27
|
+
/** Accepte une clé en Buffer, en base64 ou en hexadécimal — un `.env` ne transporte pas d'octets. */
|
|
28
|
+
function octets(k, quoi) {
|
|
29
|
+
if (!k) throw new Error(`clé absente : ${quoi}`);
|
|
30
|
+
if (k instanceof Uint8Array) return k;
|
|
31
|
+
const t = String(k).trim();
|
|
32
|
+
if (/^[0-9a-f]+$/i.test(t) && t.length % 2 === 0) return Buffer.from(t, 'hex');
|
|
33
|
+
return Buffer.from(t, 'base64');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Le port `sceau` du moteur, câblé sur crypto-box.
|
|
38
|
+
*
|
|
39
|
+
* LES VERBES N'EXISTENT QUE SI LEUR CLÉ EXISTE. Ce n'est pas une commodité : le moteur décide de
|
|
40
|
+
* refuser un export ou un certificat en regardant si le verbe est là. Rendre `signer` sans clé
|
|
41
|
+
* privée produirait une erreur au moment de l'export — c'est-à-dire à la fin de la recette,
|
|
42
|
+
* quand tout le monde attend le document. Mieux vaut qu'il manque tout de suite.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} opt
|
|
45
|
+
* @param {object} [opt.signature] `{ privee, nom }` — NOTRE identité (Ed25519). Sans elle, pas d'export.
|
|
46
|
+
* @param {object} [opt.destinataire] `{ publique }` — le certificateur (X25519). Sans elle, pas de scellement.
|
|
47
|
+
* @param {object} [opt.certificateur] `{ publique }` — le certificateur (Ed25519). Sans elle, aucun PV vérifiable.
|
|
48
|
+
*/
|
|
49
|
+
export function creerSceau({ signature, destinataire, certificateur } = {}) {
|
|
50
|
+
const port = {};
|
|
51
|
+
|
|
52
|
+
if (signature?.privee) {
|
|
53
|
+
const privee = octets(signature.privee, 'signature.privee');
|
|
54
|
+
const nom = signature.nom || 'valitrax';
|
|
55
|
+
port.signer = async (charge) => ({
|
|
56
|
+
alg: ALG_SIGNATURE,
|
|
57
|
+
cle: nom, // QUELLE clé a signé — une instance en change un jour
|
|
58
|
+
valeur: Buffer.from(sign(privee, charge)).toString('base64'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (destinataire?.publique) {
|
|
63
|
+
const pub = octets(destinataire.publique, 'destinataire.publique');
|
|
64
|
+
// `seal` prend une LISTE de destinataires : le format en accepte jusqu'à 255. Ici un seul —
|
|
65
|
+
// mais garder la liste ouverte évite de refaire l'enveloppe le jour où un client exige d'être
|
|
66
|
+
// destinataire de son propre dossier.
|
|
67
|
+
port.sceller = async (charge) => Buffer.from(seal([pub], charge)).toString('base64');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (certificateur?.publique) {
|
|
71
|
+
const pub = octets(certificateur.publique, 'certificateur.publique');
|
|
72
|
+
port.verifier = async (charge, sig) => {
|
|
73
|
+
// Un algorithme inattendu n'est pas une signature invalide : c'est un document qu'on ne
|
|
74
|
+
// sait pas lire. On refuse dans les deux cas, mais on ne prétend pas avoir vérifié.
|
|
75
|
+
if (!sig || sig.alg !== ALG_SIGNATURE || !sig.valeur) return false;
|
|
76
|
+
try { return verifySignature(pub, charge, Buffer.from(sig.valeur, 'base64')); } catch { return false; }
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return port;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Engendre les paires de clés d'une installation. À exécuter UNE fois, à la mise en service.
|
|
85
|
+
*
|
|
86
|
+
* Rendues en base64, parce qu'elles finiront dans un `.env` ou un coffre — jamais dans le code,
|
|
87
|
+
* et jamais dans un dépôt. La PRIVÉE de signature ne quitte pas l'instance qui signe ; la
|
|
88
|
+
* PUBLIQUE de réception se donne à qui doit nous écrire.
|
|
89
|
+
*/
|
|
90
|
+
export function engendrerClefs() {
|
|
91
|
+
const s = generateSigningKeyPair();
|
|
92
|
+
const r = generateRecipientKeyPair();
|
|
93
|
+
const b64 = (x) => Buffer.from(x).toString('base64');
|
|
94
|
+
return {
|
|
95
|
+
signature: { privee: b64(s.privateKey), publique: b64(s.publicKey) }, // Ed25519 — prouver l'origine
|
|
96
|
+
reception: { privee: b64(r.privateKey), publique: b64(r.publicKey) }, // X25519 — recevoir un pli scellé
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/schemas.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @mostajs/recette — schémas (EntitySchema @mostajs/orm). Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
//
|
|
3
|
+
// L'agrégat racine est la CAMPAGNE — une recette portant sur UNE version candidate. Ce n'est ni le
|
|
4
|
+
// projet (il vit chez @mostajs/qa-engine) ni la version (idem) : c'est l'ÉPREUVE qu'on leur fait
|
|
5
|
+
// subir, avec ses convoqués, ses dates et son résultat.
|
|
6
|
+
//
|
|
7
|
+
// CE MODULE NE RE-MODÉLISE RIEN DE QA-ENGINE. `releaseRef` est une RÉFÉRENCE opaque vers la
|
|
8
|
+
// `Release` de QaTrax, jamais une copie de ses champs : deux modèles de la même chose divergent,
|
|
9
|
+
// et l'on découvre la divergence le jour d'un litige.
|
|
10
|
+
|
|
11
|
+
/** La campagne : une version candidate, des convoqués, une issue. */
|
|
12
|
+
export const CampagneSchema = { name: 'Campagne', collection: 'recette_campagnes', timestamps: true, fields: {
|
|
13
|
+
no: { type: 'string' },
|
|
14
|
+
releaseRef: { type: 'string', required: true }, // → Release de @mostajs/qa-engine, OPAQUE
|
|
15
|
+
projet: { type: 'string' },
|
|
16
|
+
tenantId: { type: 'string', default: null }, // le client — cloisonnement strict
|
|
17
|
+
perimetre: { type: 'string' }, // ce que la recette couvre, en clair
|
|
18
|
+
instanceUrl: { type: 'string' }, // l'instance éprouvée — jamais la production
|
|
19
|
+
// Les six étapes du parcours, dans l'ordre. `abandonnee` est la seule sortie latérale.
|
|
20
|
+
state: { type: 'string', required: true, default: 'candidate' },
|
|
21
|
+
stateHistory: { type: 'array', default: [] }, // [{from,to,at,by,reason}] IMMUABLE
|
|
22
|
+
ouverteLe: { type: 'date', default: null },
|
|
23
|
+
closeLe: { type: 'date', default: null },
|
|
24
|
+
} };
|
|
25
|
+
|
|
26
|
+
/** Le feuillet remis à UNE personne : son rôle, sa langue, son état de retour. */
|
|
27
|
+
export const FeuilletSchema = { name: 'Feuillet', collection: 'recette_feuillets', timestamps: true, fields: {
|
|
28
|
+
campagneId: { type: 'string', required: true },
|
|
29
|
+
role: { type: 'string', required: true }, // 'A' | 'S' | 'client' | …
|
|
30
|
+
destinataireId: { type: 'string', default: null },
|
|
31
|
+
destinataire: { type: 'string' }, // libellé lisible, si le compte n'existe pas encore
|
|
32
|
+
langue: { type: 'string', default: 'fr' },
|
|
33
|
+
planVersion: { type: 'string' }, // empreinte du plan au TIRAGE — voir §manifeste
|
|
34
|
+
// `remis` → `rendu` → `depouille`. Savoir CE QUI MANQUE est plus utile que savoir ce qui est revenu.
|
|
35
|
+
state: { type: 'string', required: true, default: 'remis' },
|
|
36
|
+
remisLe: { type: 'date', default: null },
|
|
37
|
+
renduLe: { type: 'date', default: null },
|
|
38
|
+
} };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* UN POINT DE RECETTE — ce que le client LIT, et sur quoi il coche.
|
|
42
|
+
*
|
|
43
|
+
* DEUX CÔTÉS, ET UNE FRONTIÈRE ENTRE EUX.
|
|
44
|
+
*
|
|
45
|
+
* Le côté TRAME vient de QaTrax (format `mostajs-devtest/1`) : `ref`, `specRef`, `titreTrame`,
|
|
46
|
+
* priorité. Il se réimporte à chaque version candidate, et l'import l'écrase — c'est son rôle.
|
|
47
|
+
*
|
|
48
|
+
* Le côté ENRICHISSEMENT appartient à ValiTrax : le libellé écrit POUR LE CLIENT, l'attendu tel
|
|
49
|
+
* qu'il le reconnaîtra, le rôle qui le joue, l'ordre, la langue. **L'import n'y touche JAMAIS.**
|
|
50
|
+
* Sans cette garde, chaque réimport effacerait le travail humain — et l'on ne s'en apercevrait
|
|
51
|
+
* qu'en distribuant les feuillets. C'est la règle de `seedRbac` dans LabTrax, pour la même raison.
|
|
52
|
+
*
|
|
53
|
+
* « Vérifier que POST /dossiers refuse un livrable non accepté » est un titre de trame.
|
|
54
|
+
* « Essayez de programmer une soutenance sur un dossier dont un livrable manque — le refus doit
|
|
55
|
+
* dire lequel » est un libellé de recette. Même exigence, deux lecteurs, deux textes.
|
|
56
|
+
*/
|
|
57
|
+
export const PointSchema = { name: 'Point', collection: 'recette_points', timestamps: true, fields: {
|
|
58
|
+
campagneId: { type: 'string', required: true },
|
|
59
|
+
ref: { type: 'string', required: true }, // la clé d'idempotence : 'T-APP-9'
|
|
60
|
+
// ── côté TRAME — réécrit à chaque import ────────────────────────────────────────────────
|
|
61
|
+
specRef: { type: 'string', default: null }, // → l'exigence, ce qui rend la couverture vérifiable
|
|
62
|
+
titreTrame: { type: 'string' },
|
|
63
|
+
priorite: { type: 'string', default: 'medium' },
|
|
64
|
+
typeTrame: { type: 'string', default: null }, // 'manual' | 'automated'
|
|
65
|
+
etapesTrame: { type: 'array', default: [] }, // [{action, expected}] — la matière première
|
|
66
|
+
importeLe: { type: 'date', default: null },
|
|
67
|
+
// ── côté ENRICHISSEMENT — l'import ne le touche jamais ──────────────────────────────────
|
|
68
|
+
libelle: { type: 'string', default: null }, // « ce que vous faites »
|
|
69
|
+
attendu: { type: 'string', default: null }, // « ce que vous devez voir »
|
|
70
|
+
role: { type: 'string', default: null }, // qui le joue
|
|
71
|
+
ordre: { type: 'number', default: 0 },
|
|
72
|
+
langue: { type: 'string', default: null },
|
|
73
|
+
// LA SÉLECTION SE FAIT ICI, pas chez QaTrax : c'est le responsable de recette qui sait ce que
|
|
74
|
+
// SON client doit éprouver. La trame arrive entière ; on en retient une partie.
|
|
75
|
+
retenu: { type: 'boolean', default: false },
|
|
76
|
+
} };
|
|
77
|
+
|
|
78
|
+
/** Un relevé : UNE observation sur UNE action, par UNE personne. L'unité de vérité. */
|
|
79
|
+
export const ReleveSchema = { name: 'Releve', collection: 'recette_releves', timestamps: true, fields: {
|
|
80
|
+
feuilletId: { type: 'string', required: true },
|
|
81
|
+
campagneId: { type: 'string', required: true },
|
|
82
|
+
ref: { type: 'string', required: true }, // l'action du plan : 'DH9', 'S12'…
|
|
83
|
+
// `ok` conforme · `ko` le logiciel marche mais AUTREMENT que le plan l'annonce (c'est le
|
|
84
|
+
// DOCUMENT qu'on corrige) · `er` panne (c'est le CODE). Absent = non joué : les trois valeurs
|
|
85
|
+
// plus l'absence en font quatre, sans quatrième colonne.
|
|
86
|
+
issue: { type: 'string', required: true, enum: ['ok', 'ko', 'er'] },
|
|
87
|
+
at: { type: 'date', default: null }, // l'HEURE — c'est elle qui retrouve la trace serveur
|
|
88
|
+
texte: { type: 'string' },
|
|
89
|
+
source: { type: 'string', default: 'formulaire', enum: ['formulaire', 'pdf', 'image'] },
|
|
90
|
+
// Une observation lue par reconnaissance d'image ne vaut pas une case cochée à l'écran.
|
|
91
|
+
confiance: { type: 'number', default: 1 },
|
|
92
|
+
} };
|
|
93
|
+
|
|
94
|
+
/** Une anomalie : ce qu'un client DÉCLARE. Ce n'est pas encore un bug. */
|
|
95
|
+
export const AnomalieSchema = { name: 'Anomalie', collection: 'recette_anomalies', timestamps: true, fields: {
|
|
96
|
+
campagneId: { type: 'string', required: true },
|
|
97
|
+
feuilletId: { type: 'string', default: null },
|
|
98
|
+
releveRef: { type: 'string', default: null },
|
|
99
|
+
declareePar: { type: 'string', required: true },
|
|
100
|
+
titre: { type: 'string', required: true },
|
|
101
|
+
description: { type: 'string' },
|
|
102
|
+
// DEUX gravités, et jamais une seule. Celle que le client PROPOSE n'est pas écrasée par celle
|
|
103
|
+
// que l'éditeur QUALIFIE : effacer la première, c'est effacer le désaccord, et le désaccord est
|
|
104
|
+
// précisément ce qu'un procès-verbal doit pouvoir montrer.
|
|
105
|
+
graviteProposee: { type: 'string', default: null, enum: ['bloquante', 'majeure', 'mineure', null] },
|
|
106
|
+
graviteQualifiee: { type: 'string', default: null, enum: ['bloquante', 'majeure', 'mineure', null] },
|
|
107
|
+
state: { type: 'string', required: true, default: 'declaree' }, // declaree|qualifiee|ecartee|corrigee|verifiee
|
|
108
|
+
motifEcart: { type: 'string', default: null },
|
|
109
|
+
bugRef: { type: 'string', default: null }, // → Bug de @mostajs/qa-engine, après promotion
|
|
110
|
+
qualifieePar: { type: 'string', default: null },
|
|
111
|
+
qualifieeLe: { type: 'date', default: null },
|
|
112
|
+
} };
|
|
113
|
+
|
|
114
|
+
/** Le DOSSIER DE RECETTE : le livrable figé, celui que QaTrax ingère. */
|
|
115
|
+
export const DossierSchema = { name: 'Dossier', collection: 'recette_dossiers', timestamps: true, fields: {
|
|
116
|
+
campagneId: { type: 'string', required: true, unique: true },
|
|
117
|
+
no: { type: 'string' },
|
|
118
|
+
figeLe: { type: 'date', required: true },
|
|
119
|
+
figePar: { type: 'string' },
|
|
120
|
+
// L'EMPREINTE fait le lien avec le certificat. Un certificat porte sur CE dossier ; sans
|
|
121
|
+
// empreinte, il porterait sur « la recette », c'est-à-dire sur rien d'opposable.
|
|
122
|
+
empreinte: { type: 'string', required: true },
|
|
123
|
+
contenu: { type: 'object', default: () => ({}) }, // l'agrégat figé : compteurs, relevés, anomalies
|
|
124
|
+
// LE DOSSIER VOYAGE COMME UN FICHIER, pas par un appel réseau. ValiTrax n'appelle jamais
|
|
125
|
+
// QaTrax : le client — ou notre service — TRANSPORTE l'export et le dépose. La signature est
|
|
126
|
+
// ce qui rend ce transport sûr : elle dit d'où vient le document, quoi qu'il ait traversé.
|
|
127
|
+
signature: { type: 'object', default: null }, // { alg, valeur, cle } — clé PRIVÉE de l'émetteur
|
|
128
|
+
scelle: { type: 'boolean', default: false }, // chiffré pour le destinataire (sa clé PUBLIQUE)
|
|
129
|
+
exporteLe: { type: 'date', default: null },
|
|
130
|
+
exportePar: { type: 'string', default: null },
|
|
131
|
+
depotRef: { type: 'string', default: null }, // accusé rendu par QaTrax APRÈS dépôt manuel
|
|
132
|
+
} };
|
|
133
|
+
|
|
134
|
+
/** Le CERTIFICAT : ce que QaTrax RENVOIE. Ce module ne l'émet pas — il le reçoit et le garde. */
|
|
135
|
+
export const CertificatSchema = { name: 'Certificat', collection: 'recette_certificats', timestamps: true, fields: {
|
|
136
|
+
campagneId: { type: 'string', required: true },
|
|
137
|
+
no: { type: 'string', required: true },
|
|
138
|
+
empreinteDossier: { type: 'string', required: true }, // doit correspondre au dossier déposé
|
|
139
|
+
verdict: { type: 'string', required: true, enum: ['accepte', 'accepte-avec-reserves', 'refuse'] },
|
|
140
|
+
reserves: { type: 'array', default: [] },
|
|
141
|
+
emisPar: { type: 'string' },
|
|
142
|
+
emisLe: { type: 'date', default: null },
|
|
143
|
+
// LE PV EST SIGNÉ PAR QATRAX, et ValiTrax le VÉRIFIE avant de l'accepter. Un certificat dont
|
|
144
|
+
// on ne contrôle pas la signature vaut ce que vaut le courriel qui l'apportait — c'est-à-dire
|
|
145
|
+
// rien, le jour où quelqu'un conteste. Même construction que la licence signée de LabTrax :
|
|
146
|
+
// clé publique du tiers, vérification HORS LIGNE.
|
|
147
|
+
signature: { type: 'object', required: true }, // { alg, valeur, cle }
|
|
148
|
+
verificationUrl: { type: 'string' }, // la preuve que le client peut présenter seul
|
|
149
|
+
// LE DOCUMENT EST CONSERVÉ ICI, pas seulement pointé. QaTrax le FAIT, ValiTrax l'EXPOSE : le
|
|
150
|
+
// client ne va jamais sur QaTrax, qui est un outil de développement. Un lien vers une instance
|
|
151
|
+
// de dev se casse le jour où l'on renomme un vhost, et le client se retrouve sans preuve —
|
|
152
|
+
// alors que la preuve était l'objet de tout l'exercice. → @mostajs/ged
|
|
153
|
+
documentRef: { type: 'string', default: null },
|
|
154
|
+
// LE FEU. Il vient AVEC le certificat, il ne se coche pas ici.
|
|
155
|
+
goExploitation: { type: 'boolean', default: false },
|
|
156
|
+
goLe: { type: 'date', default: null },
|
|
157
|
+
} };
|
|
158
|
+
|
|
159
|
+
export const RECETTE_SCHEMAS = [CampagneSchema, PointSchema, FeuilletSchema, ReleveSchema, AnomalieSchema, DossierSchema, CertificatSchema];
|