Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
3214 lines (3046 loc) · 208 KB
/
Copy pathindex.html
File metadata and controls
3214 lines (3046 loc) · 208 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<!--
Ces quatre lignes ne servent qu'à la version publiée en statique (GitHub
Pages) : elles donnent à la page un vrai document — mode « standards », jeu de
caractères, largeur d'écran pour les téléphones, titre d'onglet. Dans
l'environnement d'édition elles sont sans effet (le document existe déjà).
-->
<!--
AGENT : avant d'écrire quoi que ce soit, lisez src/docs/ATELIER.md — la
méthode, les dix garde-fous, la vérification SANS TERMINAL
(src/scripts/harnais-atelier.mjs : syntaxe, style, épreuves, générateurs) et
ce qui compte comme « terminé ». L'architecture est dans src/README.md, les
règles du projet dans src/AGENTS.md.
CE FICHIER PORTE LE SERVICE DE DÉMONSTRATION (le bloc « text/x-server-plugin »
ci-dessous), que la plateforme d'édition exécute dans un fil séparé. Toute
règle métier y a son JUMEAU dans src/server/mysql/ : les deux doivent suivre
ensemble, et le jeu de conformité (src/tests/conformite-service.mjs) est ce qui
les empêche de diverger (voir docs/REPRISE.md, § 2).
-->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scribae — trames, rédaction et publication des actes administratifs</title>
<script>
// Mode d'affichage : appliqué AVANT tout rendu, pour éviter l'éclair blanc du
// chargement quand l'agent travaille en sombre. C'est la seule copie de la clé
// de stockage de `src/lib/theme.js` (l'application reprend la main ensuite).
try {
var pref = localStorage.getItem("scribae.theme") || "auto";
var dark = pref === "dark" || (pref === "auto" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
// La langue du document : les actes sont en français — c'est ce qui permet la
// coupure des mots en fin de ligne (hyphens:auto) et une lecture correcte par
// les synthèses vocales.
if (!document.documentElement.lang) document.documentElement.lang = "fr";
// LA DÉMONSTRATION DU PROJET n'a pas à être indexée. Son jeu de données est
// entièrement fictif (une commune imaginaire, des arrêtés qui se
// réinitialisent) : une fiche d'arrêté fictive lue dans un moteur de recherche
// se prendrait pour un acte réel. Ce n'est PAS vrai du logiciel auto-hébergé,
// dont le recueil public est fait pour être trouvé — la règle ne vaut donc que
// pour l'adresse de la démonstration, qui est celle du fichier `CNAME` du
// dépôt. Voir docs/GITHUB.md (« Démonstration statique ») et l'audit, NC-III-008.
if (location.hostname === "demo.scribae.eu") {
var robots = document.createElement("meta");
robots.name = "robots";
robots.content = "noindex, nofollow";
(document.head || document.documentElement).appendChild(robots);
}
</script>
<script type="text/x-server-plugin">
// ============================================================================
// Service de signature et de publication — API REST.
//
// Ce script est le code du SERVICE (il s'exécute côté serveur, de façon
// autoritaire). Il est public : tout le monde peut le lire, donc il ne contient
// AUCUN secret. Les clés d'écriture n'y sont pas inscrites : elles sont
// remises par un administrateur, seule leur empreinte SHA-256 est conservée
// dans l'état durable du service (privé), et chaque clé porte un RÔLE qui
// commande les routes qu'elle peut appeler. Tant qu'aucune clé n'a été
// déposée, toutes les écritures sont refusées et seules les ressources
// PUBLIQUES (recueil, publications, résolution ELI) sont servies : c'est la
// posture d'une démonstration, et elle ne peut pas être franchie par un tiers
// anonyme.
//
// Ressources exposées :
// /v1/actes dépôt des actes finalisés
// /v1/actes/{id}/signature envoi en signature auprès d'un prestataire
// /v1/webhooks/signature notification entrante du prestataire (retour signé)
// /v1/signatures/{id} suivi du circuit de signature
// /v1/actes/{id}/transmission télétransmission au contrôle de légalité
// /v1/actes/{id}/publication publication au recueil + attribution de l'ELI
// /v1/publications registre public des publications
// /v1/informations informations (billets) publiées au recueil
// /v1/atelier/acces état de la restriction d'accès à l'atelier
// /v1/eli/{...} résolution d'un identifiant ELI
// /v1/admin/purge remise à zéro du service (actes, circuits, publications)
//
// Persistance : l'unique mémoire durable est `state` (Uint8Array), où l'on
// range un document JSON en unités UTF-16 (voir loadDb/saveDb). Son en-tête
// porte la version du format et la longueur du JSON sur 32 bits — une seule
// unité UTF-16 ne suffisait pas au-delà de 65 535 caractères.
// ============================================================================
const SERVICE = "Service de signature et de publication";
const SERVICE_VERSION = "1.0.0";
// Version du format d'état. La 2 portait une longueur sur **32 bits** : la 1 la
// rangeait dans une seule unité UTF-16, donc elle débordait silencieusement dès
// que l'état JSON dépassait 65 535 caractères — saveDb écrivait une longueur
// fausse, loadDb relisait un document tronqué et repartait de zéro.
//
// La 3 écrit l'état en DOUBLE TAMPON (deux copies, un index actif), pour la
// raison qui suit. La couche de persistance de l'environnement peut prélever un
// instantané de `state` À TOUT MOMENT, y compris au milieu d'une écriture : avec
// une seule copie, un instantané pris là portait la longueur neuve et un mélange
// d'anciens et de nouveaux caractères — un document illisible, et l'état ainsi
// PERDU (c'est l'« état qui ne survit pas toujours » de l'aperçu). Une copie
// reste donc toujours intacte : on écrit l'autre, puis on bascule l'index d'un
// seul mot. Le prix est que la place se partage entre les deux copies : le
// plafond d'un document est celui d'UNE copie (`MAX_COPIE`, calculé sur la
// taille réelle de `state`). Un état à l'ancien format est simplement
// recommencé (cas décrit par le commentaire de `loadDb`).
const STATE_VERSION = 3;
const MAX_CHARS = 8000000; // plafond d'UNE copie du document JSON stocké
const MAX_DOC = 400000; // taille maximale d'un document accepté par l'API
// Taille maximale d'une PIÈCE (son contenu, en base64). Les fichiers joints —
// l'original signé d'une reprise, la version signée d'un acte du circuit
// externe — se déposaient chez l'hôte de la plateforme (`uploadPlugin`), qui
// n'existe pas en auto-hébergement : l'application les range donc DANS le
// service quand l'hôte n'en offre pas (voir src/lib/fichiers.js). Ce service-ci
// n'a pas de dépôt de fichiers à lui : les pièces tiennent dans son état, d'où
// un plafond modeste — les scans lourds appartiennent au service auto-hébergé,
// dont la documentation dit où ils sont rangés et comment les sauvegarder.
const MAX_PIECE = 900000;
const MAX_PUBLIES = 40; // au-delà, la plus ancienne publication est retirée
const MAX_SIGNATURES = 80;
const MAX_ACTES = 80;
// LE MOTIF DU REFUS DE LA SIGNATURE INTERNE, écrit UNE fois : ce service-ci vit
// dans le navigateur, n'a aucun `.env`, et ne peut donc pas tenir de coffre. Il
// le dit — à la route de signature (`hEnvoyerEnSignature`) comme à celle des
// réglages (`GET /v1/config`, champ `signatureInterne`), pour que l'application
// ne propose pas un circuit que ce service refusera. Voir la même règle côté
// auto-hébergé (src/server/mysql/actes.mjs, `hSignerInterne`).
const MOTIF_SIGNATURE_INTERNE = "Le service de démonstration de la plateforme ne tient pas de coffre de signature : la signature interne n'est disponible qu'en auto-hébergement (SCRIBA_SIGNATURE_KV_KEY).";
// Rôles et portée des clés d'écriture. Les quatre premiers forment une
// hiérarchie (administrateur > editeur > redacteur > lecteur) ; `prestataire`
// est hors hiérarchie : il ne donne accès qu'à la notification de signature.
const RANG = { administrateur: 4, editeur: 3, redacteur: 2, lecteur: 1 };
const ROLES = ["administrateur", "editeur", "redacteur", "lecteur", "prestataire"];
// ------------------------------------------------------------------- stockage
// En-tête (unités UTF-16) : [0] version, [1] poids faible de la longueur,
// [2] poids fort, [3] index de la copie ACTIVE. La suite porte DEUX copies du
// document, à des places fixes (voir `SLOT`) : chacune commence par sa propre
// longueur (deux unités), puis son JSON. La longueur de L'EN-TÊTE décrit la
// RÉGION UTILISÉE (les deux copies), pour que l'hôte qui enregistre l'état
// (`etatUtilise`, src/pages/host.js) recopie bien tout ce qu'il faut — le
// lecteur, lui, ne s'y fie pas : chaque copie porte sa longueur.
const U16_OFFSET = state.byteOffset + (state.byteOffset % 2);
const U16 = new Uint16Array(state.buffer, U16_OFFSET, Math.floor((state.length - (U16_OFFSET - state.byteOffset)) / 2));
const HEADER = 4;
// Ce que chaque copie peut tenir : la moitié de la place, moins son en-tête.
const SLOT = Math.max(2, Math.floor((U16.length - HEADER) / 2));
const MAX_COPIE = Math.max(0, Math.min(MAX_CHARS, SLOT - 2));
const debCopie = (i) => HEADER + (i ? SLOT : 0);
// LA VERSION EST POSÉE UNE FOIS, À L'AMORÇAGE — et jamais réécrite. C'est ce qui
// permet à une écriture de ne toucher QUE la copie inactive et l'index : un
// instantané de `state` pris n'importe quand porte donc toujours une version
// stable, et la copie qu'elle désigne est toujours complète. Un état à l'ancien
// format (ou vierge) est simplement recommencé : sa version réécrite, sa
// première copie relue ne passe pas le contrôle d'en-tête, et `loadDb` rend un
// état vide.
U16[0] = STATE_VERSION;
function dbVide() {
return { v: STATE_VERSION, seq: 0, actes: {}, signatures: {}, publies: {}, idem: {}, collections: {}, cles: {}, journal: [], pieces: {} };
}
// `loadDb` marque ici si la relecture a ABOUTI. C'est ce qui dit, à l'amorçage,
// s'il faut repartir d'un en-tête vierge — premier démarrage, format ancien, ou
// état abîmé : dans les trois cas, aucune copie n'est lisible, et garder une
// longueur ou un index qui ne désignent rien ferait relire à `saveDb` des
// octets laissés par un format précédent.
let copieValide = false;
function loadDb() {
if (U16[0] !== STATE_VERSION) return dbVide();
// La copie ACTIVE est la seule qu'on lise : l'autre peut être à demi écrite.
const actif = U16[3] === 1 ? 1 : 0;
const deb = debCopie(actif);
const n = U16[deb] + U16[deb + 1] * 65536; // 32 bits, en unités UTF-16
if (!n || n > MAX_COPIE || deb + 2 + n > U16.length) return dbVide();
// Contrôle d'en-tête, AVANT toute lecture de masse : un document d'état
// commence toujours par « {"v" » et finit toujours par « } ». Un état abîmé
// porte une longueur qui ne correspond à rien ; le reconnaître ici coûte deux
// unités, là où le relire coûterait de construire une chaîne de plusieurs
// mégaoctets — de quoi dépasser le budget de calcul d'un gestionnaire.
if (U16[deb + 2] !== 0x7b || U16[deb + 3] !== 0x22 || U16[deb + 2 + n - 1] !== 0x7d) return dbVide();
let s = "";
const CH = 8192;
for (let i = 0; i < n; i += CH) {
const end = Math.min(n, i + CH);
s += String.fromCharCode.apply(null, U16.subarray(deb + 2 + i, deb + 2 + end));
}
try {
const db = JSON.parse(s);
db.actes = db.actes || {};
db.signatures = db.signatures || {};
db.publies = db.publies || {};
db.idem = db.idem || {};
db.collections = db.collections || {};
db.cles = db.cles || {};
db.journal = db.journal || [];
db.pieces = db.pieces || {};
copieValide = true;
return db;
} catch (e) {
console.log("État illisible, réinitialisation :", String(e));
return dbVide();
}
}
// La longueur de l'en-tête décrit la RÉGION UTILISÉE (les deux copies), en
// unités, à partir de HEADER. Elle occupe deux unités : elle doit tenir sur
// 32 bits, pas 16.
const longueur = () => U16[1] + U16[2] * 65536;
function setLongueur(n) {
U16[1] = n % 65536;
U16[2] = (n - U16[1]) / 65536;
}
// L'état durable traverse la couche de persistance de l'environnement
// d'exécution, qui n'est pas garantie binaire : une unité UTF-16 au-delà de 127
// (tous nos accents, nos guillemets français, nos tirets) peut y être altérée —
// le document revient alors tronqué ou farci de caractères de contrôle, et
// `loadDb` repart de zéro au démarrage suivant. On n'écrit donc QUE de l'ASCII :
// tout caractère non ASCII est remplacé par son échappement « \uXXXX », que
// `JSON.parse` relit à l'identique. Le coût (l'état grossit d'environ un tiers)
// est le prix de la fiabilité.
//
// L'écriture se fait par PLAGES : la boucle avance tant que le caractère est
// ASCII, et ne paie le coût d'une concaténation que sur un caractère accentué.
// Une expression régulière avec fonction de remplacement serait plus lente d'un
// ordre de grandeur — et cette fonction s'exécute à CHAQUE écriture de l'état,
// dans le budget de calcul d'un gestionnaire de requête (voir `saveDb`).
function asciiSeul(s) {
const n = s.length;
let out = "", i = 0, debut = 0;
for (; i < n; i++) {
if (s.charCodeAt(i) < 128) continue;
out += s.slice(debut, i) + "\\u" + s.charCodeAt(i).toString(16).padStart(4, "0");
debut = i + 1;
}
return debut ? out + s.slice(debut) : s;
}
function saveDb(db) {
const s = asciiSeul(JSON.stringify(db));
if (s.length > MAX_COPIE) {
console.log("État plein : écriture refusée (" + s.length + " unités, plafond d'une copie " + MAX_COPIE + ")");
return false;
}
// ÉCRITURE EN DOUBLE TAMPON. La couche de persistance de l'environnement peut
// prélever un instantané de `state` à tout moment — y compris au milieu de
// cette fonction. On écrit donc la copie INACTIVE (celle que le lecteur ne
// regarde pas), puis on ne touche qu'UN SEUL mot pour basculer : l'index actif.
// Un instantané pris avant la bascule voit l'ancienne copie, intacte ; pris
// après, la neuve, complète. Aucun mélange d'ancien et de neuf n'est possible,
// là où une copie unique se corrompait — et l'état était alors perdu.
const actif = U16[3] === 1 ? 1 : 0;
const cible = actif ? 0 : 1;
const deb = debCopie(cible);
U16[deb] = s.length % 65536;
U16[deb + 1] = (s.length - U16[deb]) / 65536;
for (let i = 0; i < s.length; i++) U16[deb + 2 + i] = s.charCodeAt(i);
U16[3] = cible;
// SI LA COPIE NEUVE EST EN HAUT, ON LA REDESCEND EN BAS (`copyWithin`, aucune
// réallocation) : la région que l'hôte a à recopier reste ainsi celle d'un
// petit document, au lieu d'atteindre la moitié du tampon. L'index reste braqué
// sur la copie COMPLÈTE pendant la descente : un instantané pris là rend donc
// encore, à coup sûr, la version neuve.
if (cible === 1) {
U16.copyWithin(HEADER, debCopie(1), debCopie(1) + 2 + s.length);
U16[3] = 0;
}
// La longueur de l'en-tête est la FIN de la copie ACTIVE, à partir de HEADER :
// c'est exactement ce que l'hôte doit recopier pour qu'une relecture trouve
// une copie entière (`etatUtilise`, src/pages/host.js), et rien de plus.
setLongueur((U16[3] ? SLOT : 0) + 2 + s.length);
return true;
}
const db = loadDb();
// Aucune copie lisible (premier démarrage, ancien format, état abîmé) : l'en-tête
// est remis à zéro AVANT la première écriture, en-têtes de copie compris, pour
// qu'aucune longueur héritée d'un format précédent ne soit prise pour une copie.
if (!copieValide) {
U16[1] = 0;
U16[2] = 0;
U16[3] = 0;
for (const i of [0, 1]) { U16[debCopie(i)] = 0; U16[debCopie(i) + 1] = 0; }
}
function nextId(prefix, table) {
let n = (db.seq || 0) + 1;
let id;
do { id = prefix + "-" + String(n).padStart(4, "0"); n++; } while (table[id]);
db.seq = n;
return id;
}
// Éviction : on ne garde que les plus récentes (la plus ancienne sort d'abord).
function evince(table, max, dateKey) {
const keys = Object.keys(table);
if (keys.length <= max) return;
keys.sort((a, b) => cmp(table[a][dateKey], table[b][dateKey]));
for (const k of keys.slice(0, keys.length - max)) delete table[k];
}
// Comparaison de chaînes. Le moteur du service n'expose pas `Intl`, et
// `localeCompare` y lève donc une exception : l'utiliser faisait échouer toutes
// les listes (actes, signatures, publications) dès qu'il y avait au moins deux
// éléments à comparer — le comparateur n'étant appelé qu'à partir du deuxième.
// Ces clés sont des dates ISO et des identifiants : l'ordre lexicographique y
// est exactement l'ordre attendu.
function cmp(a, b) {
const x = String(a == null ? "" : a), y = String(b == null ? "" : b);
return x < y ? -1 : x > y ? 1 : 0;
}
// Comparaison de TEXTES (libellés d'entités, de thèmes, objets d'actes) : mêmes
// raisons que `cmp`, plus une difficulté propre — `localeCompare` savait ignorer
// accents et casse, le moteur du service ne le sait pas. On plie donc soi-même
// la casse et les accents français, ce qui suffit pour que « École » se range
// avec « ecole » et non après « zoo ».
const PLIAGE_ACCENTS = {
"à": "a", "á": "a", "â": "a", "ã": "a", "ä": "a", "å": "a", "ç": "c",
"è": "e", "é": "e", "ê": "e", "ë": "e", "ì": "i", "í": "i", "î": "i", "ï": "i",
"ò": "o", "ó": "o", "ô": "o", "õ": "o", "ö": "o",
"ù": "u", "ú": "u", "û": "u", "ü": "u", "ñ": "n", "ý": "y", "ÿ": "y",
"œ": "oe", "æ": "ae",
};
function plierTexte(s) {
return String(s == null ? "" : s).toLowerCase().replace(/[àáâãäåçèéêëìíîïòóôõöùúûüñýÿœæ]/g, (c) => PLIAGE_ACCENTS[c] || c);
}
function cmpTexte(a, b) {
const x = plierTexte(a), y = plierTexte(b);
return x < y ? -1 : x > y ? 1 : 0;
}
// ------------------------------------------------------------------ utilitaires
function utf8Bytes(str) {
const out = [];
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
if (c < 0x80) out.push(c);
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
else if (c >= 0xd800 && c <= 0xdbff) {
const c2 = str.charCodeAt(i + 1);
if (c2 >= 0xdc00 && c2 <= 0xdfff) { i++; const cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63)); }
else out.push(0xef, 0xbf, 0xbd);
} else if (c >= 0xdc00 && c <= 0xdfff) out.push(0xef, 0xbf, 0xbd);
else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
}
return out;
}
const K256 = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
// SHA-256 synchrone (le serveur n'a ni crypto ni TextEncoder).
function sha256Hex(str) {
const bytes = utf8Bytes(str);
const bitLen = bytes.length * 8;
bytes.push(0x80);
while (bytes.length % 64 !== 56) bytes.push(0);
const hi = Math.floor(bitLen / 4294967296);
const lo = bitLen % 4294967296;
bytes.push((hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255, (lo >>> 24) & 255, (lo >>> 16) & 255, (lo >>> 8) & 255, lo & 255);
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a,
h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
const w = new Array(64);
for (let i = 0; i < bytes.length; i += 64) {
for (let j = 0; j < 16; j++) w[j] = ((bytes[i + 4 * j] << 24) | (bytes[i + 4 * j + 1] << 16) | (bytes[i + 4 * j + 2] << 8) | bytes[i + 4 * j + 3]) | 0;
for (let j = 16; j < 64; j++) {
const x = w[j - 15], y = w[j - 2];
const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10);
w[j] = (w[j - 16] + s0 + w[j - 7] + s1) | 0;
}
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
for (let j = 0; j < 64; j++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + K256[j] + w[j]) | 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
}
h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0;
h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0;
}
let out = "";
for (const v of [h0, h1, h2, h3, h4, h5, h6, h7]) out += (v >>> 0).toString(16).padStart(8, "0");
return out;
}
const nowIso = () => new Date().toISOString();
const today = () => nowIso().slice(0, 10);
// ------------------------------------------------------- contrôle de légalité
// La télétransmission au contrôle de légalité : l'acte signé part vers l'API
// d'envoi de la préfecture, qui en accuse réception. L'accusé de réception vaut
// certificat informatique de transmission, et c'est lui que le client dépose
// sur le document avant de publier l'acte. Le service ne transmet que si le
// client le lui a demandé au dépôt (`controleLegalite`), et refuse de publier
// un acte dont la transmission manque.
const CONTROLE_LEGALITE = {
destinataire: "Préfecture — contrôle de légalité",
mode: "ctes",
apiUrl: "https://api.ctes.valmont-sur-loire.fr/v1/transmissions",
delai: 900, // millisecondes de latence simulée de l'API d'envoi
};
const MOIS_FR = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"];
// « 22 janvier 2026 à 09 h 14 » — sans Intl (le moteur du service ne l'expose
// pas), et identique à la mention produite côté client (src/lib/legalite.js).
function dateHeureFr(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const h = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return d.getDate() + " " + MOIS_FR[d.getMonth()] + " " + d.getFullYear() + " à " + h + " h " + mi;
}
function certificatTransmission({ reference, recuLe, destinataire, empreinte, demonstration = false, declaration = null }) {
const d = destinataire || CONTROLE_LEGALITE.destinataire;
const r = recuLe || nowIso();
const decl = declaration && typeof declaration === "object" ? declaration : null;
const par = decl ? String(decl.parNom || decl.par || "") : "";
const nature = decl
? "Déclaration de transmission au contrôle de légalité"
: demonstration ? "Simulation d'accusé de réception de télétransmission" : "Accusé de réception de télétransmission";
const emisPar = decl
? "Déclaration" + (par ? " de " + par : "") + " (acte transmis hors application)"
: demonstration ? "Simulation locale (aucun appel à l'API @ctes)" : "Contrôle de légalité — télétransmission @ctes";
const mention = decl
? "Transmis au contrôle de légalité le " + dateHeureFr(r) + (par ? " (déclaration de " + par + ")" : " (déclaration)")
: demonstration
? "Transmis au contrôle de légalité le " + dateHeureFr(r) + " (mention de démonstration — transmission simulée, sans appel sortant)"
: "Transmis au contrôle de légalité le " + dateHeureFr(r);
return {
nature,
emisPar,
emisLe: r,
destinataire: d,
reference: String(reference || ""),
empreinte: String(empreinte || ""),
algorithme: "SHA-256",
demonstration: !!demonstration,
declaration: !!decl,
sceau: sha256Hex([reference, r, d, empreinte].join("|")),
mention,
};
}
function slug(s) {
return String(s || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
}
// ------------------------------------------------------------------ réponses
const ok = (status, body, headers) => ({ status, headers: headers || {}, body });
const err = (status, message, extra, headers) => ({ status, headers: headers || {}, body: { erreur: message, ...(extra || {}) } });
// ---------------------------------------------------------------- autorisation
// Le service n'a pas de porte unique : chaque clé porte un rôle, et chaque
// route dit le rôle qu'elle exige. La comparaison des empreintes est à TEMPS
// CONSTANT — une comparaison `!==` sortirait au premier caractère différent et
// laisserait mesurer l'empreinte attendue, octet par octet.
function egalConstant(a, b) {
const x = String(a == null ? "" : a), y = String(b == null ? "" : b);
if (x.length !== y.length) return false;
let d = 0;
for (let i = 0; i < x.length; i++) d |= x.charCodeAt(i) ^ y.charCodeAt(i);
return d === 0;
}
function jetonPresente(headers) {
const raw = String((headers && (headers.authorization || headers.Authorization)) || "").trim();
const m = /^Bearer\s+(.+)$/.exec(raw);
return m ? m[1] : "";
}
// Identité portée par la clé présentée : { id, role, label } ou null.
function identiteDe(headers) {
const jeton = jetonPresente(headers);
if (!jeton) return null;
const h = sha256Hex(jeton);
const cles = db.cles || {};
for (const id of Object.keys(cles)) {
const c = cles[id];
if (c && egalConstant(c.hash, h)) return { id, role: c.role, label: c.label || "" };
}
return null;
}
const MOTIF_CLE_ABSENTE = "Clé d'API absente. Ajoutez l'en-tête « Authorization: Bearer <clé> ».";
// `regle` : { min: "redacteur" } (hiérarchie), { exact: [...] }, ou rien.
function authFail(headers, regle) {
const cles = db.cles || {};
if (!Object.keys(cles).length) {
return err(403, "Ce service n'est pas encore provisionné : aucune clé d'écriture n'a été déposée. Un administrateur doit créer la clé du service (Administration › Base de données).", { code: "service_non_provisionne" });
}
if (!jetonPresente(headers)) return err(401, MOTIF_CLE_ABSENTE, { code: "jeton_absent" });
const id = identiteDe(headers);
if (!id) return err(403, "Clé d'API invalide.", { code: "jeton_invalide" });
if (regle) {
if (regle.exact && regle.exact.indexOf(id.role) < 0) {
return err(403, "Cette clé (rôle « " + id.role + " ») n'a pas le rôle requis : " + regle.exact.join(", ") + ".", { code: "role_insuffisant", role: id.role });
}
if (regle.min && (RANG[id.role] || 0) < RANG[regle.min]) {
return err(403, "Cette clé (rôle « " + id.role + " ») n'a pas le rôle requis : au moins « " + regle.min + " ».", { code: "role_insuffisant", role: id.role });
}
}
return null;
}
const writes = new Map();
function allowWrite(conn, limit = 90) {
const key = String((conn && conn.net && conn.net[3]) || (conn && conn.id) || "anon");
const t = Date.now();
let e = writes.get(key);
if (!e || t - e.t > 60000) { e = { t, n: 0 }; writes.set(key, e); }
e.n += 1;
return e.n <= limit;
}
// ------------------------------------------------- adresses IP et accès à l'atelier
// Même règle que le service auto-hébergé (src/server/mysql/ips.mjs et
// atelier.mjs) : la liste blanche dit qui entre. Le SERVICE de démonstration de
// la plateforme, lui, ne voit PAS l'adresse de l'appelant — aucun proxy ne la
// lui transmet —, et il ne peut donc pas refuser l'entrée : il dit l'état du
// réglage, il SIMULE l'adresse qu'on lui demande d'éprouver (le simulateur de
// l'écran d'administration), et il n'applique la restriction que si une adresse
// lui parvient réellement (en-tête X-Forwarded-For). Sur une installation
// auto-hébergée, c'est le service qui décide, et il répond 403.
//
// La liste accepte : une adresse, un préfixe CIDR, un champ « a-b », une plage
// abrégée « 10.0.0.* », et « # » ouvre un commentaire. Une entrée incomprise est
// rapportée, jamais ignorée en silence.
const IP_V4_MAX = (1n << 32n) - 1n;
function ipV4(txt) {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(txt);
if (!m) return null;
let v = 0n;
for (let i = 1; i <= 4; i += 1) {
const o = Number(m[i]);
if (o > 255) return null;
v = (v << 8n) | BigInt(o);
}
return v;
}
function ipV6Groupes(partie) {
const bouts = String(partie).split(":").filter((x) => x !== "");
const g = [];
for (let i = 0; i < bouts.length; i += 1) {
const b = bouts[i];
if (i === bouts.length - 1 && b.includes(".")) {
const v4 = ipV4(b);
if (v4 === null) return null;
g.push(Number((v4 >> 16n) & 65535n), Number(v4 & 65535n));
} else {
if (!/^[0-9a-f]{1,4}$/i.test(b)) return null;
g.push(parseInt(b, 16));
}
}
return g;
}
function ipV6(txt) {
if (!txt.includes(":")) return null;
const d = txt.split("::");
if (d.length > 2) return null;
let groupes;
if (d.length === 2) {
const tete = ipV6Groupes(d[0]);
const queue = ipV6Groupes(d[1]);
if (tete === null || queue === null) return null;
const manquants = 8 - (tete.length + queue.length);
if (manquants < 1) return null;
groupes = tete.concat(Array(manquants).fill(0), queue);
} else {
groupes = ipV6Groupes(txt);
if (groupes === null || groupes.length !== 8) return null;
}
return groupes.reduce((a, g) => (a << 16n) | BigInt(g), 0n);
}
// Une écriture mixte (« ::ffff:192.168.1.1 ») désigne l'IPv4 qu'elle porte : une
// liste écrite en IPv4 doit la reconnaître.
function ipLire(txt) {
const t = String(txt == null ? "" : txt).trim();
if (!t) return null;
const v4 = ipV4(t);
if (v4 !== null) return { valeur: v4, bits: 32 };
const v6 = ipV6(t);
if (v6 === null) return null;
if ((v6 >> 32n) === 65535n) return { valeur: v6 & IP_V4_MAX, bits: 32 };
return { valeur: v6, bits: 128 };
}
function ipEcrire(v, bits) {
if (bits === 32) return [24n, 16n, 8n, 0n].map((d) => Number((v >> d) & 255n)).join(".");
const g = [];
for (let i = 7; i >= 0; i -= 1) g.push(Number((v >> BigInt(16 * i)) & 65535n).toString(16));
let debut = -1;
let meilleurD = -1;
let meilleurL = 0;
for (let i = 0; i <= 8; i += 1) {
if (i < 8 && g[i] === "0") { if (debut < 0) debut = i; }
else if (debut >= 0) {
if (i - debut > meilleurL) { meilleurL = i - debut; meilleurD = debut; }
debut = -1;
}
}
if (meilleurL < 2) return g.join(":");
return g.slice(0, meilleurD).join(":") + "::" + g.slice(meilleurD + meilleurL).join(":");
}
// Une entrée de liste → son champ d'adresses, ou la faute qui l'empêche.
function ipEntree(brut) {
const t = String(brut == null ? "" : brut).split("#")[0].trim();
if (!t) return null;
const faute = (motif) => ({ entree: t, erreur: motif });
if (t.includes("-")) {
const [g, d] = t.split("-");
const debut = ipLire(g);
const fin = ipLire(d);
if (!debut || !fin) return faute("champ d'adresses : les deux bornes doivent être des adresses");
if (debut.bits !== fin.bits) return faute("champ d'adresses : bornes de familles différentes");
if (debut.valeur > fin.valeur) return faute("champ d'adresses : la borne basse dépasse la borne haute");
return { debut: debut.valeur, fin: fin.valeur, bits: debut.bits, libelle: ipEcrire(debut.valeur, debut.bits) + "-" + ipEcrire(fin.valeur, fin.bits) };
}
if (t.includes("*")) {
const m = /^(\d{1,3}(?:\.\d{1,3})*)(?:\.\*)+$/.exec(t);
if (!m) return faute("plage abrégée : syntaxe attendue « 10.0.0.* »");
const fixes = m[1].split(".");
const reste = 4 - fixes.length;
if (!reste) return faute("plage abrégée : la dernière étoile doit couvrir au moins un octet");
const debut = ipV4(fixes.concat(Array(reste).fill("0")).join("."));
const fin = ipV4(fixes.concat(Array(reste).fill("255")).join("."));
if (debut === null || fin === null) return faute("plage abrégée : adresse IPv4 attendue");
return { debut, fin, bits: 32, libelle: ipEcrire(debut, 32) + "-" + ipEcrire(fin, 32) };
}
if (t.includes("/")) {
const i = t.lastIndexOf("/");
const adresse = ipLire(t.slice(0, i));
const taille = String(t.slice(i + 1)).trim();
if (!adresse) return faute("préfixe : l'adresse qui précède « / » est illisible");
if (!/^\d{1,3}$/.test(taille)) return faute("préfixe : la longueur après « / » doit être un nombre");
const n = Number(taille);
if (n > adresse.bits) return faute("préfixe : la longueur doit être comprise entre 0 et " + adresse.bits);
const haut = adresse.bits === 32 ? IP_V4_MAX : (1n << 128n) - 1n;
const masque = n === 0 ? 0n : (haut << BigInt(adresse.bits - n)) & haut;
const debut = adresse.valeur & masque;
const fin = debut | (haut & ~masque);
return { debut, fin, bits: adresse.bits, libelle: ipEcrire(debut, adresse.bits) + "/" + n };
}
const a = ipLire(t);
if (!a) return faute("adresse illisible (IPv4 ou IPv6 attendue)");
return { debut: a.valeur, fin: a.valeur, bits: a.bits, libelle: ipEcrire(a.valeur, a.bits) };
}
function ipListe(brut) {
const parts = Array.isArray(brut) ? brut : String(brut == null ? "" : brut).split(/[,\n;]/);
const entrees = [];
const erreurs = [];
for (const item of parts) {
const r = ipEntree(item);
if (!r) continue;
if (r.erreur) erreurs.push({ entree: r.entree, motif: r.erreur });
else entrees.push(r);
}
return { entrees, erreurs };
}
// Une liste est-elle DEMANDÉE ? Le texte porte-t-il autre chose qu'un vide ou un
// commentaire ? La question se pose séparément des entrées LUES, parce qu'une
// liste peut n'en produire aucune : écrite de travers, elle doit fermer l'atelier
// et le dire (`erreurs`), jamais l'ouvrir en silence.
function ipListeDemandee(brut) {
const parts = Array.isArray(brut) ? brut : String(brut == null ? "" : brut).split(/[,\n;]/);
return parts.some((x) => String(x == null ? "" : x).split("#")[0].trim() !== "");
}
function ipEntreeQui(ip, entrees) {
const a = ipLire(ip);
if (!a) return null;
for (const e of entrees) {
if (e.bits !== a.bits) continue;
if (a.valeur >= e.debut && a.valeur <= e.fin) return e;
}
return null;
}
// Les espaces réseau réservés : ils servent au MESSAGE montré à l'agent
// (« vous passez bien par un réseau interne »), jamais à décider.
function ipInterne(ip) {
const a = ipLire(ip);
if (!a) return false;
if (a.bits === 32) {
const dans = (base, taille) => {
const b = ipV4(base);
const haut = (IP_V4_MAX << BigInt(32 - taille)) & IP_V4_MAX;
return (a.valeur & haut) === (b & haut);
};
return dans("10.0.0.0", 8) || dans("172.16.0.0", 12) || dans("192.168.0.0", 16)
|| dans("127.0.0.0", 8) || dans("169.254.0.0", 16) || dans("100.64.0.0", 10);
}
const fc = ipV6("fc00::");
const fe = ipV6("fe80::");
return (a.valeur >> 121n) === (fc >> 121n) || (a.valeur >> 118n) === (fe >> 118n);
}
const MESSAGE_ATELIER =
"L'atelier des actes est réservé au réseau de la collectivité. Depuis une autre adresse, "
+ "l'espace public du recueil reste consultable.";
// Le réglage, tel qu'il vit dans le référentiel
// (`config.publication.atelier`, Administration › Accès à l'atelier).
function reglageAtelier() {
const rec = db.collections && db.collections.config && db.collections.config.records
&& db.collections.config.records.self;
const doc = rec && rec.payload;
const a = doc && doc.publication && doc.publication.atelier;
return a && typeof a === "object" ? a : {};
}
// L'adresse de l'appelant, si elle parvient au service. Sur la plateforme, elle
// n'arrive pas : le service ne peut donc pas restreindre l'entrée — il le DIT
// (`restriction_appliquee: false`).
function ipDeLEntete(headers) {
const xff = String((headers && (headers["x-forwarded-for"] || headers["X-Forwarded-For"])) || "").split(",")[0].trim();
if (!xff) return "";
const a = ipLire(xff);
return a ? ipEcrire(a.valeur, a.bits) : xff;
}
function etatAtelier(ip) {
const a = reglageAtelier();
const liste = ipListe(a.ips || "");
// La restriction est ACTIVE dès qu'une liste a été DEMANDÉE, même si aucune de
// ses entrées n'est lisible : une liste écrite de travers doit FERMER l'atelier,
// jamais l'ouvrir en croyant l'avoir fermé (voir src/server/mysql/atelier.mjs,
// `etat`). « Aucune liste » reste le seul cas qui ouvre.
const actif = ipListeDemandee(a.ips);
const adresse = String(ip || "").trim();
const regle = actif ? ipEntreeQui(adresse, liste.entrees) : null;
const connue = !!ipLire(adresse);
return {
ip: adresse,
actif,
// Une adresse inconnue n'est pas refusée : le service de démonstration ne
// peut pas la juger, et refuser fermerait l'atelier à tout le monde.
autorise: actif && connue ? !!regle : true,
connue,
interne: ipInterne(adresse),
source: actif ? "referentiel" : "",
regle: regle ? regle.libelle : "",
liste: liste.entrees.map((e) => e.libelle),
erreurs: liste.erreurs,
message: String(a.message || "").trim() || MESSAGE_ATELIER,
restriction_appliquee: false,
note: "Service de démonstration : il ne voit pas l'adresse de l'appelant, et n'applique donc pas la restriction — il la dit, et la simule sur « ?ip= ». Si ce panneau reste « ouvert » après l'écriture d'une liste, c'est que le référentiel de l'aperçu n'est pas transmis au service de démonstration (l'application travaille alors sur son stockage local). L'installation auto-hébergée, elle, décide : 403 « atelier_hors_reseau » (voir src/server/mysql/atelier.mjs).",
};
}
// L'atelier est-il ouvert à cet appelant ? Vrai tant qu'aucune adresse ne
// permet de trancher — voir `etatAtelier`.
function atelierPermet(headers) {
const ip = ipDeLEntete(headers);
if (!ip) return true;
const e = etatAtelier(ip);
return !e.actif || e.autorise;
}
// ------------------------------------------------------- les informations publiées
// Les billets du recueil public (voir src/lib/informations.js). La route ne rend
// que les billets PUBLIÉS ; un brouillon ne sort que vers une clé de service.
const resumeInformation = (i) => ({
id: i.id, titre: i.titre, slug: i.slug, resume: i.resume, corps: i.corps,
date: i.date, auteur: i.auteur, publie: i.publie === true, epingle: i.epingle === true,
creeLe: i.creeLe, modifieLe: i.modifieLe || "",
});
function hInformations(headers) {
const c = collection("informations");
const estEditeur = !authFail(headers, { min: "editeur" });
const tous = Object.keys(c.records).map((k) => c.records[k].payload).filter((i) => i && typeof i === "object");
const liste = tous.filter((i) => i.publie === true || estEditeur);
// Du plus récent au plus ancien : `cmp` sur des dates ISO, jamais
// `localeCompare` (voir `cmp` : il lève dans le moteur du service, et la liste
// échouait dès le DEUXIÈME billet — le comparateur n'étant appelé qu'à partir
// de là, le défaut ne se voyait pas sur un recueil vide).
liste.sort((a, b) => cmp(String(b.date || b.creeLe || ""), String(a.date || a.creeLe || "")));
return ok(200, { informations: liste.map(resumeInformation) });
}
// -------------------------------------------------- projections
// Les listes ne renvoient jamais les documents : seulement les métadonnées.
function resumeActe(a) {
return { id: a.id, numero: a.numero, objet: a.objet, nature: a.nature, entityName: a.entityName, dateSignature: a.dateSignature, statut: a.statut, publishable: a.publishable !== false, controleLegalite: a.controleLegalite === true, transmission: a.transmission || null, sha256: a.sha256, deposeLe: a.deposeLe, signatureId: a.signatureId || null, publication: a.publication || null, validation: a.validation || null, revision: a.revision || null, signatureMode: a.signatureMode || "electronique", certificationRequise: a.certificationRequise === true, originalExterne: resumeOriginalExterne(a) || null, certification: a.certification || null };
}
// La version signée d'un acte du circuit externe, en abrégé (les listes ne
// renvoient jamais le document : ici, une adresse et une empreinte).
function resumeOriginalExterne(a) {
const s = a && a.originalExterne;
if (!s || !s.url) return null;
return { url: s.url, sha256: s.sha256, nom: s.nom, taille: s.taille, type: s.type, deposeLe: s.deposeLe, deposePar: s.deposePar, deposeParNom: s.deposeParNom, pieceId: s.pieceId || "", certification: a.certification || null };
}
function resumeSignature(s) {
return { id: s.id, acteId: s.acteId, numero: s.numero, statut: s.statut, signataires: s.signataires, creeLe: s.creeLe, signeLe: s.signeLe || null, motif: s.motif || null, niveau: s.niveau || "", attribution: s.attribution || "", verifie: s.verifie === true, empreinte: (s.documentSigne && s.documentSigne.document && s.documentSigne.document.sha256) || null };
}
function resumePublication(p, latest) {
return { cle: p.cle, eli: p.eli, eliUri: p.eliUri, url: p.url, numero: p.numero, nature: p.nature, themeId: p.themeId || "", themeLabel: p.themeLabel || "", objet: p.objet, entityName: p.entityName, dateDocument: p.dateDocument, datePublication: p.datePublication, dateOpposabilite: p.dateOpposabilite, kind: p.kind, recueil: p.recueil, publieeLe: p.publieeLe, latest: !!latest, epingle: p.epingle === true, reserve: p.reserve === true, transmission: p.transmission || null, signature: p.signature || null, versions: p.versions || [], informative: p.informative === true, reprise: p.reprise === true, provenance: p.provenance || "", adoption: p.adoption || null, juridique: p.juridique === false ? false : undefined, natureDoc: p.natureDoc || undefined, originalExterne: p.originalExterne ? { url: p.originalExterne.url, sha256: p.originalExterne.sha256, nom: p.originalExterne.nom, taille: p.originalExterne.taille, deposeLe: p.originalExterne.deposeLe, pieceId: p.originalExterne.pieceId || "", certification: p.originalExterne.certification || null } : null };
}
// Les publications RÉSERVÉES AUX AGENTS : publiées (ELI, original, versions),
// mais servies qu'aux requêtes qui portent une clé de service — la même règle
// que le service auto-hébergé (src/server/mysql/actes.mjs, `visiblePour`).
const reservee = (p) => !!p && p.reserve === true;
const visiblePour = (p, agent) => !!p && (agent || !reservee(p));
function dernierVisible(eliUri, agent) {
const v = versionsOf(eliUri).filter((p) => visiblePour(p, agent));
return v.length ? v[v.length - 1] : null;
}
// La part PUBLIQUE d'un original signé : sans son dossier interne (adresse du
// signataire, compte, moyen d'authentification, courriels) et sans les mentions
// nominatives du signataire. C'est la SEULE forme sous laquelle un original
// sort par une route publique — même règle que le service auto-hébergé
// (src/server/mysql/actes.mjs). La part interne se lit sur une route protégée
// (`/v1/actes/{id}/dossier-signature`).
function sansInterne(v) {
if (!v || typeof v !== "object") return v;
const { interne, ...reste } = v;
if (!Array.isArray(v.signatures)) return reste;
return {
...reste,
signatures: v.signatures.map((s) => {
const sig = { ...((s && s.signataire) || {}) };
delete sig.courriel; delete sig.personId; delete sig.compteId; delete sig.compteOutil; delete sig.rapproche;
return { ...s, signataire: sig };
}),
};
}
function clePublication(eliUri, dateExpr) {
return slug(eliUri.replace(/^eli:\/fr\//, "")) + "@" + (dateExpr || today());
}
function versionsOf(eliUri) {
const all = Object.keys(db.publies).map((k) => db.publies[k]).filter((p) => p.eliUri === eliUri);
all.sort((a, b) => cmp(a.dateDocument, b.dateDocument) || cmp(a.publieeLe, b.publieeLe));
return all;
}
function latestOf(eliUri) {
const v = versionsOf(eliUri);
return v.length ? v[v.length - 1] : null;
}
// ------------------------------------------------------------------ OpenAPI
function openapi() {
const paths = {
"/v1/health": { get: { operationId: "sante", summary: "État du service", description: "Vérifie que le service répond et donne le nombre d'objets conservés.", tags: ["Service"], responses: { 200: { description: "Service disponible" } } } },
"/v1/auth/etat": { get: { operationId: "etatAutorisation", summary: "État de l'autorisation du service", description: "Route PUBLIQUE : dit si le service a été provisionné (au moins une clé existe) et quels rôles existent. Le client l'appelle avant toute écriture pour savoir s'il doit se provisionner. Un service NON provisionné est en LECTURE SEULE : il ne sert que les ressources publiques.", tags: ["Autorisation"], responses: { 200: { description: "État de l'autorisation" } } } },
"/v1/auth/bootstrap": { post: { operationId: "provisionnerService", summary: "Provisionner le service (dépôt de la première clé)", description: "Geste d'installation : dépose la PREMIÈRE clé d'administration. La valeur de la clé est tirée par le client (crypto.getRandomValues) et n'est jamais choisie par le service : celui-ci n'en conserve que l'empreinte SHA-256. Un service déjà provisionné refuse ce geste (409 `service_deja_provisionne`).", tags: ["Autorisation"], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["cle"], properties: { cle: { type: "string", minLength: 32, description: "La clé tirée au hasard (32 caractères au moins)" }, role: { type: "string", default: "administrateur", enum: ["administrateur"] }, label: { type: "string", description: "Libellé de la clé (pour la retrouver dans la liste)" } } } } } }, responses: { 201: { description: "Service provisionné" }, 409: { description: "Service déjà provisionné (code `service_deja_provisionne`)" }, 422: { description: "Clé trop courte (code `cle_trop_courte`)" } } } },
"/v1/auth/cles": {
get: { operationId: "listerCles", summary: "Lister les clés d'API", description: "Rend les clés connues du service — identifiant, libellé, rôle, date de création —, jamais leur valeur ni leur empreinte. Route d'administration.", security: [{ bearerAuth: [] }], tags: ["Autorisation"], responses: { 200: { description: "Les clés connues" }, 403: { description: "Rôle administrateur requis" } } },
post: { operationId: "creerCle", summary: "Créer une clé d'API", description: "Crée une clé rattachée à un rôle (`lecteur`, `redacteur`, `editeur`, `administrateur`, ou `prestataire` pour la seule notification de signature). C'est le client qui tire la valeur et n'en transmet que l'empreinte. Route d'administration.", security: [{ bearerAuth: [] }], tags: ["Autorisation"], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["cle", "role"], properties: { cle: { type: "string", minLength: 32 }, role: { type: "string", enum: ["lecteur", "redacteur", "editeur", "administrateur", "prestataire"] }, label: { type: "string" } } } } } }, responses: { 201: { description: "Clé créée" }, 403: { description: "Rôle administrateur requis" }, 422: { description: "Clé trop courte (code `cle_trop_courte`)" } } },
},
"/v1/auth/cles/{id}/revoquer": { post: { operationId: "revoquerCle", summary: "Révoquer une clé d'API", description: "Retire une clé. Le service refuse de révoquer la DERNIÈRE clé d'administration (409 `derniere_cle_admin`) : il ne peut pas se retrouver sans moyen d'être administré.", security: [{ bearerAuth: [] }], tags: ["Autorisation"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "Clé révoquée" }, 403: { description: "Rôle administrateur requis" }, 404: { description: "Clé inconnue (code `cle_inconnue`)" }, 409: { description: "Dernière clé d'administration (code `derniere_cle_admin`)" } } } },
"/v1/journal": { get: { operationId: "lireJournal", summary: "Lire le journal d'audit du service", description: "Journal APPEND-ONLY tenu par le service (et non par le poste client) : chaque geste sensible — dépôt, ouverture de signature, notification, transmission, publication, retrait, épinglage, provisionnement et gestion des clés — y laisse une ligne. Chaque ligne scelle la précédente par son empreinte SHA-256 : modifier une ligne rompt la chaîne, ce que le champ `scelle` révèle. Route d'administration.", security: [{ bearerAuth: [] }], tags: ["Autorisation"], responses: { 200: { description: "Les dernières entrées, et l'état du scellement" }, 403: { description: "Rôle administrateur requis" } } } },
"/v1/db/health": { get: { operationId: "santeBase", summary: "État de la base de données", description: "Donne le pilote de persistance et le nombre d'enregistrements par collection (référentiel, trames, actes, comptes, métadonnées).", tags: ["Base de données"], responses: { 200: { description: "Base disponible" } } } },
"/v1/db/collections/{collection}": { get: { operationId: "lireCollection", summary: "Lire une collection", description: "Renvoie tous les enregistrements d'une collection, chacun avec sa révision.", tags: ["Base de données"], parameters: [{ name: "collection", in: "path", required: true, schema: { type: "string", enum: ["config", "trames", "actes", "reprises", "users", "meta", "journal", "presence", "informations"] } }], responses: { 200: { description: "Les enregistrements de la collection" }, 404: { description: "Collection inconnue" } } } },
"/v1/db/collections/{collection}/sync": { post: { operationId: "synchroniserCollection", summary: "Synchroniser une collection", description: "Applique des écritures et des suppressions enregistrement par enregistrement. Chaque écriture porte la révision connue du client : si le serveur en détient une autre, l'enregistrement est renvoyé en conflit au lieu d'être écrasé.", security: [{ bearerAuth: [] }], tags: ["Base de données"], parameters: [{ name: "collection", in: "path", required: true, schema: { type: "string" } }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { upserts: { type: "array", items: { type: "object", properties: { id: { type: "string" }, ord: { type: "integer" }, rev: { type: "integer", nullable: true }, payload: {} } } }, deletes: { type: "array", items: { type: "object", properties: { id: { type: "string" }, rev: { type: "integer", nullable: true } } } }, force: { type: "boolean", description: "Écrase sans contrôle de révision (reprise de données). Réservé au rôle `administrateur` : toute autre clé reçoit 403 `force_reserve_admin`. Chaque appel est journalisé." } } } } } }, responses: { 200: { description: "Synchronisation appliquée (avec la liste des conflits éventuels)" }, 401: { description: "Jeton absent" }, 403: { description: "Jeton invalide, ou `force` demandé sans le rôle administrateur (code `force_reserve_admin`)" }, 413: { description: "Trop d'enregistrements" }, 507: { description: "Base pleine" } } } },
"/v1/actes": {
get: { operationId: "listerActes", summary: "Lister les actes déposés", tags: ["Actes"], responses: { 200: { description: "Liste des actes finalisés reçus par le service" } } },
post: {
operationId: "deposerActe", summary: "Déposer un acte finalisé", tags: ["Actes"],
description: "Reçoit l'acte finalisé (Akoma Ntoso) et le conserve en vue de la signature. Redéposer un document identique encore en circuit renvoie le même acte (200 au lieu de 201). Le champ `publishable` (true par défaut) indique si l'acte pourra être publié au recueil : mis à false pour un acte individuel (acte issu d'une trame déclarée non publiable), il rend la publication impossible même après signature. Le champ `controleLegalite` (false par défaut) déclare que l'acte doit être transmis au contrôle de légalité avant sa publication : le service refusera alors de le publier tant que la transmission n'a pas été enregistrée (409, code `transmission_absente`).",
security: [{ bearerAuth: [] }],
requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["akn"], properties: { akn: { type: "string", description: "Le document Akoma Ntoso 3.0" }, numero: { type: "string" }, objet: { type: "string" }, nature: { type: "string" }, entityId: { type: "string" }, entityName: { type: "string" }, dateSignature: { type: "string", format: "date" }, trameId: { type: "string" }, signatureMode: { type: "string", enum: ["electronique", "externe"], default: "electronique", description: "Circuit qui mène l'acte : électronique (prestataire, API), ou externe (papier ou outil tiers — le client déclarera la version signée par /v1/actes/{id}/signature-externe)." }, certificationRequise: { type: "boolean", default: false, description: "Circuit externe : true si un réviseur compétent doit certifier la conformité de la pièce signée avant publication (409 `conformite_non_certifiee`)." }, themeId: { type: "string", description: "Famille de la trame dont l'acte est issu : le THÈME sous lequel le recueil public classe l'acte." }, themeLabel: { type: "string", description: "Libellé du thème au moment du dépôt (le recueil public l'affiche tel quel)." }, ecarts: { type: "integer" }, publishable: { type: "boolean", default: true, description: "false pour un acte individuel non publiable" }, controleLegalite: { type: "boolean", default: false, description: "true si l'acte doit être transmis au contrôle de légalité avant publication" }, validation: { type: "object", nullable: true, description: "État du parapheur au dépôt (statut, libellé du circuit, empreinte du texte validé, sort de chaque étape). Un acte dont la validation n'est pas au statut « valide » ne pourra pas être envoyé en signature (409).", properties: { statut: { type: "string" }, circuitLabel: { type: "string" }, empreinte: { type: "string" }, closLe: { type: "string" }, etapes: { type: "array", items: { type: "string" } } } }, revision: { type: "object", nullable: true, description: "État de la révision au dépôt (statut, empreinte du texte révisé, date, corrections). Un acte dont la révision n'est pas au statut « valide » ne pourra pas être envoyé en signature (409, code `revision_incomplete`).", properties: { statut: { type: "string" }, empreinte: { type: "string" }, valideLe: { type: "string" }, corrige: { type: "boolean" }, par: { type: "string" } } } } } } } },
responses: { 201: { description: "Acte déposé" }, 200: { description: "Acte déjà déposé (idempotent)" }, 401: { description: "Jeton absent" }, 403: { description: "Jeton invalide" }, 413: { description: "Document trop volumineux" } },
},
},
"/v1/actes/{id}": { get: { operationId: "lireActe", summary: "Lire un acte déposé", tags: ["Actes"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "L'acte et ses métadonnées" }, 404: { description: "Acte inconnu" } } } },
"/v1/actes/{id}/document": { get: { operationId: "lireDocumentActe", summary: "Télécharger le document Akoma Ntoso", tags: ["Actes"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "Document au format application/akn+xml" }, 404: { description: "Acte inconnu" } } } },
"/v1/actes/{id}/signature": {
post: {
operationId: "envoyerEnSignature", summary: "Envoyer un acte en signature", tags: ["Signature"],
description: "Ouvre un circuit de signature auprès du prestataire (ESUP-Signature ou équivalent). Le service renvoie immédiatement un identifiant de circuit ; ce sont le prestataire (par notification) puis le suivi qui feront évoluer l'état. Le corps porte les réglages du prestataire (`api`) : transport, adresse, identifiant, niveau, adresse de notification, délai et points de terminaison. Ces réglages viennent du référentiel (Administration › Signature). La CLÉ du prestataire ne figure jamais ici : elle ne vit qu'auprès du service (SCRIBA_SIGNATURE_API_CLE).",
security: [{ bearerAuth: [] }],
requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", enum: ["interne"], description: "interne : le SERVICE signe lui-même, avec la clé du signataire gardée scellée dans son coffre — aucune clé ne quitte le serveur ; l'original signé est rendu dans la réponse. Auto-hébergement seulement." }, signataires: { type: "array", items: { type: "object", properties: { nom: { type: "string" }, courriel: { type: "string" }, fonction: { type: "string" }, ordre: { type: "integer" } } } }, niveau: { type: "string", enum: ["simple", "avancee", "qualifiee"] }, urlNotification: { type: "string", description: "URL appelée par le prestataire à l'issue de la signature" }, api: { type: "object", description: "Réglages du prestataire de signature (transport, adresse, identifiant, niveau, notification, délai, points de terminaison). Aucun secret : la clé reste au service.", properties: { transport: { type: "string", enum: ["service", "demonstration"] }, url: { type: "string" }, prestataire: { type: "string" }, niveau: { type: "string" }, urlNotification: { type: "string" }, timeoutMs: { type: "integer" }, cheminDocument: { type: "string" }, cheminSignataires: { type: "string" }, cheminDemarrer: { type: "string" }, cheminStatut: { type: "string" } } } } } } } },
responses: { 202: { description: "Circuit ouvert" }, 404: { description: "Acte inconnu" }, 409: { description: "Acte déjà signé, ou circuit de validation non achevé (code `validation_incomplete`), ou révision non achevée (code `revision_incomplete`)" }, 401: { description: "Jeton absent" } },
},
},
"/v1/webhooks/signature": {
post: {
operationId: "notifierSignature", summary: "Notification du prestataire (retour de l'acte signé)", tags: ["Signature"],
description: "Appelée par le prestataire lorsque la signature est apposée. Le service recalcule l'empreinte SHA-256 du document signé et la compare à celle de l'acte déposé : une différence est refusée (409), la signature n'est jamais acceptée sur un document qui n'est pas celui qui a été déposé.",
requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["signatureId", "documentSigne"], properties: { signatureId: { type: "string" }, statut: { type: "string", enum: ["signee", "refusee"] }, documentSigne: { type: "object", description: "L'original signé (document + signatures + horodatage)" } } } } } },
responses: { 200: { description: "Notification acceptée" }, 404: { description: "Circuit inconnu" }, 409: { description: "Empreinte du document signé différente de celle déposée" } },
},
},
"/v1/signatures": { get: { operationId: "listerSignatures", summary: "Lister les circuits de signature", tags: ["Signature"], responses: { 200: { description: "Circuits ouverts" } } } },
"/v1/signatures/{id}": { get: { operationId: "suivreSignature", summary: "Suivre un circuit de signature", tags: ["Signature"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "Statut du circuit (en_attente, signee, refusee, rejetee)" }, 404: { description: "Circuit inconnu" } } } },
"/v1/signatures/{id}/document-signe": { get: { operationId: "lireDocumentSigne", summary: "Récupérer l'acte signé", tags: ["Signature"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "L'original signé" }, 404: { description: "Acte signé non disponible" } } } },
"/v1/actes/{id}/transmission": {
post: {
operationId: "transmettreControleLegalite", summary: "Transmettre l'acte signé au contrôle de légalité", tags: ["Contrôle de légalité"],
description: "Adresse l'acte signé à l'API d'envoi du contrôle de légalité (télétransmission @ctes), OU déclare une transmission faite hors application. TROIS VOIES : (1) DÉCLARATION — le corps porte `declaration` (`at` et `destinataire` requis, plus `reference`, `motif`, `mode`, `personId`) : le service n'adresse AUCUNE requête à l'API, et le certificat conservé est une DÉCLARATION, dont la mention nomme son auteur. C'est la voie du régime déclaratif, et celle de l'acte transmis autrement en régime API. (2) SIMULATION — sans `declaration`, le service de démonstration ne sort pas sur le réseau : le certificat est fabriqué localement et porte `demonstration: true`, avec une mention qui le dit. (3) Le service auto-hébergé, quand son API est branchée, adresse réellement l'acte et conserve l'accusé de réception rendu (voir src/server/mysql/controle-legalite.mjs). Le service refuse (409) de transmettre un acte qui n'est pas signé ; un acte déjà transmis renvoie son certificat tel quel (idempotent). C'est cette étape, et non la publication, qui fait courir le délai de deux mois du représentant de l'État.",
security: [{ bearerAuth: [] }],
requestBody: { required: false, content: { "application/json": { schema: { type: "object", properties: { at: { type: "string", description: "Horodatage de la remise (ISO 8601) ; à défaut, l'heure du service" }, mode: { type: "string", enum: ["ctes", "prefecture", "sous_prefecture", "arrete_controle", "autre"] }, destinataire: { type: "string" }, auteur: { type: "string" }, entite: { type: "string" }, declaration: { type: "object", nullable: true, description: "Déclaration d'une transmission faite HORS application : aucune requête n'est adressée à l'API, et le certificat conservé est une déclaration (il nomme son auteur). `at` et `destinataire` sont requis.", properties: { at: { type: "string", description: "Date de la transmission déclarée (ISO 8601)" }, destinataire: { type: "string", description: "À qui l'acte a été transmis" }, reference: { type: "string", description: "Référence de l'envoi (bordereau, accusé, identifiant @ctes) ; à défaut, le service en forge une" }, motif: { type: "string", description: "Pourquoi la transmission est déclarée plutôt que faite par l'API (API injoignable, envoi hors application…)" }, mode: { type: "string", enum: ["ctes", "prefecture", "sous_prefecture", "arrete_controle", "autre"] }, personId: { type: "string", description: "Personne du référentiel qui déclare (le réviseur)" }, auteur: { type: "string", description: "Nom déclaré du déclarant" }, entite: { type: "string", description: "Entité qui a transmis" } } } } } } } },
responses: { 201: { description: "Transmis : certificat de transmission délivré (déclaration, ou simulation marquée demonstration: true) ; pour une déclaration, la réponse porte `attribution` (`verifiee` | `declaree`) et `auteur` — ce que le service a pu attester" }, 200: { description: "Acte déjà transmis (idempotent)" }, 404: { description: "Acte inconnu" }, 409: { description: "Acte non signé (code `acte_non_signe`)" }, 422: { description: "Déclaration incomplète (code `declaration_incomplete`) ou illisible (code `declaration_invalide`)" }, 401: { description: "Jeton absent" } },
},
get: { operationId: "lireTransmission", summary: "Lire le certificat de transmission d'un acte", tags: ["Contrôle de légalité"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "La transmission et son certificat" }, 404: { description: "Acte inconnu, ou acte non transmis (code `transmission_absente`)" } } },
},
"/v1/actes/{id}/signature-externe": {
post: {
operationId: "declarerVersionSignee", summary: "Déclarer la version signée (circuit externe)", tags: ["Signature"],
description: "Circuit externe (papier ou outil tiers, sans API) : l'acte a été signé hors de l'application ; le client dépose ici le PDF signé et son empreinte SHA-256. Le service n'a pas de signature cryptographique à vérifier — il enregistre la pièce et fait passer l'acte au statut « signée ». Une nouvelle version signée annule la certification de conformité précédente. La publication reste refusée (409 `conformite_non_certifiee`) tant que la conformité n'est pas certifiée, quand le client l'a déclarée requise au dépôt.",
security: [{ bearerAuth: [] }],
requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["signe"], properties: { signe: { type: "object", description: "La version signée déposée", properties: { url: { type: "string" }, sha256: { type: "string" }, nom: { type: "string" }, taille: { type: "integer" }, deposeLe: { type: "string" }, deposePar: { type: "string" }, deposeParNom: { type: "string" } } }, certificationRequise: { type: "boolean", description: "true si un réviseur compétent doit certifier la conformité avant publication" } } } } } },
responses: { 201: { description: "Version signée enregistrée" }, 404: { description: "Acte inconnu" }, 409: { description: "Acte déjà publié, ou circuit non externe (code `circuit_non_externe`)" }, 422: { description: "Version signée absente (code `version_signee_absente`)" }, 401: { description: "Jeton absent" } },
},
},
"/v1/actes/{id}/conformite": {
post: {
operationId: "certifierConformite", summary: "Certifier la conformité de la version signée (circuit externe)", tags: ["Signature"],
description: "Le contrôle du réviseur dans le circuit externe : il atteste que la pièce signée déposée est conforme à la version numérique qui sera publiée. La certification porte sur l'empreinte de la version signée : une empreinte différente de celle déposée est refusée (409 `certification_incoherente`). Une conformité refusée (`non_conforme`) replace l'acte au statut « déposé » : il attend une nouvelle version signée.",
security: [{ bearerAuth: [] }],
requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["certification"], properties: { certification: { type: "object", properties: { statut: { type: "string", enum: ["conforme", "non_conforme"] }, par: { type: "string" }, parNom: { type: "string" }, le: { type: "string" }, empreinte: { type: "string", description: "Empreinte du texte de la version numérique" }, sha256Signe: { type: "string", description: "Empreinte de la version signée certifiée" }, points: { type: "array", items: { type: "string" } }, remarque: { type: "string" }, motif: { type: "string", description: "Motif du refus (statut non_conforme)" } } } } } } } },
responses: { 201: { description: "Certification enregistrée" }, 404: { description: "Acte inconnu" }, 409: { description: "Aucune version signée déposée, ou certification incohérente" }, 422: { description: "Certification invalide" }, 401: { description: "Jeton absent" } },
},
},
"/v1/actes/{id}/publication": {
post: {
operationId: "publierActe", summary: "Publier l'acte signé et attribuer son ELI", tags: ["Publication"],
description: "Dépose la version en ligne au recueil et attribue l'identifiant ELI. La publication est refusée (409) tant que l'acte n'est pas signé : c'est la chaîne d'intégrité ; refusée aussi (409) si l'acte a été déclaré non publiable à son dépôt (acte individuel, code `acte_non_publiable`) ; refusée aussi (409) si l'acte a été déclaré soumis au contrôle de légalité mais n'a pas encore été transmis (code `transmission_absente`) ; et refusée (422) si la date de publication précède la date de signature. Le champ `juridique: false` publie un DOCUMENT NON JURIDIQUE (verbatim de séance, déclaration, vœu) : le service n'inscrit alors AUCUNE date d'opposabilité — il l'impose vide, quel que soit ce que le client propose — et le recueil public le présente comme un document, sans opposabilité ni délai de recours. Fournir un en-tête « Idempotency-Key » rend l'appel rejouable sans créer de doublon.",
security: [{ bearerAuth: [] }],
requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["html", "akn", "original"], properties: { recueil: { type: "string" }, themeId: { type: "string", description: "Famille de la trame : le thème sous lequel le recueil public classe l'acte." }, themeLabel: { type: "string", description: "Libellé du thème." }, datePublication: { type: "string", format: "date" }, opposabilite: { type: "object", properties: { mode: { type: "string", enum: ["lendemain", "jours"] }, jours: { type: "integer" } } }, kind: { type: "string", enum: ["originale", "consolidee", "modificative"] }, juridique: { type: "boolean", description: "false : document non juridique (verbatim, déclaration, vœu) — publié sans opposabilité, la date d'opposabilité est imposée vide par le service." }, natureDoc: { type: "string", enum: ["verbatim", "declaration", "voeu"], description: "La nature du document non juridique, pour la notice du recueil." }, html: { type: "string", description: "La version en ligne" }, akn: { type: "string" }, jsonld: { type: "string" }, md: { type: "string", description: "Le texte de l'acte en Markdown (sert les robots et les agents)" }, texte: { type: "string", description: "Le texte de l'acte en texte brut" }, original: { type: "object", description: "L'original signé (paquet du circuit électronique, ou — circuit externe — un original de substitution décrit par `originalExterne`)" }, originalExterne: { type: "object", nullable: true, description: "Circuit externe : la version signée déposée (PDF) et la certification de conformité du réviseur. Requise (409 `version_signee_absente`) ; la certification doit être « conforme » (409 `conformite_non_certifiee`) quand l'acte a été déclaré avec `certificationRequise`.", properties: { url: { type: "string" }, sha256: { type: "string" }, nom: { type: "string" }, taille: { type: "integer" }, deposeLe: { type: "string" }, deposePar: { type: "string" }, deposeParNom: { type: "string" }, certification: { type: "object", nullable: true } } } } } } } },
responses: { 201: { description: "Publié : ELI attribué" }, 200: { description: "Appel rejoué (Idempotency-Key)" }, 404: { description: "Acte inconnu" }, 409: { description: "Acte non signé, acte déclaré non publiable, ou acte non transmis au contrôle de légalité" }, 422: { description: "Version en ligne manquante" } },
},
},
"/v1/pieces": { post: { operationId: "deposerPiece", summary: "Déposer une pièce (fichier joint)", description: "Range un fichier joint à un acte — l'original signé d'une reprise d'acte ancien, ou la version signée d'un acte du circuit externe — et rend son ADRESSE de lecture. Le contenu voyage en base64 dans un corps JSON : le canal temps réel de l'édition en ligne ne transporte pas de binaire, et l'API REST lit ses corps en UTF-8. Les métadonnées (nom, type, taille, empreinte SHA-256) sont DÉCLARÉES par le client : c'est lui qui lit le fichier. Ce service-ci range la pièce dans son état, dont le plafond est modeste (MAX_PIECE), et rend une adresse `data:` ; le service auto-hébergé la range à part (table `sb_piece`, ou un fichier par pièce) et rend l'adresse HTTP `/v1/pieces/{id}`. L'application y recourt quand l'hébergement n'offre pas de dépôt de fichiers de son côté (`root.uploadPlugin`), c'est-à-dire en auto-hébergement : voir src/lib/fichiers.js.", security: [{ bearerAuth: [] }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["nom", "base64"], properties: { nom: { type: "string", description: "Nom du fichier, tel qu'il sera proposé au téléchargement" }, type: { type: "string", description: "Type MIME (`application/pdf`, `image/jpeg`…)" }, taille: { type: "integer", description: "Taille du fichier en octets" }, sha256: { type: "string", description: "Empreinte SHA-256, calculée par le client" }, base64: { type: "string", description: "Le contenu du fichier, en base64" } } } } } }, tags: ["Pièces"], responses: { 201: { description: "Pièce déposée : son identifiant et son adresse de lecture" }, 400: { description: "Contenu illisible (code `piece_illisible`)" }, 401: { description: "Jeton absent" }, 403: { description: "Rôle rédacteur requis" }, 413: { description: "Pièce trop volumineuse (code `piece_trop_volumineuse`)" }, 422: { description: "Nom ou contenu absent (codes `piece_sans_nom`, `piece_absente`)" }, 507: { description: "Le service n'a plus de place disponible" } } } },
"/v1/pieces/{id}": { get: { operationId: "lirePiece", summary: "Lire une pièce (fichier joint)", description: "Rend la pièce déposée. Route PUBLIQUE, et ce n'est pas un oubli : le recueil public cite cette adresse — c'est elle qui montre l'original signé d'un acte ancien, ou la version signée d'un acte du circuit externe. L'identifiant, tiré au hasard à la taille d'une clé, est le seul droit d'entrée. Le service auto-hébergé rend les OCTETS de la pièce avec son type MIME (elle s'ouvre donc dans un lien, un cadre de lecture ou un téléchargement) ; le service de démonstration, qui n'a pas de façade HTTP, rend le même contenu en JSON + base64, avec l'adresse `data:` correspondante. Une pièce inconnue est un 404 `piece_inconnue`.", tags: ["Pièces"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "La pièce (octets, ou JSON + base64 selon le service)" }, 404: { description: "Pièce inconnue (code `piece_inconnue`)" } } }, delete: { operationId: "supprimerPiece", summary: "Retirer une pièce", description: "Retire une pièce déposée. Geste de PROPRETÉ — le rédacteur qui venait de joindre un original et se ravise —, jamais une dépublication. Refusé (409 `piece_referencee`) tant qu'un acte déposé ou une publication cite la pièce : la retirer laisserait une page du recueil avec un lien mort.", security: [{ bearerAuth: [] }], tags: ["Pièces"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "Pièce retirée" }, 401: { description: "Jeton absent" }, 403: { description: "Rôle rédacteur requis" }, 404: { description: "Pièce inconnue (code `piece_inconnue`)" }, 409: { description: "Pièce citée par un acte ou une publication (code `piece_referencee`)" } } } },
"/v1/admin/purge": { post: { operationId: "purgerService", summary: "Remettre le service à zéro", description: "Vide les actes déposés, les circuits de signature et les publications — le pendant, côté service, du bouton « Repartir d'un référentiel vierge ». Le recueil public lit le service : vider le seul navigateur laisserait les publications de démonstration en ligne. Exige `{ \"confirmation\": \"repurge\" }` : un appel accidentel ne doit pas l'emporter. Réservé à l'administration.", security: [{ bearerAuth: [] }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["confirmation"], properties: { confirmation: { type: "string", description: "Le mot « repurge »" } } } } } }, tags: ["Service"], responses: { 200: { description: "Service purgé" }, 400: { description: "Confirmation absente (code `confirmation_absente`)" }, 401: { description: "Jeton absent" }, 403: { description: "Jeton invalide ou rôle insuffisant" } } } },
"/v1/publications": { get: { operationId: "listerPublications", summary: "Registre public des publications", description: "Les publications, de la plus récente à la plus ancienne. Les publications marquées `reserve` (circulaires internes) ne sont rendues qu'à l'appelant qui porte une clé de service ; elles sont invisibles aux visiteurs anonymes.", tags: ["Publication"], responses: { 200: { description: "Publications, de la plus récente à la plus ancienne" } } } },
"/v1/informations": { get: { operationId: "listerInformations", summary: "Informations publiées au recueil", description: "Les billets publiés par la collectivité (actualités, avis, communications), du plus récent au plus ancien. Route PUBLIQUE : seuls les billets publiés sont rendus ; un brouillon ne sort que vers une clé de service de rôle `editeur` au moins.", tags: ["Publication"], responses: { 200: { description: "Les informations publiées" } } } },
"/v1/atelier/acces": { get: { operationId: "accesAtelier", summary: "L'accès à l'atelier depuis cette adresse", description: "Route PUBLIQUE. Dit si l'accès à l'atelier est restreint à certains réseaux (`actif`), quelle liste s'applique (`liste`) et le message à montrer en cas de refus. Le paramètre `ip` permet de DEMANDER « et si j'arrivais de là ? » — c'est le simulateur de l'écran d'administration. Sur le service auto-hébergé, la restriction est appliquée à toutes les routes de l'atelier, qui répondent alors 403 `atelier_hors_reseau` ; le service de démonstration de la plateforme, lui, ne voit pas l'adresse de l'appelant et ne peut donc pas restreindre l'entrée (`restriction_appliquee: false`).", tags: ["Service"], parameters: [{ name: "ip", in: "query", required: false, schema: { type: "string" }, description: "Adresse à éprouver (simulation)" }], responses: { 200: { description: "État de l'accès depuis cette adresse" } } } },
"/v1/config": { get: { operationId: "reglagesDeploiement", summary: "Réglages posés par le déploiement", description: "Rend les variables de RÉFÉRENTIEL posées dans le `.env` du déploiement (chemins pointés, `{ \"brand.name\": \"…\" }`), les valeurs refusées (`erreurs`), l'ÉTAT DU PRESTATAIRE DE SIGNATURE (`prestataire` : transport, adresse, niveau, chemins, et un booléen disant si la clé est là — jamais la clé) et celui du COFFRE DE SIGNATURE INTERNE (`signatureInterne` : `disponible`, `motif` quand il est éteint, `niveau`, `algorithme`, nombre de `signataires` munis d'un certificat, `horodatage`). Ce service-ci n'a pas de `.env` : il rend des variables vides, un circuit électronique déclaré SIMULÉ, et un coffre FERMÉ avec son motif — c'est ce qui permet à l'application de n'offrir que les circuits que ce service peut réellement mener. Route PUBLIQUE, aucun secret : elle sert à l'écran de connexion avant toute session.", tags: ["Service"], responses: { 200: { description: "Réglages posés, valeurs refusées, état du prestataire et du coffre" } } } },
"/v1/publications/{cle}": { get: { operationId: "lirePublication", summary: "Lire une publication (version en ligne, formats, original)", description: "Renvoie 404 pour une publication réservée quand l'appelant est anonyme.", tags: ["Publication"], parameters: [{ name: "cle", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "Publication complète" }, 404: { description: "Publication inconnue" } } } },
"/v1/publications/{cle}/retrait": { post: { operationId: "retirerPublication", summary: "Retirer une publication du recueil", description: "Geste EXCEPTIONNEL : un acte administratif publié ne se retire jamais. Seul un motif technique le justifie (dépôt en double, erreur de dépôt, acte publié avant d'être signé…) ; un acte dont le retrait se justifierait autrement se modifie ou s'abroge, et reste au recueil. Le motif est exigé (422 `motif_absent` s'il manque) et conservé sur l'acte : le retrait laisse une trace. L'acte déposé redevient « signé », donc publiable de nouveau ; les autres versions publiées sous le même ELI ne sont pas touchées.", security: [{ bearerAuth: [] }], tags: ["Publication"], parameters: [{ name: "cle", in: "path", required: true, schema: { type: "string" } }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["motif"], properties: { motif: { type: "string", description: "Le motif technique du retrait (au moins 8 caractères)" }, auteur: { type: "string", description: "Qui a procédé au retrait (pour la trace)" } } } } } }, responses: { 200: { description: "Publication retirée du recueil" }, 404: { description: "Publication inconnue" }, 422: { description: "Motif technique absent" }, 401: { description: "Jeton absent" } } } },
"/v1/publications/{cle}/epingle": { post: { operationId: "epinglerPublication", summary: "Épingler un acte au recueil (le mettre à la une)", description: "Met en avant un acte publié sur la page d'accueil du recueil public (bande « À la une »). Le drapeau suit l'ACTE — son identifiant ELI — et non la version déposée : il est posé sur toutes les versions publiées sous cet identifiant, et une version publiée plus tard l'hérite. Le geste est réversible (`epingle: false`) et ne touche pas au texte publié.", security: [{ bearerAuth: [] }], tags: ["Publication"], parameters: [{ name: "cle", in: "path", required: true, schema: { type: "string" } }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["epingle"], properties: { epingle: { type: "boolean", description: "true pour mettre à la une, false pour l'en retirer" }, auteur: { type: "string", description: "Qui a épinglé (pour la trace)" } } } } } }, responses: { 200: { description: "Publication épinglée ou désépinglée" }, 404: { description: "Publication inconnue" }, 401: { description: "Jeton absent" } } } },
"/v1/eli/{code}/{annee}/{numero}/{entite}": { get: { operationId: "resoudreEli", summary: "Résoudre un identifiant ELI", tags: ["Publication"], description: "Renvoie la version en vigueur et l'historique des versions publiées sous le même ELI.", parameters: [{ name: "code", in: "path", required: true, schema: { type: "string" } }, { name: "annee", in: "path", required: true, schema: { type: "string" } }, { name: "numero", in: "path", required: true, schema: { type: "string" } }, { name: "entite", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "La version en vigueur et ses versions" }, 404: { description: "ELI inconnu" } } } },
"/v1/actes/{id}/dossier-signature": { get: { operationId: "lireDossierSignature", summary: "Lire le dossier de signature interne", description: "Rend la PART INTERNE de l'original signé : mentions nominatives du signataire (nom, courriel, compte) et trace des courriels de notification. Ces données ne sont jamais diffusées : elles ne sortent que par cette route, protégée par un jeton.", security: [{ bearerAuth: [] }], tags: ["Signature"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "Le dossier interne" }, 401: { description: "Jeton absent" }, 404: { description: "Acte inconnu, ou aucun dossier interne (code `dossier_absent`)" } } } },
"/v1/courriel": { get: { operationId: "etatCourriel", summary: "État du service de courriel", description: "Rend la configuration d'envoi du déploiement. Sur la plateforme de démonstration, l'envoi est indiqué INDISPONIBLE : elle n'a pas d'accès à un serveur SMTP. Le service auto-hébergé, lui, répond `disponible: true` quand SMTP_* est renseigné.", tags: ["Courriel"], responses: { 200: { description: "État du service de courriel" } } } },
"/v1/courriel/envoi": { post: { operationId: "envoyerCourriel", summary: "Envoyer un courriel de notification", description: "Sur la plateforme de démonstration, cette route répond 503 (aucun accès SMTP) : l'application constate alors l'envoi « non envoyé », avec le motif. Le service auto-hébergé l'exécute réellement.", security: [{ bearerAuth: [] }], tags: ["Courriel"], responses: { 200: { description: "Message remis au serveur SMTP (service auto-hébergé)" }, 503: { description: "Aucun serveur SMTP sur ce service (code `courriel_indisponible`)" } } } },
"/v1/courriel/test": { post: { operationId: "testerCourriel", summary: "Envoyer un courriel de test", description: "Comme `/v1/courriel/envoi` : indisponible sur la plateforme de démonstration, actif sur le service auto-hébergé.", security: [{ bearerAuth: [] }], tags: ["Courriel"], responses: { 200: { description: "Message d'essai remis au serveur SMTP" }, 503: { description: "Aucun serveur SMTP sur ce service" } } } },
};
return {
openapi: "3.1.0",
info: {
title: SERVICE,
version: SERVICE_VERSION,
description: "API de dépôt, de signature électronique et de publication des actes administratifs. Sont PUBLICS : la santé du service, le registre du recueil (`/v1/publications`) et la résolution des identifiants ELI. Tout le reste exige une clé (`Authorization: Bearer`), et chaque clé porte un rôle — `lecteur`, `redacteur`, `editeur`, `administrateur` (hiérarchie) ou `prestataire` (notification de signature uniquement) — qui commande les routes qu'elle peut appeler. Aucune clé n'est inscrite dans le code : un service neuf n'accepte qu'un dépôt de clé initial (`/v1/auth/bootstrap`) et reste en lecture seule tant qu'il n'a pas été provisionné.",
},
servers: [{ url: "https://api.valmont-sur-loire.fr", description: "Production" }, { url: "https://api.recette.valmont-sur-loire.fr", description: "Recette" }],
security: [],
paths,
components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } },
};
}
// -------------------------------------------------------------------- routage
function lireActe(id) { return db.actes[id] || null; }
function lireSignature(id) { return db.signatures[id] || null; }
function lirePublication(cle) { return db.publies[cle] || null; }
function hSante() {
return ok(200, {
statut: "ok", service: SERVICE, version: SERVICE_VERSION, maintenant: nowIso(),
objets: { actes: Object.keys(db.actes).length, signatures: Object.keys(db.signatures).length, publications: Object.keys(db.publies).length },
// Capacité utile d'une COPIE du document, en unités UTF-16 (l'état est écrit
// en double tampon : la place se partage entre les deux — voir `saveDb`).
capacite: MAX_COPIE,
// Place RÉELLEMENT occupée par l'état, en unités UTF-16 (en-tête et les deux
// copies compris) : c'est ce que l'écriture de l'état a à recopier à chaque
// geste, et donc ce qui commande le coût d'un gestionnaire. Un état qui
// grossit sans fin finit par coûter plus que le budget d'une requête : cette
// mesure est là pour le voir venir (voir `saveDb`, et MAX_CHARS).
utilise: (U16[0] === STATE_VERSION ? longueur() : 0) + HEADER,
});
}
// Remise à zéro du SERVICE de démonstration : vide les actes déposés, les
// circuits de signature et les publications. C'est le pendant, côté plateforme,
// du bouton « Repartir d'un référentiel vierge » (voir src/ui/views/referentiel.js) :
// le recueil public lit le service, donc vider le seul navigateur laisserait la
// fiction en ligne. Exige un mot de confirmation — un appel accidentel ne doit
// pas l'emporter — et le rôle administrateur (voir la table des routes).
function hPurger(ctx) {