Author SHA1 Message Date
YvvandClaude Opus 5.5 68111c351e HANDOFF : dc2de09 en ligne (24/10, 01h37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:40:50 +02:00
YvvandClaude Opus 5.5 125ec6614c HANDOFF : demande de fusion !7 ouverte sur sso-connect, restes pour Yvv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:40:28 +02:00
YvvandClaude Opus 5.5 7e6d243b46 HANDOFF : connexion Ğ1 par sso-connect, première contribution prête
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:58:41 +02:00
YvvandClaude Opus 5.5 f5635b13b3 HANDOFF : relectures des sessions, restes à traiter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:50:42 +02:00
YvvandClaude Opus 5.5 dc2de0947c v3 : relecture de decbc0b — lien annulé retiré de la page, liens annulés avant les sessions, pluriel espagnol
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:26:56 +02:00
YvvandClaude Opus 5.5 decbc0ba7b v3 : relecture de cf634c2 — renouvellement sans effet sur la réponse, lien en attente fermé avec les sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:02:26 +02:00
YvvandClaude Opus 5.5 cf634c25d2 v3 : une session dure jusqu'à ce qu'on la ferme, l'administration ferme celles d'une identité
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:45:26 +02:00
YvvandClaude Opus 5.5 422b45ab75 HANDOFF : première publication des textes (4cc0fdd), rappels de reprise
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 03:57:58 +02:00
YvvandClaude Opus 5.5 8ec0b48e8f HANDOFF : 744d157 en ligne, incident de l'agent CI, restes des relectures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:50:17 +02:00
YvvandClaude Opus 5.5 744d157b01 v3 : publication — un envoi refusé repart, un dépôt neuf reçoit l'historique
Douzième relecture (850f9c4 : ok), défauts anciens de publish.rs mis en
jeu par le nouveau dépôt :
- un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans
  droit d'écriture) repart au passage suivant, au lieu d'une file marquée
  faite avec un dépôt vide ;
- un changement de remote vers un dépôt neuf et vide lui envoie
  l'historique ;
- au démarrage, ce qui attendait repart aussitôt (le délai croissant
  pouvait atteindre 6 h).
trustinWallet montre l'état de la publication (en attente, dernier échec,
dernier commit) et offre « Publier maintenant ».

Boucle : 69 tests Rust, svelte-check, 9 vitest, e2e 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:30:52 +02:00
YvvandClaude Opus 5.5 850f9c46b0 v3 : publication sur yvv/decision-depot-textes-reference
Le dépôt des textes créé par Yvv sur git.open.us.org (2026-10-21) :
séparé du code, pour que la clé du serveur n'écrive jamais dans
yvv/decision et que le dépôt se transfère d'un bloc à l'ouverture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:17:11 +02:00
YvvandClaude Opus 5.5 8a3216037c v3 : dixième relecture — mention « sur invitation », noms attendus protégés
- Tout auteur venu par invitation porte « sur invitation » à côté de son
  nom (propositions, révisions, signalements) : un nom pris avant l'arrivée
  de son titulaire Ğ1, ou une lettre sosie, ne passe pas pour un membre de
  la toile.
- Un lien d'équipe refuse aussi le nom qu'une invitation attend encore.

Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:06:15 +02:00
YvvandClaude Opus 5.5 753b52202b v3 : neuvième relecture — noms uniques, places justes, quantités
- Noms des liens d'équipe : comparés sans casse, accents ni ponctuation
  (ld_engine::names), lettres latines seulement (pas de sosie cyrillique,
  grec ou pleine chasse), une ligne ; index unique (migration additive
  0003) contre deux ouvertures simultanées sous le même nom.
- Une personne déjà connectée ne prend une place que si le lien lui
  ajoute un groupe ; « Ce n'est pas moi » la déconnecte sur un navigateur
  partagé ; un lien collé dans le même onglet est relu.
- Coller ou couper un mot de quantité n'est pas une coquille
  (« sur tout » → « surtout », « par tout » → « partout »).
- Libellés sans caractère de contrôle ; la soumission revérifie le titre.

Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:40:04 +02:00
YvvandClaude Opus 5.5 fd19dbf4ec v3 : liens d'équipe, publication sur git.open.us.org, huitième relecture
Liens d'équipe (Yvv, 2026-10-21) : un lien réutilisable jusqu'à une date
et pour un nombre de personnes, révocable. Chaque ouverture demande un nom
et crée sa propre identité d'invitation, jamais administratrice ; un nom
déjà pris est refusé ; une personne déjà connectée reçoit les groupes.
trustinWallet montre par quel lien chaque identité est venue, et donne un
nouveau lien de connexion à une personne invitée (autre appareil).
Migration additive 0002 (table team_links, colonne identities.team_link).

Publication : yvv/libredecision-textes sur git.open.us.org, pour
l'instant (un dépôt pour l'ouverture, puis le dépôt officiel, viendront).

Huitième relecture (c3d6bf8 : ok), points corrigés :
- une négation n'est jamais coupée ni formée par collage (« ja mais ») ;
- caractères invisibles : tout l'ensemble Unicode des ignorables et les
  contrôles autres que saut de ligne et tabulation ;
- sauts de ligne et indentation font partie de la forme (listes) ;
- un titre tient sur une ligne.

Boucle : 65 tests Rust, svelte-check, 9 vitest, e2e 13/13 (C12 ajouté).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:12:21 +02:00
YvvandClaude Opus 5.5 c3d6bf88b9 v3 : septième relecture — collages limités, titre jugé à part, découpage contrôlé
- Mots collés ou coupés : deux au plus, jamais sur une négation
  (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond).
- Titre et corps jugés chacun pour soi, puis ensemble : le titre n'absorbe
  plus le début du corps.
- Caractères invisibles refusés aussi au découpage d'un brouillon et à la
  soumission.
- Tabulation et retour chariot ne sont plus des espaces libres ; les lettres
  latines rares (ǃ, Ʒ…) font partie de la forme.
- Le diff renvoyé après une coquille couvre le titre.

Boucle : 62 tests Rust, svelte-check, 9 vitest, e2e 12/12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:41:58 +02:00
YvvandClaude Opus 5.5 fa8e8dc1f0 v3 : sixième relecture — une coquille touche les lettres et les espaces, rien d'autre
- La forme du texte (tout ce qui n'est ni lettre latine ni espace, dans
  l'ordre, avec les espaces qui touchent un chiffre) ne bouge plus : ponctuation,
  emphase, signes, parenthèses passent par une proposition (un signe moins devant
  un nombre en gras, deux conditions fusionnées, une parenthèse déplacée).
  Apostrophes et guillemets de tous styles comptent pour un même signe.
- Les espaces ne font que coller ou couper des mots, jamais déplacer une
  frontière (« les avoir » → « le savoir »).
- Identifiants qui contiennent un chiffre (clés, références) comparés à
  l'identique, casse comprise, comme les adresses.
- Correction écrite seulement sur le texte jugé (révision, statut, texte) :
  sinon conflit « changed » — plus de course avec une entrée en vigueur.
- Déploiement : « previous » n'est pas repris d'un pipeline annulé.
- Interface (fr/en/es) : la note de correction dit la règle telle qu'elle est.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:52:04 +02:00
YvvandClaude Opus 5.5 6c0754a5c3 v3 : cinquième relecture — rien de ce qui touche un chiffre n'est une coquille
- Signature : tout caractère qui n'est pas une lettre latine et touche un
  chiffre compte, espaces et ponctuation comprises (« 5, 0 », « 1:5 »,
  « 1…5 ») ; un tiret compte devant ou après un chiffre à travers les espaces
  (« - 5 ») ; seules quatre espaces sont libres (ordinaire, insécable, fine
  insécable, saut de ligne).
- Négations complétées (guère, point, nothing, nobody, nadie, jamás…).
- Adoption : le texte qui entre en vigueur est celui qui a été compté — une
  révision arrivée pendant le passage l'arrête.
- Déploiement : « previous » est gardé avant le témoin de déploiement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:23:15 +02:00
YvvandClaude Opus 5.5 1eac6f856a v3 : quatrième relecture — coquille en liste blanche, mesurée contre le texte voté
- Une coquille ne touche que des lettres latines, des espaces et la ponctuation
  ordinaire ; tout le reste est figé : chiffres de toute écriture, signes,
  relations, parenthèses, sosies (U+201A, U+2044, pleine chasse), marques
  superposées (U+0336, U+0338), autres alphabets, rayure Markdown, contenu entre
  accents graves, séparateurs et tirets collés à un chiffre.
- Adresses (domaines, liens, courriels) comparées à l'identique.
- Glissement de clavier au cœur du mot seulement (ni les deux premières ni les
  deux dernières lettres : préfixes, nombre, genre, temps, mode) ; mots de
  position ajoutés (dessus/dessous, avant/après…).
- Chaque correction est mesurée contre le texte voté (dernière révision initiale
  ou de fond) : les glissements ne s'additionnent plus ; le texte soumis d'un
  brouillon devient sa révision initiale.
- Déploiement : « previous » = l'image du conteneur en service, pas la dernière
  construite ; migrations inconnues tolérées pour qu'une image précédente démarre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 05:58:36 +02:00
YvvandClaude Opus 5.5 9203fd2501 v3 : troisième relecture — une coquille n'inverse plus un sens ; retour à la version précédente
- Mot mal tapé = un seul glissement (une lettre ajoutée, retirée, ou deux
  voisines inversées), jamais au début du mot (préfixes : valide/invalide,
  limitée/illimitée), jamais une lettre remplacée (deux/dix, majorité/minorité),
  jamais sur un mot de négation ou de quantité, mots de 4 lettres au moins.
- Parenthèses, crochets, « / » et tout ce qui est entre accents graves comptent
  comme le sens (ceil(N^(1/5)) → ceil(N^1/5) n'est plus une coquille).
- Caractères invisibles (inversion du sens d'écriture, largeur nulle, BOM)
  refusés dans les textes, titres, traductions et motifs de signalement.
- Déploiement : la version en service est gardée (image « previous ») ; un
  témoin marque le déploiement en cours ; « retablir » relance la maquette au
  premier déploiement, la version précédente ensuite ; la liste des conteneurs
  retirés n'est plus vidée par un pipeline annulé.
- Tests : 59 Rust (dont 14 scénarios d'API, avec deux → dix sur E2 en vigueur
  et l'inversion bidi), e2e 12 / 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 05:21:08 +02:00
YvvandClaude Opus 5.5 ac9c904e7e v3 : seconde relecture — une coquille ne touche ni chiffre, ni signe, ni relation ; retour arrière du déploiement
- Correction de coquille : un chiffre, un signe, un séparateur dans un nombre ou
  une relation changés (« >= » → « <= », « + » → « - », « 182.625 » → « 182 625 »,
  « % » → « ‰ ») rendent la modification « de fond ». Testé dans le moteur et
  par l'API sur le vrai texte K1.
- Déploiement : les conteneurs retirés de la racine qui tournaient sont notés ;
  si la suite échoue, l'étape « retablir » arrête le produit et les relance.
  Le produit est recréé à chaque déploiement (--force-recreate).
- Cookies préfixés en https : __Host-ld_session, __Secure-ld_oauth (pas de
  cookie planté par un site voisin sous librodrome.org).
- syft épinglé (v1.55.0) dans les deux pipelines : ce conteneur a le socket Docker.
- Docs : limites connues assumées, groupe d'essai à deux voix, error.identity_kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 04:54:30 +02:00
YvvandClaude Opus 5.5 94907afb20 v3 : points de la relecture indépendante — une personne une voix, ordre de déploiement, durcissements
- Dans Monnaie libre (électorat = toile de confiance), seuls les membres de la
  toile votent (trustin_access::votes_in) ; une identité d'invitation y lit,
  propose et signale. L'interface le dit à la place des boutons (fr/en/es).
- Un lien d'invitation ne vise jamais une identité Ğ1 ; le lien est réservé
  avant de créer l'identité (deux acceptations simultanées : une seule passe).
- Un nombre n'est jamais une coquille (« 50 » → « 5,0 », « 1 5 » → « 15 »).
- Redirection après connexion : caractères de contrôle refusés.
- En-têtes : X-Frame-Options DENY, frame-ancestors 'none', nosniff, Referrer-Policy.
- Déploiement : le produit passe en premier ; la maquette attend son succès.
  Les conteneurs, même arrêtés, qui revendiquent encore la racine sortent de
  « restart: always » (ils reviendraient au redémarrage de Docker).
- Tests : 57 Rust (dont 14 scénarios d'API), e2e 12 / 12 avec C11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 04:23:51 +02:00
YvvandClaude Opus 5.5 1f9c241b41 v3 : déploiement (image unique, produit à la racine, maquette en sous-domaine) et documentation du chantier
- docker/app.Dockerfile : une image Rust + Svelte, la construction rejoue toute
  la boucle (fmt, clippy -D warnings, tests, svelte-check, vitest) ; utilisateur
  non-root, volume /data. docker/app-compose.yml pour sonic (fabio).
- .woodpecker/app.yml : produit sur decision.librodrome.org, attend la maquette,
  écarte tout conteneur qui revendique encore la racine, healthcheck du commit.
- .woodpecker/maquette.yml (ex .woodpecker.yml) : la maquette v2 telle quelle,
  sur maquette.decision.librodrome.org, mêmes volumes.
- .env.example de la maquette rangé dans maquette/ ; celui de la v3 à la racine.
- Docs : README, CONTRIBUTING (travail à deux), CLAUDE.md v3, HANDOFF,
  docs/chantiers (feuille de route, fiche, contrôles, onboarding de Chiara,
  demande à poka), docs/adr 0001–0006.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:50:44 +02:00
YvvandClaude Opus 5.5 c2669a3d0b v3 : publication robuste et visible dans trustinWallet
- Clé de publication ed25519 générée au démarrage dans le volume ; sa partie
  publique et l'état de la file d'envoi s'affichent dans /acces.
- Publication initiale à l'instanciation d'un document seedé publiable.
- Reprise au démarrage : chaque document publié repasse une fois dans la file ;
  la date publiée est celle du dernier changement, un document inchangé ne
  fait pas de commit (testé contre un dépôt nu).
- Le travailleur fait un premier passage sans attendre ; journaux sans codes
  couleur hors terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:50:22 +02:00
YvvandClaude Opus 5.5 7a022dd35e v3 : interface Svelte — accueil, groupes, lecteur du texte en vigueur, panneau contextuel, éditeur à assistant, trustinWallet
- Accueil sobre, menu complet à gauche (Décisions, Docs de référence, Mandats,
  Protocoles), intro demandée, quatre sections, connexion Ğ1.
- Lecteur : texte en vigueur en Literata, actions contextuelles par article
  (le vote, propositions, proposer), propositions en cours sous la « surface »
  avec leur niveau d'eau, aperçus « si les bien cotées passent » / « si tout
  passe », langue du texte fr/en/es (traductions éditoriales).
- Panneau : vote pour/contre, seuil et formule (MathML), inertie et son
  changement (voté en inertie +), coquille sans vote, traduction, signalement
  de contradiction, historique des corrections.
- trustinWallet (/acces) : groupes et membres, invitations à usage unique,
  administrateurs, retours d'usage.
- Contrôles : vitest (markdown, catalogues, anti-lexique), Playwright C1–C10 + M1.
- Sections du seed portées jusqu'à l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:58:34 +02:00
YvvandClaude Opus 5.5 67ddee28b2 v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:04:42 +02:00
YvvandClaude Fable 5 4b1732916d ci : deploy résilient — repli certificat existant + arrêt de l'ancienne stack
- ACME : le challenge HTTP-01 échoue en IPv6 (AAAA présent, webroot non
  servi en v6) ; quand un certificat est déjà en place (valide jusqu'au
  30/09), on le conserve avec avertissement au lieu de bloquer le déploiement
  — sans certificat existant, échec franc inchangé
- l'org forge renommée EHV→LPK a changé le COMPOSE_PROJECT_NAME : le
  « compose stop » ne touchait plus l'ancienne stack ehv-decision-main-*
  (double inscription fabio, trafic partagé v1/v2) → arrêt par filtre de nom
  avant déploiement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 04:14:00 +02:00
YvvandClaude Fable 5 ef0347b6aa ci : sbom-publish non bloquant (DTrack en panne côté serveur)
- le POST vers Dependency-Track meurt à la connexion (secret dtrack_domain
  vide ou service éteint) et bloquait le déploiement au step sbom-publish
- failure: ignore — télémétrie ; le scan CVE trivy reste bloquant
- à réexaminer côté serveur : service DTrack + secrets dependency_track_token
  / dtrack_domain du repo Woodpecker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 03:25:25 +02:00
YvvandClaude Fable 5 a80f82cb64 ci : SBOM CycloneDX épinglé en spec 1.5 (compat trivy du runner)
- syft installé en « latest » émet une spec plus récente que ce que le
  trivy 0.69.3 du runner décode → « invalid specification version »
  au step sbom-scan (pipeline 26, tout le reste était vert)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:55:08 +02:00
YvvandClaude Fable 5 db156884c9 Fusion de la relance CI faite côté forge (e9f064d)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:01:30 +02:00
YvvandClaude Fable 5 493836ff11 build : @yvv/nuxt-base en git+https + git dans les environnements npm
- la dép git+ssh échouait en CI : pas de clé SSH dans les conteneurs, et
  node:20-slim n'a pas le binaire git (ENOENT) — npm signalait un faux
  « tarball corrupted »
- package.json + lock : git+https://git.open.us.org/… (#v0.1.0 → b1c6a2f,
  même commit épinglé) — nécessite le repo yvv-nuxt-base public
- git + ca-certificates ajoutés : step test-frontend (.woodpecker.yml) et
  image de base du frontend.Dockerfile
- vérifié : npm install + build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 01:13:22 +02:00
aya e9f064dc78 force CI 2026-08-11 22:21:00 +02:00
YvvandClaude Fable 5 a8998f0624 ci : relance de la pipeline (config forge non récupérée sur le run 21)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:10:22 +02:00
YvvandClaude Fable 5 3301b764f7 v2 : correctifs QA — overlays posés, accueil jamais orphelin, finitions mobiles
- overlays Nuxt UI (UModal/USlideover) : géométrie posée en CSS
  (assets/css/overlays.css) — les utilitaires tailwind attendus par @nuxt/ui
  ne sont pas générés (UnoCSS ne scanne pas node_modules) : drawer mobile et
  modales de confirmation se rendaient hors écran
- a11y : title/description sur les 3 overlays (DialogTitle/Description
  rendus, warnings reka-ui éteints), fermeture du drawer ancrée et testée
- suppression du collectif actif : bascule sur un collectif restant —
  jamais d'accueil orphelin (test store ajouté, 343 vitest verts)
- observatoire : la route « transmis » entre dans les barres (base 100 %)
- overflows mobiles à zéro : formules KaTeX (défilement), table vigueur
  (défilement < 768px), pill cible (retour à la ligne), inputs decider et
  mandat (border-box)
- vérifié navigateur : drawer, modale, 14 routes sans débordement,
  zéro erreur console

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 20:11:54 +02:00
YvvandClaude Fable 5 38fd053e87 Fusion feat/v2-refonte : libreDecision v2 — démocratie d'exercice
- 8 moteurs purs (342 tests verts), 14 écrans, 5 modalités, 7 gabarits
- local-first : IndexedDB + export/import Bundle, seeds Atelier + Ğ1
- correctif QA : désignation « c'est moi » dans un collectif importé
- documentation v2 réécrite, orphelins v1 supprimés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:54:31 +02:00
YvvandClaude Fable 5 cf59bbca0d v2 : désignation « c'est moi » dans un collectif importé (correctif QA)
- les seeds (Ğ1, Atelier) n'ont aucun isMe : la carte identité propose
  désormais de se désigner parmi les personnes existantes (chips) ou de
  s'ajouter (formulaire), au lieu d'un état vide sans issue
- vitest 13 fichiers verts, build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:53:16 +02:00
YvvandClaude Fable 5 f2829339cf chore : sort les PDF des livres du suivi git (restent sur disque)
- docs/*.pdf ignorés — sources doctrinales locales, pas des artefacts du code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:11:19 +02:00
YvvandClaude Fable 5 7cfc7ea352 v2 : nettoyage des orphelins v1 + documentation livrable
- 43 fichiers v1 supprimés (composants documents/protocols/sanctuary/toolbox,
  stores auth/documents/groups/mandates/organizations/protocols/votes,
  composables api/notifications/formula/websocket, utils doublons du moteur)
- nuxt.config épuré (polkadot retiré, KaTeX et apiBase gardés), meta v2
- README.md, CONTRIBUTING.md, CLAUDE.md réécrits pour la v2
- Build zéro erreur, 342/342 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:09:05 +02:00
YvvandClaude Fable 5 e164b5f6c1 v2 : les 14 écrans — le tour complet de la démocratie d'exercice
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes,
  Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation)
- Registre + fiche décision (timeline, périmètre premier/second lieu auditable,
  affluence non-ignorable, S'instruire condensé, éléments + cartographie de
  clôture, épreuve du réel, Remettre en question, PV A4, gravure)
- Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité
  avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse,
  Pour moi, Explorer, cristallisation-geste —, élection à départage humain)
- Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des
  formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux
  de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser)
- Onboarding 7 gabarits + Données locales (export/import, attributs, atelier)
- voting→framing gardé (Reformuler d'un réglage figé non cristallisé)
- Pages v1 supprimées (login, documents, mandates, protocols, sanctuary,
  tools, decisions/new) — 342 tests verts, build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:47:20 +02:00
YvvandClaude Fable 5 d886302b59 v2 : couche données complète + shell + primitives communes
- stores/collective.ts (état local-first, gabarits, import/export, seeds)
  + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→
  sessions→cristallisation→épreuve du réel→révocation)
- data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord,
  5 points de départ, Institution symétrique)
- composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch
  (index unique Cmd+K/Q0)
- shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/
  Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1,
  LdAvatarStack (premier/second lieu), LdCountdown (suspension striée)
- 338 tests vitest verts, npm run build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 10:30:35 +02:00
YvvandClaude Fable 5 6f2bf62295 v2 : seed Duniter Ğ1 extrait sans perte (bundle 331 Ko + 22 tests)
- export_seed_bundle.py : parsing ast de seed.py (zéro dépendance DB),
  uuid5 déterministes, sha256 reproductible
- 33 clauses Certification + 59 Forgeron v2.0.0-fr, voteRecord 97/23/19
  W=7224 seuil 97 D30M50B.1G.2S.1, 11 personnes (5 forgerons), 3 mandats,
  10 protocoles, Pacte 20 clauses, 118 décisions fondatrices chaînées
- Écarts v1 documentés dans le script (sessions de démo non portées,
  ComTech reconstruit depuis la recherche forum, presets câblés par clause)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:48:13 +02:00
YvvandClaude Fable 5 f707b5f15d v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:22:19 +02:00
YvvandClaude Fable 5 53d8752e40 v2 : conception ultracode + moteur de formules porté en TS
- Blueprint définitif (docs/dev/BLUEPRINT-V2.md + JSON) : panel 4 visions
  + 3 juges, corpus doctrinal (Mon nom est personne, Une économie du don,
  SejeteralO), 4 critiques d'intégration, 3 vérificateurs, réparations
- Corpus d'intention + lectures brutes archivés
- Moteur porté Python→TS pur : threshold (inertie WoT, Smith, ComTech),
  nuancé 6 niveaux, DSL modeParams — 45 tests vitest, parité croisée
  vérifiée (Forgeron W=7224 T=120 ⇒ 94)
- Contrat de domaine v2 (types/domain.ts) + lexique UI (lexicon.ts)
- vitest + idb-keyval en dépendances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:59:33 +02:00
YvvandClaude Opus 4.8 172eab4c7c design(sceau): hexagramme 井 traits épais, coins nets, aspect carré
- viewBox 112×105, height 8, rx supprimé (rendu cut)
- inline app.vue + SVG public alignés
- géométrie calée sur les modèles IMAGES/PERVIER/Hexagrammes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 06:54:05 +02:00
YvvandClaude Opus 4.8 39883ee444 build(deps): flip @yvv/nuxt-base vers la dép git distante (v0.1.0)
- extends: ['../../yvv-nuxt-base'] → ['@yvv/nuxt-base']
- package.json : dép git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0
- résout au commit taggé b1c6a2f (identique au layer local)
- build npm vert, prod :3099 → 200, smoke moods 8/8 (4 moods × light/dark × mobile/desktop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:33:54 +02:00
YvvandClaude Opus 4.8 55020033ee chore(git): ignore les backups .db.bak (données runtime)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:45:02 +02:00
YvvandClaude Opus 4.8 a9e586cda1 refactor(moods): adopte le layer @yvv/nuxt-base pour le mécanisme d'ambiances
- nuxt.config: extends ['../../yvv-nuxt-base']
- useMood.ts: délègue à useMood(moods) du layer, exporté useLibreMood
  (évite la collision avec l'auto-import du layer)
- MoodSwitcher.vue + app.vue: appellent useLibreMood()
- moods.css inchangé (couleurs = identité projet)

Mécanisme partagé (application DOM, persistance, sync color-mode) mutualisé
sans toucher le contrat CSS. Vérifié headless (Playwright) : 4 moods × light/dark
× desktop/mobile OK, persistance au reload OK, zéro erreur JS, zéro régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:07:02 +02:00
YvvandClaude Sonnet 4.6 59fff64f9e Compartimentation : isolation stricte des données par espace de travail
- Ajout clause else IS NULL sur tous les endpoints list (protocols, decisions,
  mandates, documents, groups, votes) — sans X-Organization → données globales
  seulement, jamais le contenu d'un autre espace
- _get_protocol/_get_decision/_get_mandate : org_id propagé à tous les
  endpoints GET/PUT/DELETE/advance/assign/revoke/steps → 404 si UUID d'un
  autre espace
- votes.py : list_vote_sessions filtre via JOIN VotingProtocol.organization_id
- groups.py : suppression _org_id_from_header() mort, create_group assigne
  organization_id correctement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:52:16 +02:00
YvvandClaude Sonnet 4.6 f56d84e76b Mandats : origin→FK identité + nomination auto + boutons + tests intégration
- origin TEXT → origin_id UUID FK duniter_identities (migration e3f4a5b6c7d8)
- GET /auth/identities?q= : recherche d'identités par nom/adresse
- MandateCreate.nomination_mode : auto (auto-assign auteur), collective, postpone
- Wizard new.vue : champ origine = picker identité, checkbox "Démarrer maintenant"
- [id].vue : modal "Assigner" = search-picker (résout UUID vs adresse SS58), affiche
  origin_display_name + mandatee_display_name, inputs natifs (<input>/<textarea>)
- Erreurs API visibles dans l'UI (plus de catch silencieux)
- test_mandate_flows.py : 17 tests intégration SQLite réels (origin, nomination,
  assign, lifecycle, revocation, interactions croisées) — 241 tests total OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:48:27 +02:00
YvvandClaude Sonnet 4.6 3423ac2e7e Migrations : fix ADD CONSTRAINT IF NOT EXISTS (syntaxe PostgreSQL invalide)
Remplace par un bloc DO $$ IF NOT EXISTS ... $$ pour le FK circulaire
item_versions→document_items. Testé localement sur pg-test : 5 migrations
OK + idempotence OK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:57:39 +02:00
YvvandClaude Sonnet 4.6 2d2ac79cd5 Alembic : migration initiale + chaîne idempotente IF NOT EXISTS
- 0b9c1d2e3f4a : migration initiale (CREATE TABLE IF NOT EXISTS) — safe
  sur une DB déjà bootstrappée via create_all()
- 70914b334cfb : ADD COLUMN IF NOT EXISTS (organization_id) — was down_revision=None
- b78571ae9e00 : CREATE TABLE IF NOT EXISTS qualification_protocols
- c4e812fb3a01 : CREATE TABLE IF NOT EXISTS groups + group_members
- d91a3c7f8b02 : ADD COLUMN IF NOT EXISTS origin (mandates)
- Dockerfile prod : restaure alembic upgrade head au démarrage

Sur le serveur prod, exécuter une fois :
  docker exec <projet>-backend alembic upgrade head

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:47:20 +02:00
YvvandClaude Sonnet 4.6 21bca67e6a Docker : supprime alembic upgrade head du startup
Le lifespan FastAPI appelle déjà init_db() (create_all) au démarrage.
Lancer alembic en plus cassait sur une DB vide (migration initiale manquante).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:16:50 +02:00
YvvandClaude Sonnet 4.6 56d72eeec2 Auth : mode prototype factice en prod + fix test DB manquante
- Login : panneau proto-mode en avant quand DEMO_MODE actif (profils API)
  masque le formulaire extension-required ; note trustWallet à venir
- auth.ts : TODO trustWallet avec protocole postMessage prévu
- routers/auth.py : TODO trustWallet au point de vérification signature
- test_middleware : fixture _create_tables (autouse) — ASGITransport ne
  déclenche pas le lifespan, init_db() ne tournait pas → duniter_identities
  introuvable au verify ; 224/224 passent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 00:34:49 +02:00
YvvandClaude Sonnet 4.6 3b339b643c Mandats : refonte wizard nomination-first + types statutory/functional
- Réordonnancement étapes : nomination → infos → résumé (au lieu de infos → nomination → toolbox → résumé)
- Cas nomination renommé : 'election' → 'collective' ; auto-désignation scindée en mode période/réunion
- Types mandat ajoutés : statutory, functional (labels + filtres index)
- Légères corrections texte modalities (ortho, reformulation)
- Fix TS : assertions non-null sur statusGroupMap et paramValues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:33:16 +02:00
YvvandClaude Sonnet 4.6 3ba9c43ce3 Mandat : wizard création 4 étapes + boîte à outils élection
- Mandate model : champ origin (contexte/déclencheur) + migration
- MandateCreate schema : origin, starts_at, ends_at
- mandates/new.vue : wizard complet
  · Étape 1 : type · nom · origine · description · durée (relative ou dates)
  · Étape 2 : auto-désignation (ratification) ou désignation collective
  · Étape 3 : boîte à outils — sans/avec candidature, 6 modalités,
    paramètres configurables (durée, quorum, seuil, slider+input)
  · Étape 4 : récap + génération automatique des étapes du mandat
- Tous les boutons "Nouveau mandat" pointent vers /mandates/new
- decisions/new.vue : "Demander un mandat" → /mandates/new

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 05:29:13 +02:00
YvvandClaude Sonnet 4.6 1f92f153c5 Revert Claude : retour au stub Qwen — interface stable, branchement à venir
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 05:10:49 +02:00
YvvandClaude Sonnet 4.6 9b6322c546 IA : Claude substitut Qwen + auth rate limit prototype
- qualify_ai_service : ai_frame_async() avec Claude Haiku
  · round 1 → questions contextualisées si ANTHROPIC_API_KEY définie
  · round 2 → explication enrichie par Claude
  · fallback transparent sur ai_frame() si pas de clé (tests inchangés)
- config : ANTHROPIC_API_KEY + ANTHROPIC_MODEL (claude-haiku-4-5-20251001)
- requirements : anthropic>=0.97.0
- main : auth rate limit = RATE_LIMIT_DEFAULT partout (prototype mode)
  → supporte accès démo/test sans lockout en prod comme en dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 23:55:10 +02:00
YvvandClaude Sonnet 4.6 9a8f10efdf Groupes d'identités : modèle DB, router, store, UI cercles + Protocoles
- Group + GroupMember : modèle SQLAlchemy + migration + router CRUD
- /api/v1/groups : liste, création, suppression, membres (add/remove)
- groups.ts : store Pinia (fetchAll, getGroup, create, remove, addMember, removeMember)
- decisions/new.vue : cercles 1 & 2 en mode texte libre OU groupe prédéfini
  (affected_count calculé depuis le member_count du groupe)
- protocols/index.vue : section Groupes avec expand/collapse, ajout/suppression membres
- lang="fr" + spellcheck sur tous les textareas ; placeholder cercle 2 corrigé
- n8n channels : prévu sprint futur (texte libre → webhook appel à contribution)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:25:44 +02:00
YvvandClaude Sonnet 4.6 e2ae8b196e Qualify : service IA + endpoint ai-chat + wizard Décider (3 cercles + AI 2-rounds)
- qualify_ai_service.py : stub IA 2-allers-retours (réversibilité + urgence)
- qualify.py router : endpoint POST /ai-chat → AIChatRequest/AIChatResponse
- test_qualifier_ai.py : 11 tests A1-A7 (questions stables, done=True au 2e round)
- decisions/new.vue : wizard 4 étapes — branche mandat (liste + lien demande) /
  hors-mandat (3 cercles textarea), questions IA, résultat + boîte à outils,
  formulaire final

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:44:00 +02:00
YvvandClaude Sonnet 4.6 5c51cffc93 Qualifier : corrections R2/R6 + router + modèle DB + wizard frontend
Corrections moteur (TDD) :
- R2 : within_mandate → record_in_observatory=True (Observatoire des décisions)
- R6 : >50 personnes → collective recommandé, pas obligatoire (confidence=recommended)
- R3 supprimée : affected_count=1 hors périmètre de l'outil
- R9-R12 renommés G1-G4 (garde-fous internes)
- 23 tests, 213/213 verts

Étape 1 — Router /api/v1/qualify :
- POST / → qualify() avec config depuis DB ou defaults
- GET /protocol → protocole actif
- POST /protocol → créer/remplacer (auth requise)

Étape 2 — Modèle QualificationProtocol :
- Table qualification_protocols (seuils configurables via admin)
- Migration Alembic + seed du protocole par défaut

Étape 3 — Wizard frontend decisions/new.vue :
- Étape 1 : formulaire de qualification (mandat, affected_count, structurant, contexte)
- Étape 2 : résultat (type, raisons, modalités, observatoire, on-chain)
- Étape 3 : formulaire de décision (titre, description, protocole si collectif)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:12:01 +02:00
YvvandClaude Sonnet 4.6 428299c9c8 TDD Qualifier : moteur de qualification des décisions (R1–R12)
- engine/qualifier.py : QualificationConfig (seuils configurables),
  QualificationInput, QualificationResult, DecisionType enum, qualify()
- Règles : within_mandate→consultation_avis, affected_count→routing,
  is_structural→recommend_onchain, seuils lus depuis config
- Stub suggest_modalities_from_context() — interface LLM définie,
  intégration Qwen3.6 (MacStudio) à venir
- 22 tests, 212/212 verts, zéro régression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:16:35 +02:00
YvvandClaude Sonnet 4.6 fc84600f97 Fix auth : CORS outermost + dev rate limit + filtre workspace réactif
- Middleware : CORSMiddleware ajouté en dernier = plus externe = tous les
  codes de retour (dont 429) portent Access-Control-Allow-Origin
  → résout "no response / Failed to fetch" sur POST /auth/challenge
- Dev mode : rate_limit_auth = RATE_LIMIT_DEFAULT (60/min) au lieu de 10/min
  → plus de blocage login après quelques reconnexions
- app.vue : watcher activeSlug → refetch documents/décisions/protocoles/mandats
  → le sélecteur de workspace filtre désormais le contenu en temps réel
- TDD : 4 tests middleware (RED→GREEN) + doc méthode docs/dev/tdd-methode.md
- Régression : 190/190 tests verts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:51:28 +02:00
YvvandClaude Sonnet 4.6 79e468b40f Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)
- Modèles Organization + OrgMember, migration Alembic (SQLite compatible)
- organization_id nullable sur Document, Decision, Mandate, VotingProtocol
- Service, schéma, router /organizations + dependency get_active_org_id
- Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1
- Backend : list/create filtrés par header X-Organization
- Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org
- Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight)
  → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:17:14 +02:00
YvvandClaude Sonnet 4.6 224e5b0f5e Auth Duniter v2 : vérification réelle + extension signing + titres outils
Backend :
- Vérification Sr25519/Ed25519 réelle via substrateinterface (bypass démo)
- Message signé : <Bytes>{challenge}</Bytes> (convention polkadot.js)
- DEV_PROFILES : Charlie → Référent structure, Dave → Auteur (WoT member)

Frontend :
- Signing via extension polkadot.js / Cesium2 (_signWithExtension)
- @polkadot/extension-dapp + @polkadot/util installés
- Vite : global=globalThis + optimizeDeps pour les packages polkadot
- Boîte à outils : titres complets des 4 sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 03:36:51 +01:00
YvvandClaude Sonnet 4.6 02629c4e68 CI seed : corrige nom container (sans suffixe -1)
ehv-decision-main-backend, pas ehv-decision-main-backend-1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:58:22 +01:00
YvvandClaude Sonnet 4.6 b63c6f95e1 Home : titres complets des blocs + burger mobile-only + seed CI
- Titres cards : "Décisions et consultation d'avis", "Documents de référence",
  "Mandats et nominations", "Protocoles et fonctionnement"
- Burger supprimé sur desktop (≥768px), conservé mobile uniquement
- CI : step seed (docker exec python seed.py) entre test-deploy et healthcheck

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:45:38 +01:00
YvvandClaude Sonnet 4.6 075c3c944c CI : suppression .woodpecker/seed.yml — restaure les triggers push
Le fichier séparé cassait Woodpecker 3.x (multi-file + when:manual
empêchait tous les triggers push depuis commit 1842303c).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:21:40 +01:00
Yvv 8c2e8d5d5b ci: webhook repaired — retrigger 2026-03-24 02:14:59 +01:00
Yvv b9d2fd1c6d ci: retrigger pipeline home fix 2026-03-24 02:11:56 +01:00
YvvandClaude Sonnet 4.6 99c5e9dd5a Home : renommage libreDecision + chiffres dynamiques + centrage + typo
- Titre : ğ(Decision) → libreDecision (libre italic/muted + Decision accent)
- Chiffres : plus de valeurs hardcodées, tout dynamique depuis les stores
  (mandates store ajouté, protocoles depuis protocols.protocols.length)
- Affichage intelligent : total quand actif = 0, labels contextuels
- Grilles centrées : margin: 0 auto sur .dash
- Font-weight 800 → 700 partout (moins gras, plus classe)
- Typo : "Nuance" → "Nuancé" dans les tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:06:02 +01:00
YvvandClaude Sonnet 4.6 0310dcba42 ci: pipeline seed séparé (.woodpecker/seed.yml, event: manual)
Pipeline dédié au seeding DB, déclenché uniquement manuellement.
Retire le step seed du pipeline principal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:57:37 +01:00
YvvandClaude Sonnet 4.6 1842303c3b ci: step seed manuel (event: manual)
Déclenche python seed.py dans le container backend via docker exec.
Uniquement sur event: manual — ne s'exécute pas sur les push normaux.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:56:14 +01:00
YvvandClaude Sonnet 4.6 798b3a83d4 Domaine : libredecision.org → decision.librodrome.org partout
- docker-compose.yml : labels Traefik + CORS_ORIGINS + NUXT_PUBLIC_API_BASE
- docs/deployment.md : toutes les URLs et exemples curl
- CLAUDE.md : référence domaine
- .env.example : DOMAIN + noms DB/USER glibredecision → libredecision
- package-lock.json : name glibredecision → libredecision

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:42:33 +01:00
YvvandClaude Sonnet 4.6 a8dc0dfb40 Typo Plus Jakarta Sans + renommage libreDecision + mode démo prod + seed mandats
- Fonte : Nunito → Plus Jakarta Sans (moderne, ronde sans être toy)
- Logo : ğ(Decision) → libreDecision (libre italic/muted + Decision bold)
- Footer et watermark DocumentPreview mis à jour
- Mode démo : DEMO_MODE flag dans config.py + auth.py (profils rapides en prod)
- docker-compose : ENVIRONMENT=production explicite + DEMO_MODE=true par défaut
- Seed : +décision Licence G1 v0.4.0, +3 mandats (ComTech, Admin Forgerons, Modération)
  runner 7→10 étapes, import Mandate/MandateStep ajouté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:42:22 +01:00
YvvandClaude Sonnet 4.6 feaf4de7e0 Menu : Boîte à outils en premier + sidebar collapsible icônes
- Boîte à outils (/tools) en 1ère position dans la nav (desktop + mobile)
- Sidebar desktop repliable (toggle pictos-only, 14rem → 3.75rem)
  - labels masqués par transition CSS fluide (max-width + opacity)
  - état persisté en localStorage (libred-sidebar-collapsed)
- Page document : toggle Vue structurée / Aperçu document
  - sous-toggle En vigueur / Selon les votes
  - composant DocumentPreview (rendu PDF-like)
    - filigrane discret, items ordonnés par sort_order
    - mode projection : proposed_text substitu + encadrement orange
    - footer horodaté, print-friendly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:41:37 +01:00
syoulandClaude Sonnet 4.6 73c5bf148c ci: remplace Traefik par Fabio/Consul (pattern sonic)
- docker-compose.yml : labels SERVICE_* Registrator, réseau sonic external,
  container_name explicite, name COMPOSE_PROJECT_NAME
- pipeline : APP_DOMAIN (au lieu de DOMAIN), ACME sonic-acme-1 pour TLS,
  test-deploy sans suffixe -1 (container_name fixe)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:25:06 +01:00
syoulandClaude Sonnet 4.6 6509137892 fix: sync package-lock.json avec package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:02:47 +01:00
syoulandClaude Sonnet 4.6 488114791c ci: build local sans registry, pattern sejeteralo
- Suppression write-docker-creds et secrets docker_registry/username/password
- build-backend/frontend : docker build local sur sonic (docker.sock)
- sbom-generate : scan des images locales via docker.sock
- docker-compose.yml : ajout image: libredecision-{backend,frontend}:latest
- deploy : suppression docker compose pull (images locales)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:59:42 +01:00
syoulandClaude Sonnet 4.6 3e702fdbf3 ci: remplace plugin-docker-buildx par docker:27-cli + socket
Evite le mode privileged (non supporté par YunoHost Woodpecker).
Pattern: write-docker-creds (from_secret) → docker-backend/frontend (volumes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:41:32 +01:00
syoul e24c2a65a0 launch pipeline 2026-03-23 14:32:04 +01:00
syoulandClaude Sonnet 4.6 53fc9927ef ci: refonte pipeline selon bonnes pratiques sonic
- Format when/steps migré vers liste Woodpecker next
- Séparation from_secret / volumes (bug Woodpecker)
- Ajout security-check, SBOM (syft+trivy+dtrack), write-env,
  test-env, test-deploy, healthcheck, notify-failure
- Deploy SSH → volumes Docker (docker.sock + /opt/libredecision)
- privileged: true sur les steps docker-buildx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:25:24 +01:00
syoul a9599ba32a ci: refonte pipeline selon bonnes pratiques sonic 2026-03-23 14:24:16 +01:00
YvvandClaude Sonnet 4.6 ed9ed11cd4 Toolbox 30rem sticky + accordéons collapsibles + renommage libreDecision
- Boîte à outils élargie à 30rem (×1.75) — flottante sticky, zéro scroll visible
- ToolboxSection : nouveau composant accordéon générique (chevron, défaut fermé)
- ToolboxVignette : titre cliquable, bullets/actions cachés par défaut
- 4 pages : ContextMapper/SocioElection/WorkflowMilestones/inertie → ToolboxSection
- Suppression doublon SectionLayout (common/) — conflit de nommage résolu
- Renommage complet Glibredecision → libreDecision dans configs/docker/CI
- README.md + CONTRIBUTING.md ajoutés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:48:20 +01:00
YvvandClaude Sonnet 4.6 290548703d Boîtes à outils enrichies : ContextMapper, SocioElection, WorkflowMilestones
- ContextMapper : 4 questions contexte → méthode de décision optimale
  (advice process Laloux, vote inertiel WoT, consentement sociocratique, Smith…)
- SocioElection : guide élection sociocratique 6 étapes + advice process + clarté de rôle
- WorkflowMilestones : 11 jalons de protocole (7 essentiels), durées recommandées, principes Ostrom
- WorkspaceSelector : sélecteur de collectif multi-site dans le header
- SectionLayout : toolbox en USlideover droit sur mobile, sidebar sticky desktop
- Décisions : ContextMapper intégré + guide consentement
- Mandats : SocioElection intégré + cycle de mandat
- Documents : guide inertie 4 niveaux + structure + IPFS
- Protocoles : WorkflowMilestones + protocole élection sociocratique ajouté
- Renommage projet Glibredecision → libreDecision (dossier + sources)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:13:08 +01:00
439 changed files with 71607 additions and 16208 deletions
+19
View File
@@ -0,0 +1,19 @@
# Build context of docker/app.Dockerfile (the repository root).
# The maquette builds from its own context (maquette/), not from here.
.git
.remember
.env
.env.*
!.env.example
data
target
maquette
docs
web/node_modules
web/build
web/.svelte-kit
web/test-results
web/playwright-report
**/*.db
**/*.db-wal
**/*.db-shm
+28 -43
View File
@@ -1,49 +1,34 @@
# PostgreSQL
POSTGRES_DB=glibredecision
POSTGRES_USER=glibredecision
POSTGRES_PASSWORD=change-me-in-production
DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision
# libreDecision v3 — variables du serveur (ld-server). Toutes facultatives en dev.
# Le serveur lit son environnement, pas de fichier : `set -a; . ./.env; set +a` avant
# `cargo run -p ld-server`. Le .env n'est jamais commité.
# Backend
SECRET_KEY=change-me-in-production-with-a-real-secret-key
DEBUG=true
# Écoute et dossiers
LD_BIND=0.0.0.0:8002
LD_DATA_DIR=data # SQLite, clone du dépôt des textes, clé de publication — données runtime
LD_WEB_DIR=web/build # interface compilée (npm run build)
LD_SEED_DIR=seeds
LD_PUBLIC_URL=http://127.0.0.1:3002
# Environment: development, staging, production
ENVIRONMENT=development
# Connexion Ğ1 — client OAuth2 de connect.monnaie-libre.fr (les deux sont requis, sinon désactivée)
# LD_CONNECT_URL=https://connect.monnaie-libre.fr
# LD_CONNECT_CLIENT_ID=
# LD_CONNECT_CLIENT_SECRET=
# Logging: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
# Accès (trustinWallet)
LD_ADMIN_G1_IDS= # ex. idty:8352 — administrateurs des accès, séparés par des virgules
LD_DEV_LOGIN=1 # dev local uniquement : connexion sans clé, membre de tous les groupes
LD_BOOTSTRAP_ADMIN=0 # 1 = au premier démarrage sans admin, écrit data/bootstrap-admin.txt (0600)
# CORS
CORS_ORIGINS=["http://localhost:3002"]
# Publication des textes en vigueur (dépôt git lu par les applis clientes)
# LD_PUBLISH_REMOTE=gitea@git.open.us.org:yvv/decision-depot-textes-reference.git
# LD_PUBLISH_BRANCH=main
# LD_PUBLISH_KEY=data/keys/publish_ed25519 # générée au démarrage si absente
# LD_PUBLISH_AUTHOR=libreDecision
# LD_PUBLISH_EMAIL=decision@librodrome.org
# Database connection pool
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=10
# Taille de la toile de confiance (W) — lue sur la chaîne, repli sur l'indexeur puis sur la valeur fixe
# LD_RPC_ENDPOINTS=https://rpc.duniter.org,https://g1.axiom-team.fr/ws/
# LD_INDEXERS=https://indexer.duniter.org/v1/graphql
# LD_WOT_FALLBACK=5745
# Auth / Sessions
SESSION_TTL_HOURS=24
CHALLENGE_EXPIRE_SECONDS=300
# Rate limiting (requests per minute)
RATE_LIMIT_DEFAULT=60
RATE_LIMIT_AUTH=10
RATE_LIMIT_VOTE=30
# Duniter V2 RPC
DUNITER_RPC_URL=wss://gdev.p2p.legal/ws
DUNITER_RPC_TIMEOUT_SECONDS=10
# Blockchain cache TTL (seconds)
BLOCKCHAIN_CACHE_TTL_SECONDS=3600
# IPFS
IPFS_API_URL=http://localhost:5001
IPFS_GATEWAY_URL=http://localhost:8080
IPFS_TIMEOUT_SECONDS=30
# Frontend
NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1
# Docker / Production
DOMAIN=glibredecision.org
RUST_LOG=info,sqlx=warn
+13
View File
@@ -25,6 +25,7 @@ Thumbs.db
# Database
*.db
*.db.bak*
*.sqlite
# Docker volumes
@@ -40,3 +41,15 @@ htmlcov/
# IPFS
ipfs-data/
docs/*.pdf
# v3 — Rust + Svelte
/target/
/data/
web/build/
web/.svelte-kit/
web/test-results/
web/playwright-report/
*.db-wal
*.db-shm
web/node_modules/
-75
View File
@@ -1,75 +0,0 @@
when:
branch: main
event: push
steps:
test-backend:
image: python:3.11-slim
commands:
- cd backend
- pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short
test-frontend:
image: node:20-slim
commands:
- cd frontend
- npm ci
- npm run build
docker-backend:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- test-backend
settings:
repo: ${CI_FORGE_URL}/${CI_REPO}
dockerfile: docker/backend.Dockerfile
context: .
tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
docker-frontend:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- test-frontend
settings:
repo: ${CI_FORGE_URL}/${CI_REPO}
dockerfile: docker/frontend.Dockerfile
context: .
tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
deploy:
image: appleboy/drone-ssh
depends_on:
- docker-backend
- docker-frontend
settings:
host:
from_secret: deploy_host
username:
from_secret: deploy_username
key:
from_secret: deploy_key
port: 22
script:
- cd /opt/glibredecision
- docker compose -f docker/docker-compose.yml pull
- docker compose -f docker/docker-compose.yml up -d --remove-orphans
- docker image prune -f
+250
View File
@@ -0,0 +1,250 @@
# libreDecision v3 — le produit (Rust + Svelte) sur decision.librodrome.org.
# Une seule image : la construction rejoue toute la boucle (fmt, clippy -D warnings,
# tests Rust, svelte-check, vitest) — un contrôle rouge, pas d'image.
# Données runtime : volume nommé lpk-libredecision-app_data (SQLite, clone du dépôt
# des textes, clé de publication). Jamais `down -v`, jamais recréé.
when:
- branch: main
event: push
# Le produit passe en premier : tant que son image n'est pas construite et testée, la
# maquette continue de servir la racine. La maquette (maquette.yml) attend ce workflow
# et ne part sur maquette.decision.librodrome.org qu'après un déploiement réussi.
steps:
- name: security-check
image: alpine:3.20
commands:
- |
if [ -f .env ]; then
echo "ERREUR: .env ne doit pas etre commite dans le depot"
exit 1
fi
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
- echo "Security check OK"
# NOTE: volumes + pas de from_secret : compatible
- name: build-app
image: docker:27-cli
depends_on:
- security-check
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8)
- docker build -t libredecision-app:latest -f docker/app.Dockerfile --target production --build-arg LD_BUILD=$BUILD .
- echo "Image app construite ($BUILD)"
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-generate
image: alpine:3.20
depends_on:
- build-app
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
- mkdir -p .reports
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
- syft libredecision-app:latest -o cyclonedx-json@1.5 --file .reports/sbom-app.cyclonedx.json
- echo "SBOM genere"
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-scan
image: aquasec/trivy:latest
depends_on:
- sbom-generate
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
- trivy sbom --format json --output .reports/trivy-app.json .reports/sbom-app.cyclonedx.json
- echo "Scan CVE termine"
# NOTE: from_secret + pas de volumes : compatible
# failure: ignore — télémétrie, ne bloque pas le déploiement (cf. maquette.yml).
- name: sbom-publish
image: alpine/curl:latest
failure: ignore
depends_on:
- sbom-scan
environment:
DTRACK_TOKEN:
from_secret: dependency_track_token
DTRACK_DOMAIN:
from_secret: dtrack_domain
commands:
- |
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
-H "X-Api-Key: $DTRACK_TOKEN" \
-F "autoCreate=true" \
-F "projectName=libredecision-app" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-app.cyclonedx.json")
echo "HTTP $HTTP (app) : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
# NOTE: volumes + pas de from_secret : compatible
# Fabio route via les labels SERVICE_* de docker/app-compose.yml.
- name: deploy
image: docker:27-cli
depends_on:
- sbom-publish
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision-app:/opt/libredecision-app
commands:
- DOMAIN=decision.librodrome.org
# La version en service (celle qui tourne, validée par son propre healthcheck) est
# gardée sous « previous » : « retablir » la relance si ce déploiement échoue.
# Un témoin resté d'un pipeline annulé : ce qui tourne n'a pas été validé, on garde l'ancien « previous ».
- |
IMG=$(docker inspect --format '{{if .State.Running}}{{.Image}}{{end}}' lpk-libredecision-app 2>/dev/null || true)
if [ -n "$IMG" ] && [ ! -e /opt/libredecision-app/deploying ]; then
docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)"
fi
# Témoin : à partir d'ici, un échec appelle « retablir ». Le healthcheck réussi l'efface.
- touch /opt/libredecision-app/deploying
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
# Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son
# déplacement, d'anciennes stacks ehv-decision-main-*) est arrêté et sort de
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
# recréée par son propre workflow, sous son nouveau domaine.
# Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici :
# un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance.
- |
RETIRES=/opt/libredecision-app/retires
touch "$RETIRES"
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
NAME=$(docker inspect --format '{{.Name}}' "$C")
[ "$NAME" = "/lpk-libredecision-app" ] && continue
echo "Retrait de $NAME de la racine (revendique $TAG)"
if [ "$(docker inspect --format '{{.State.Running}}' "$C")" = "true" ]; then
echo "$C" >> "$RETIRES"
fi
docker update --restart=no "$C" >/dev/null
docker stop "$C" >/dev/null
done
done
# Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1.
- cd /opt/libredecision-app && docker compose stop
- |
ACME_EXIT=0
docker exec sonic-acme-1 /app/acme.sh \
--home /etc/acme.sh \
--issue -d "$DOMAIN" \
--webroot /usr/share/nginx/html \
--server letsencrypt \
--accountemail support+acme@asycn.io || ACME_EXIT=$?
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
echo "TLS OK (acme exit $ACME_EXIT)"
elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then
echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve"
else
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
exit 1
fi
# Image construite localement dans la pipeline : pas de docker compose pull
- cd /opt/libredecision-app && docker compose up -d --remove-orphans --force-recreate
- cd /opt/libredecision-app && docker compose ps
- name: test-deploy
image: docker:27-cli
depends_on:
- deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- |
STATUS=$(docker inspect --format '{{.State.Status}}' lpk-libredecision-app 2>/dev/null || echo "absent")
echo "lpk-libredecision-app : $STATUS"
[ "$STATUS" = "running" ] || { docker logs --tail 50 lpk-libredecision-app 2>&1; echo "FAIL: non running"; exit 1; }
echo "PASS: conteneur running"
# Le produit répond, et c'est bien ce commit qui répond.
# NOTE: volumes + pas de from_secret : compatible
- name: healthcheck
image: alpine:3.20
depends_on:
- test-deploy
volumes:
- /opt/libredecision-app:/opt/libredecision-app
commands:
- apk add --no-cache --quiet curl
- |
BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8)
TARGET="https://decision.librodrome.org/api/health"
MAX=60
i=0
until [ $i -ge $MAX ]; do
BODY=$(curl -sS -m 10 "$TARGET" 2>/dev/null || true)
echo "Tentative $((i+1))/$MAX - $BODY"
case "$BODY" in
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
echo "PASS: $BUILD repond sur $TARGET"
: > /opt/libredecision-app/retires
rm -f /opt/libredecision-app/deploying
exit 0 ;;
esac
i=$((i+1))
sleep 10
done
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
exit 1
# Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire.
# Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la
# racine repart (restart: always rendu). Échec après, plus tard : la version précédente du
# produit (image « previous ») est relancée.
# NOTE: volumes + pas de from_secret : compatible
- name: retablir
image: docker:27-cli
# En graphe (depends_on), une étape sans dépendance démarrerait au début : celle-ci
# attend la fin du healthcheck (fait, échoué ou sauté).
depends_on:
- healthcheck
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision-app:/opt/libredecision-app
commands:
- |
D=/opt/libredecision-app
if [ ! -e "$D/deploying" ]; then echo "Echec avant le deploiement : rien a retablir"; exit 0; fi
if [ -s "$D/retires" ]; then
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
for C in $(cat "$D/retires"); do
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
done
: > "$D/retires"
elif docker image inspect libredecision-app:previous >/dev/null 2>&1; then
echo "Retour a la version precedente du produit"
docker tag libredecision-app:previous libredecision-app:latest
cd "$D" && docker compose up -d --force-recreate
else
echo "Aucune version precedente a relancer"
fi
rm -f "$D/deploying"
when:
- status: failure
- name: notify-failure
image: alpine:3.20
depends_on:
- retablir
commands:
- 'echo "ECHEC pipeline app #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
+261
View File
@@ -0,0 +1,261 @@
# La maquette Nuxt v2 (FastAPI + Postgres + IPFS), conservée telle quelle pour
# présenter le projet. Depuis la v3 elle vit sur maquette.decision.librodrome.org ;
# le produit (.woodpecker/app.yml) prend la racine decision.librodrome.org.
# Ce workflow attend le produit : si le produit échoue, la maquette ne bouge pas.
# Le nom de projet compose reste lpk-decision-main : mêmes volumes, aucune donnée perdue.
when:
- branch: main
event: push
depends_on:
- app
steps:
- name: security-check
image: alpine:3.20
commands:
- |
if [ -f .env ]; then
echo "ERREUR: .env ne doit pas etre commite dans le depot"
exit 1
fi
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
- echo "Security check OK"
- name: test-backend
image: python:3.11-slim
commands:
- cd maquette/backend
- pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short
- name: test-frontend
image: node:20-slim
commands:
# git requis : dépendance @yvv/nuxt-base en git+https (node:20-slim n'a pas git)
- apt-get update && apt-get install -y --no-install-recommends git ca-certificates
- cd maquette/frontend
- npm ci
- npm run build
# NOTE: volumes + pas de from_secret : compatible
- name: build-backend
image: docker:27-cli
depends_on:
- test-backend
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker build -t libredecision-backend:latest -f maquette/docker/backend.Dockerfile --target production maquette
- echo "Image backend construite"
# NOTE: volumes + pas de from_secret : compatible
- name: build-frontend
image: docker:27-cli
depends_on:
- test-frontend
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker build -t libredecision-frontend:latest -f maquette/docker/frontend.Dockerfile --target production maquette
- echo "Image frontend construite"
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-generate
image: alpine:3.20
depends_on:
- build-backend
- build-frontend
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
- mkdir -p .reports
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
- syft libredecision-frontend:latest -o cyclonedx-json@1.5 --file .reports/sbom-frontend.cyclonedx.json
- echo "SBOM generes"
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-scan
image: aquasec/trivy:latest
depends_on:
- sbom-generate
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
- trivy sbom --format json --output .reports/trivy-backend.json .reports/sbom-backend.cyclonedx.json
- trivy sbom --format json --output .reports/trivy-frontend.json .reports/sbom-frontend.cyclonedx.json
- echo "Scan CVE termine"
# NOTE: from_secret + pas de volumes : compatible
# failure: ignore — la publication vers Dependency-Track est de la télémétrie :
# elle ne doit pas bloquer le déploiement quand DTrack est éteint ou le secret
# dtrack_domain absent (le scan trivy, lui, reste bloquant). Vu en panne le 12/08.
- name: sbom-publish
image: alpine/curl:latest
failure: ignore
depends_on:
- sbom-scan
environment:
DTRACK_TOKEN:
from_secret: dependency_track_token
DTRACK_DOMAIN:
from_secret: dtrack_domain
commands:
- |
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
for COMPONENT in backend frontend; do
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
-H "X-Api-Key: $DTRACK_TOKEN" \
-F "autoCreate=true" \
-F "projectName=libredecision-$COMPONENT" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-$COMPONENT.cyclonedx.json")
echo "HTTP $HTTP ($COMPONENT) : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
done
# NOTE: from_secret + pas de volumes : compatible
- name: write-env
image: alpine:3.20
depends_on:
- sbom-publish
environment:
POSTGRES_PASSWORD:
from_secret: postgres_password
SECRET_KEY:
from_secret: secret_key
commands:
- echo "APP_DOMAIN=maquette.decision.librodrome.org" > .env.deploy
- echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> .env.deploy
- echo "SECRET_KEY=$SECRET_KEY" >> .env.deploy
- OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z') && REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z') && BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-') && echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
- echo ".env.deploy cree ($(wc -c < .env.deploy) octets)"
- name: test-env
image: alpine:3.20
depends_on:
- write-env
commands:
- |
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
echo "PASS: .env.deploy present"
- |
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
- |
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
echo "PASS: APP_DOMAIN = $VAL"
# NOTE: volumes + pas de from_secret : compatible
# Fabio/Traefik routing géré via labels du docker-compose.yml
- name: deploy
image: docker:27-cli
depends_on:
- test-env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- cp .env.deploy /opt/libredecision/.env
- chmod 600 /opt/libredecision/.env
- cp maquette/docker/docker-compose.yml /opt/libredecision/docker-compose.yml
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
# L'org forge a ete renommee (EHV -> LPK) : le nom de projet compose a change,
# on arrete donc aussi l'ancienne stack (ehv-decision-main-*) pour eviter la
# double inscription fabio et le trafic partage v1/v2.
- docker ps -q --filter "name=decision-main" | xargs -r docker stop
- cd /opt/libredecision && docker compose stop
- |
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
ACME_EXIT=0
docker exec sonic-acme-1 /app/acme.sh \
--home /etc/acme.sh \
--issue -d "$DOMAIN" \
--webroot /usr/share/nginx/html \
--server letsencrypt \
--accountemail support+acme@asycn.io || ACME_EXIT=$?
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
echo "TLS OK (acme exit $ACME_EXIT)"
elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then
echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve"
echo " Cause vue le 12/08 : challenge HTTP-01 invalide en IPv6 (AAAA present mais webroot"
echo " non servi en v6) - corriger frontal/DNS avant l'expiration du certificat."
else
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
exit 1
fi
# Images construites localement dans la pipeline : pas de docker compose pull
- cd /opt/libredecision && docker compose up -d --remove-orphans
- cd /opt/libredecision && docker compose ps
# Vérification que les containers sont running
- name: test-deploy
image: docker:27-cli
depends_on:
- deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
for SVC in backend frontend; do
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-$SVC" 2>/dev/null || echo "absent")
echo "$PROJECT-$SVC : $STATUS"
[ "$STATUS" = "running" ] || { echo "FAIL: $PROJECT-$SVC non running"; exit 1; }
done
echo "PASS: tous les containers running"
- name: seed
image: docker:27-cli
depends_on:
- test-deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
docker exec "$PROJECT-backend" python seed.py
echo "Seed terminée"
- name: healthcheck
image: alpine:3.20
depends_on:
- seed
commands:
- apk add --no-cache --quiet curl
- |
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
TARGET="https://$SITE"
echo "Healthcheck $TARGET..."
MAX=60
i=0
until [ $i -ge $MAX ]; do
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
echo "PASS: app repond sur $TARGET"
exit 0
fi
i=$((i+1))
sleep 10
done
echo "FAIL: app ne repond pas apres 10 minutes"
exit 1
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
+96 -20
View File
@@ -1,25 +1,101 @@
# Glibredecision
# libreDecision
Plateforme de decisions collectives pour la communaute Duniter/G1.
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
boîte à outils. **v3 : Rust + Svelte**, un chantier à la fois, chacun livré en production avant
le suivant (`docs/chantiers/README.md`).
**Référence du chantier en cours : `docs/chantiers/acte-certification/CHANTIER.md`** — toute
décision de comportement s'y adosse ; les décisions durables dans `docs/adr/`.
La maquette v2 (Nuxt + FastAPI) vit dans `maquette/`, **intacte** (Yvv la présente) — ne pas y
développer.
## Protocole de début de session
1. `git pull --rebase origin main` (puis la branche du chantier : `chantier/acte-certification`)
2. Lire `HANDOFF.md` (où on en est, ce qui attend Yvv)
3. Si l'objectif de la session n'est pas précisé, le demander
## Stack
- **Frontend**: Nuxt 4 + Nuxt UI v3 + Pinia + UnoCSS (port 3002)
- **Backend**: Python FastAPI + SQLAlchemy async + PostgreSQL asyncpg (port 8002)
- **Auth**: Duniter V2 Ed25519 challenge-response
- **Sanctuaire**: IPFS (kubo) + hash on-chain (system.remark)
## Commands
- Backend: `cd backend && uvicorn app.main:app --port 8002 --reload`
- Backend tests: `cd backend && pytest tests/ -v`
- Frontend: `cd frontend && npm run dev`
- Frontend build: `cd frontend && npm run build`
- Migrations: `cd backend && alembic upgrade head`
- Docker: `docker compose -f docker/docker-compose.yml up`
- **Serveur** : Rust edition 2024, toolchain **1.99** épinglée (`rust-toolchain.toml`) ;
rustup installé sans PATH → `export PATH=$HOME/.cargo/bin:$PATH`. axum 0.8, sqlx 0.8
(SQLite, requêtes runtime + `migrate!`), tokio, reqwest rustls.
- **Interface** : SvelteKit 2 / Svelte 5 (runes), adapter-static SPA, vite 7, vitest,
Playwright ; npm. Polices Literata (textes) + Nunito (interface), fontsource.
- **Ports** : dev interface **3002** (strict, proxy `/api` → 8002) · serveur **8002** ·
serveur jetable des e2e **8092**.
- **Déploiement** : une image (`docker/app.Dockerfile`), Woodpecker `ci.open.us.org` dépôt
LPK/decision → sonic (fabio). `.woodpecker/app.yml` = le produit sur
**decision.librodrome.org** ; `.woodpecker/maquette.yml` = la maquette sur
**maquette.decision.librodrome.org** (le produit passe en premier et retire de la racine tout conteneur qui la revendique ; la maquette attend un déploiement réussi du produit).
Push sur `prod` (`gitea@git.open.us.org:EHV/decision.git`) branche `main` = déploiement.
## Conventions
- French for UI text and documentation
- English for code (variable names, comments, docstrings)
- API versioned under `/api/v1/`
- Pydantic v2 for all schemas
- Async everywhere (SQLAlchemy, FastAPI)
- Ed25519 signatures for vote integrity
## Structure
```
crates/
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle, presets), edit (coquille vs fond,
# diff), assist (découpage, similarité, longueur), compose (adoption, aperçus)
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut
ld-server/
migrations/ # schéma SQLite — additif seulement
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
# traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W
# sur la chaîne) · seed.rs · web.rs (sert web/build)
tests/api.rs # scénarios bout en bout
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur)
web/src/
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp…
routes/ # / · connexion · invitation · equipe · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
# protocoles (à venir)
web/e2e/ # Playwright C1–C12 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
docker/ # app.Dockerfile · app-compose.yml (sonic)
.woodpecker/ # app.yml · maquette.yml
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v1)
```
## Données runtime — jamais écrasées
- **Production** : volume `lpk-libredecision-app_data` (`/data` : `libredecision.db`, `textes/`
= clone du dépôt publié, `keys/` = clé de publication, `bootstrap-admin.txt`). Jamais
`down -v`, jamais recréé.
- **Maquette** : volumes `lpk-decision-main_postgres-data` / `_ipfs-data` (inchangés).
- **Local** : `data/` (gitignoré) — le supprimer repart du seed. Les e2e utilisent `/tmp/ld-e2e`.
- Un document seedé n'est **jamais** réinstancié : dès qu'il existe en base, c'est de la donnée.
## Commandes — la boucle (tout vert = fait)
```bash
export PATH=$HOME/.cargo/bin:$PATH
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 13 contrôles
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
cd web && npm run dev # :3002 à chaud
docker build -f docker/app.Dockerfile --target production . # rejoue toute la boucle
```
## Conventions / pièges
- **UI français, code anglais** ; **trilingue** : aucune chaîne affichée en dur, tout passe par
`web/src/lib/i18n/fr.ts` puis en/es (test de parité + `FORBIDDEN_UI_TERMS`). Tutoyer ; le sujet
est la personne ou le groupe, jamais le système. Le français du texte voté fait foi.
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
une coquille ne demande pas de vote, un changement de fond si ; un signalement de
contradiction bloque ; l'inertie se vote au cran au-dessus ; le groupe d'essai ne publie jamais.
- **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie
`ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`).
- **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ;
un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio
masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
`fr-FR` (sinon l'interface part en anglais).
- **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit`
(`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` —
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/decision-depot-textes-reference`
(provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
+63
View File
@@ -0,0 +1,63 @@
# Contribuer à libreDecision
On travaille **un chantier à la fois**, livré complet et en production avant le suivant
(`docs/chantiers/README.md`). Le chantier en cours a sa fiche :
`docs/chantiers/acte-certification/CHANTIER.md` — toute décision de comportement s'y adosse,
ou propose de l'amender, argumentée.
Première fois ? Commence par `docs/chantiers/acte-certification/ONBOARDING.md`.
## Les branches — travailler à deux
```
main = la production. Tout push sur prod/main déploie decision.librodrome.org.
chantier/acte-certification = la branche commune du binôme (Yvv · Chiara), toujours verte.
chiara/<sujet>, yvv/<sujet> = une branche courte par sujet, partie de la branche du chantier.
```
1. `git switch chantier/acte-certification && git pull --rebase origin chantier/acte-certification`
2. `git switch -c chiara/<sujet>` — un sujet, une branche, quelques commits.
3. Boucle verte (ci-dessous), puis push de la branche et demande de fusion vers
`chantier/acte-certification` sur la forge (`git.open.us.org/yvv/decision`).
4. Le chantier passe dans `main` quand la boucle est verte et les contrôles d'usage passés.
On ne pousse jamais directement sur `main`, et jamais de `--force` sur une branche partagée.
## La boucle — une tâche n'est faite que si tout est vert
```bash
export PATH=$HOME/.cargo/bin:$PATH
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cd web
npm run check # svelte-check, zéro avertissement
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
npm run build
npm run e2e # Playwright : C1–C12 (bureau) + M1 (mobile), serveur jetable sur :8092
```
L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement.
## Conventions
- **Interface en français, code en anglais** (identifiants, commentaires, messages de log).
- **Trilingue dès le départ** : toute chaîne affichée vit dans `web/src/lib/i18n/fr.ts`
(la source), puis `en.ts` et `es.ts`. Le test de parité refuse une clé manquante ou un
paramètre `{x}` qui diffère. Le texte voté est le français ; les traductions sont éditoriales.
- **Ton** : tutoyer la personne ; le sujet est la personne ou le collectif, jamais le système.
Mots interdits à l'écran (`FORBIDDEN_UI_TERMS`) : « triage », « verdict », « déléguer »…
- **Moteur pur** : `crates/ld-engine` n'a ni I/O, ni base, ni réseau. Toute règle de vote,
d'édition ou de composition s'y écrit et s'y teste d'abord.
- **Accès** : le serveur ne décide jamais seul qui voit quoi — il demande à `trustin-access`.
- **Données runtime** (`data/`, volume `lpk-libredecision-app_data`) : jamais écrasées, jamais
réinitialisées ; une migration ajoute, elle ne réécrit pas.
- **Design** : sans bordures (profondeur par fond et ombre), arrondis 16 / 12 / 20 / 14 px,
deux ambiances (pierre, nuit), Literata pour les textes, Nunito pour l'interface.
- **Commits** : en français, une intention par commit, `git add` avec chemins explicites.
## Signaler
**« Faire un retour »** (dans le menu de gauche, sur chaque page) envoie une note datée avec la
page, la langue et la taille de l'écran ; elles se lisent dans trustinWallet (`/acces`).
Pour un bug reproductible, une issue sur la forge avec les étapes.
Generated
+2742
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[workspace]
resolver = "3"
members = ["crates/ld-engine", "crates/trustin-access", "crates/ld-server"]
[workspace.package]
version = "0.3.0"
edition = "2024"
rust-version = "1.90"
repository = "https://git.open.us.org/LPK/decision"
publish = false
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
unwrap_used = "warn"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
+98
View File
@@ -0,0 +1,98 @@
# HANDOFF — libreDecision
> Dernière mise à jour : 2026-10-23, session orch du chantier 1.
## Où on en est
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org,
version `dc2de09` depuis le 2026-10-24 à 01h37 (sessions jusqu'à ce qu'on les ferme ; avant :
`744d157`, le 2026-10-22) ;
la maquette est sur maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). En ligne :
liens d'équipe, nouveau lien de connexion, noms uniques et mention « sur invitation », règles de
coquilles des relectures 7 à 10, publication vers `yvv/decision-depot-textes-reference` (état et
« Publier maintenant » dans `/acces`). Première publication le 2026-10-22 à 03 h 22 (commit
`4cc0fdd` : `monnaie-libre/acte-engagement-certification/` fr, en, es, document.json, README).
**Prêt, pas encore en ligne** (2026-10-23) : les sessions durent **jusqu'à ce qu'on les ferme**
(400 jours recomptés à chaque visite, au lieu de 30 jours fixes) ; dans `/acces`, « Fermer ses
sessions » par identité (appareil perdu, départ), jamais celle d'où l'on agit, avec le lien de
connexion encore en attente pour elle. Raison : une personne invitée n'a pas de clé Ğ1 pour
revenir, une session expirée lui coûtait un nouveau lien. Les sessions ouvertes pour 30 jours
passent à 400 jours à leur prochaine visite.
Incident du 2026-10-21 : Docker a redémarré sur sonic à 21 h 47 ; l'agent Woodpecker
`aya-woodpeckerci-local-agent-1` gardait l'ancienne socket (pipeline n° 31 : « Cannot connect to
the Docker daemon », rien construit). Remède : `docker restart aya-woodpeckerci-local-agent-1`
(fait avec l'accord d'Yvv), puis « Redémarrer » le pipeline dans l'interface Woodpecker.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 71 tests dont 17 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement :
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
des journaux), publication immédiate au démarrage, redémarrage sans commit superflu.
- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la
racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part
sur maquette.decision.librodrome.org, seulement si le produit a réussi).
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
`fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : ok`, `8a32160 : ok`,
`850f9c4 : ok`, `744d157 : ok` (en ligne) ; pour les sessions : `cf634c2 : ok`, `decbc0b : à
reprendre`, `dc2de09 : ok` (en ligne le 2026-10-24). La règle est désormais simple et explicable : **une coquille touche les lettres et les espaces,
rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens
conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
personne, une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant
usage, redirection après connexion, en-têtes anti-encadrement, cookies `__Host-`, retour
arrière automatique du déploiement (maquette au premier, version précédente ensuite), courses
entre correction et entrée en vigueur, syft épinglé.
## Ce qui attend Yvv
1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`.
2. ~~Dépôt des textes~~ : fait le 2026-10-22 — `yvv/decision-depot-textes-reference` (privé), clé de
déploiement en écriture ajoutée par Yvv. À l'ouverture : rendre le dépôt public (Paramètres ›
Dépôt), ou le transférer ; changer de dépôt : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`.
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un
seul usage : il crée l'identité administratrice). Pour l'équipe : un **lien d'équipe**
(`/acces` → Liens d'équipe ; chaque personne qui l'ouvre a sa propre identité), ou une
invitation par personne. Pour soi sur un autre appareil : « Nouveau lien de connexion ».
4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`.
5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours).
6. **Connexion Ğ1 par sso-connect** (2026-10-23) : plan dans
`trustinWallet/docs/PLAN-sso-connect.md`. Les portefeuilles ne signent que pour
`connect.monnaie-libre.fr` : libreDecision en sera client (point 1), jamais son propre
serveur. Première contribution **ouverte le 2026-10-23** :
https://git.duniter.org/clients/sso-connect/-/merge_requests/7 (depuis ton fork
`Yvv/sso-connect`, branche `feat/page-neutre` : page neutre entre portefeuilles, et la page
nomme le site qui demande la connexion). Commentaire posté à 1000i100 sur sa !6 (faille des
adresses locales). Restent à toi : révoquer le jeton `mr-sso-connect`, poster
`trustinWallet/docs/MESSAGE-forum-sso-connect.md` sur le fil, et trancher la stratégie de
trustinWallet (couche de confiance qui fait signer par les portefeuilles existants, ou
portefeuille complet ; bulletins de vote signés et ancrés par une remarque sur la chaîne
plutôt qu'une transaction par vote).
## Pour reprendre
- **Premier geste de la session suivante** (Yvv, 2026-10-22 : « redonne cette consigne quand je
relance ce chantier ») : rappeler à Yvv le compte git.open.us.org de Chiara (collaboratrice
de `yvv/decision`) et les deux choix en attente, la licence du code et le texte F1.2 du seed.
- Lire `docs/chantiers/acte-certification/CHANTIER.md` puis `CONTROLES.md`.
- Les retours de Chiara arrivent dans `/acces` → Retours : chacun est un contrôle à repasser.
- Quand les identifiants connect arrivent : `DEMANDE-CONNECT.md` § « À la réponse ».
- Chantier suivant (après la mise en production et les retours) : Acte d'engagement forgeron.
- Restes des relectures, non bloquants, pour la prochaine série :
- `publish.rs` : motif `ls-remote` en `refs/heads/<branche>` (une branche `x/main` le trompe) ;
« Dernière publication » rattachée au dépôt affiché ; compter les documents en attente, pas
les lignes de la file ;
- mention « sur invitation » : en `kind <> 'g1'` (tout type futur) ; tests de ce qui s'affiche
(révision visible, signalement, bout en bout) ; auteur des retours dans `/acces` avec son type ;
- noms : sosies latins étendus et ligatures (ı, ł, œ, ß…), invitations créées par un
administrateur sans contrôle de nom ;
- `sbom-publish` échoue (curl 60 : certificat du serveur Dependency-Track refusé) — étape en
`failure: ignore`, déjà le cas avant la v3 ;
- sessions (relecture de `dc2de09`) : un contrôle e2e « nouveau lien, fermer ses sessions, le
lien disparaît » ; la réservation d'un lien et l'ouverture de sa session dans une même
transaction (sinon un lien ouvert à l'instant de la fermeture laisse une session) ; après
« Annuler » une invitation dans la liste, retirer aussi le lien affiché (défaut déjà en
ligne) ; `Cache-Control: no-store` sur `/api`.
+114
View File
@@ -0,0 +1,114 @@
# libreDecision
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
boîte à outils.
En ligne : **https://decision.librodrome.org** (le produit, v3) · la maquette v2, conservée pour
présenter le projet : https://maquette.decision.librodrome.org (`maquette/`).
## Manifeste
> Personne n'a jamais vécu en démocratie — alors nous la rêvons de jour, et nous la commençons
> à notre échelle. libreDecision attrape chaque décision en une phrase et propose le chemin le
> plus court : décide seul et trace-le, décide sous mandat, écoute puis décide, ou décidez
> ensemble — jamais plus lourd que l'enjeu. Ici on ne délègue pas, on mandate, et on reprend ;
> on n'est pas consulté, on s'instruit, puis on se prononce ; on ne s'affronte pas en deux
> camps, on règle des curseurs — et la position de chacun reste visible dans le faisceau, la
> médiane n'écrase personne, la dernière main est toujours humaine. Les concernés en premier
> lieu ont voix par construction, chacun voit pourquoi ; lever la main « ça me concerne » est
> un droit, pas une demande. Chaque décision dit ce qu'elle engage, ce qu'elle cherche à
> changer, et repasse l'épreuve du réel : rien n'est définitif, toute décision en vigueur garde
> sa poignée, se tromper est une donnée qu'on consigne. Les règles du jeu elles-mêmes — le
> Pacte — s'écrivent au fil de l'eau, par ceux qu'elles gouvernent, sous les boussoles
> Autonomie, Équilibre, Liaison. Du trio d'amis à la toile Ğ1 : le même geste, dix secondes,
> et l'observatoire qui apprend de nous. La ville change, le puits demeure.
## Les quatre outils
| Outil | Ce qu'il fait | État |
|---|---|---|
| **Décisions** | funnel assisté pour choisir les modalités, journal des décisions | à venir |
| **Docs de référence** | acte d'engagement, licence, charte, constitution… sous vote permanent | **actif** |
| **Mandats** | création et révocation de tout mandat | à venir |
| **Protocoles** | workflows sans organe statutaire, rôles tournants, automatisations | à venir |
Le produit avance **un chantier à la fois**, chacun livré complet et en production avant le
suivant — feuille de route : [`docs/chantiers/README.md`](docs/chantiers/README.md).
Chantier en cours : **Acte d'engagement Certification** (Monnaie libre), en binôme Yvv + Chiara.
## Comment marche un doc de référence
- On arrive sur **le texte en vigueur**, seul, lisible. Trois gestes contextuels : consulter le
vote, voir les propositions, proposer une modification.
- Chaque article est sous **vote permanent** : une proposition qui atteint son seuil (formule
inertielle Ğ1, plancher C de votants « pour ») **remonte en vigueur** et remplace la version
en place — si elle tient une marge plus large. Les textes de départ sont des *seeds a minima*,
à zéro vote.
- **Corriger n'est pas voter** : une coquille se corrige sans vote ; un changement de fond
ouvre une nouvelle révision, dont les votes antérieurs sont à reconfirmer.
- Deux propositions qui **se contredisent** ne passent pas ensemble : un signalement bloque la
seconde tant qu'il n'est pas levé.
- L'**inertie** de chaque article se vote aussi (par défaut : un cran au-dessus).
- Tout ce qui entre en vigueur est **publié automatiquement** dans un dépôt git
(fr/en/es + JSON) lu par les applications clientes.
Le détail, les décisions et leurs raisons : [`docs/chantiers/acte-certification/CHANTIER.md`](docs/chantiers/acte-certification/CHANTIER.md)
et [`docs/adr/`](docs/adr/).
## Stack
- **Serveur** : Rust (edition 2024, toolchain 1.99 épinglée) — axum 0.8, sqlx/SQLite, tokio.
Trois crates : `ld-engine` (moteur pur : seuils, édition, assistant, composition),
`trustin-access` (règles d'accès de trustinWallet), `ld-server` (API + publication + service
de l'interface).
- **Interface** : SvelteKit 2 / Svelte 5 (runes), SPA statique servie par le serveur ;
trilingue fr · en · es dès le départ (le français fait foi).
- **Déploiement** : une seule image Docker, Woodpecker → sonic (fabio), volume de données nommé.
## Démarrer
```bash
export PATH=$HOME/.cargo/bin:$PATH
cd web && npm ci && npm run build && cd .. # l'interface compilée
LD_DEV_LOGIN=1 cargo run -p ld-server # API + interface sur :8002
cd web && npm run dev # interface à chaud sur :3002 (proxy /api → :8002)
```
`LD_DEV_LOGIN=1` ouvre une connexion de développement (sans clé, membre de tous les groupes) —
jamais en production. Toutes les variables : [`.env.example`](.env.example).
## Vérifier (la boucle)
```bash
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cd web && npm run check && npm test && npm run build && npm run e2e
```
Les contrôles d'usage (C1–C12, M1) sont décrits dans
[`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ;
les tests de Chiara en sont la version humaine.
## Structure
```
crates/
ld-engine/ # moteur pur, sans I/O : threshold, edit, assist, compose
trustin-access/ # qui voit, qui ouvre, qui gère les accès
ld-server/ # axum : auth Ğ1 + invitations, docs, propositions, votes, adoption, publication
migrations/ # schéma SQLite (données runtime : jamais réécrites)
tests/api.rs # scénarios bout en bout de l'API
seeds/ # documents de départ (JSON trilingue), instanciés une seule fois par groupe
web/
src/lib/ # api, session, i18n (fr source, en, es), composants
src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)…
e2e/ # Playwright : C1–C12, M1
docker/ # app.Dockerfile + app-compose.yml (sonic)
.woodpecker/ # app.yml (le produit) · maquette.yml (la maquette)
docs/ # chantiers/, adr/, dev/ (blueprint v2), recherche/
maquette/ # la v2 Nuxt + FastAPI, telle quelle
```
## Licence
Logiciel libre — la licence reste à choisir (question ouverte à Yvv) ; le fichier LICENSE sera
posé avant toute publication du code.
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "ld-engine"
description = "Pure decision engines of libreDecision: inertial threshold, edit classification, editing assistant, document composition."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
unicode-normalization = "0.1"
[dev-dependencies]
serde_json = "1"
[lints]
workspace = true
+232
View File
@@ -0,0 +1,232 @@
//! Rule-based editing assistant — no AI yet (Yvv, 2026-10-21: « D'abord des
//! règles simples, ensuite on mettra de l'IA, mais locale »).
//!
//! It never decides. It says, while the author writes:
//! - « this text seems to carry several decisions — split it? » with the parts;
//! - « this text is close to <unit> — make it a variant instead? »;
//! - « this text is long ».
//!
//! The local AI planned later plugs in behind the same `Hint` type.
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Hint {
/// Several decisions in one text: the suggested parts, in order.
Split { parts: Vec<String> },
/// Close to an existing unit or proposal.
Similar { target: String, score: f64 },
/// Longer than a unit usually is.
Long { chars: usize },
}
pub const LONG_UNIT_CHARS: usize = 700;
pub const SIMILARITY_HINT: f64 = 0.45;
/// Markers of a commitment or a rule, folded (lowercase, no accents).
const DECISION_MARKERS: &[&str] = &[
"je m'engage",
"je m engage",
"j'ai ",
"je suis ",
"je me suis",
"je verifie",
"je ne ",
"doit ",
"doivent ",
"s'engage",
"est tenu",
"est interdit",
"obligatoire",
"il est interdit",
"verifiez",
"ne certifiez",
"contactez",
"assurez",
"i commit",
"i have ",
"must ",
"shall ",
"me comprometo",
"debe ",
];
const STOPWORDS: &[&str] = &[
"le", "la", "les", "un", "une", "des", "de", "du", "d", "l", "et", "ou", "a", "au", "aux", "en", "que", "qui",
"je", "j", "me", "m", "se", "s", "ce", "cet", "cette", "ces", "par", "pour", "sur", "dans", "avec", "est", "sont",
"the", "of", "and", "to", "in", "is", "el", "los", "las", "y", "que", "con", "por", "para",
];
fn fold(s: &str) -> String {
s.nfd()
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
.flat_map(char::to_lowercase)
.map(|c| if c == '’' { '\'' } else { c })
.collect()
}
fn is_decision(segment: &str) -> bool {
let f = fold(segment);
DECISION_MARKERS.iter().any(|m| f.contains(m))
}
/// Paragraphs, list items: the natural cut lines of a modular text.
fn segments(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
let flush = |current: &mut String, out: &mut Vec<String>| {
let t = current.trim();
if !t.is_empty() {
out.push(t.to_owned());
}
current.clear();
};
for line in text.lines() {
let trimmed = line.trim_start();
let is_item = trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed
.split_once(['.', ')'])
.is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
if trimmed.is_empty() || is_item {
flush(&mut current, &mut out);
}
if !trimmed.is_empty() {
if !current.is_empty() {
current.push('\n');
}
current.push_str(line);
}
}
flush(&mut current, &mut out);
out
}
/// Sentences of a single paragraph (split after . ! ? followed by a space).
fn sentences(paragraph: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut chars = paragraph.chars().peekable();
while let Some(c) = chars.next() {
current.push(c);
if matches!(c, '.' | '!' | '?') && chars.peek().is_some_and(|n| n.is_whitespace()) {
out.push(current.trim().to_owned());
current.clear();
}
}
if !current.trim().is_empty() {
out.push(current.trim().to_owned());
}
out
}
/// The parts to propose separately, or `None` when the text holds one decision.
pub fn split_suggestion(text: &str) -> Option<Vec<String>> {
let segs = segments(text);
if segs.len() >= 2 && segs.iter().filter(|s| is_decision(s)).count() >= 2 {
return Some(segs);
}
if segs.len() == 1 {
let sents = sentences(&segs[0]);
let committing = sents.iter().filter(|s| is_decision(s)).count();
if sents.len() >= 2 && committing >= 2 {
return Some(sents);
}
}
None
}
fn content_words(text: &str) -> Vec<String> {
fold(text)
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.chars().count() > 2 && !STOPWORDS.contains(w))
.map(str::to_owned)
.collect()
}
/// Jaccard similarity over content words and their bigrams.
pub fn similarity(a: &str, b: &str) -> f64 {
use std::collections::BTreeSet;
let grams = |t: &str| -> BTreeSet<String> {
let w = content_words(t);
let mut set: BTreeSet<String> = w.iter().cloned().collect();
set.extend(w.windows(2).map(|p| format!("{} {}", p[0], p[1])));
set
};
let (x, y) = (grams(a), grams(b));
if x.is_empty() || y.is_empty() {
return 0.0;
}
let inter = x.intersection(&y).count() as f64;
let union = x.union(&y).count() as f64;
inter / union
}
/// All hints for a draft, given the texts it may collide with (`(id, text)`).
pub fn hints(text: &str, others: &[(String, String)]) -> Vec<Hint> {
let mut out = Vec::new();
if let Some(parts) = split_suggestion(text) {
out.push(Hint::Split { parts });
}
let mut close: Vec<(String, f64)> =
others.iter().map(|(id, t)| (id.clone(), similarity(text, t))).filter(|(_, s)| *s >= SIMILARITY_HINT).collect();
close.sort_by(|a, b| b.1.total_cmp(&a.1));
out.extend(close.into_iter().take(3).map(|(target, score)| Hint::Similar { target, score }));
let chars = text.chars().count();
if chars > LONG_UNIT_CHARS {
out.push(Hint::Long { chars });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_commitments_in_one_text_are_flagged() {
let text = "Je m'engage sur l'honneur à n'avoir qu'un seul compte.\n\nJe m'engage à ne certifier que des personnes qui respectent cet engagement.";
let parts = split_suggestion(text).expect("two decisions");
assert_eq!(parts.len(), 2);
assert!(parts[0].starts_with("Je m'engage sur"));
}
#[test]
fn two_commitments_in_one_paragraph() {
let text = "Je m'engage à vérifier la clé. Je m'engage aussi à rester joignable.";
assert_eq!(split_suggestion(text).map(|p| p.len()), Some(2));
}
#[test]
fn a_list_of_rules_is_flagged_but_one_explained_commitment_is_not() {
let list = "- Vérifiez la clé avec la personne.\n- Contactez les certifieurs existants.";
assert_eq!(split_suggestion(list).map(|p| p.len()), Some(2));
let single = "Je m'engage sur l'honneur à n'avoir qu'un seul compte. C'est le socle de la toile.";
assert_eq!(split_suggestion(single), None);
}
#[test]
fn preamble_like_text_is_not_split() {
let text = "Le présent acte définit les obligations réciproques des membres. Cet acte est l'unique relation contractuelle de notre toile.";
assert_eq!(split_suggestion(text), None);
}
#[test]
fn similarity_finds_a_variant() {
let current = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.";
let variant = "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.";
let other = "J'ai vérifié avec la personne certifiée qu'elle a généré son document de révocation.";
assert!(similarity(current, variant) >= SIMILARITY_HINT);
assert!(similarity(current, other) < 0.2);
let h = hints(variant, &[("E1".into(), current.into()), ("E6".into(), other.into())]);
assert!(matches!(&h[..], [Hint::Similar { target, .. }] if target == "E1"));
}
#[test]
fn long_text() {
let text = "mot ".repeat(200);
assert!(hints(&text, &[]).iter().any(|h| matches!(h, Hint::Long { .. })));
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Composition of a modular document under permanent vote.
//!
//! A document is an ordered list of **units** (preamble, articles, headings).
//! A unit is a slot: it may hold one version in force, or none yet. Every
//! **proposal** targets exactly one unit — one proposal, one decision — and is
//! one of: a text for the unit (new or replacing), its removal, or a change of
//! its inertia. Proposals on the same unit and the same dimension (content or
//! inertia) are rivals: at most one of them enters into force at a time.
//!
//! A rival replaces the version in force only if it reached its threshold
//! **and** holds a wider margin than the version in force (for − required).
//! The displaced version goes back among the proposals with its votes; it can
//! only return by widening its margin again — no oscillation. A seed a minima
//! (in force without any vote) has the margin of zero votes, so the first
//! rival that reaches its threshold replaces it.
//!
//! This module answers three questions, purely:
//! - which proposals enter into force now (`adoptions`);
//! - what the document would read if the well-rated, or all, proposals passed
//! (`preview`);
//! - which formula a proposal is judged by (`params_for`).
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VersionText {
pub id: String,
pub title: String,
pub body: String,
/// Votes still cast on the version in force (permanent vote).
#[serde(default)]
pub tally: Tally,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UnitState {
pub id: String,
pub position: f64,
pub inertia: InertiaPreset,
pub in_force: Option<VersionText>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "inertia", rename_all = "lowercase")]
pub enum ProposalKind {
/// A text for the unit: an addition if the unit is empty, a replacement otherwise.
Text,
/// Remove the unit from the text in force.
Remove,
/// Change the inertia of the unit.
Inertia(InertiaPreset),
}
impl ProposalKind {
fn is_content(self) -> bool {
!matches!(self, Self::Inertia(_))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProposalState {
pub id: String,
pub unit_id: String,
pub kind: ProposalKind,
pub title: String,
pub body: String,
pub tally: Tally,
/// Sortable submission stamp (RFC 3339): the earlier wins a perfect tie.
pub submitted_at: String,
/// An active contradiction signal forbids entering into force.
pub blocked: bool,
}
/// A change of inertia is voted one level above the unit (« inertie + »).
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams {
let preset = match kind {
ProposalKind::Inertia(_) => unit.inertia.raised(),
ProposalKind::Text | ProposalKind::Remove => unit.inertia,
};
FormulaParams::for_inertia(preset, floor)
}
/// A proposal that changes nothing cannot enter into force.
fn is_effective(unit: &UnitState, p: &ProposalState) -> bool {
match p.kind {
ProposalKind::Text => unit.in_force.as_ref().is_none_or(|v| v.title != p.title || v.body != p.body),
ProposalKind::Remove => unit.in_force.is_some(),
ProposalKind::Inertia(target) => target != unit.inertia,
}
}
#[derive(Clone, Debug)]
struct Scored<'a> {
proposal: &'a ProposalState,
margin: i64,
reached: bool,
well_rated: bool,
}
fn score<'a>(
unit: &UnitState,
p: &'a ProposalState,
electorate: u32,
floor: f64,
) -> Result<Scored<'a>, ThresholdError> {
let e = evaluate(p.tally, electorate, &params_for(unit, p.kind, floor))?;
Ok(Scored {
proposal: p,
margin: i64::from(p.tally.for_votes) - i64::from(e.required),
reached: e.reached,
well_rated: e.well_rated,
})
}
/// Best of rivals: widest margin, then most *for* votes, then earliest.
fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
rivals.sort_by(|a, b| {
b.margin
.cmp(&a.margin)
.then(b.proposal.tally.for_votes.cmp(&a.proposal.tally.for_votes))
.then(a.proposal.submitted_at.cmp(&b.proposal.submitted_at))
.then(a.proposal.id.cmp(&b.proposal.id))
});
rivals.first().map(|s| s.proposal)
}
/// Margin of the version in force, judged like a text proposal on its unit.
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> {
let Some(v) = &unit.in_force else { return Ok(None) };
let e = evaluate(v.tally, electorate, &params_for(unit, ProposalKind::Text, floor))?;
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
}
/// The proposals that enter into force now: per unit and dimension, the best
/// of those that reached their threshold, are effective and not blocked — and,
/// for content, hold a wider margin than the version in force.
pub fn adoptions(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
) -> Result<Vec<String>, ThresholdError> {
let mut out = Vec::new();
for unit in units {
let incumbent = in_force_margin(unit, electorate, floor)?;
for content in [true, false] {
let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
if p.blocked || !is_effective(unit, p) {
continue;
}
let s = score(unit, p, electorate, floor)?;
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
if s.reached && beats_incumbent {
rivals.push(s);
}
}
if let Some(winner) = best(rivals) {
out.push(winner.id.clone());
}
}
}
Ok(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewMode {
/// Only the in-force text.
InForce,
/// As if every well-rated proposal passed (« si les bien cotées passent »).
WellRated,
/// As if every proposal passed, whatever its rating (« si tout passe »).
All,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewMark {
Unchanged,
Added,
Replaced,
Removed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewUnit {
pub unit_id: String,
pub title: String,
pub body: String,
pub mark: PreviewMark,
/// The proposal that would bring the change.
pub proposal_id: Option<String>,
}
/// The document as it would read. Removed units are kept with their mark so
/// the screen can show them struck through.
pub fn preview(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
mode: PreviewMode,
) -> Result<Vec<PreviewUnit>, ThresholdError> {
let mut ordered: Vec<&UnitState> = units.iter().collect();
ordered.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(&b.id)));
let mut out = Vec::new();
for unit in ordered {
let chosen = if mode == PreviewMode::InForce {
None
} else {
let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content()) {
if !is_effective(unit, p) {
continue;
}
let s = score(unit, p, electorate, floor)?;
if mode == PreviewMode::All || s.well_rated {
rivals.push(s);
}
}
if mode == PreviewMode::All {
// Whatever its rating: the most supported, then the earliest.
for r in &mut rivals {
r.margin = 0;
}
}
best(rivals)
};
match (chosen, &unit.in_force) {
(Some(p), current) => {
let mark = match (p.kind, current) {
(ProposalKind::Remove, _) => PreviewMark::Removed,
(_, Some(_)) => PreviewMark::Replaced,
(_, None) => PreviewMark::Added,
};
let (title, body) = match (p.kind, current) {
(ProposalKind::Remove, Some(v)) => (v.title.clone(), v.body.clone()),
_ => (p.title.clone(), p.body.clone()),
};
out.push(PreviewUnit { unit_id: unit.id.clone(), title, body, mark, proposal_id: Some(p.id.clone()) });
}
(None, Some(v)) => out.push(PreviewUnit {
unit_id: unit.id.clone(),
title: v.title.clone(),
body: v.body.clone(),
mark: PreviewMark::Unchanged,
proposal_id: None,
}),
(None, None) => {}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(id: &str, position: f64, in_force: Option<&str>) -> UnitState {
UnitState {
id: id.into(),
position,
inertia: InertiaPreset::Standard,
in_force: in_force.map(|b| VersionText {
id: format!("{id}-v"),
title: id.into(),
body: b.into(),
tally: Tally::default(),
}),
}
}
fn prop(id: &str, unit_id: &str, kind: ProposalKind, body: &str, for_votes: u32, against: u32) -> ProposalState {
ProposalState {
id: id.into(),
unit_id: unit_id.into(),
kind,
title: unit_id.into(),
body: body.into(),
tally: Tally { for_votes, against },
submitted_at: format!("2026-10-21T00:00:0{}Z", id.len() % 10),
blocked: false,
}
}
const W: u32 = 5745;
const C: f64 = 50.0;
#[test]
fn nothing_passes_at_zero_votes() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
assert!(adoptions(&units, &props, W, C).expect("valid").is_empty());
}
#[test]
fn the_floor_holds_then_releases() {
let units = [unit("E3", 7.0, None)];
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty());
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]);
}
#[test]
fn rivals_on_one_unit_yield_one_winner() {
let units = [unit("E3", 7.0, None)];
let props = [
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
];
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]);
}
#[test]
fn inertia_and_content_are_independent_dimensions() {
let units = [unit("E1", 4.0, Some("unicité"))];
let props = [
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0),
];
let won = adoptions(&units, &props, W, C).expect("valid");
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
}
#[test]
fn inertia_change_is_voted_one_level_up() {
let u = unit("E1", 4.0, Some("x"));
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C);
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct());
assert_eq!(p.gradient, InertiaPreset::High.gradient());
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient());
}
#[test]
fn blocked_or_ineffective_never_pass() {
let units = [unit("E1", 4.0, Some("unicité"))];
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
blocked.blocked = true;
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0);
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty());
}
#[test]
fn a_rival_must_beat_the_version_in_force() {
let mut units = [unit("E1", 4.0, Some("unicité"))];
// The seed a minima has no vote: the first rival that reaches its threshold wins.
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits.
if let Some(v) = units[0].in_force.as_mut() {
v.tally = Tally { for_votes: 60, against: 0 };
}
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty());
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]);
// The same rule holds for a removal; inertia does not compete with the text.
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty());
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)];
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]);
}
#[test]
fn previews() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None), unit("E1", 4.0, Some("unicité"))];
let props = [
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
];
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid");
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid");
assert_eq!(
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
);
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
assert_eq!(
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
);
}
#[test]
fn all_mode_prefers_the_most_supported_rival() {
let units = [unit("E3", 7.0, None)];
let props = [
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
];
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
}
}
+642
View File
@@ -0,0 +1,642 @@
//! Edit classification — a typo fix is not a decision.
//!
//! Once a proposal is under vote, its author may still correct it. The engine
//! tells two kinds of corrections apart:
//!
//! - **typographic**: letters and spaces only — accents, case, the style of
//! apostrophes and quotes, words glued or split, or at most two misspelt
//! words fixed by a single slip each (one letter added, removed or swapped
//! in the middle of the word, never on a negation or a quantity). Never a
//! punctuation mark, a figure, a sign, an emphasis, a formula or an address.
//! The votes stay; the correction is shown with its diff.
//! - **substantive**: anything else. It opens a new revision and every vote
//! cast on the previous one waits for its author to reconfirm it.
//!
//! The rule is deliberately conservative: when in doubt, it is substantive.
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EditKind {
Unchanged,
Typographic,
Substantive,
}
/// Words whose presence flips a meaning; touching one is never a typo.
const NEGATIONS: &[&str] = &[
// fr
"ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne",
"guere", "point", // en
"not", "no", "never", "without", "none", "nor", "nothing", "nobody", "nowhere", "neither", // es
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", "nadie", "jamas", "ni",
];
/// Words that carry a quantity, a scope or a relation (folded); changing one is
/// never a typo.
#[rustfmt::skip]
const QUANTITIES: &[&str] = &[
// fr
"un", "une", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize",
"quatorze", "quinze", "seize", "vingt", "trente", "quarante", "cinquante", "soixante", "septante", "huitante",
"octante", "nonante", "cent", "cents", "mille", "million", "millions", "milliard", "milliards", "premier",
"premiere", "premiers", "premieres", "second", "seconde", "deuxieme", "troisieme", "dernier", "derniere", "double",
"triple", "moitie", "tiers", "quart", "moins", "minimum", "maximum", "majorite", "minorite", "unanimite", "tous",
"toutes", "tout", "toute", "chaque", "seul", "seule", "seuls", "seules", "dessus", "dessous", "superieur",
"superieure", "inferieur", "inferieure", "avant", "apres", "sous", "entre", "hors", "sauf", "inclus", "exclus",
// en
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "twenty",
"thirty", "forty", "fifty", "sixty", "hundred", "thousand", "million", "first", "second", "third", "last", "half",
"twice", "less", "more", "least", "most", "majority", "minority", "all", "every", "each", "only", "above", "below",
"over", "under", "before", "after", "within", "except",
// es
"uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "veinte",
"treinta", "cuarenta", "cincuenta", "cien", "ciento", "mil", "millon", "primero", "primera", "segundo", "segunda",
"ultimo", "ultima", "mitad", "doble", "menos", "mas", "minimo", "maximo", "mayoria", "minoria", "todos", "todas",
"todo", "toda", "cada", "solo", "sola", "encima", "debajo", "antes", "despues", "sobre", "bajo", "excepto",
];
const MAX_TYPO_WORDS: usize = 2;
/// Shorter words have too many neighbours one letter away ("can" / "cant").
const MIN_TYPO_WORD: usize = 5;
/// Letters at each end of a word that a slip never touches: the start carries
/// prefixes ("valide" / "invalide"), the end carries number, gender, tense and
/// mood ("certifierai" / "certifierais", "será" / "sería").
const FIXED_ENDS: usize = 2;
/// Lowercase, strip diacritics.
fn fold(s: &str) -> String {
s.nfd().filter(|c| !is_combining(*c)).flat_map(char::to_lowercase).collect()
}
fn is_combining(c: char) -> bool {
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F)
}
/// Letters and digits only, folded: what survives every typographic change.
fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
}
/// A letter of the Latin alphabet as French, English and Spanish write it,
/// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the
/// only letters a typo fix may touch. Any other letter may be a look-alike
/// (Cyrillic « а », « ǃ » for « ! », « Ʒ » for « 3 ») or reorder the display
/// (Hebrew, Arabic): it is kept as it is.
fn is_latin_letter(c: char) -> bool {
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
}
/// The only spaces free to change between words; any other (carriage return,
/// tab, hair, thin, ogham…) is kept as it is, and so are line breaks and the
/// indentation that opens a line: in Markdown they make lists and paragraphs.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}'];
/// Invisible characters: the Unicode default-ignorable set (zero-width,
/// bidirectional overrides and isolates, soft hyphen, variation selectors,
/// tags, byte order mark…) and control characters other than line breaks and
/// tabs (an escape sequence rewrites a terminal). They can make "12" display as
/// "21" ("Trojan Source") or hide text in the published files. A text of
/// reference never needs them; they are refused.
pub fn has_hidden_controls(s: &str) -> bool {
s.chars().any(|c| {
(c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
|| matches!(
c as u32,
0x00AD
| 0x034F
| 0x061C
| 0x115F..=0x1160
| 0x17B4..=0x17B5
| 0x180B..=0x180F
| 0x200B..=0x200F
| 0x202A..=0x202E
| 0x2060..=0x206F
| 0x3164
| 0xFE00..=0xFE0F
| 0xFEFF
| 0xFFA0
| 0xFFF0..=0xFFF8
| 0x1BCA0..=0x1BCA3
| 0x1D173..=0x1D17A
| 0xE0000..=0xE0FFF
)
})
}
/// Apostrophes and quotation marks of every style are one and the same sign:
/// « Je m'engage » → « Je m’engage » is typography.
fn same_mark(c: char) -> char {
match c {
'’' | '‘' | 'ʼ' => '\'',
'“' | '”' | '«' | '»' => '"',
other => other,
}
}
/// The shape of a text: every run of Latin letters becomes one mark, free spaces
/// vanish, and **everything else stays as it is, in order** — punctuation,
/// figures, signs, emphasis, brackets, look-alikes, other alphabets. A typo fix
/// keeps the shape: it touches letters and spaces only (2026-10-21, after six
/// independent reviews showed that any free punctuation can carry a meaning:
/// a minus sign before a bold number, a list merged into one condition…).
fn shape(s: &str) -> String {
let cs: Vec<char> = s.nfc().collect();
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
let mut out = String::new();
let mut in_word = false;
let mut line_start = true;
for (i, &c) in cs.iter().enumerate() {
if c == '\n' || (line_start && c.is_whitespace()) {
// Line breaks and indentation are structure: « - a\n- b » is a list.
out.push(c);
in_word = false;
line_start = true;
continue;
}
line_start = false;
if is_latin_letter(c) {
if !in_word {
out.push('a');
}
in_word = true;
} else if FREE_SPACES.contains(&c) && !is_figure(i.checked_sub(1)) && !is_figure(Some(i + 1)) {
// A space between letters may come and go: « latransmission » and
// « la transmission » share a shape (segmentation is checked apart).
// Next to a figure it stays: « 1 5 » is not « 15 ».
} else {
out.push(same_mark(c));
in_word = false;
}
}
out
}
/// What stands between backticks: formulas and identifiers, compared exactly.
fn code_spans(s: &str) -> Vec<&str> {
s.split('`').skip(1).step_by(2).collect()
}
/// Addresses (links, domains, mails, paths) and identifiers that hold a figure
/// (keys, fingerprints, references): compared exactly, case included.
fn exact_tokens(s: &str) -> Vec<String> {
s.split_whitespace()
.map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|t| {
let cs: Vec<char> = t.chars().collect();
t.contains("://")
|| t.contains('@')
|| cs.iter().any(|c| c.is_numeric()) && cs.iter().any(|c| c.is_alphabetic())
|| (1..cs.len().saturating_sub(1))
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
})
.map(str::to_owned)
.collect()
}
/// A single slip of the keyboard inside a word: one letter added, removed, or
/// two neighbours swapped — never at either end (see `FIXED_ENDS`), and never a
/// letter replaced by another ("deux" / "dix", "majorité" / "minorité").
fn one_slip(x: &str, y: &str) -> bool {
let (a, b): (Vec<char>, Vec<char>) = (x.chars().collect(), y.chars().collect());
if a.len().min(b.len()) < MIN_TYPO_WORD {
return false;
}
let inside = |i: usize, len: usize| i >= FIXED_ENDS && i + FIXED_ENDS < len;
match a.len().abs_diff(b.len()) {
1 => {
let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
(0..long.len()).any(|i| {
let mut l = long.clone();
l.remove(i);
inside(i, long.len()) && &l == short
})
}
0 => {
let at: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
at.len() == 2
&& at[1] == at[0] + 1
&& a[at[0]] == b[at[1]]
&& a[at[1]] == b[at[0]]
&& inside(at[0], a.len())
&& inside(at[1], a.len())
}
_ => false,
}
}
/// Folded words (runs of letters/digits).
fn words(s: &str) -> Vec<String> {
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
}
/// Same letters, at most two words glued or split, never a negation:
/// « latransmission » → « la transmission ». A boundary moved is not a typo
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
// Neither the word glued or split nor its pieces may be a negation or a
// quantity (« ja mais », « sur tout » → « surtout », « par tout » → « partout »).
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t) || QUANTITIES.contains(t));
let (mut i, mut j, mut moves) = (0, 0, 0);
while i < a.len() && j < b.len() {
if a[i] == b[j] {
(i, j) = (i + 1, j + 1);
continue;
}
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&a[i], &b[j], &b[j + 1]) {
(i, j) = (i + 1, j + 2);
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&b[j], &a[i], &a[i + 1]) {
(i, j) = (i + 2, j + 1);
} else {
return false;
}
moves += 1;
if moves > MAX_TYPO_WORDS {
return false;
}
}
i == a.len() && j == b.len()
}
/// Classify `new` against `old`. For a text under vote, `old` must be the text
/// that was **voted** (its last initial or substantive revision), not the
/// current one: typo fixes are measured together, so slips never add up.
pub fn classify(old: &str, new: &str) -> EditKind {
if old == new {
return EditKind::Unchanged;
}
if has_hidden_controls(new)
|| shape(old) != shape(new)
|| code_spans(old) != code_spans(new)
|| exact_tokens(old) != exact_tokens(new)
{
return EditKind::Substantive;
}
let (a, b) = (words(old), words(new));
if skeleton(old) == skeleton(new) {
// Accents, case, apostrophes; words glued or split, never re-cut.
return if only_glued_or_split(&a, &b) { EditKind::Typographic } else { EditKind::Substantive };
}
if a.len() != b.len() {
return EditKind::Substantive;
}
let mut changed = 0;
for (x, y) in a.iter().zip(&b) {
if x == y {
continue;
}
changed += 1;
let touches_meaning = changed > MAX_TYPO_WORDS
|| x.chars().any(char::is_numeric)
|| y.chars().any(char::is_numeric)
|| [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str()))
|| !one_slip(x, y);
if touches_meaning {
return EditKind::Substantive;
}
}
EditKind::Typographic
}
pub fn levenshtein(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0; b.len() + 1];
for (i, ca) in a.chars().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != *cb);
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiffOp {
Same,
Added,
Removed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffPart {
pub op: DiffOp,
pub text: String,
}
/// Tokens that keep every character: words, and the runs between them.
fn tokens(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut in_word: Option<bool> = None;
for (i, c) in s.char_indices() {
let w = c.is_alphanumeric();
if in_word.is_some_and(|prev| prev != w) {
out.push(&s[start..i]);
start = i;
}
in_word = Some(w);
}
if start < s.len() {
out.push(&s[start..]);
}
out
}
const MAX_DIFF_TOKENS: usize = 4000;
/// Word-level diff for display. Concatenating `Same` + `Added` gives `new`,
/// `Same` + `Removed` gives `old`.
pub fn diff(old: &str, new: &str) -> Vec<DiffPart> {
let (a, b) = (tokens(old), tokens(new));
if a.len() * b.len() > MAX_DIFF_TOKENS * MAX_DIFF_TOKENS / 4 {
return vec![
DiffPart { op: DiffOp::Removed, text: old.to_owned() },
DiffPart { op: DiffOp::Added, text: new.to_owned() },
];
}
// LCS table, suffix form.
let (n, m) = (a.len(), b.len());
let mut lcs = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
lcs[i][j] = if a[i] == b[j] { lcs[i + 1][j + 1] + 1 } else { lcs[i + 1][j].max(lcs[i][j + 1]) };
}
}
let mut parts: Vec<DiffPart> = Vec::new();
let mut push = |op: DiffOp, text: &str| match parts.last_mut() {
Some(last) if last.op == op => last.text.push_str(text),
_ => parts.push(DiffPart { op, text: text.to_owned() }),
};
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if a[i] == b[j] {
push(DiffOp::Same, a[i]);
i += 1;
j += 1;
} else if lcs[i + 1][j] >= lcs[i][j + 1] {
push(DiffOp::Removed, a[i]);
i += 1;
} else {
push(DiffOp::Added, b[j]);
j += 1;
}
}
for t in &a[i..] {
push(DiffOp::Removed, t);
}
for t in &b[j..] {
push(DiffOp::Added, t);
}
parts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_glued_word_of_the_seed_is_a_typo() {
let old = "Toute Certification doit s'accompagner de latransmission de ce document";
let new = "Toute Certification doit s'accompagner de la transmission de ce document";
assert_eq!(classify(old, new), EditKind::Typographic);
}
#[test]
fn glued_or_split_words_stay_few_and_never_touch_a_negation() {
// Seventh independent review, 2026-10-21: a text in force disfigured at will.
for (old, new) in [
("à ne certifier que", "à necertifier que"),
("à ne certifier que", "àne certifier que"),
("Si non, la demande attend", "Sinon, la demande attend"),
("la transmission de ce document à chacun", "latransmission dece documentà chacun"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
assert_eq!(
classify("de latransmission dece document", "de la transmission de ce document"),
EditKind::Typographic
);
}
#[test]
fn a_negation_or_a_quantity_is_never_cut_nor_formed() {
// Eighth independent review, 2026-10-21: the pieces were checked, not the word.
for (old, new) in [
("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"),
("aucun compte", "au cun compte"),
("sans retour", "san s retour"),
("rien de plus", "ri en de plus"),
("nowhere else", "now here else"),
// Ninth review: a quantity glued changes a scope.
("Je vérifie sur tout le territoire", "Je vérifie surtout le territoire"),
("par tout membre", "partout membre"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
}
}
#[test]
fn line_breaks_and_indentation_are_structure() {
for (old, new) in [
("- une garantie\n- une autre", "- une garantie - une autre"),
("- une garantie\n - une précision", "- une garantie\n- une précision"),
("la\ntransmission", "la transmission"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old:?} → {new:?}");
}
assert_eq!(
classify("- une garantie\n- latransmission", "- une garantie\n- la transmission"),
EditKind::Typographic
);
}
#[test]
fn odd_spaces_and_look_alike_letters_are_kept() {
assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive);
assert_eq!(classify("un texte\rde référence", "un texte de référence"), EditKind::Substantive);
assert_eq!(classify("toute certification", "toute certiǃfication"), EditKind::Substantive);
assert_eq!(classify("toute certification", "toute certifiƷcation"), EditKind::Substantive);
assert_eq!(classify("le coeur de l'ŒUVRE, niño", "le Coeur de l'œuvre, Niño"), EditKind::Typographic);
}
#[test]
fn accents_case_apostrophes_quotes() {
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
assert_eq!(classify("le \"Pacte\" de la monnaie", "le « Pacte » de la Monnaie"), EditKind::Typographic);
assert_eq!(classify("same", "same"), EditKind::Unchanged);
}
#[test]
fn punctuation_and_emphasis_go_through_a_proposal() {
// Sixth independent review: free punctuation can always carry a meaning.
for (old, new) in [
("Je m'engage à vérifier.", "Je m'engage à vérifier !"),
("Je m'engage à vérifier.", "Je m'engage à vérifier ?"),
("un **être humain**", "un être humain"),
("tous les **5 jours**", "tous les -**5 jours**"),
("Seuil = C + B^W", "Seuil = C + B^-W"),
("max(0, T-C)", "max(0, TC)"),
("(Y = ceil(N))", "(Y = -ceil(N))"),
("- avoir un compte\n- être joignable", "- avoir un compte, être joignable"),
("(sauf le cas a) et le cas b", "(sauf le cas a et le cas b)"),
("libre - le texte", "libre — le texte"),
("- une liste", "* une liste"),
("clé DsEx1pS33abc", "clé dSeX1Ps33abc"),
("les avoir vérifiés", "le savoir vérifiés"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
assert_eq!(classify("toute latransmission", "toute la transmission"), EditKind::Typographic);
assert_eq!(classify("la trans mission", "la transmission"), EditKind::Typographic);
}
#[test]
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
// Same letters, different meaning (independent reviews, 2026-10-21).
for (old, new) in [
("au moins 50 voix", "au moins 5,0 voix"),
("1 5 jours", "15 jours"),
("10 000 membres", "10,000 membres"),
("un écart de -5", "un écart de 5"),
("1/5 des voix", "1.5 des voix"),
("50 % des voix", "50 ‰ des voix"),
("avoir >= 5 certifications", "avoir <= 5 certifications"),
("DU(n) + c² × M(n)", "DU(n) - c² × M(n)"),
("182.625 jours", "182 625 jours"),
("c²", "c2"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
assert_eq!(classify("50 voix pour", "50 voix Pour"), EditKind::Typographic);
// A bracket always counts, even as a list marker: conservative.
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive);
assert_eq!(classify("1. premier point", "1. Premier point"), EditKind::Typographic);
}
#[test]
fn a_word_turned_around_is_never_a_typo() {
// Third independent review, 2026-10-21: one or two letters that invert a meaning.
for (old, new) in [
("ces deux présents engagements", "ces dix présents engagements"),
("Nouveau compte valide si", "Nouveau compte invalide si"),
("100 certifications possibles", "100 certifications impossibles"),
("à la majorité des voix", "à la minorité des voix"),
("un minimum de garanties", "un maximum de garanties"),
("les membres inclus", "les membres exclus"),
("une personne majeure", "une personne mineure"),
("une durée limitée", "une durée illimitée"),
("un mandat révocable", "un mandat irrévocable"),
("des comptes indépendants", "des comptes dépendants"),
("pendant six mois", "pendant dix mois"),
("sept certifications", "cent certifications"),
("seize jours", "treize jours"),
("five members", "nine members"),
("you can certify", "you cant certify"),
("Y = ceil(N^(1/5))", "Y = ceil(N^1/5)"),
("M(n)/N(n)", "M(n)N(n)"),
("DU(n) - c", "DU(n) / c"),
("la formule `a - b`", "la formule `a * b`"),
// Fourth independent review: look-alikes, overlays, addresses, endings.
("au moins 50 voix", "au moins 5\u{201a}0 voix"),
("**12 mois**", "**1\u{201a}2 mois**"),
("15 jours", "1\u{2044}5 jours"),
("2 ans", "\u{ff0d}2 ans"),
(">= 5", "\u{226f}= 5"),
("a = b", "a \u{2260} b"),
("a = b", "a =\u{0338} b"),
("ne jamais certifier", "ne j\u{0336}a\u{0336}m\u{0336}a\u{0336}i\u{0336}s\u{0336} certifier"),
("la clé est vérifiée", "la clé est ~~vérifiée~~"),
("une personne connue", "une personne conn\u{05d5}ue"),
("une personne connue", "une personne c\u{043e}nnue"),
(
"https://git.duniter.org/documents/g1_monetary_license",
"https://git.dunniter.org/documents/g1_monetary_license",
),
("au-dessus de 50 %", "au-dessous de 50 %"),
("je certifierai", "je certifierais"),
("será válido", "sería válido"),
("they must certify", "they mustn certify"),
// Fifth independent review: anything touching a figure, special spaces, signs across spaces.
("au moins 50 voix", "au moins 5,\u{202f}0 voix"),
("**12 mois**", "**1,\u{202f}2 mois**"),
("100 certifications", "10,\u{200a}0 certifications"),
("80%", "8,\u{200a}0%"),
("15 jours", "1:5 jours"),
("15 jours", "1…5 jours"),
("15 jours", "1*5 jours"),
("15 jours", "1;5 jours"),
("15 jours", "1‘5 jours"),
("un écart de 5", "un écart de -\u{200a}5"),
("un écart de 5", "un écart de - 5"),
("un écart de 5", "un écart de \u{1680}5"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
}
#[test]
fn hidden_controls_are_spotted() {
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
assert!(has_hidden_controls("5\u{200b}0"));
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
assert!(!has_hidden_controls("une ligne\nune autre\tavec tabulation"));
// The whole default-ignorable set and the control characters (eighth review).
for hidden in ["\u{ad}", "\u{180e}", "\u{fe0f}", "\u{e0041}", "\u{206e}", "\u{1b}[2J", "\u{7f}", "\u{85}"] {
assert!(has_hidden_controls(&format!("cer{hidden}tification")), "{hidden:?}");
}
}
#[test]
fn misspelt_words() {
// The end of a word carries number and tense: a plural fixed is a revision.
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Substantive);
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
assert_eq!(classify("un acceuil chaleureux", "un accueil chaleureux"), EditKind::Typographic);
// A letter replaced by another is not a slip: the author revises.
assert_eq!(classify("une certifixation", "une certification"), EditKind::Substantive);
// « personne » is also a negation (ne … personne): conservative.
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
}
#[test]
fn meaning_changes() {
// A number.
assert_eq!(classify("tous les 5 jours", "tous les 6 jours"), EditKind::Substantive);
// A negation, even one letter apart.
assert_eq!(classify("je peux certifier", "je ne peux certifier"), EditKind::Substantive);
assert_eq!(classify("plus de deux", "pas de deux"), EditKind::Substantive);
// A different word.
assert_eq!(classify("au moins deux canaux", "au moins trois canaux"), EditKind::Substantive);
// Three fixes are no longer a typo.
assert_eq!(classify("aaaa bbbb cccc", "aaab bbbc cccd"), EditKind::Substantive);
// A word added.
assert_eq!(classify("en visio", "en visio directe"), EditKind::Substantive);
}
#[test]
fn diff_reconstructs_both_sides() {
let old = "J'ai rencontré la personne physiquement (préférable).";
let new = "J'ai rencontré la personne en visio (préférable).";
let parts = diff(old, new);
let rebuild = |keep: DiffOp| -> String {
parts.iter().filter(|p| p.op == DiffOp::Same || p.op == keep).map(|p| p.text.as_str()).collect()
};
assert_eq!(rebuild(DiffOp::Removed), old);
assert_eq!(rebuild(DiffOp::Added), new);
assert!(parts.iter().any(|p| p.op == DiffOp::Removed && p.text.contains("physiquement")));
}
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("été", "ete"), 2);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Pure engines of libreDecision — no IO, no clock, no randomness.
//!
//! Everything that decides an outcome lives here so it can be tested in
//! isolation and ported verbatim: the inertial threshold (parity with the
//! Python/TypeScript reference of the v2 maquette), the edit classifier that
//! separates a typo fix from a change of meaning, the rule-based editing
//! assistant, and the composition of a document under permanent vote.
pub mod assist;
pub mod compose;
pub mod edit;
pub mod names;
pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally};
+84
View File
@@ -0,0 +1,84 @@
//! Names people give themselves (team links): what is accepted, and the key two
//! names collide on.
//!
//! A name is shown as the author of proposals, revisions and signals, so nobody
//! may take a name already there, even dressed differently: « émile » for
//! « Émile », an accent written apart, a Cyrillic « в » or a Greek « Υ » for a
//! Latin letter, full-width « Yvv ». Hence a short alphabet — Latin letters
//! as French, English and Spanish write them, figures, space, hyphen,
//! apostrophe, dot — and a key that forgets case, accents and punctuation.
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::is_combining_mark;
const MIN_NAME: usize = 2;
const MAX_NAME: usize = 60;
#[derive(Debug, PartialEq, Eq)]
pub struct Name {
/// As shown: NFC, spaces collapsed.
pub display: String,
/// What two names collide on.
pub key: String,
}
fn allowed(c: char) -> bool {
c.is_ascii_alphanumeric()
|| (c.is_alphabetic() && matches!(c as u32, 0xC0..=0x17F))
|| matches!(c, ' ' | '-' | '\'' | '’' | '.')
}
/// Lowercase, no accents, letters and figures only: « Émile », « EMILE »,
/// « É-mile » share the key « emile ».
pub fn key(s: &str) -> String {
s.nfkd().filter(|c| !is_combining_mark(*c)).flat_map(char::to_lowercase).filter(|c| c.is_alphanumeric()).collect()
}
/// A name a person may take, or `None`.
pub fn check(raw: &str) -> Option<Name> {
let raw = raw.trim();
// One line, ordinary spaces only (no line or paragraph separator).
if raw.chars().any(|c| c.is_whitespace() && c != ' ') {
return None;
}
let display = raw.nfc().collect::<String>().split(' ').filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
let n = display.chars().count();
if !(MIN_NAME..=MAX_NAME).contains(&n) || !display.chars().all(allowed) {
return None;
}
let key = key(&display);
if key.chars().count() < MIN_NAME {
return None;
}
Some(Name { display, key })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_names() {
assert_eq!(check(" Émile Zola ").map(|n| n.display), Some("Émile Zola".into()));
assert_eq!(check("Jean-Marc O'Neil").map(|n| n.key), Some("jeanmarconeil".into()));
assert_eq!(check("Núria").map(|n| n.key), Some("nuria".into()));
assert!(check("Chiara 2").is_some());
}
#[test]
fn the_same_name_dressed_differently_collides() {
let emile = key("Émile");
for other in ["émile", "EMILE", "E\u{301}mile", "É-mile", "Emile."] {
assert_eq!(check(other).map(|n| n.key), Some(emile.clone()), "{other}");
}
}
#[test]
fn look_alikes_and_invisibles_are_refused() {
// Eighth and ninth independent reviews, 2026-10-21.
for name in ["Yvв", "Υvv", "Yvv", "Yv\u{2028}v", "Yvv\u{fff9}", "Yv\u{200b}v", "Y\nvv", "Y", "--", "Yvv 🙂"]
{
assert_eq!(check(name), None, "{name:?}");
}
}
}
+285
View File
@@ -0,0 +1,285 @@
//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula.
//!
//! ```text
//! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C)
//! ```
//!
//! R is the minimum number of *for* votes; a proposal is adopted when
//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for +
//! against). At low participation the formula asks for near-unanimity, at full
//! participation the simple majority M suffices. C is the minimal number of
//! *for* votes below which nothing passes — the guard of a permanent vote
//! (Yvv, 2026-10-21: « Nombre minimal de votants »).
//!
//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the
//! Python reference, with one deliberate guard — the participation ratio is
//! capped at 1, so a stale W can never lower the bar below M.
use serde::{Deserialize, Serialize};
/// The four inertia levels of a modular text. Wiring of the v2 maquette:
/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InertiaPreset {
Low,
Standard,
High,
Max,
}
impl InertiaPreset {
pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max];
pub fn majority_pct(self) -> f64 {
match self {
Self::Low | Self::Standard => 50.0,
Self::High => 60.0,
Self::Max => 66.0,
}
}
pub fn gradient(self) -> f64 {
match self {
Self::Low => 0.1,
Self::Standard => 0.2,
Self::High => 0.4,
Self::Max => 0.6,
}
}
/// « Inertie + » : one level up, capped at max. A change of inertia is
/// itself voted at the raised level of the unit it touches.
pub fn raised(self) -> Self {
match self {
Self::Low => Self::Standard,
Self::Standard => Self::High,
Self::High | Self::Max => Self::Max,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Standard => "standard",
Self::High => "high",
Self::Max => "max",
}
}
pub fn parse(raw: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.as_str() == raw)
}
}
/// The letters of the formula, named.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FormulaParams {
/// M, in percent (0–100).
pub majority_pct: f64,
/// G.
pub gradient: f64,
/// B.
pub base: f64,
/// C — minimal number of *for* votes.
pub constant: f64,
}
impl FormulaParams {
pub const DEFAULT_BASE: f64 = 0.1;
pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self {
Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThresholdError {
EmptyElectorate,
MajorityOutOfRange,
}
/// The required ratio of *for* votes among the votes cast beyond C.
pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 {
let m = params.majority_pct / 100.0;
let participation = (f64::from(cast) / f64::from(electorate.max(1))).min(1.0);
m + (1.0 - m) * (1.0 - participation.powf(params.gradient))
}
/// ⌈R⌉ — the minimum number of *for* votes for adoption.
pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
if electorate == 0 {
return Err(ThresholdError::EmptyElectorate);
}
if !(0.0..=100.0).contains(&params.majority_pct) {
return Err(ThresholdError::MajorityOutOfRange);
}
let c = params.constant;
let offset = params.base.powf(f64::from(electorate));
let raw = if cast == 0 {
c + offset
} else {
c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)
};
// `raw` is finite and non-negative for any valid input; saturate rather than wrap.
Ok(clamp_ceil(raw))
}
fn clamp_ceil(raw: f64) -> u32 {
let ceiled = raw.ceil();
if ceiled <= 0.0 {
0
} else if ceiled >= f64::from(u32::MAX) {
u32::MAX
} else {
// Exact: `ceiled` is an integer in (0, u32::MAX).
ceiled as u32
}
}
/// Votes counted on one proposal.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tally {
#[serde(rename = "for")]
pub for_votes: u32,
pub against: u32,
}
impl Tally {
pub fn cast(&self) -> u32 {
self.for_votes.saturating_add(self.against)
}
}
/// Where a proposal stands, in words a screen can say.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Evaluation {
pub cast: u32,
/// ⌈R⌉ at the current participation.
pub required: u32,
/// `for ≥ ⌈R⌉` with at least one vote cast.
pub reached: bool,
/// *for* votes still missing (0 once reached).
pub missing: u32,
/// The majority the inertia asks for at this participation.
pub required_ratio: f64,
/// « Bien cotée » : it holds the majority its inertia asks for, it only
/// lacks voters (C ignored). Used by the "if the well-rated pass" preview.
pub well_rated: bool,
}
pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> {
let cast = tally.cast();
let required = wot_threshold(electorate, cast, params)?;
let without_floor = FormulaParams { constant: 0.0, ..*params };
let required_without_floor = wot_threshold(electorate, cast, &without_floor)?;
let reached = cast > 0 && tally.for_votes >= required;
Ok(Evaluation {
cast,
required,
reached,
missing: required.saturating_sub(tally.for_votes),
required_ratio: required_ratio(electorate, cast, params),
well_rated: cast > 0 && tally.for_votes >= required_without_floor,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn g1vote(m: f64, g: f64) -> FormulaParams {
FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 }
}
#[test]
fn reference_case_of_the_smith_vote() {
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94.
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
assert!(e.reached);
assert_eq!(e.missing, 0);
}
#[test]
fn parity_with_the_typescript_reference() {
// Values computed by maquette/frontend/app/engine/threshold.ts.
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 0, &p), Ok(0));
assert_eq!(wot_threshold(7224, 1, &p), Ok(1));
assert_eq!(wot_threshold(100, 100, &p), Ok(50));
assert_eq!(wot_threshold(5, 5, &p), Ok(3));
let high = g1vote(60.0, 0.4);
assert_eq!(wot_threshold(7224, 120, &high), Ok(111));
}
#[test]
fn one_vote_passes_without_floor_and_never_with_it() {
// The trap of a permanent vote: a lone "for" meets the inertial bar.
let bare = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0);
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached);
let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid");
assert!(!e.reached);
assert_eq!(e.required, 50);
assert_eq!(e.missing, 49);
assert!(e.well_rated, "unanimous, it only lacks voters");
}
#[test]
fn floor_is_a_minimum_of_for_votes() {
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
// 50 unanimous votes reach it, 49 do not.
assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached);
assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached);
// Beyond the floor the inertial majority applies to the rest.
let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid");
assert!(!e.reached, "near-unanimity is asked of the votes beyond C");
}
#[test]
fn no_vote_is_never_reached() {
let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0);
let e = evaluate(Tally::default(), 3, &p).expect("valid");
assert!(!e.reached);
assert!(!e.well_rated);
}
#[test]
fn small_group_needs_everyone() {
// Test group of two people, C = 1: both must vote for.
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0);
assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
}
#[test]
fn stale_electorate_cannot_lower_the_bar_below_majority() {
let p = g1vote(50.0, 0.2);
// 30 votes cast for an electorate believed to be 10: the reference
// would ask 12 (ratio below M), the cap keeps the simple majority:
// ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up.
let r = wot_threshold(10, 30, &p).expect("valid");
assert_eq!(r, 16);
}
#[test]
fn inertia_levels_rise_and_cap() {
assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard);
assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High);
assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max);
for p in InertiaPreset::ALL {
assert_eq!(InertiaPreset::parse(p.as_str()), Some(p));
}
}
#[test]
fn errors() {
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate));
let bad = g1vote(120.0, 0.2);
assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange));
}
}
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "ld-server"
description = "libreDecision server: API, Ğ1 sign-in, storage, publication of adopted texts, static web."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
ld-engine = { path = "../ld-engine" }
trustin-access = { path = "../trustin-access" }
anyhow = "1"
axum = { version = "0.8", features = ["macros"] }
base64 = "0.22"
hex = "0.4"
rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
thiserror = "2"
time = { version = "0.3", features = ["formatting", "parsing", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process", "fs"] }
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header", "limit"] }
tower = { version = "0.5", features = ["util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
twox-hash = { version = "2", default-features = false, features = ["xxhash64"] }
url = "2"
[dev-dependencies]
http-body-util = "0.1"
[lints]
workspace = true
+182
View File
@@ -0,0 +1,182 @@
-- libreDecision v3 — initial schema. Runtime data: never dropped, never rewritten.
-- Dates are RFC 3339 UTC strings (sortable).
CREATE TABLE identities (
id TEXT PRIMARY KEY, -- idty:<index> | inv:<hex> | dev:<name>
kind TEXT NOT NULL, -- g1 | invite | dev
display_name TEXT NOT NULL,
address TEXT, -- SS58 seen at the last Ğ1 sign-in, display only
g1_member INTEGER NOT NULL DEFAULT 0,
admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_seen_at TEXT
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
identity_id TEXT NOT NULL REFERENCES identities(id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE invitations (
token_hash TEXT PRIMARY KEY,
label TEXT NOT NULL,
admin INTEGER NOT NULL DEFAULT 0,
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on acceptance
created_by TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
identity_id TEXT
);
CREATE TABLE groups (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent TEXT,
policy TEXT NOT NULL, -- g1-members | managed
hidden INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL
);
CREATE TABLE memberships (
group_slug TEXT NOT NULL REFERENCES groups(slug),
identity_id TEXT NOT NULL REFERENCES identities(id),
granted_by TEXT,
granted_at TEXT NOT NULL,
PRIMARY KEY (group_slug, identity_id)
);
CREATE TABLE documents (
id TEXT PRIMARY KEY,
group_slug TEXT NOT NULL REFERENCES groups(slug),
slug TEXT NOT NULL,
title TEXT NOT NULL, -- JSON {lang: text}
summary TEXT NOT NULL, -- JSON {lang: text}
source_lang TEXT NOT NULL,
vote_floor REAL NOT NULL, -- C, minimal number of "for" votes
publish_path TEXT, -- path in the texts repository; NULL = never published
created_at TEXT NOT NULL,
UNIQUE (group_slug, slug)
);
CREATE TABLE units (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(id),
code TEXT NOT NULL,
section TEXT, -- grouping key of the seed (introduction, technique…)
position REAL NOT NULL,
inertia TEXT NOT NULL, -- low | standard | high | max
in_force_id TEXT, -- proposals.id of the version in force, NULL = empty slot
created_at TEXT NOT NULL
);
CREATE INDEX units_doc ON units(doc_id);
-- A proposal is a version of a unit: proposed, in force, or past.
CREATE TABLE proposals (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
kind TEXT NOT NULL, -- text | remove | inertia
inertia_target TEXT,
title TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL, -- draft | open | in_force | superseded | withdrawn
origin TEXT NOT NULL, -- seed | member
label TEXT,
author_id TEXT REFERENCES identities(id),
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
submitted_at TEXT,
entered_at TEXT, -- entered into force by vote; NULL for a seed a minima
left_at TEXT,
confirmed_at TEXT -- a seed a minima that later reached its threshold
);
CREATE INDEX proposals_unit ON proposals(unit_id);
CREATE TABLE revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proposal_id TEXT NOT NULL REFERENCES proposals(id),
revision INTEGER NOT NULL,
kind TEXT NOT NULL, -- initial | typo | substantive
title TEXT NOT NULL,
body TEXT NOT NULL,
author_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX revisions_proposal ON revisions(proposal_id);
CREATE TABLE votes (
proposal_id TEXT NOT NULL REFERENCES proposals(id),
identity_id TEXT NOT NULL REFERENCES identities(id),
choice TEXT NOT NULL, -- for | against
revision INTEGER NOT NULL, -- counted only while equal to proposals.revision
cast_at TEXT NOT NULL,
PRIMARY KEY (proposal_id, identity_id)
);
CREATE TABLE signals (
id TEXT PRIMARY KEY,
proposal_id TEXT NOT NULL REFERENCES proposals(id),
target_proposal_id TEXT NOT NULL REFERENCES proposals(id),
reason TEXT NOT NULL,
author_id TEXT NOT NULL REFERENCES identities(id),
created_at TEXT NOT NULL,
lifted_at TEXT,
lifted_by TEXT -- identity id, or 'revision' when the text changed
);
CREATE TABLE translations (
proposal_id TEXT NOT NULL REFERENCES proposals(id),
lang TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
source_hash TEXT NOT NULL, -- hash of the source title + body it translates
origin TEXT NOT NULL, -- seed | member
updated_by TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (proposal_id, lang)
);
CREATE TABLE adoptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proposal_id TEXT NOT NULL,
unit_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
kind TEXT NOT NULL,
votes_for INTEGER NOT NULL,
votes_against INTEGER NOT NULL,
electorate INTEGER NOT NULL,
required INTEGER NOT NULL,
params TEXT NOT NULL,
at TEXT NOT NULL
);
CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
doc_id TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_at TEXT NOT NULL,
done_at TEXT,
commit_sha TEXT
);
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id TEXT,
route TEXT NOT NULL,
body TEXT NOT NULL,
context TEXT,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'new'
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -0,0 +1,17 @@
-- Team links (2026-10-21): one link shared with a team until a date; each opening
-- makes its own invited identity, never an administrator. Additive only.
CREATE TABLE team_links (
token_hash TEXT PRIMARY KEY,
label TEXT NOT NULL, -- what the link is for, shown to whoever opens it
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on each opening
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
max_uses INTEGER NOT NULL,
uses INTEGER NOT NULL DEFAULT 0,
revoked_at TEXT
);
-- The team link an identity came from, if any (shown to administrators).
ALTER TABLE identities ADD COLUMN team_link TEXT;
@@ -0,0 +1,5 @@
-- The key a name collides on (ld_engine::names), set for the names people give
-- themselves through a team link: two people never share a name. Additive only.
ALTER TABLE identities ADD COLUMN name_key TEXT;
CREATE UNIQUE INDEX identities_name_key ON identities(name_key) WHERE name_key IS NOT NULL;
+330
View File
@@ -0,0 +1,330 @@
//! trustinWallet — access management, for its administrators only — and the
//! « Retour » channel that turns usage tests into controls.
use crate::AppState;
use crate::auth::{self, IDENTITY_COLUMNS, IdentityRow, User};
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use serde::Deserialize;
use serde_json::json;
fn admin_only(user: &IdentityRow) -> AppResult<()> {
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
}
pub async fn overview(
State(state): State<AppState>,
headers: HeaderMap,
User(user): User,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
#[derive(sqlx::FromRow)]
struct Member {
group_slug: String,
identity_id: String,
granted_at: String,
}
let groups: Vec<(String, String, String, bool)> =
sqlx::query_as("SELECT slug, name, policy, hidden FROM groups ORDER BY position").fetch_all(&state.db).await?;
let members = sqlx::query_as::<_, Member>("SELECT group_slug, identity_id, granted_at FROM memberships")
.fetch_all(&state.db)
.await?;
let identities = sqlx::query_as::<_, IdentityRow>(&format!(
"SELECT {IDENTITY_COLUMNS} FROM identities ORDER BY admin DESC, display_name COLLATE NOCASE"
))
.fetch_all(&state.db)
.await?;
let last_seen: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?;
// Open sessions, but the one this page is read with.
let sessions: Vec<(String, i64)> = sqlx::query_as(
"SELECT identity_id, COUNT(*) FROM sessions WHERE expires_at > ? AND token_hash != ? GROUP BY identity_id",
)
.bind(util::now())
.bind(auth::session_hash(&state, &headers).unwrap_or_default())
.fetch_all(&state.db)
.await?;
let came_by: Vec<(String, String)> =
sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link")
.fetch_all(&state.db)
.await?;
let invitations: Vec<(String, String, bool, String, String, Option<String>)> = sqlx::query_as(
"SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations
WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
)
.bind(util::now())
.fetch_all(&state.db)
.await?;
let mut publication = json!({
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
"publicKey": crate::publish::public_key(&state),
});
if state.cfg.publish.is_some() {
publication["status"] = crate::publish::status(&state).await?;
}
Ok(Json(json!({
"publication": publication,
"groups": groups.iter().map(|(slug, name, policy, hidden)| json!({
"slug": slug, "name": name, "policy": policy, "hidden": hidden,
"members": members.iter().filter(|m| &m.group_slug == slug)
.map(|m| json!({ "identityId": m.identity_id, "grantedAt": m.granted_at })).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"identities": identities.iter().map(|i| json!({
"id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin,
"lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()),
"teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()),
"sessions": sessions.iter().find(|(id, _)| id == &i.id).map_or(0, |(_, n)| *n),
})).collect::<Vec<_>>(),
"teamLinks": crate::team::list(&state).await?,
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
"id": hash, "label": label, "admin": admin,
"groups": serde_json::from_str::<Vec<String>>(groups).unwrap_or_default(),
"expiresAt": expires, "identityId": identity,
})).collect::<Vec<_>>(),
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GrantBody {
identity_id: String,
}
pub async fn grant(
State(state): State<AppState>,
User(user): User,
Path(slug): Path<String>,
Json(b): Json<GrantBody>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
auth::load_identity(&state, &b.identity_id).await?;
let done = sqlx::query(
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
SELECT slug, ?, ?, ? FROM groups WHERE slug = ?",
)
.bind(&b.identity_id)
.bind(&user.id)
.bind(util::now())
.bind(&slug)
.execute(&state.db)
.await?;
tracing::info!(group = %slug, identity = %b.identity_id, by = %user.id, "access granted");
Ok(Json(json!({ "granted": done.rows_affected() == 1 })))
}
pub async fn revoke(
State(state): State<AppState>,
User(user): User,
Path((slug, identity)): Path<(String, String)>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
let done = sqlx::query("DELETE FROM memberships WHERE group_slug = ? AND identity_id = ?")
.bind(&slug)
.bind(&identity)
.execute(&state.db)
.await?;
tracing::info!(group = %slug, identity = %identity, by = %user.id, "access revoked");
Ok(Json(json!({ "revoked": done.rows_affected() == 1 })))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InviteBody {
label: String,
#[serde(default)]
admin: bool,
#[serde(default)]
groups: Vec<String>,
identity_id: Option<String>,
}
/// The link is returned once and never stored in clear.
pub async fn invite(
State(state): State<AppState>,
User(user): User,
Json(b): Json<InviteBody>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
let label = b.label.trim();
if label.is_empty()
|| label.chars().count() > 60
|| label.chars().any(char::is_control)
|| ld_engine::edit::has_hidden_controls(label)
{
return Err(AppError::Invalid("label"));
}
// A new link for an existing person is for invitation identities only: a link
// to a Ğ1 identity would let an administrator open a session in its name.
if let Some(id) = &b.identity_id
&& auth::load_identity(&state, id).await?.kind != "invite"
{
return Err(AppError::Invalid("identity_kind"));
}
let token =
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
Ok(Json(json!({ "link": auth::invitation_link(&state, &token) })))
}
pub async fn cancel_invitation(
State(state): State<AppState>,
User(user): User,
Path(hash): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
sqlx::query("UPDATE invitations SET expires_at = ? WHERE token_hash = ? AND used_at IS NULL")
.bind(util::now())
.bind(&hash)
.execute(&state.db)
.await?;
Ok(Json(json!({ "ok": true })))
}
/// « Publier maintenant » : a key just added, a remote just created — no need
/// to wait for the backoff.
pub async fn publish_now(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
if state.cfg.publish.is_none() {
return Err(AppError::Conflict("publication_off"));
}
crate::publish::retry_now(&state).await?;
tracing::info!(by = %user.id, "publication retried now");
Ok(Json(json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct AdminBody {
admin: bool,
}
pub async fn set_admin(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<AdminBody>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
auth::load_identity(&state, &id).await?;
if !b.admin {
let others: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1 AND id != ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
if others == 0 {
return Err(AppError::Conflict("last_admin"));
}
}
sqlx::query("UPDATE identities SET admin = ? WHERE id = ?").bind(b.admin).bind(&id).execute(&state.db).await?;
tracing::info!(identity = %id, admin = b.admin, by = %user.id, "administrator changed");
Ok(Json(json!({ "admin": b.admin })))
}
/// A lost device, a person who left: every session of this identity closes,
/// but the one this request comes with, and so does a sign-in link still
/// waiting for it (it may sit on the lost device).
pub async fn close_sessions(
State(state): State<AppState>,
headers: HeaderMap,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
auth::load_identity(&state, &id).await?;
let current = auth::session_hash(&state, &headers).unwrap_or_default();
// The waiting link first: opened in between, it would open a session that stays.
let links = sqlx::query(
"UPDATE invitations SET expires_at = ? WHERE identity_id = ? AND used_at IS NULL AND expires_at > ?",
)
.bind(util::now())
.bind(&id)
.bind(util::now())
.execute(&state.db)
.await?
.rows_affected();
// Counted as the overview counts them; the expired ones go too.
let open = sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ? AND expires_at > ?")
.bind(&id)
.bind(&current)
.bind(util::now())
.execute(&state.db)
.await?
.rows_affected();
sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?")
.bind(&id)
.bind(&current)
.execute(&state.db)
.await?;
tracing::info!(identity = %id, closed = open, links, by = %user.id, "sessions closed");
Ok(Json(json!({ "closed": open, "links": links })))
}
#[derive(Deserialize)]
pub struct FeedbackBody {
route: String,
body: String,
context: Option<serde_json::Value>,
}
pub async fn send_feedback(
State(state): State<AppState>,
User(user): User,
Json(b): Json<FeedbackBody>,
) -> AppResult<Json<serde_json::Value>> {
let body = b.body.trim();
if body.is_empty() || body.chars().count() > 4000 || b.route.len() > 300 {
return Err(AppError::Invalid("body"));
}
let context = b.context.map(|c| c.to_string()).filter(|c| c.len() < 4000);
sqlx::query("INSERT INTO feedback (identity_id, route, body, context, created_at) VALUES (?, ?, ?, ?, ?)")
.bind(&user.id)
.bind(&b.route)
.bind(body)
.bind(context)
.bind(util::now())
.execute(&state.db)
.await?;
Ok(Json(json!({ "ok": true })))
}
pub async fn feedback_list(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
#[derive(sqlx::FromRow, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Row {
id: i64,
author: Option<String>,
route: String,
body: String,
context: Option<String>,
created_at: String,
status: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT f.id, i.display_name AS author, f.route, f.body, f.context, f.created_at, f.status
FROM feedback f LEFT JOIN identities i ON i.id = f.identity_id ORDER BY f.id DESC LIMIT 500",
)
.fetch_all(&state.db)
.await?;
Ok(Json(json!({ "feedback": rows })))
}
#[derive(Deserialize)]
pub struct StatusBody {
status: String,
}
pub async fn feedback_status(
State(state): State<AppState>,
User(user): User,
Path(id): Path<i64>,
Json(b): Json<StatusBody>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
if !matches!(b.status.as_str(), "new" | "seen" | "done") {
return Err(AppError::Invalid("status"));
}
sqlx::query("UPDATE feedback SET status = ? WHERE id = ?").bind(&b.status).bind(id).execute(&state.db).await?;
Ok(Json(json!({ "ok": true })))
}
+201
View File
@@ -0,0 +1,201 @@
//! Entering into force — automatic, as soon as a proposal reaches its
//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle
//! remonte en vigueur »). The human gesture is the vote itself; the engine
//! only counts. Every entry is recorded with its numbers and queued for
//! publication to the texts repository.
use crate::AppState;
use crate::docs::{self, DocRow, DocState};
use crate::error::AppResult;
use crate::util;
use ld_engine::compose::{self, ProposalKind, UnitState};
use serde::Serialize;
use std::time::Duration;
use tokio::sync::Mutex;
/// One adoption pass at a time per process: SQLite has a single writer, and
/// two concurrent votes must not both promote rivals of the same unit.
static PASS: Mutex<()> = Mutex::const_new(());
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Entered {
pub proposal_id: String,
pub unit_id: String,
pub code: String,
pub kind: &'static str,
}
pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> {
if doc.publish_path.is_none() {
return Ok(());
}
let now = util::now();
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
.bind(&doc.id)
.bind(reason)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await?;
state.publish_wake.notify_one();
Ok(())
}
/// Seeds a minima that later reach their own threshold are marked confirmed.
async fn confirm_seeds(state: &AppState, st: &DocState) -> AppResult<()> {
for unit in &st.units {
let Some(fid) = &unit.in_force_id else { continue };
let Some(p) = st.proposals.iter().find(|p| &p.id == fid) else { continue };
if p.entered_at.is_some() || p.confirmed_at.is_some() {
continue;
}
if st.evaluation(unit, p).is_some_and(|e| e.reached) {
sqlx::query("UPDATE proposals SET confirmed_at = ? WHERE id = ? AND confirmed_at IS NULL")
.bind(util::now())
.bind(&p.id)
.execute(&state.db)
.await?;
}
}
Ok(())
}
pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
let _pass = PASS.lock().await;
let st = docs::load_state(state, doc_id).await?;
confirm_seeds(state, &st).await?;
let units = st.unit_states();
let winners = compose::adoptions(&units, &st.proposal_states(), st.electorate, st.doc.vote_floor)
.map_err(|_| crate::error::AppError::Invalid("electorate"))?;
let mut entered = Vec::new();
for id in winners {
let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue };
let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue };
let us = units.iter().find(|u| u.id == unit.id);
let tally = st.tally(&p.id);
let eval = st.evaluation(unit, p);
let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor));
let now = util::now();
let mut tx = state.db.begin().await?;
// The text that enters is the text that was counted: a revision that landed
// since the state was read stops it (the next pass will judge it again).
let unchanged = sqlx::query(
"UPDATE proposals SET updated_at = updated_at
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status = ?",
)
.bind(&p.id)
.bind(p.revision)
.bind(&p.title)
.bind(&p.body)
.bind(&p.status)
.execute(&mut *tx)
.await?;
if unchanged.rows_affected() != 1 {
continue;
}
let kind = match p.kind() {
ProposalKind::Text | ProposalKind::Remove => {
// The displaced version goes back among the proposals, with its votes.
if let Some(old) = &unit.in_force_id {
sqlx::query("UPDATE proposals SET status = 'open', left_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(&now)
.bind(old)
.execute(&mut *tx)
.await?;
}
if p.kind() == ProposalKind::Text {
sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ?, left_at = NULL, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(&now)
.bind(&p.id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
.bind(&p.id)
.bind(&unit.id)
.execute(&mut *tx)
.await?;
"text"
} else {
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(&now)
.bind(&p.id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ?")
.bind(&unit.id)
.execute(&mut *tx)
.await?;
"remove"
}
}
ProposalKind::Inertia(target) => {
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(&now)
.bind(&p.id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(target.as_str())
.bind(&unit.id)
.execute(&mut *tx)
.await?;
"inertia"
}
};
sqlx::query(
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&p.id)
.bind(&unit.id)
.bind(&st.doc.id)
.bind(kind)
.bind(i64::from(tally.for_votes))
.bind(i64::from(tally.against))
.bind(i64::from(st.electorate))
.bind(eval.map_or(0, |e| i64::from(e.required)))
.bind(serde_json::to_string(&params).unwrap_or_default())
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
tracing::info!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, kind, "entered into force");
entered.push(Entered { proposal_id: p.id.clone(), unit_id: unit.id.clone(), code: unit.code.clone(), kind });
}
if !entered.is_empty() {
let codes: Vec<&str> = entered.iter().map(|e| e.code.as_str()).collect();
enqueue_publication(state, &st.doc, &format!("entrée en vigueur : {}", codes.join(", "))).await?;
}
Ok(entered)
}
/// The electorate changes (the web of trust grows or shrinks): every few
/// minutes, every document is passed through the engine again.
pub async fn sweeper(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(300));
loop {
tick.tick().await;
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
Ok(ids) => ids,
Err(e) => {
tracing::warn!(error = %e, "sweeper");
continue;
}
};
for id in ids {
if let Err(e) = run(&state, &id).await {
tracing::warn!(doc = %id, error = %e, "sweeper pass");
}
}
}
}
/// Unit states, re-exported for tests.
pub fn unit_states(st: &DocState) -> Vec<UnitState> {
st.unit_states()
}
+620
View File
@@ -0,0 +1,620 @@
//! Sign-in: the Ğ1 access of the Monnaie libre forum, invitation links, and a
//! local development login.
//!
//! **Ğ1.** libreDecision is an OAuth2 client of `connect.monnaie-libre.fr`
//! (sso-connect): the person signs a one-time challenge in Ğecko, the service
//! reads the chain and answers with the on-chain identity. The account is
//! keyed on `idty:<index>`, which survives a change of wallet key. Members of
//! the web of trust only — sso-connect refuses the others before we see them.
//!
//! **Invitation.** A single-use link created in trustinWallet by an access
//! administrator; it signs a person in as an invited identity (tests, or a
//! person without Ğ1 sign-in yet), with the groups the link grants.
//!
//! **Dev.** `LD_DEV_LOGIN=1` only, never in production.
use crate::AppState;
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{FromRequestParts, Query, State};
use axum::http::header::{COOKIE, LOCATION, SET_COOKIE};
use axum::http::request::Parts;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
use trustin_access::{Identity, IdentityKind};
pub const SESSION_COOKIE: &str = "ld_session";
const OAUTH_COOKIE: &str = "ld_oauth";
/// Over https, the prefixed names: a neighbouring site under the same parent
/// domain cannot plant a `__Host-` cookie (session fixation), nor a
/// `__Secure-` one without https.
fn session_cookie(state: &AppState) -> &'static str {
if state.cfg.secure_cookies() { "__Host-ld_session" } else { SESSION_COOKIE }
}
fn oauth_cookie(state: &AppState) -> &'static str {
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
}
/// A session lasts until it is closed (sign-out, or an administrator): 400 days
/// is the longest a browser keeps a cookie, and each visit starts them again.
const SESSION_DAYS: i64 = 400;
const INVITATION_DAYS: i64 = 14;
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_owned())
}
fn cookie(state: &AppState, name: &str, value: &str, path: &str, max_age: i64) -> HeaderValue {
let secure = if state.cfg.secure_cookies() { "; Secure" } else { "" };
let raw = format!("{name}={value}; Path={path}; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}");
HeaderValue::from_str(&raw).unwrap_or_else(|_| HeaderValue::from_static(""))
}
#[derive(Serialize, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
pub struct IdentityRow {
pub id: String,
pub kind: String,
pub display_name: String,
pub address: Option<String>,
pub g1_member: bool,
pub admin: bool,
}
impl IdentityRow {
pub fn to_identity(&self) -> Identity {
Identity {
id: self.id.clone(),
kind: IdentityKind::parse(&self.kind).unwrap_or(IdentityKind::Invite),
display_name: self.display_name.clone(),
g1_member: self.g1_member,
admin: self.admin,
}
}
}
pub const IDENTITY_COLUMNS: &str = "id, kind, display_name, address, g1_member, admin";
/// The signed-in person, if any.
pub struct Viewer(pub Option<IdentityRow>);
/// The signed-in person; 401 otherwise.
pub struct User(pub IdentityRow);
impl FromRequestParts<AppState> for Viewer {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let Some(token) = cookie_value(&parts.headers, session_cookie(state)) else {
return Ok(Self(None));
};
let row = sqlx::query_as::<_, IdentityRow>(&format!(
"SELECT i.{} FROM sessions s JOIN identities i ON i.id = s.identity_id
WHERE s.token_hash = ? AND s.expires_at > ?",
IDENTITY_COLUMNS.replace(", ", ", i.")
))
.bind(util::sha256_hex(&token))
.bind(util::now())
.fetch_optional(&state.db)
.await?;
Ok(Self(row))
}
}
impl FromRequestParts<AppState> for User {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
match Viewer::from_request_parts(parts, state).await?.0 {
Some(row) => Ok(Self(row)),
None => Err(AppError::Unauthenticated),
}
}
}
pub(crate) async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderValue> {
let token = util::token();
sqlx::query("INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, ?, ?, ?)")
.bind(util::sha256_hex(&token))
.bind(identity_id)
.bind(util::now())
.bind(util::in_days(SESSION_DAYS))
.execute(&state.db)
.await?;
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
.bind(util::now())
.bind(identity_id)
.execute(&state.db)
.await?;
Ok(cookie(state, session_cookie(state), &token, "/", SESSION_DAYS * 86_400))
}
pub async fn load_identity(state: &AppState, id: &str) -> AppResult<IdentityRow> {
sqlx::query_as::<_, IdentityRow>(&format!("SELECT {IDENTITY_COLUMNS} FROM identities WHERE id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
pub(crate) async fn grant_groups(
state: &AppState,
identity_id: &str,
groups: &[String],
by: Option<&str>,
) -> AppResult<()> {
for slug in groups {
sqlx::query(
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
SELECT slug, ?, ?, ? FROM groups WHERE slug = ?",
)
.bind(identity_id)
.bind(by)
.bind(util::now())
.bind(slug)
.execute(&state.db)
.await?;
}
Ok(())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AuthConfig {
g1: bool,
dev: bool,
}
pub async fn config(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!(AuthConfig { g1: state.cfg.connect.is_some(), dev: state.cfg.dev_login }))
}
/// The hash of the session this request comes with, if any.
pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option<String> {
cookie_value(headers, session_cookie(state)).map(|token| util::sha256_hex(&token))
}
/// Who is signed in. Once a day at most, the visit starts the session's days
/// again, in the database and in the browser. A renewal that fails is logged,
/// never answered as signed out.
pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> Response {
let mut res = Json(json!({ "me": viewer })).into_response();
if let (Some(me), Some(token)) = (&viewer, cookie_value(&headers, session_cookie(&state))) {
match renew(&state, &token).await {
Ok(true) => {
res.headers_mut()
.append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400));
let seen = sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
.bind(util::now())
.bind(&me.id)
.execute(&state.db)
.await;
if let Err(e) = seen {
tracing::warn!(error = %e, "last visit not recorded");
}
}
Ok(false) => {}
Err(e) => tracing::warn!(error = %e, "session not renewed"),
}
}
res
}
/// Whether the session was renewed: still open, and renewed more than a day ago.
async fn renew(state: &AppState, token: &str) -> AppResult<bool> {
let renewed =
sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ? AND expires_at < ?")
.bind(util::in_days(SESSION_DAYS))
.bind(util::sha256_hex(token))
.bind(util::now())
.bind(util::in_days(SESSION_DAYS - 1))
.execute(&state.db)
.await?;
Ok(renewed.rows_affected() == 1)
}
/// Only local paths, never `//host` nor a scheme.
fn safe_return(raw: Option<&str>) -> String {
match raw {
// Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil".
Some(p)
if p.starts_with('/')
&& !p.starts_with("//")
&& !p.contains('\\')
&& !p.chars().any(char::is_control)
&& p.len() < 512 =>
{
p.to_owned()
}
_ => "/groupes".to_owned(),
}
}
#[derive(Deserialize)]
pub struct StartQuery {
#[serde(rename = "return")]
return_to: Option<String>,
}
fn redirect(to: &str, cookies: Vec<HeaderValue>) -> Response {
let mut res = StatusCode::FOUND.into_response();
if let Ok(v) = HeaderValue::from_str(to) {
res.headers_mut().insert(LOCATION, v);
}
for c in cookies {
res.headers_mut().append(SET_COOKIE, c);
}
res
}
/// Step 1: send the browser to the approval page of connect.monnaie-libre.fr.
pub async fn g1_start(State(state): State<AppState>, Query(q): Query<StartQuery>) -> AppResult<Response> {
let connect = state.cfg.connect.as_ref().ok_or(AppError::Unavailable("g1_not_configured"))?;
let csrf = util::token();
let back = safe_return(q.return_to.as_deref());
let mut url = url::Url::parse(&format!("{}/oauth/authorize", connect.base_url)).map_err(anyhow::Error::from)?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &connect.client_id)
.append_pair("redirect_uri", &state.cfg.connect_callback())
.append_pair("state", &csrf);
let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::<String>());
Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)]))
}
#[derive(Deserialize)]
pub struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
}
/// What sso-connect's `/oauth/userinfo` answers — and nothing else.
#[derive(Deserialize)]
pub struct UserInfo {
pub id: String,
pub username: String,
pub address: Option<String>,
#[serde(default)]
pub member: bool,
}
/// Step 3: check the state, exchange the code, read the identity, open a session.
pub async fn g1_callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<CallbackQuery>,
) -> Response {
let clear = cookie(&state, oauth_cookie(&state), "", "/api/auth/g1", 0);
let stash = cookie_value(&headers, oauth_cookie(&state)).unwrap_or_default();
let (expected, back) = stash.split_once('.').unwrap_or(("", ""));
let back =
safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref());
let fail = |code: &str| redirect(&format!("/connexion?erreur={code}"), vec![clear.clone()]);
if let Some(err) = q.error.as_deref() {
tracing::warn!(error = err, "connect returned an error");
return fail("refus");
}
let (Some(code), Some(got)) = (q.code, q.state) else { return fail("incomplet") };
if expected.is_empty() || expected != got {
return fail("etat");
}
match exchange(&state, &code).await {
Ok(info) => match sign_in_g1(&state, info).await {
Ok(session) => redirect(&back, vec![clear, session]),
Err(e) => {
tracing::error!(error = %e, "g1 sign-in");
fail("interne")
}
},
Err(e) => {
tracing::warn!(error = %e, "code exchange");
fail("echange")
}
}
}
async fn exchange(state: &AppState, code: &str) -> anyhow::Result<UserInfo> {
let connect = state.cfg.connect.as_ref().ok_or_else(|| anyhow::anyhow!("not configured"))?;
let token: TokenResponse = state
.http
.post(format!("{}/oauth/token", connect.base_url))
.basic_auth(&connect.client_id, Some(&connect.client_secret))
.form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", &state.cfg.connect_callback())])
.send()
.await?
.error_for_status()?
.json()
.await?;
let info: UserInfo = state
.http
.get(format!("{}/oauth/userinfo", connect.base_url))
.bearer_auth(token.access_token)
.send()
.await?
.error_for_status()?
.json()
.await?;
anyhow::ensure!(info.id.starts_with("idty:"), "unexpected identity key");
Ok(info)
}
pub async fn sign_in_g1(state: &AppState, info: UserInfo) -> AppResult<HeaderValue> {
let admin = state.cfg.admin_g1_ids.iter().any(|a| a == &info.id);
sqlx::query(
"INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at)
VALUES (?, 'g1', ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address,
g1_member = excluded.g1_member, admin = MAX(identities.admin, excluded.admin)",
)
.bind(&info.id)
.bind(&info.username)
.bind(&info.address)
.bind(info.member)
.bind(admin)
.bind(util::now())
.execute(&state.db)
.await?;
open_session(state, &info.id).await
}
#[derive(Deserialize)]
pub struct InvitationBody {
token: String,
}
#[derive(sqlx::FromRow)]
struct InvitationRow {
token_hash: String,
label: String,
admin: bool,
groups: String,
created_by: Option<String>,
identity_id: Option<String>,
}
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
let hash = util::sha256_hex(body.token.trim());
let now = util::now();
// Reserve the link first: of two simultaneous acceptances, one only goes on.
let reserved =
sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?")
.bind(&now)
.bind(&hash)
.bind(&now)
.execute(&state.db)
.await?;
if reserved.rows_affected() != 1 {
return Err(AppError::Invalid("invitation_invalid"));
}
let inv = sqlx::query_as::<_, InvitationRow>(
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?",
)
.bind(&hash)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Invalid("invitation_invalid"))?;
let identity_id = match inv.identity_id.clone() {
Some(existing) => {
if load_identity(&state, &existing).await?.kind != "invite" {
return Err(AppError::Invalid("invitation_invalid"));
}
existing
}
None => {
let id = format!("inv:{}", util::id());
sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'invite', ?, 0, ?, ?)",
)
.bind(&id)
.bind(&inv.label)
.bind(inv.admin)
.bind(util::now())
.execute(&state.db)
.await?;
id
}
};
if inv.admin {
sqlx::query("UPDATE identities SET admin = 1 WHERE id = ?").bind(&identity_id).execute(&state.db).await?;
}
let groups: Vec<String> = serde_json::from_str(&inv.groups).unwrap_or_default();
grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?;
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
.bind(&identity_id)
.bind(&inv.token_hash)
.execute(&state.db)
.await?;
let session = open_session(&state, &identity_id).await?;
let me = load_identity(&state, &identity_id).await?;
let mut res = Json(json!({ "me": me })).into_response();
res.headers_mut().append(SET_COOKIE, session);
Ok(res)
}
/// Create an invitation; returns the raw token, shown once.
pub async fn create_invitation(
state: &AppState,
label: &str,
admin: bool,
groups: &[String],
created_by: Option<&str>,
identity_id: Option<&str>,
) -> AppResult<String> {
let token = util::token();
sqlx::query(
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(util::sha256_hex(&token))
.bind(label)
.bind(admin)
.bind(serde_json::to_string(groups).unwrap_or_else(|_| "[]".into()))
.bind(created_by)
.bind(util::now())
.bind(util::in_days(INVITATION_DAYS))
.bind(identity_id)
.execute(&state.db)
.await?;
Ok(token)
}
pub fn invitation_link(state: &AppState, token: &str) -> String {
format!("{}/invitation#{token}", state.cfg.public_url)
}
#[derive(Deserialize)]
pub struct DevBody {
name: String,
#[serde(default)]
admin: bool,
}
/// Local development: any name, member of every group. Refused unless LD_DEV_LOGIN=1.
pub async fn dev_login(State(state): State<AppState>, Json(body): Json<DevBody>) -> AppResult<Response> {
if !state.cfg.dev_login {
return Err(AppError::NotFound);
}
let name = body.name.trim();
if name.is_empty() || name.len() > 40 {
return Err(AppError::Invalid("name"));
}
let slug: String = name.to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect();
let id = format!("dev:{slug}");
sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'dev', ?, 0, ?, ?)
ON CONFLICT(id) DO UPDATE SET admin = excluded.admin",
)
.bind(&id)
.bind(name)
.bind(body.admin)
.bind(util::now())
.execute(&state.db)
.await?;
let all: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
grant_groups(&state, &id, &all, Some("dev")).await?;
let session = open_session(&state, &id).await?;
let me = load_identity(&state, &id).await?;
let mut res = Json(json!({ "me": me })).into_response();
res.headers_mut().append(SET_COOKIE, session);
Ok(res)
}
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
if let Some(token) = cookie_value(&headers, session_cookie(&state)) {
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
.bind(util::sha256_hex(&token))
.execute(&state.db)
.await?;
}
let mut res = Json(json!({ "me": null })).into_response();
res.headers_mut().append(SET_COOKIE, cookie(&state, session_cookie(&state), "", "/", 0));
Ok(res)
}
/// First start with LD_BOOTSTRAP_ADMIN=1 and no administrator yet: write a
/// single-use administrator invitation to `<data>/bootstrap-admin.txt` (0600).
/// Nothing is printed to the logs.
pub async fn bootstrap(state: &AppState) -> anyhow::Result<()> {
if !state.cfg.bootstrap_admin {
return Ok(());
}
let admins: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1").fetch_one(&state.db).await?;
let pending: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE admin = 1 AND used_at IS NULL AND expires_at > ?")
.bind(util::now())
.fetch_one(&state.db)
.await?;
if admins > 0 || pending > 0 {
return Ok(());
}
let groups: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
let token = create_invitation(state, "Yvv", true, &groups, None, None)
.await
.map_err(|e| anyhow::anyhow!("bootstrap invitation: {e}"))?;
let path = state.cfg.data_dir.join("bootstrap-admin.txt");
write_private(&path, &format!("{}\n", invitation_link(state, &token)))?;
tracing::info!(path = %path.display(), "administrator invitation written");
Ok(())
}
fn write_private(path: &std::path::Path, content: &str) -> std::io::Result<()> {
use std::io::Write;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?;
f.write_all(content.as_bytes())
}
#[cfg(not(unix))]
{
std::fs::write(path, content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn return_paths_stay_local() {
assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre");
assert_eq!(safe_return(Some("//evil.example")), "/groupes");
assert_eq!(safe_return(Some("https://evil.example")), "/groupes");
assert_eq!(safe_return(Some("/\\evil")), "/groupes");
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes");
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes");
assert_eq!(safe_return(None), "/groupes");
}
#[tokio::test]
async fn a_session_over_is_never_renewed() {
let db = crate::open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(crate::Config::for_tests(), db).await.expect("state");
sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES ('dev:a', 'dev', 'A', 0, 0, ?)")
.bind(util::now())
.execute(&state.db)
.await
.expect("identity");
for (token, expires) in [("over", util::in_seconds(-1)), ("open", util::in_days(10))] {
sqlx::query(
"INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, 'dev:a', ?, ?)",
)
.bind(util::sha256_hex(token))
.bind(util::now())
.bind(expires)
.execute(&state.db)
.await
.expect("session");
}
assert!(!renew(&state, "over").await.expect("renew"));
assert!(renew(&state, "open").await.expect("renew"));
assert!(!renew(&state, "open").await.expect("renew"), "once a day at most");
}
#[test]
fn cookie_parsing() {
let mut h = HeaderMap::new();
h.insert(COOKIE, HeaderValue::from_static("a=1; ld_session=abc; b=2"));
assert_eq!(cookie_value(&h, SESSION_COOKIE).as_deref(), Some("abc"));
assert_eq!(cookie_value(&h, "zz"), None);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Configuration, from the environment only. Secrets never have a default.
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Connect {
/// Base URL of the sso-connect instance (no trailing slash).
pub base_url: String,
pub client_id: String,
pub client_secret: String,
}
#[derive(Clone, Debug)]
pub struct Publish {
/// Remote of the texts repository, e.g. `gitea@git.open.us.org:yvv/decision-depot-textes-reference.git`.
pub remote: String,
pub branch: String,
/// Private SSH key allowed to push there (deploy key).
pub key: PathBuf,
pub author_name: String,
pub author_email: String,
}
#[derive(Clone, Debug)]
pub struct Config {
pub bind: String,
pub data_dir: PathBuf,
pub web_dir: PathBuf,
pub seed_dir: PathBuf,
/// Public origin, e.g. `https://decision.librodrome.org` — callbacks and cookies.
pub public_url: String,
pub connect: Option<Connect>,
pub admin_g1_ids: Vec<String>,
pub dev_login: bool,
pub bootstrap_admin: bool,
pub publish: Option<Publish>,
pub rpc_endpoints: Vec<String>,
pub indexers: Vec<String>,
pub wot_fallback: u32,
pub build: String,
}
fn var(name: &str) -> Option<String> {
std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
}
fn flag(name: &str) -> bool {
var(name).is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
fn list(name: &str, default: &[&str]) -> Vec<String> {
match var(name) {
Some(v) => v.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(),
None => default.iter().map(|s| (*s).to_owned()).collect(),
}
}
impl Config {
pub fn from_env() -> Self {
let data_dir = PathBuf::from(var("LD_DATA_DIR").unwrap_or_else(|| "data".into()));
let connect = match (var("LD_CONNECT_CLIENT_ID"), var("LD_CONNECT_CLIENT_SECRET")) {
(Some(client_id), Some(client_secret)) => Some(Connect {
base_url: var("LD_CONNECT_URL")
.unwrap_or_else(|| "https://connect.monnaie-libre.fr".into())
.trim_end_matches('/')
.to_owned(),
client_id,
client_secret,
}),
_ => None,
};
let publish = var("LD_PUBLISH_REMOTE").map(|remote| Publish {
remote,
branch: var("LD_PUBLISH_BRANCH").unwrap_or_else(|| "main".into()),
key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")),
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
});
Self {
bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()),
web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())),
seed_dir: PathBuf::from(var("LD_SEED_DIR").unwrap_or_else(|| "seeds".into())),
public_url: var("LD_PUBLIC_URL")
.unwrap_or_else(|| "http://127.0.0.1:3002".into())
.trim_end_matches('/')
.to_owned(),
connect,
admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]),
dev_login: flag("LD_DEV_LOGIN"),
bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"),
publish,
rpc_endpoints: list(
"LD_RPC_ENDPOINTS",
&[
"https://rpc.duniter.org",
"https://g1.axiom-team.fr/ws/",
"https://g1.asycn.io/ws/",
"https://g1.coinduf.eu",
],
),
indexers: list(
"LD_INDEXERS",
&["https://indexer.duniter.org/v1/graphql", "https://g1-squid.axiom-team.fr/v1/graphql"],
),
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
data_dir,
}
}
/// Cookies are `Secure` as soon as the public origin is https.
pub fn secure_cookies(&self) -> bool {
self.public_url.starts_with("https://")
}
pub fn connect_callback(&self) -> String {
format!("{}/api/auth/g1/callback", self.public_url)
}
/// For tests: an in-memory configuration with dev login.
pub fn for_tests() -> Self {
Self {
bind: "127.0.0.1:0".into(),
data_dir: std::env::temp_dir().join("ld-tests"),
web_dir: PathBuf::from("web/build"),
seed_dir: PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../seeds")),
public_url: "http://127.0.0.1:3002".into(),
connect: None,
admin_g1_ids: vec!["idty:8352".into()],
dev_login: true,
bootstrap_admin: false,
publish: None,
rpc_endpoints: vec![],
indexers: vec![],
wot_fallback: 5745,
build: "test".into(),
}
}
}
+632
View File
@@ -0,0 +1,632 @@
//! Reading: groups, tools, documents, previews.
//!
//! The state of a document is loaded once (`DocState`) and turned into the
//! engine's pure types; every number shown on screen comes from the engine.
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::error::{AppError, AppResult};
use crate::util::{self, LangMap};
use axum::Json;
use axum::extract::{Path, Query, State};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{BTreeMap, HashMap, HashSet};
use trustin_access::{Group, GroupPolicy};
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct GroupRow {
pub slug: String,
pub name: String,
pub parent: Option<String>,
pub policy: String,
pub hidden: bool,
pub position: i64,
}
impl GroupRow {
pub fn to_group(&self) -> Group {
Group {
slug: self.slug.clone(),
name: self.name.clone(),
parent: self.parent.clone(),
policy: GroupPolicy::parse(&self.policy).unwrap_or(GroupPolicy::Managed),
hidden: self.hidden,
}
}
}
pub async fn granted(state: &AppState, group: &str, identity: &str) -> AppResult<bool> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ? AND identity_id = ?")
.bind(group)
.bind(identity)
.fetch_one(&state.db)
.await?;
Ok(n > 0)
}
pub async fn load_group(state: &AppState, slug: &str) -> AppResult<GroupRow> {
sqlx::query_as::<_, GroupRow>("SELECT slug, name, parent, policy, hidden, position FROM groups WHERE slug = ?")
.bind(slug)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// 404 for a group one cannot see, 403 for one seen but not open.
pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<()> {
let id = user.to_identity();
let g = group.to_group();
let has = granted(state, &group.slug, &user.id).await?;
if !trustin_access::sees(&id, &g, has) {
return Err(AppError::NotFound);
}
if !trustin_access::opens(&id, &g, has) {
return Err(AppError::Forbidden);
}
Ok(())
}
/// May this identity vote in the group (see `trustin_access::votes_in`)?
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
let has = granted(state, &group.slug, &user.id).await?;
Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GroupView {
slug: String,
name: String,
parent: Option<String>,
policy: String,
open: bool,
}
pub async fn groups(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
let rows = sqlx::query_as::<_, GroupRow>(
"SELECT slug, name, parent, policy, hidden, position FROM groups ORDER BY position",
)
.fetch_all(&state.db)
.await?;
let id = user.to_identity();
let mut out = Vec::new();
for row in rows {
let has = granted(&state, &row.slug, &user.id).await?;
let g = row.to_group();
if trustin_access::sees(&id, &g, has) {
out.push(GroupView {
open: trustin_access::opens(&id, &g, has),
slug: row.slug,
name: row.name,
parent: row.parent,
policy: row.policy,
});
}
}
Ok(Json(json!({ "groups": out })))
}
#[derive(Serialize, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
struct DocListRow {
id: String,
slug: String,
#[sqlx(rename = "title")]
#[serde(skip)]
title_raw: String,
#[sqlx(rename = "summary")]
#[serde(skip)]
summary_raw: String,
in_force: i64,
open: i64,
}
/// The tools of a group. Only "Docs de référence" is active for now.
pub const TOOLS: [(&str, bool); 4] = [("decisions", false), ("docs", true), ("mandats", false), ("protocoles", false)];
pub async fn group(
State(state): State<AppState>,
User(user): User,
Path(slug): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let row = load_group(&state, &slug).await?;
let id = user.to_identity();
let has = granted(&state, &slug, &user.id).await?;
let g = row.to_group();
if !trustin_access::sees(&id, &g, has) {
return Err(AppError::NotFound);
}
let open = trustin_access::opens(&id, &g, has);
let docs = if open {
sqlx::query_as::<_, DocListRow>(
"SELECT d.id, d.slug, d.title, d.summary,
(SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.in_force_id IS NOT NULL) AS in_force,
(SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = d.id AND p.status = 'open') AS open
FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at",
)
.bind(&slug)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|d| {
json!({
"id": d.id, "slug": d.slug,
"title": util::parse_lang(&d.title_raw), "summary": util::parse_lang(&d.summary_raw),
"inForce": d.in_force, "open": d.open,
})
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let tools: Vec<_> = TOOLS.iter().map(|(key, active)| json!({ "key": key, "active": active })).collect();
Ok(Json(json!({
"group": { "slug": row.slug, "name": row.name, "parent": row.parent, "policy": row.policy, "open": open },
"tools": tools,
"docs": docs,
})))
}
// ---------------------------------------------------------------------------
// Document state
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct DocRow {
pub id: String,
pub group_slug: String,
pub slug: String,
pub title: String,
pub summary: String,
pub source_lang: String,
pub vote_floor: f64,
pub publish_path: Option<String>,
}
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct UnitRow {
pub id: String,
pub code: String,
pub section: Option<String>,
pub position: f64,
pub inertia: String,
pub in_force_id: Option<String>,
}
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct ProposalRow {
pub id: String,
pub unit_id: String,
pub kind: String,
pub inertia_target: Option<String>,
pub title: String,
pub body: String,
pub status: String,
pub origin: String,
pub label: Option<String>,
pub author_id: Option<String>,
pub author_name: Option<String>,
/// The author came by invitation, not with a Ğ1 key: shown next to the name,
/// so a name taken ahead of its owner, or a look-alike, does not pass for them.
pub author_invited: Option<bool>,
pub revision: i64,
pub created_at: String,
pub updated_at: String,
pub submitted_at: Option<String>,
pub entered_at: Option<String>,
pub confirmed_at: Option<String>,
}
pub const PROPOSAL_SELECT: &str =
"SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin,
p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at,
p.entered_at, p.confirmed_at
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
impl ProposalRow {
pub fn kind(&self) -> ProposalKind {
match self.kind.as_str() {
"remove" => ProposalKind::Remove,
"inertia" => ProposalKind::Inertia(
self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard),
),
_ => ProposalKind::Text,
}
}
}
pub fn inertia_of(unit: &UnitRow) -> InertiaPreset {
InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard)
}
pub struct DocState {
pub doc: DocRow,
pub group: GroupRow,
pub units: Vec<UnitRow>,
pub proposals: Vec<ProposalRow>,
pub tallies: HashMap<String, Tally>,
pub blocked: HashSet<String>,
pub signal_counts: HashMap<String, i64>,
pub electorate: u32,
pub electorate_source: &'static str,
}
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
sqlx::query_as::<_, DocRow>(
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?",
)
.bind(doc_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// W: the Ğ1 web of trust for Monnaie libre, the members of the group otherwise.
pub async fn electorate(state: &AppState, group: &GroupRow) -> AppResult<(u32, &'static str)> {
if group.policy == GroupPolicy::G1Members.as_str() {
let cached: Option<String> =
sqlx::query_scalar("SELECT value FROM settings WHERE key = 'wot_size'").fetch_optional(&state.db).await?;
let w = cached.and_then(|v| v.parse().ok()).unwrap_or(state.cfg.wot_fallback);
return Ok((w.max(1), "wot"));
}
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ?")
.bind(&group.slug)
.fetch_one(&state.db)
.await?;
Ok((u32::try_from(n).unwrap_or(u32::MAX).max(1), "group"))
}
pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
let doc = load_doc(state, doc_id).await?;
let group = load_group(state, &doc.group_slug).await?;
let units = sqlx::query_as::<_, UnitRow>(
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
"{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?)
AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.id"
))
.bind(doc_id)
.fetch_all(&state.db)
.await?;
#[derive(sqlx::FromRow)]
struct TallyRow {
proposal_id: String,
choice: String,
n: i64,
}
let rows = sqlx::query_as::<_, TallyRow>(
"SELECT v.proposal_id, v.choice, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut tallies: HashMap<String, Tally> = HashMap::new();
for r in rows {
let t = tallies.entry(r.proposal_id).or_default();
let n = u32::try_from(r.n).unwrap_or(0);
match r.choice.as_str() {
"for" => t.for_votes = n,
"against" => t.against = n,
_ => {}
}
}
#[derive(sqlx::FromRow)]
struct SignalRow {
proposal_id: String,
target_proposal_id: String,
}
let signals = sqlx::query_as::<_, SignalRow>(
"SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
let mut blocked = HashSet::new();
let mut signal_counts: HashMap<String, i64> = HashMap::new();
for s in &signals {
*signal_counts.entry(s.proposal_id.clone()).or_default() += 1;
*signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1;
// Two texts that contradict each other never stand in force together.
if in_force.contains(s.target_proposal_id.as_str()) {
blocked.insert(s.proposal_id.clone());
}
if in_force.contains(s.proposal_id.as_str()) {
blocked.insert(s.target_proposal_id.clone());
}
}
let (electorate, electorate_source) = electorate(state, &group).await?;
Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source })
}
impl DocState {
pub fn tally(&self, id: &str) -> Tally {
self.tallies.get(id).copied().unwrap_or_default()
}
pub fn unit_states(&self) -> Vec<UnitState> {
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
self.units
.iter()
.map(|u| UnitState {
id: u.id.clone(),
position: u.position,
inertia: inertia_of(u),
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| VersionText {
id: p.id.clone(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
}),
})
.collect()
}
/// Open proposals only — the in-force versions compete as incumbents.
pub fn proposal_states(&self) -> Vec<ProposalState> {
self.proposals
.iter()
.filter(|p| p.status == "open")
.map(|p| ProposalState {
id: p.id.clone(),
unit_id: p.unit_id.clone(),
kind: p.kind(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
blocked: self.blocked.contains(&p.id),
})
.collect()
}
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok()
}
}
// ---------------------------------------------------------------------------
// Document view
// ---------------------------------------------------------------------------
#[derive(sqlx::FromRow)]
struct TranslationRow {
proposal_id: String,
lang: String,
title: String,
body: String,
source_hash: String,
origin: String,
}
pub async fn translations_for(
state: &AppState,
ids: &[&ProposalRow],
) -> AppResult<HashMap<String, BTreeMap<String, serde_json::Value>>> {
let mut out: HashMap<String, BTreeMap<String, serde_json::Value>> = HashMap::new();
if ids.is_empty() {
return Ok(out);
}
let marks = vec!["?"; ids.len()].join(",");
let sql = format!(
"SELECT proposal_id, lang, title, body, source_hash, origin FROM translations WHERE proposal_id IN ({marks})"
);
let mut q = sqlx::query_as::<_, TranslationRow>(&sql);
for p in ids {
q = q.bind(&p.id);
}
let rows = q.fetch_all(&state.db).await?;
let current: HashMap<&str, String> =
ids.iter().map(|p| (p.id.as_str(), util::source_hash(&p.title, &p.body))).collect();
for r in rows {
let stale = current.get(r.proposal_id.as_str()).is_none_or(|h| *h != r.source_hash);
out.entry(r.proposal_id)
.or_default()
.insert(r.lang, json!({ "title": r.title, "body": r.body, "stale": stale, "origin": r.origin }));
}
Ok(out)
}
#[derive(sqlx::FromRow)]
struct MyVote {
proposal_id: String,
choice: String,
revision: i64,
}
fn proposal_json(
st: &DocState,
unit: &UnitRow,
p: &ProposalRow,
mine: Option<&MyVote>,
translations: Option<&BTreeMap<String, serde_json::Value>>,
me: &str,
) -> serde_json::Value {
let eval = st.evaluation(unit, p);
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())),
};
let voted_at = match p.kind() {
ProposalKind::Inertia(_) => inertia_of(unit).raised(),
_ => inertia_of(unit),
};
json!({
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
"author": p.author_id.as_ref().map(|id| json!({
"id": id, "name": p.author_name, "invited": p.author_invited.unwrap_or(false),
})),
"mine": p.author_id.as_deref() == Some(me),
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at.as_str(),
"blocked": st.blocked.contains(&p.id),
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
"translations": translations.cloned().unwrap_or_default(),
})
}
#[derive(Deserialize)]
pub struct DocQuery {}
pub async fn document(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let st = load_state(&state, &id).await?;
require_open(&state, &user, &st.group).await?;
let visible: Vec<&ProposalRow> =
st.proposals.iter().filter(|p| p.status != "draft" || p.author_id.as_deref() == Some(&user.id)).collect();
let translations = translations_for(&state, &visible).await?;
let mine: HashMap<String, MyVote> = sqlx::query_as::<_, MyVote>(
"SELECT v.proposal_id, v.choice, v.revision FROM votes v JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND v.identity_id = ?",
)
.bind(&id)
.bind(&user.id)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|v| (v.proposal_id.clone(), v))
.collect();
let units: Vec<_> = st
.units
.iter()
.map(|u| {
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| proposal_json(&st, u, p, mine.get(&p.id), translations.get(&p.id), &user.id))
.collect();
let in_force = u
.in_force_id
.as_ref()
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia,
"inForce": in_force, "proposals": proposals,
})
})
.collect();
let publication = publication_status(&state, &id).await?;
let can_vote = may_vote(&state, &user, &st.group).await?;
Ok(Json(json!({
"canVote": can_vote,
"doc": {
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
"electorate": st.electorate, "electorateSource": st.electorate_source,
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
"publication": publication,
},
"units": units,
"inertia": InertiaPreset::ALL.iter().map(|p| json!({
"key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(),
})).collect::<Vec<_>>(),
})))
}
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
#[derive(sqlx::FromRow)]
struct Row {
created_at: String,
done_at: Option<String>,
commit_sha: Option<String>,
last_error: Option<String>,
attempts: i64,
}
let last = sqlx::query_as::<_, Row>(
"SELECT created_at, done_at, commit_sha, last_error, attempts FROM outbox WHERE doc_id = ? ORDER BY id DESC LIMIT 1",
)
.bind(doc_id)
.fetch_optional(&state.db)
.await?;
Ok(match last {
None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }),
Some(r) => json!({
"state": if r.done_at.is_some() { "done" } else { "pending" },
"configured": state.cfg.publish.is_some(),
"since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha,
"attempts": r.attempts, "error": r.last_error.map(|_| "retrying"),
}),
})
}
#[derive(Deserialize)]
pub struct PreviewQuery {
mode: Option<String>,
}
pub async fn preview(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Query(q): Query<PreviewQuery>,
) -> AppResult<Json<serde_json::Value>> {
let st = load_state(&state, &id).await?;
require_open(&state, &user, &st.group).await?;
let mode = match q.mode.as_deref() {
Some("all") => PreviewMode::All,
Some("wellrated") => PreviewMode::WellRated,
_ => PreviewMode::InForce,
};
let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, mode)
.map_err(|_| AppError::Invalid("electorate"))?;
// Translations of the versions shown.
let shown: HashSet<String> = units
.iter()
.map(|u| {
u.proposal_id.clone().unwrap_or_else(|| {
st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()).unwrap_or_default()
})
})
.collect();
let rows: Vec<&ProposalRow> = st.proposals.iter().filter(|p| shown.contains(&p.id)).collect();
let tr = translations_for(&state, &rows).await?;
let out: Vec<_> = units
.into_iter()
.map(|u| {
let vid = u
.proposal_id
.clone()
.or_else(|| st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()));
let translations = vid.as_ref().and_then(|v| tr.get(v)).cloned().unwrap_or_default();
json!({
"unitId": u.unit_id, "title": u.title, "body": u.body, "mark": u.mark,
"proposalId": u.proposal_id, "versionId": vid, "translations": translations,
})
})
.collect();
Ok(Json(json!({ "mode": q.mode.unwrap_or_else(|| "inforce".into()), "units": out })))
}
/// Unused for now; keeps the language list close to the documents.
pub fn source_langs() -> LangMap {
util::LANGS.iter().map(|l| ((*l).to_owned(), String::new())).collect()
}
+50
View File
@@ -0,0 +1,50 @@
//! API errors: a stable machine code the interface translates, never a stack.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("unauthenticated")]
Unauthenticated,
#[error("forbidden")]
Forbidden,
#[error("not found")]
NotFound,
#[error("invalid: {0}")]
Invalid(&'static str),
#[error("conflict: {0}")]
Conflict(&'static str),
#[error("unavailable: {0}")]
Unavailable(&'static str),
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type AppResult<T> = Result<T, AppError>;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
Self::Unauthenticated => (StatusCode::UNAUTHORIZED, "unauthenticated"),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
Self::Invalid(code) => (StatusCode::BAD_REQUEST, *code),
Self::Conflict(code) => (StatusCode::CONFLICT, *code),
Self::Unavailable(code) => (StatusCode::SERVICE_UNAVAILABLE, *code),
Self::Db(e) => {
tracing::error!(error = %e, "database");
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
Self::Other(e) => {
tracing::error!(error = %e, "internal");
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
};
(status, Json(json!({ "error": code }))).into_response()
}
}
+209
View File
@@ -0,0 +1,209 @@
//! libreDecision server.
//!
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
//! and runs two background workers: the electorate refresher (size of the Ğ1
//! web of trust) and the publisher (adopted texts pushed to the git repository
//! that client applications read).
pub mod access;
pub mod adoption;
pub mod auth;
pub mod config;
pub mod docs;
pub mod error;
pub mod proposals;
pub mod publish;
pub mod seed;
pub mod team;
pub mod util;
pub mod web;
pub mod wot;
use axum::Router;
use axum::extract::{Request, State};
use axum::http::{Method, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
pub use config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub cfg: Arc<Config>,
pub http: reqwest::Client,
/// Wakes the publisher as soon as an adoption is recorded.
pub publish_wake: Arc<Notify>,
}
impl AppState {
pub async fn new(cfg: Config, db: SqlitePool) -> anyhow::Result<Self> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) })
}
}
/// Open (or create) the database and apply migrations.
pub async fn open_db(url: &str) -> anyhow::Result<SqlitePool> {
let options = SqliteConnectOptions::from_str(url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5));
let max = if url.contains(":memory:") { 1 } else { 8 };
let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?;
// A previous image (rollback) must still start on a database a newer one has
// migrated: migrations are additive, the unknown ones are tolerated.
let mut migrator = sqlx::migrate!("./migrations");
migrator.set_ignore_missing(true);
migrator.run(&pool).await?;
Ok(pool)
}
/// Mutating API calls must carry this header: a cross-site form cannot set it,
/// and no CORS is ever granted, so it closes CSRF together with SameSite=Lax.
pub const CSRF_HEADER: &str = "x-requested-with";
pub const CSRF_VALUE: &str = "libredecision";
async fn csrf_guard(req: Request, next: Next) -> Response {
let safe = matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS);
let has_header = req.headers().get(CSRF_HEADER).and_then(|v| v.to_str().ok()) == Some(CSRF_VALUE);
if safe || has_header {
next.run(req).await
} else {
(StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "error": "csrf" }))).into_response()
}
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
let db_ok = sqlx::query_scalar::<_, i64>("SELECT 1").fetch_one(&state.db).await.is_ok();
axum::Json(serde_json::json!({ "ok": db_ok, "build": state.cfg.build }))
}
pub fn api_router() -> Router<AppState> {
Router::new()
.route("/health", get(health))
.route("/auth/config", get(auth::config))
.route("/auth/g1/start", get(auth::g1_start))
.route("/auth/g1/callback", get(auth::g1_callback))
.route("/auth/invitation", post(auth::accept_invitation))
.route("/auth/team", post(team::join))
.route("/auth/team/peek", post(team::peek))
.route("/auth/dev", post(auth::dev_login))
.route("/auth/logout", post(auth::logout))
.route("/me", get(auth::me))
.route("/groups", get(docs::groups))
.route("/groups/{slug}", get(docs::group))
.route("/docs/{id}", get(docs::document))
.route("/docs/{id}/preview", get(docs::preview))
.route("/docs/{id}/assist", post(proposals::assist))
.route("/docs/{id}/proposals", post(proposals::create))
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
.route("/proposals/{id}/submit", post(proposals::submit))
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
.route("/proposals/{id}/split", post(proposals::split))
.route("/proposals/{id}/vote", post(proposals::vote))
.route("/proposals/{id}/signals", post(proposals::signal))
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
.route("/signals/{id}/lift", post(proposals::lift_signal))
.route("/units/{id}/inertia", post(proposals::propose_inertia))
.route("/feedback", post(access::send_feedback))
.route("/access", get(access::overview))
.route("/access/groups/{slug}/members", post(access::grant))
.route("/access/groups/{slug}/members/{identity}", delete(access::revoke))
.route("/access/invitations", post(access::invite))
.route("/access/invitations/{hash}", delete(access::cancel_invitation))
.route("/access/publication/retry", post(access::publish_now))
.route("/access/team-links", post(team::create))
.route("/access/team-links/{hash}", delete(team::revoke))
.route("/access/identities/{id}/admin", post(access::set_admin))
.route("/access/identities/{id}/sessions", delete(access::close_sessions))
.route("/access/feedback", get(access::feedback_list))
.route("/access/feedback/{id}", post(access::feedback_status))
.layer(middleware::from_fn(csrf_guard))
}
pub fn app(state: AppState) -> Router {
use axum::http::{HeaderName, HeaderValue, header};
use tower_http::set_header::SetResponseHeaderLayer;
// Neighbouring sites under *.librodrome.org are same-site: none may frame us.
let security: [(HeaderName, &'static str); 4] = [
(header::X_FRAME_OPTIONS, "DENY"),
(header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
(header::REFERRER_POLICY, "same-origin"),
];
let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir));
for (name, value) in security {
router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)));
}
router
.layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024))
.layer(tower_http::compression::CompressionLayer::new())
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state)
}
/// Ctrl-C, or SIGTERM from `docker stop`.
async fn shutdown() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let term = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut s) => {
s.recv().await;
}
Err(_) => std::future::pending::<()>().await,
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {}
() = term => {}
}
}
pub async fn run() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()),
)
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
.init();
let cfg = Config::from_env();
tokio::fs::create_dir_all(&cfg.data_dir).await?;
let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display());
let db = open_db(&db_url).await?;
let state = AppState::new(cfg, db).await?;
seed::ensure(&state).await?;
auth::bootstrap(&state).await?;
if let Err(e) = publish::ensure_key(&state).await {
tracing::warn!(error = %e, "publication key");
}
publish::resync(&state).await?;
tokio::spawn(wot::refresher(state.clone()));
tokio::spawn(publish::worker(state.clone()));
tokio::spawn(adoption::sweeper(state.clone()));
let listener = tokio::net::TcpListener::bind(&state.cfg.bind).await?;
tracing::info!(bind = %state.cfg.bind, build = %state.cfg.build, "libreDecision listening");
axum::serve(listener, app(state).into_make_service_with_connect_info::<std::net::SocketAddr>())
.with_graceful_shutdown(shutdown())
.await?;
Ok(())
}
+6
View File
@@ -0,0 +1,6 @@
//! libreDecision server — entry point. Everything else lives in the library.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
ld_server::run().await
}
+889
View File
@@ -0,0 +1,889 @@
//! Writing: drafts, submission, corrections, votes, contradiction signals,
//! inertia changes, editorial translations.
//!
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
//! système qui nécessite un vote pour corriger une coquille »):
//! - a **draft** is free: its author edits it at will, nobody votes on it;
//! - once **submitted**, a correction classified *typographic* by the engine
//! keeps every vote and is shown with its diff; a *substantive* one opens a
//! new revision and every earlier vote waits for its author to reconfirm;
//! - a text **in force** accepts typographic corrections from any member of
//! the group, recorded with their author and diff, and republished — a
//! change of meaning goes through a proposal.
use crate::AppState;
use crate::adoption;
use crate::auth::{IdentityRow, User};
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
use ld_engine::edit::{self, EditKind};
use ld_engine::threshold::InertiaPreset;
use serde::Deserialize;
use serde_json::json;
const MAX_TITLE: usize = 120;
const MAX_BODY: usize = 4000;
const MIN_REASON: usize = 10;
const MAX_REASON: usize = 600;
async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
.bind(unit_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// Loads the document of a proposal and checks the person may open its group.
async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> {
let p = load_proposal(state, proposal_id).await?;
let doc_id = doc_of_unit(state, &p.unit_id).await?;
let st = docs::load_state(state, &doc_id).await?;
docs::require_open(state, user, &st.group).await?;
if p.status == "draft" && p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::NotFound);
}
Ok((p, st))
}
fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
return Err(AppError::Invalid("hidden_characters"));
}
if title.trim().is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
return Err(AppError::Invalid("title"));
}
if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY {
return Err(AppError::Invalid("body"));
}
Ok(())
}
/// The text the votes were cast on: its last initial or substantive revision.
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
ORDER BY id DESC LIMIT 1",
)
.bind(&p.id)
.fetch_optional(&state.db)
.await?;
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
}
async fn record_revision(
state: &AppState,
proposal_id: &str,
revision: i64,
kind: &str,
title: &str,
body: &str,
author: Option<&str>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(proposal_id)
.bind(revision)
.bind(kind)
.bind(title)
.bind(body)
.bind(author)
.bind(util::now())
.execute(&state.db)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Assistant
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistBody {
text: String,
proposal_id: Option<String>,
}
pub async fn assist(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(body): Json<AssistBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let others: Vec<(String, String)> = st
.proposals
.iter()
.filter(|p| p.status != "draft" && Some(&p.id) != body.proposal_id.as_ref() && p.kind == "text")
.map(|p| (p.id.clone(), format!("{}\n{}", p.title, p.body)))
.collect();
let hints: Vec<_> = assist::hints(&body.text, &others)
.into_iter()
.map(|h| match h {
Hint::Similar { target, score } => {
let p = st.proposals.iter().find(|p| p.id == target);
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
json!({
"kind": "similar", "score": score, "proposalId": target,
"unitId": unit.map(|u| &u.id), "code": unit.map(|u| &u.code),
"title": p.map(|p| &p.title), "inForce": p.is_some_and(|p| p.status == "in_force"),
})
}
other => serde_json::to_value(other).unwrap_or_default(),
})
.collect();
Ok(Json(json!({ "hints": hints })))
}
// ---------------------------------------------------------------------------
// Create, edit, submit, withdraw, split
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateBody {
kind: Option<String>,
unit_id: Option<String>,
after_unit_id: Option<String>,
title: String,
#[serde(default)]
body: String,
#[serde(default)]
submit: bool,
}
/// Position right after `after` (or at the end), between it and the next unit.
fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 {
let mut positions: Vec<f64> = units.iter().map(|u| u.position).collect();
positions.sort_by(f64::total_cmp);
let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else {
return positions.last().copied().unwrap_or(0.0) + 1.0;
};
match positions.iter().find(|p| **p > anchor) {
Some(next) => (anchor + next) / 2.0,
None => anchor + 1.0,
}
}
async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult<String> {
let id = util::id();
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
.bind(&st.doc.id)
.fetch_one(&state.db)
.await?;
// A new article belongs to the section of the one it follows.
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)",
)
.bind(&id)
.bind(&st.doc.id)
.bind(format!("N{}", n + 1))
.bind(section)
.bind(position_after(&st.units, after))
.bind(util::now())
.execute(&state.db)
.await?;
Ok(id)
}
struct NewProposal<'a> {
unit_id: &'a str,
kind: &'a str,
inertia_target: Option<&'a str>,
title: &'a str,
body: &'a str,
author: &'a str,
submit: bool,
}
async fn insert_proposal(state: &AppState, n: NewProposal<'_>) -> AppResult<String> {
let NewProposal { unit_id, kind, inertia_target, title, body, author, submit } = n;
let id = util::id();
let now = util::now();
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'member', ?, 1, ?, ?, ?)",
)
.bind(&id)
.bind(unit_id)
.bind(kind)
.bind(inertia_target)
.bind(title.trim())
.bind(body.trim())
.bind(if submit { "open" } else { "draft" })
.bind(author)
.bind(&now)
.bind(&now)
.bind(submit.then_some(&now))
.execute(&state.db)
.await?;
record_revision(state, &id, 1, "initial", title.trim(), body.trim(), Some(author)).await?;
Ok(id)
}
pub async fn create(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(b): Json<CreateBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let kind = match b.kind.as_deref().unwrap_or("text") {
"text" => "text",
"remove" => "remove",
_ => return Err(AppError::Invalid("kind")),
};
check_text(&b.title, &b.body, kind == "text")?;
let unit_id = match b.unit_id.as_deref() {
Some(u) => {
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
if kind == "remove" && unit.in_force_id.is_none() {
return Err(AppError::Invalid("nothing_to_remove"));
}
unit.id.clone()
}
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
None => return Err(AppError::Invalid("unit")),
};
let id = insert_proposal(
&state,
NewProposal {
unit_id: &unit_id,
kind,
inertia_target: None,
title: &b.title,
body: &b.body,
author: &user.id,
submit: b.submit,
},
)
.await?;
Ok(Json(json!({ "id": id, "unitId": unit_id })))
}
#[derive(Deserialize)]
pub struct EditBody {
title: String,
body: String,
}
pub async fn edit(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<EditBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
check_text(&b.title, &b.body, p.kind == "text")?;
let (title, body) = (b.title.trim(), b.body.trim());
let is_author = p.author_id.as_deref() == Some(user.id.as_str());
let now = util::now();
if p.status == "draft" {
if !is_author {
return Err(AppError::Forbidden);
}
sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?")
.bind(title)
.bind(body)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
return Ok(Json(json!({ "edit": "draft" })));
}
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("closed"));
}
// Measured against the text that was voted, not the current one: successive
// typo fixes are judged together and never add up to a change of meaning.
// Title and body are judged apart (a title never absorbs the start of the
// body) and together (two slips at most in all).
let next = format!("{title}\n{body}");
let kind = if title == p.title && body == p.body {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
let kinds = [
edit::classify(&voted_title, title),
edit::classify(&voted_body, body),
edit::classify(&format!("{voted_title}\n{voted_body}"), &next),
];
// All unchanged: back to the voted text, itself a correction.
if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic }
};
match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
EditKind::Typographic => {
// Anyone in the group may fix a typo of a seed text or of a text in force.
if !(is_author || p.origin == "seed" || p.status == "in_force") {
return Err(AppError::Forbidden);
}
// Written only over the text that was judged: an entry into force or another
// correction in between makes it a conflict, never a silent change.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?",
)
.bind(title)
.bind(body)
.bind(&now)
.bind(&id)
.bind(p.revision)
.bind(&p.status)
.bind(&p.title)
.bind(&p.body)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?;
// A typo fix does not change the meaning: up-to-date translations stay up to date.
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
.bind(util::source_hash(title, body))
.bind(&id)
.bind(util::source_hash(&p.title, &p.body))
.execute(&state.db)
.await?;
if p.status == "in_force" {
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
}
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
}
EditKind::Substantive => {
if p.status == "in_force" {
return Err(AppError::Conflict("substantive_in_force"));
}
if !is_author {
return Err(AppError::Conflict("substantive_not_author"));
}
let revision = p.revision + 1;
// Only on the open proposal that was read: never on a text that entered into
// force in the meantime.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ?
WHERE id = ? AND revision = ? AND status = 'open' AND title = ? AND body = ?",
)
.bind(title)
.bind(body)
.bind(revision)
.bind(&now)
.bind(&id)
.bind(p.revision)
.bind(&p.title)
.bind(&p.body)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
// Signals were about the former text: they lapse, their authors may signal again.
sqlx::query(
"UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL",
)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
}
}
}
pub async fn submit(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::Forbidden);
}
if p.status != "draft" {
return Err(AppError::Conflict("not_draft"));
}
// Drafts written under older rules are checked again before the vote opens.
check_text(&p.title, &p.body, p.kind == "text")?;
let now = util::now();
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
// A draft is edited freely: the text submitted is the one votes will be cast on.
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
.bind(&p.title)
.bind(&p.body)
.bind(&id)
.execute(&state.db)
.await?;
if initial.rows_affected() == 0 {
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
}
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "open", "entered": entered })))
}
pub async fn withdraw(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (p, _) = guarded(&state, &user, &id).await?;
if p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::Forbidden);
}
if !matches!(p.status.as_str(), "draft" | "open") {
return Err(AppError::Conflict("closed"));
}
sqlx::query("UPDATE proposals SET status = 'withdrawn', left_at = ?, updated_at = ? WHERE id = ?")
.bind(util::now())
.bind(util::now())
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(json!({ "status": "withdrawn" })))
}
#[derive(Deserialize)]
pub struct SplitBody {
parts: Vec<String>,
}
/// One draft becomes several: the first part stays, each other part becomes a
/// draft on a new unit placed right after. Drafts only — nobody voted yet.
pub async fn split(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<SplitBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::Forbidden);
}
if p.status != "draft" || p.kind != "text" {
return Err(AppError::Conflict("not_draft"));
}
let parts: Vec<&str> = b.parts.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) {
return Err(AppError::Invalid("parts"));
}
if parts.iter().any(|s| edit::has_hidden_controls(s)) {
return Err(AppError::Invalid("hidden_characters"));
}
sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?")
.bind(parts[0])
.bind(util::now())
.bind(&id)
.execute(&state.db)
.await?;
let mut created = vec![id.clone()];
let mut after = p.unit_id.clone();
for (n, part) in parts.iter().enumerate().skip(1) {
let fresh = docs::load_state(&state, &st.doc.id).await?;
let unit = new_unit(&state, &fresh, Some(&after)).await?;
let title = format!("{} ({})", p.title, n + 1);
let title: String = title.chars().take(MAX_TITLE).collect();
created.push(
insert_proposal(
&state,
NewProposal {
unit_id: &unit,
kind: "text",
inertia_target: None,
title: &title,
body: part,
author: &user.id,
submit: false,
},
)
.await?,
);
after = unit;
}
Ok(Json(json!({ "drafts": created })))
}
// ---------------------------------------------------------------------------
// Detail
// ---------------------------------------------------------------------------
pub async fn detail(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
let unit = st.units.iter().find(|u| u.id == p.unit_id).ok_or(AppError::NotFound)?;
let in_force = match &unit.in_force_id {
Some(fid) if *fid != p.id => Some(load_proposal(&state, fid).await?),
_ => None,
};
#[derive(sqlx::FromRow, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Rev {
revision: i64,
kind: String,
title: String,
body: String,
author_name: Option<String>,
author_invited: Option<bool>,
created_at: String,
}
let revs = sqlx::query_as::<_, Rev>(
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
i.kind = 'invite' AS author_invited, r.created_at
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
)
.bind(&id)
.fetch_all(&state.db)
.await?;
// Each correction with the diff from the text before it.
let mut history = Vec::new();
let mut previous: Option<&Rev> = None;
for r in &revs {
let diff = previous
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
history.push(json!({
"revision": r.revision, "kind": r.kind, "author": r.author_name,
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff,
}));
previous = Some(r);
}
#[derive(sqlx::FromRow)]
struct Sig {
id: String,
proposal_id: String,
target_proposal_id: String,
reason: String,
author_id: String,
author_name: String,
author_invited: bool,
created_at: String,
lifted_at: Option<String>,
lifted_by: Option<String>,
}
let sigs = sqlx::query_as::<_, Sig>(
"SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name,
i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by
FROM signals s JOIN identities i ON i.id = s.author_id
WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC",
)
.bind(&id)
.bind(&id)
.fetch_all(&state.db)
.await?;
let mut signals = Vec::new();
for s in sigs {
let other_id = if s.proposal_id == id { &s.target_proposal_id } else { &s.proposal_id };
let other = load_proposal(&state, other_id).await.ok();
let other_code = match &other {
Some(o) => st.units.iter().find(|u| u.id == o.unit_id).map(|u| u.code.clone()),
None => None,
};
signals.push(json!({
"id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
"mine": s.author_id == user.id,
"at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by,
"other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })),
}));
}
let my_vote: Option<(String, i64)> =
sqlx::query_as("SELECT choice, revision FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&id)
.bind(&user.id)
.fetch_optional(&state.db)
.await?;
let diff = match (&in_force, p.kind.as_str()) {
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
_ => None,
};
let eval = st.evaluation(unit, &p);
let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) };
let tr = docs::translations_for(&state, &[&p]).await?;
Ok(Json(json!({
"proposal": {
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body,
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(),
"blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
},
"unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia },
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
"diff": diff,
"history": history,
"signals": signals,
"electorate": st.electorate,
"voteFloor": st.doc.vote_floor,
})))
}
// ---------------------------------------------------------------------------
// Votes
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct VoteBody {
choice: Option<String>,
}
pub async fn vote(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<VoteBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("wot_members_only"));
}
match b.choice.as_deref() {
Some(choice @ ("for" | "against")) => {
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
revision = excluded.revision, cast_at = excluded.cast_at",
)
.bind(&id)
.bind(&user.id)
.bind(choice)
.bind(p.revision)
.bind(util::now())
.execute(&state.db)
.await?;
}
None => {
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&id)
.bind(&user.id)
.execute(&state.db)
.await?;
}
Some(_) => return Err(AppError::Invalid("choice")),
}
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "entered": entered })))
}
// ---------------------------------------------------------------------------
// Contradiction signals
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalBody {
target_id: String,
reason: String,
}
/// « These two texts contradict each other. » While the signal stands, the two
/// can never be in force together: the one not yet in force is blocked.
pub async fn signal(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<SignalBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
let reason = b.reason.trim();
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
return Err(AppError::Invalid("reason"));
}
if b.target_id == id {
return Err(AppError::Invalid("target"));
}
let target = st.proposals.iter().find(|t| t.id == b.target_id).ok_or(AppError::Invalid("target"))?;
if target.status == "draft" || !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Invalid("target"));
}
if target.unit_id == p.unit_id && target.kind == "text" && p.kind == "text" {
// Rivals on one unit never stand together anyway.
return Err(AppError::Invalid("same_unit"));
}
let sid = util::id();
sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&sid)
.bind(&id)
.bind(&b.target_id)
.bind(reason)
.bind(&user.id)
.bind(util::now())
.execute(&state.db)
.await?;
Ok(Json(json!({ "id": sid })))
}
pub async fn lift_signal(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (proposal_id, author): (String, String) =
sqlx::query_as("SELECT proposal_id, author_id FROM signals WHERE id = ? AND lifted_at IS NULL")
.bind(&id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let (_, st) = guarded(&state, &user, &proposal_id).await?;
if author != user.id && !user.admin {
return Err(AppError::Forbidden);
}
sqlx::query("UPDATE signals SET lifted_at = ?, lifted_by = ? WHERE id = ?")
.bind(util::now())
.bind(&user.id)
.bind(&id)
.execute(&state.db)
.await?;
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "entered": entered })))
}
// ---------------------------------------------------------------------------
// Inertia
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct InertiaBody {
target: String,
}
/// Propose a change of inertia; it is voted one level above the unit. An open
/// proposal for the same target is reused rather than duplicated.
pub async fn propose_inertia(
State(state): State<AppState>,
User(user): User,
Path(unit_id): Path<String>,
Json(b): Json<InertiaBody>,
) -> AppResult<Json<serde_json::Value>> {
let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?;
let doc_id = doc_of_unit(&state, &unit_id).await?;
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
if docs::inertia_of(unit) == target {
return Err(AppError::Invalid("same_inertia"));
}
if let Some(existing) = st.proposals.iter().find(|p| {
p.unit_id == unit_id
&& p.kind == "inertia"
&& p.status == "open"
&& p.inertia_target.as_deref() == Some(target.as_str())
}) {
return Ok(Json(json!({ "id": existing.id, "existing": true })));
}
let title = format!("inertia:{}", target.as_str());
let id = insert_proposal(
&state,
NewProposal {
unit_id: &unit_id,
kind: "inertia",
inertia_target: Some(target.as_str()),
title: &title,
body: "",
author: &user.id,
submit: true,
},
)
.await?;
Ok(Json(json!({ "id": id, "existing": false })))
}
// ---------------------------------------------------------------------------
// Editorial translations
// ---------------------------------------------------------------------------
pub async fn translate(
State(state): State<AppState>,
User(user): User,
Path((id, lang)): Path<(String, String)>,
Json(b): Json<EditBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if !util::is_lang(&lang) || lang == st.doc.source_lang {
return Err(AppError::Invalid("lang"));
}
check_text(&b.title, &b.body, p.kind == "text")?;
sqlx::query(
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, 'member', ?, ?)
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
source_hash = excluded.source_hash, origin = 'member', updated_by = excluded.updated_by,
updated_at = excluded.updated_at",
)
.bind(&id)
.bind(&lang)
.bind(b.title.trim())
.bind(b.body.trim())
.bind(util::source_hash(&p.title, &p.body))
.bind(&user.id)
.bind(util::now())
.execute(&state.db)
.await?;
if p.status == "in_force" {
adoption::enqueue_publication(&state, &st.doc, "traduction").await?;
}
Ok(Json(json!({ "ok": true })))
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(id: &str, position: f64) -> UnitRow {
UnitRow {
id: id.into(),
code: id.into(),
section: None,
position,
inertia: "standard".into(),
in_force_id: None,
}
}
#[test]
fn new_units_slot_between_neighbours() {
let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)];
assert_eq!(position_after(&units, Some("a")), 1.5);
assert_eq!(position_after(&units, Some("c")), 5.0);
assert_eq!(position_after(&units, None), 5.0);
assert_eq!(position_after(&[], None), 1.0);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! Publication of the texts in force to the git repository that client
//! applications read to update themselves (Yvv, 2026-10-21: « Le tout est
//! automatique poussé sur le dépôt git exploité par les applis clientes »).
//!
//! Every adoption, typo fix or translation of a text in force queues an
//! outbox row. The worker renders the document — one Markdown file per
//! language plus a JSON file — commits it in the server's own clone and pushes
//! with a deploy key. A failure is retried with backoff; the vote never waits
//! for the network.
use crate::AppState;
use crate::docs::{self, DocState};
use crate::util;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::process::Command;
const IDLE: Duration = Duration::from_secs(60);
const MAX_BACKOFF_SECS: i64 = 6 * 3600;
/// The deploy key of the server: generated on first start, never leaves the
/// data volume. Its public half is shown to access administrators, who add it
/// as a deploy key (write) on the texts repository.
pub async fn ensure_key(state: &AppState) -> anyhow::Result<()> {
let Some(publish) = &state.cfg.publish else { return Ok(()) };
if publish.key.exists() {
return Ok(());
}
if let Some(dir) = publish.key.parent() {
tokio::fs::create_dir_all(dir).await?;
}
let out = Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "libredecision-publication", "-f"])
.arg(&publish.key)
.output()
.await?;
anyhow::ensure!(out.status.success(), "ssh-keygen: {}", String::from_utf8_lossy(&out.stderr).trim());
tracing::info!(key = %publish.key.display(), "publication key generated");
Ok(())
}
pub fn public_key(state: &AppState) -> Option<String> {
let publish = state.cfg.publish.as_ref()?;
let mut path = publish.key.clone().into_os_string();
path.push(".pub");
std::fs::read_to_string(path).ok().map(|k| k.trim().to_owned())
}
pub async fn worker(state: AppState) {
loop {
// A pass first: what was queued before the start goes out without waiting.
if state.cfg.publish.is_some()
&& let Err(e) = pass(&state).await
{
tracing::warn!(error = %e, "publication pass");
}
tokio::select! {
() = state.publish_wake.notified() => {}
() = tokio::time::sleep(IDLE) => {}
}
}
}
#[derive(sqlx::FromRow)]
struct Due {
id: i64,
doc_id: String,
attempts: i64,
}
/// One publication pass, now (tests, and the worker).
pub async fn pass(state: &AppState) -> anyhow::Result<()> {
let due = sqlx::query_as::<_, Due>(
"SELECT id, doc_id, attempts FROM outbox WHERE done_at IS NULL AND next_at <= ? ORDER BY id",
)
.bind(util::now())
.fetch_all(&state.db)
.await?;
let mut docs_due: Vec<String> = due.iter().map(|d| d.doc_id.clone()).collect();
docs_due.dedup();
for doc_id in docs_due {
let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect();
let last = rows.iter().map(|d| d.id).max().unwrap_or(0);
let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0);
match publish_doc(state, &doc_id).await {
Ok(sha) => {
sqlx::query("UPDATE outbox SET done_at = ?, commit_sha = ?, last_error = NULL WHERE doc_id = ? AND done_at IS NULL AND id <= ?")
.bind(util::now())
.bind(&sha)
.bind(&doc_id)
.bind(last)
.execute(&state.db)
.await?;
tracing::info!(doc = %doc_id, commit = sha.as_deref().unwrap_or("unchanged"), "published");
}
Err(e) => {
let wait = (60_i64 << attempts.min(12)).min(MAX_BACKOFF_SECS);
sqlx::query("UPDATE outbox SET attempts = attempts + 1, last_error = ?, next_at = ? WHERE doc_id = ? AND done_at IS NULL")
.bind(format!("{e:#}").chars().take(500).collect::<String>())
.bind(util::in_seconds(wait))
.bind(&doc_id)
.execute(&state.db)
.await?;
tracing::warn!(doc = %doc_id, error = %e, retry_in = wait, "publication failed");
}
}
}
Ok(())
}
async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result<String> {
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
let known_hosts = state.cfg.data_dir.join("keys/known_hosts");
let ssh = format!(
"ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={}",
publish.key.display(),
known_hosts.display()
);
let out = Command::new("git")
.args([
"-c",
&format!("user.name={}", publish.author_name),
"-c",
&format!("user.email={}", publish.author_email),
])
.args(args)
.current_dir(dir)
.env("GIT_SSH_COMMAND", ssh)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.await?;
if !out.status.success() {
anyhow::bail!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
/// The server's own clone: a cache of the remote, realigned on it before each
/// commit so a push never needs a merge. Returns the clone and the commit the
/// remote branch points to, if it exists.
async fn checkout(state: &AppState) -> anyhow::Result<(PathBuf, Option<String>)> {
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
let dir = state.cfg.data_dir.join("textes");
if !dir.join(".git").exists() {
tokio::fs::create_dir_all(&dir).await?;
git(&dir, state, &["init", "-q"]).await?;
git(&dir, state, &["remote", "add", "origin", &publish.remote]).await?;
}
git(&dir, state, &["remote", "set-url", "origin", &publish.remote]).await?;
let has_remote_branch = git(&dir, state, &["ls-remote", "--heads", "origin", &publish.branch]).await?;
let remote_head = has_remote_branch.split_whitespace().next().map(str::to_owned);
if remote_head.is_none() {
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch]).await?;
} else {
// This clone is a cache: whatever a failed pass left is re-rendered from the database.
git(&dir, state, &["fetch", "-q", "origin", &publish.branch]).await?;
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch, &format!("origin/{}", publish.branch)])
.await?;
}
Ok((dir, remote_head))
}
async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<String>> {
let st = docs::load_state(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
let Some(path) = st.doc.publish_path.clone() else { return Ok(None) };
let rendered = render(state, &st).await?;
let (dir, remote_head) = checkout(state).await?;
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
let target = dir.join(&path);
tokio::fs::create_dir_all(&target).await?;
for (name, content) in &rendered {
tokio::fs::write(target.join(name), content).await?;
}
let readme = dir.join("README.md");
if !readme.exists() {
tokio::fs::write(&readme, README).await?;
}
git(&dir, state, &["add", "-A", "--", &path, "README.md"]).await?;
let staged = git(&dir, state, &["diff", "--cached", "--name-only"]).await?;
if staged.is_empty() {
// Nothing new — but a commit the remote never received still goes out: a
// first push refused (a key without write access), or a new, empty
// repository after a change of remote.
let Ok(head) = git(&dir, state, &["rev-parse", "--verify", "-q", "HEAD"]).await else { return Ok(None) };
if remote_head.as_deref() == Some(head.as_str()) {
return Ok(None);
}
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
return Ok(Some(head));
}
let reasons: Vec<String> =
sqlx::query_scalar("SELECT reason FROM outbox WHERE doc_id = ? AND done_at IS NULL ORDER BY id")
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let title = util::parse_lang(&st.doc.title).get(&st.doc.source_lang).cloned().unwrap_or_default();
let mut message = format!("{title} — {}", reasons.last().cloned().unwrap_or_else(|| "mise à jour".into()));
if reasons.len() > 1 {
message.push_str("\n\n");
for r in &reasons {
message.push_str(&format!("- {r}\n"));
}
}
git(&dir, state, &["commit", "-q", "-m", &message]).await?;
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
Ok(Some(git(&dir, state, &["rev-parse", "HEAD"]).await?))
}
const README: &str = "# Textes en vigueur — libreDecision\n\n\
Ce dépôt est écrit par libreDecision (https://decision.librodrome.org) : chaque fois qu'un article \
entre en vigueur par le vote, qu'une coquille est corrigée ou qu'une traduction change, le texte en \
vigueur est republié ici. Les applications clientes le lisent pour se mettre à jour.\n\n\
- `<groupe>/<document>/fr.md` : le texte qui fait foi (langue source) ;\n\
- `en.md`, `es.md` : traductions éditoriales, sans vote ;\n\
- `document.json` : le même contenu, structuré, avec l'état du vote de chaque article.\n\n\
Ne pas modifier à la main : le prochain passage de libreDecision réécrit ces fichiers.\n";
/// The date of the last change to what is published (an entry into force, a
/// typo fix, a translation) — never "now", so that publishing an unchanged
/// document changes nothing and commits nothing.
async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result<String> {
let at: Option<String> = sqlx::query_scalar(
"SELECT MAX(d) FROM (
SELECT MAX(at) AS d FROM adoptions WHERE doc_id = ?1
UNION ALL SELECT MAX(p.updated_at) FROM proposals p JOIN units u ON u.in_force_id = p.id WHERE u.doc_id = ?1
UNION ALL SELECT MAX(t.updated_at) FROM translations t JOIN units u ON u.in_force_id = t.proposal_id WHERE u.doc_id = ?1
UNION ALL SELECT created_at FROM documents WHERE id = ?1)",
)
.bind(doc_id)
.fetch_one(&state.db)
.await?;
Ok(at.unwrap_or_else(util::now))
}
/// On start, every published document is queued once more: a change that
/// entered into force just before a restart, or a new rendering format, reaches
/// the repository. Unchanged documents produce no commit. What was waiting
/// after failures is tried again at once (a key just added, a new remote).
pub async fn resync(state: &AppState) -> anyhow::Result<()> {
if state.cfg.publish.is_none() {
return Ok(());
}
retry_now(state).await?;
let now = util::now();
sqlx::query(
"INSERT INTO outbox (doc_id, reason, created_at, next_at)
SELECT d.id, 'reprise au démarrage', ?1, ?1 FROM documents d
WHERE d.publish_path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM outbox o WHERE o.doc_id = d.id AND o.done_at IS NULL)",
)
.bind(&now)
.execute(&state.db)
.await?;
state.publish_wake.notify_one();
Ok(())
}
/// Everything still waiting goes out at the next pass, backoff forgotten.
pub async fn retry_now(state: &AppState) -> anyhow::Result<()> {
sqlx::query("UPDATE outbox SET next_at = ?, attempts = 0 WHERE done_at IS NULL")
.bind(util::now())
.execute(&state.db)
.await?;
state.publish_wake.notify_one();
Ok(())
}
/// What administrators see of the publication in trustinWallet.
pub async fn status(state: &AppState) -> anyhow::Result<serde_json::Value> {
let pending: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL").fetch_one(&state.db).await?;
let error: Option<String> = sqlx::query_scalar(
"SELECT last_error FROM outbox WHERE done_at IS NULL AND last_error IS NOT NULL ORDER BY id DESC LIMIT 1",
)
.fetch_optional(&state.db)
.await?;
let last: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT done_at, commit_sha FROM outbox WHERE done_at IS NOT NULL AND commit_sha IS NOT NULL
ORDER BY done_at DESC LIMIT 1",
)
.fetch_optional(&state.db)
.await?;
Ok(json!({
"pending": pending,
"lastError": error,
"lastPublishedAt": last.as_ref().map(|(at, _)| at.clone()),
"lastCommit": last.and_then(|(_, sha)| sha),
}))
}
/// Markdown per language + JSON. Only the units in force, in order.
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
let titles = util::parse_lang(&st.doc.title);
let src = st.doc.source_lang.clone();
let in_force: Vec<_> = st
.units
.iter()
.filter_map(|u| {
let fid = u.in_force_id.as_ref()?;
st.proposals.iter().find(|p| &p.id == fid).map(|p| (u, p))
})
.collect();
let refs: Vec<&docs::ProposalRow> = in_force.iter().map(|(_, p)| *p).collect();
let tr = docs::translations_for(state, &refs).await.map_err(|e| anyhow::anyhow!("{e}"))?;
let updated = last_change(state, &st.doc.id).await?;
let source_url = format!("{}/g/{}/docs/{}", state.cfg.public_url, st.group.slug, st.doc.slug);
let mut files = Vec::new();
for lang in util::LANGS {
let title = titles.get(lang).or_else(|| titles.get(&src)).cloned().unwrap_or_default();
let mut md = format!(
"---\ndocument: {}\nlangue: {lang}\nsource: {source_url}\nmise_a_jour: {updated}\n{}---\n\n# {title}\n",
st.doc.slug,
if lang == src { "fait_foi: oui\n".to_owned() } else { "traduction: editoriale\n".to_owned() },
);
for (_, p) in &in_force {
let t = tr.get(&p.id).and_then(|m| m.get(lang));
let (ut, ub, fallback) = match t {
Some(t) if lang != src => (
t["title"].as_str().unwrap_or(&p.title).to_owned(),
t["body"].as_str().unwrap_or(&p.body).to_owned(),
t["stale"].as_bool().unwrap_or(false),
),
_ => (p.title.clone(), p.body.clone(), lang != src),
};
md.push_str(&format!("\n## {ut}\n\n{ub}\n"));
if fallback {
md.push_str(&format!(
"\n<!-- {lang} : traduction à revoir ou absente, texte source affiché si absente -->\n"
));
}
}
files.push((format!("{lang}.md"), md));
}
let units: Vec<_> = in_force
.iter()
.map(|(u, p)| {
let mut title = serde_json::Map::new();
let mut body = serde_json::Map::new();
title.insert(src.clone(), json!(p.title));
body.insert(src.clone(), json!(p.body));
if let Some(m) = tr.get(&p.id) {
for (lang, t) in m {
title.insert(lang.clone(), t["title"].clone());
body.insert(lang.clone(), t["body"].clone());
}
}
let tally = st.tally(&p.id);
json!({
"code": u.code, "title": title, "body": body, "inertia": u.inertia,
"status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" },
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
})
})
.collect();
let json = json!({
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
"voteFloor": st.doc.vote_floor, "units": units,
});
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
Ok(files)
}
+219
View File
@@ -0,0 +1,219 @@
//! Seeds — so as not to start from a blank page.
//!
//! On start, missing groups are created and every `seeds/*.json` document is
//! instantiated in each group it names, **only if that group does not hold it
//! yet**. An existing document is never touched: from its first vote on, it
//! is runtime data.
use crate::AppState;
use crate::util::{self, LangMap};
use serde::Deserialize;
use std::path::Path;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedDoc {
pub slug: String,
pub title: LangMap,
pub summary: LangMap,
pub source_lang: String,
pub instances: Vec<SeedInstance>,
pub units: Vec<SeedUnit>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedInstance {
pub group: String,
pub vote_floor: f64,
pub publish_path: Option<String>,
}
#[derive(Deserialize)]
pub struct SeedUnit {
pub code: String,
pub section: Option<String>,
pub position: f64,
pub inertia: String,
pub versions: Vec<SeedVersion>,
}
#[derive(Deserialize)]
pub struct SeedVersion {
pub label: Option<String>,
/// `in_force` (seed a minima) or `open` (proposal at zero votes).
pub status: String,
pub title: LangMap,
pub body: LangMap,
}
pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
for (position, g) in trustin_access::default_groups().into_iter().enumerate() {
sqlx::query(
"INSERT OR IGNORE INTO groups (slug, name, parent, policy, hidden, position) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&g.slug)
.bind(&g.name)
.bind(&g.parent)
.bind(g.policy.as_str())
.bind(g.hidden)
.bind(i64::try_from(position).unwrap_or(0))
.execute(&state.db)
.await?;
}
Ok(())
}
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
ensure_groups(state).await?;
let dir = state.cfg.seed_dir.clone();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(e) => {
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
return Ok(());
}
};
let mut files = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().is_some_and(|x| x == "json") {
files.push(path);
}
}
files.sort();
for path in files {
load_file(state, &path).await?;
}
Ok(())
}
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
let raw = tokio::fs::read_to_string(path).await?;
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
for inst in &seed.instances {
let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?")
.bind(&inst.group)
.fetch_one(&state.db)
.await?;
if group_exists == 0 {
tracing::warn!(group = %inst.group, slug = %seed.slug, "seed names an unknown group");
continue;
}
let present: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE group_slug = ? AND slug = ?")
.bind(&inst.group)
.bind(&seed.slug)
.fetch_one(&state.db)
.await?;
if present > 0 {
continue;
}
instantiate(state, &seed, inst).await?;
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
}
Ok(())
}
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
let src = seed.source_lang.as_str();
let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default();
let now = util::now();
let doc_id = util::id();
let mut tx = state.db.begin().await?;
sqlx::query(
"INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&doc_id)
.bind(&inst.group)
.bind(&seed.slug)
.bind(util::lang_json(&seed.title))
.bind(util::lang_json(&seed.summary))
.bind(src)
.bind(inst.vote_floor)
.bind(&inst.publish_path)
.bind(&now)
.execute(&mut *tx)
.await?;
for unit in &seed.units {
let unit_id = util::id();
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
)
.bind(&unit_id)
.bind(&doc_id)
.bind(&unit.code)
.bind(&unit.section)
.bind(unit.position)
.bind(&unit.inertia)
.bind(&now)
.execute(&mut *tx)
.await?;
for v in &unit.versions {
let pid = util::id();
let (title, body) = (pick(&v.title), pick(&v.body));
let status = if v.status == "in_force" { "in_force" } else { "open" };
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)",
)
.bind(&pid)
.bind(&unit_id)
.bind(&title)
.bind(&body)
.bind(status)
.bind(&v.label)
.bind(&now)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)",
)
.bind(&pid)
.bind(&title)
.bind(&body)
.bind(&now)
.execute(&mut *tx)
.await?;
if status == "in_force" {
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
.bind(&pid)
.bind(&unit_id)
.execute(&mut *tx)
.await?;
}
let hash = util::source_hash(&title, &body);
for lang in util::LANGS.iter().filter(|l| **l != src) {
let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue };
sqlx::query(
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)",
)
.bind(&pid)
.bind(lang)
.bind(t)
.bind(b)
.bind(&hash)
.bind(&now)
.execute(&mut *tx)
.await?;
}
}
}
if inst.publish_path.is_some() {
sqlx::query(
"INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)",
)
.bind(&doc_id)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
+282
View File
@@ -0,0 +1,282 @@
//! Team links — one link an administrator shares with a team, until a date and
//! for a number of openings. Each opening asks the person's name and makes
//! **their own** invited identity, never an administrator: one person, one
//! voice, and access management stays with trustinWallet's administrators.
//! Someone already signed in keeps their identity and receives the groups; it
//! takes a place only if it adds a group.
//!
//! Limit, assumed: a person who opens the link twice under two names has two
//! identities. Where invited identities vote (the test group), administrators
//! see in trustinWallet which link each identity came from.
use crate::AppState;
use crate::auth::{self, IdentityRow, User, Viewer};
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::header::SET_COOKIE;
use axum::response::{IntoResponse, Response};
use ld_engine::{edit, names};
use serde::Deserialize;
use serde_json::json;
const MAX_DAYS: i64 = 90;
const MAX_USES: i64 = 500;
const MAX_LABEL: usize = 60;
pub fn link(state: &AppState, token: &str) -> String {
format!("{}/equipe#{token}", state.cfg.public_url)
}
#[derive(sqlx::FromRow)]
struct TeamLink {
token_hash: String,
label: String,
groups: String,
created_by: String,
expires_at: String,
}
/// A link that still opens: not revoked, not expired, openings left.
async fn open_link(state: &AppState, token: &str) -> AppResult<TeamLink> {
sqlx::query_as::<_, TeamLink>(
"SELECT token_hash, label, groups, created_by, expires_at FROM team_links
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
)
.bind(util::sha256_hex(token.trim()))
.bind(util::now())
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Invalid("invitation_invalid"))
}
fn slugs(raw: &str) -> Vec<String> {
serde_json::from_str(raw).unwrap_or_default()
}
#[derive(Deserialize)]
pub struct PeekBody {
token: String,
}
/// What the link opens, before the person gives a name.
pub async fn peek(
State(state): State<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<PeekBody>,
) -> AppResult<Json<serde_json::Value>> {
let l = open_link(&state, &b.token).await?;
let mut names = Vec::new();
for slug in slugs(&l.groups) {
if let Some(name) = sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE slug = ?")
.bind(&slug)
.fetch_optional(&state.db)
.await?
{
names.push(name);
}
}
Ok(Json(json!({
"label": l.label,
"groups": names,
"expiresAt": l.expires_at,
"signedInAs": viewer.map(|v| v.display_name),
})))
}
#[derive(Deserialize)]
pub struct JoinBody {
token: String,
#[serde(default)]
name: Option<String>,
}
/// Nobody joins under the name of someone already there, however it is dressed
/// (case, accents, punctuation): compared on `names::key`. The unique index on
/// `name_key` settles two openings at the same instant.
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
// People already there, and those an invitation still waits for.
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
taken.extend(
sqlx::query_scalar::<_, String>(
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
)
.bind(util::now())
.fetch_all(&state.db)
.await?,
);
if taken.iter().any(|t| names::key(t) == name.key) {
return Err(AppError::Conflict("name_taken"));
}
Ok(name)
}
/// The groups of the link this identity does not have yet.
async fn missing_groups(state: &AppState, identity_id: &str, groups: &[String]) -> AppResult<usize> {
let mine: Vec<String> = sqlx::query_scalar("SELECT group_slug FROM memberships WHERE identity_id = ?")
.bind(identity_id)
.fetch_all(&state.db)
.await?;
Ok(groups.iter().filter(|g| !mine.contains(g)).count())
}
pub async fn join(
State(state): State<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<JoinBody>,
) -> AppResult<Response> {
let l = open_link(&state, &b.token).await?;
let groups = slugs(&l.groups);
// Checked before an opening is counted.
let name = match &viewer {
Some(me) => {
// Back with the session already open: nothing to add, no place taken.
if missing_groups(&state, &me.id, &groups).await? == 0 {
return Ok(Json(json!({ "me": me })).into_response());
}
None
}
None => Some(free_name(&state, b.name.as_deref().unwrap_or_default()).await?),
};
// Count the opening first: of two at the last place, one only goes on.
let counted = sqlx::query(
"UPDATE team_links SET uses = uses + 1
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
)
.bind(&l.token_hash)
.bind(util::now())
.execute(&state.db)
.await?;
if counted.rows_affected() != 1 {
return Err(AppError::Invalid("invitation_invalid"));
}
let (identity_id, session) = match (viewer, name) {
(Some(me), _) => (me.id, None),
(None, Some(name)) => {
let id = format!("inv:{}", util::id());
let inserted = sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
)
.bind(&id)
.bind(&name.display)
.bind(util::now())
.bind(&l.token_hash)
.bind(&name.key)
.execute(&state.db)
.await;
if let Err(sqlx::Error::Database(e)) = &inserted
&& e.is_unique_violation()
{
// Same name at the same instant: give the place back.
sqlx::query("UPDATE team_links SET uses = uses - 1 WHERE token_hash = ?")
.bind(&l.token_hash)
.execute(&state.db)
.await?;
return Err(AppError::Conflict("name_taken"));
}
inserted?;
let session = auth::open_session(&state, &id).await?;
(id, Some(session))
}
(None, None) => return Err(AppError::Invalid("name")),
};
auth::grant_groups(&state, &identity_id, &groups, Some(&l.created_by)).await?;
tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened");
let me = auth::load_identity(&state, &identity_id).await?;
let mut res = Json(json!({ "me": me })).into_response();
if let Some(cookie) = session {
res.headers_mut().append(SET_COOKIE, cookie);
}
Ok(res)
}
fn admin_only(user: &IdentityRow) -> AppResult<()> {
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateBody {
label: String,
groups: Vec<String>,
days: i64,
max_uses: i64,
}
/// The link is returned once and never stored in clear.
pub async fn create(
State(state): State<AppState>,
User(user): User,
Json(b): Json<CreateBody>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
let label = b.label.trim();
if label.is_empty()
|| label.chars().count() > MAX_LABEL
|| label.chars().any(char::is_control)
|| edit::has_hidden_controls(label)
{
return Err(AppError::Invalid("label"));
}
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
return Err(AppError::Invalid("team_limits"));
}
let known: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
if b.groups.is_empty() || b.groups.iter().any(|g| !known.contains(g)) {
return Err(AppError::Invalid("groups"));
}
let token = util::token();
sqlx::query(
"INSERT INTO team_links (token_hash, label, groups, created_by, created_at, expires_at, max_uses)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(util::sha256_hex(&token))
.bind(label)
.bind(serde_json::to_string(&b.groups).unwrap_or_else(|_| "[]".into()))
.bind(&user.id)
.bind(util::now())
.bind(util::in_days(b.days))
.bind(b.max_uses)
.execute(&state.db)
.await?;
tracing::info!(by = %user.id, days = b.days, max_uses = b.max_uses, "team link created");
Ok(Json(json!({ "link": link(&state, &token) })))
}
pub async fn revoke(
State(state): State<AppState>,
User(user): User,
Path(hash): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
sqlx::query("UPDATE team_links SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL")
.bind(util::now())
.bind(&hash)
.execute(&state.db)
.await?;
tracing::info!(by = %user.id, "team link revoked");
Ok(Json(json!({ "ok": true })))
}
/// The links still open, for trustinWallet.
pub async fn list(state: &AppState) -> AppResult<Vec<serde_json::Value>> {
let rows: Vec<(String, String, String, String, i64, i64)> = sqlx::query_as(
"SELECT token_hash, label, groups, expires_at, max_uses, uses FROM team_links
WHERE revoked_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
)
.bind(util::now())
.fetch_all(&state.db)
.await?;
Ok(rows
.into_iter()
.map(|(id, label, groups, expires, max_uses, uses)| {
json!({
"id": id, "label": label, "groups": slugs(&groups),
"expiresAt": expires, "maxUses": max_uses, "uses": uses,
})
})
.collect())
}
+63
View File
@@ -0,0 +1,63 @@
//! Small shared helpers: clock, randomness, hashing, language maps.
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// Languages of the interface and of the editorial translations (Yvv, 2026-10-21).
pub const LANGS: [&str; 3] = ["fr", "en", "es"];
pub fn now() -> String {
stamp(OffsetDateTime::now_utc())
}
pub fn stamp(at: OffsetDateTime) -> String {
at.replace_nanosecond(0).unwrap_or(at).format(&Rfc3339).unwrap_or_default()
}
pub fn in_days(days: i64) -> String {
stamp(OffsetDateTime::now_utc() + time::Duration::days(days))
}
pub fn in_seconds(secs: i64) -> String {
stamp(OffsetDateTime::now_utc() + time::Duration::seconds(secs))
}
/// 32 random bytes, hex — session tokens, invitation tokens, OAuth state.
pub fn token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
/// 16 random bytes, hex — row identifiers.
pub fn id() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn sha256_hex(data: &str) -> String {
hex::encode(Sha256::digest(data.as_bytes()))
}
/// The hash a translation is pinned to: it goes stale when the source changes.
pub fn source_hash(title: &str, body: &str) -> String {
sha256_hex(&format!("{title}\n\u{1f}\n{body}"))
}
pub type LangMap = BTreeMap<String, String>;
pub fn lang_json(map: &LangMap) -> String {
serde_json::to_string(map).unwrap_or_else(|_| "{}".into())
}
pub fn parse_lang(raw: &str) -> LangMap {
serde_json::from_str(raw).unwrap_or_default()
}
pub fn is_lang(lang: &str) -> bool {
LANGS.contains(&lang)
}
+30
View File
@@ -0,0 +1,30 @@
//! The compiled Svelte interface: static files, SPA fallback, cache policy.
use axum::body::Body;
use axum::http::Request;
use axum::http::header::{CACHE_CONTROL, HeaderValue};
use axum::response::Response;
use std::path::Path;
use tower::ServiceExt;
use tower::util::BoxCloneSyncService;
use tower_http::services::{ServeDir, ServeFile};
pub type WebService = BoxCloneSyncService<Request<Body>, Response, std::convert::Infallible>;
/// Hashed assets are immutable; everything else (index.html) is revalidated.
pub fn service(dir: &Path) -> WebService {
let index = dir.join("index.html");
let serve = ServeDir::new(dir).precompressed_gzip().fallback(ServeFile::new(index));
let svc = tower::service_fn(move |req: Request<Body>| {
let immutable = req.uri().path().starts_with("/_app/immutable/");
let serve = serve.clone();
async move {
let res = serve.oneshot(req).await.unwrap_or_else(|e| match e {});
let mut res = res.map(Body::new);
let policy = if immutable { "public, max-age=31536000, immutable" } else { "no-cache" };
res.headers_mut().insert(CACHE_CONTROL, HeaderValue::from_static(policy));
Ok::<_, std::convert::Infallible>(res)
}
});
BoxCloneSyncService::new(svc)
}
+128
View File
@@ -0,0 +1,128 @@
//! W — the size of the Ğ1 web of trust, the electorate of Monnaie libre.
//!
//! Read on chain first (`Membership.CounterForMembership`, JSON-RPC
//! `state_getStorage` on a Duniter v2s node), from an indexer otherwise, and
//! kept in `settings`. A failed refresh keeps the last known value; the very
//! first start falls back to `LD_WOT_FALLBACK`.
use crate::AppState;
use crate::util;
use serde_json::json;
use std::time::Duration;
const REFRESH: Duration = Duration::from_secs(6 * 3600);
const PLAUSIBLE: std::ops::RangeInclusive<u32> = 100..=10_000_000;
fn twox_128(data: &[u8]) -> [u8; 16] {
let mut out = [0u8; 16];
out[..8].copy_from_slice(&twox_hash::XxHash64::oneshot(0, data).to_le_bytes());
out[8..].copy_from_slice(&twox_hash::XxHash64::oneshot(1, data).to_le_bytes());
out
}
/// Storage key of a plain storage value: `twox_128(pallet) ++ twox_128(item)`.
pub fn storage_key(pallet: &str, item: &str) -> String {
let mut key = Vec::with_capacity(32);
key.extend_from_slice(&twox_128(pallet.as_bytes()));
key.extend_from_slice(&twox_128(item.as_bytes()));
format!("0x{}", hex::encode(key))
}
/// SCALE `u32`, little endian.
pub fn decode_u32(hex_value: &str) -> Option<u32> {
let bytes = hex::decode(hex_value.trim_start_matches("0x")).ok()?;
let arr: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
Some(u32::from_le_bytes(arr))
}
async fn from_chain(state: &AppState) -> Option<(u32, String)> {
let key = storage_key("Membership", "CounterForMembership");
for node in &state.cfg.rpc_endpoints {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "state_getStorage", "params": [key] });
let res = state.http.post(node).json(&body).send().await;
let value: Option<serde_json::Value> = match res {
Ok(r) if r.status().is_success() => r.json().await.ok(),
_ => None,
};
if let Some(n) = value.as_ref().and_then(|v| v["result"].as_str()).and_then(decode_u32)
&& PLAUSIBLE.contains(&n)
{
return Some((n, format!("chain:{node}")));
}
}
None
}
async fn from_indexer(state: &AppState) -> Option<(u32, String)> {
let query = json!({ "query": "{ identities(filter:{isMember:{equalTo:true}}) { totalCount } }" });
for url in &state.cfg.indexers {
let res = state.http.post(url).json(&query).send().await;
let value: Option<serde_json::Value> = match res {
Ok(r) if r.status().is_success() => r.json().await.ok(),
_ => None,
};
let n = value
.as_ref()
.and_then(|v| v["data"]["identities"]["totalCount"].as_u64())
.and_then(|n| u32::try_from(n).ok());
if let Some(n) = n.filter(|n| PLAUSIBLE.contains(n)) {
return Some((n, format!("indexer:{url}")));
}
}
None
}
pub async fn refresh(state: &AppState) -> anyhow::Result<Option<u32>> {
let found = match from_chain(state).await {
Some(v) => Some(v),
None => from_indexer(state).await,
};
let Some((n, source)) = found else {
tracing::warn!("web of trust size: no node nor indexer answered, keeping the last value");
return Ok(None);
};
let now = util::now();
for (key, value) in [("wot_size", n.to_string()), ("wot_source", source.clone())] {
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(key)
.bind(value)
.bind(&now)
.execute(&state.db)
.await?;
}
tracing::info!(size = n, %source, "web of trust size");
Ok(Some(n))
}
pub async fn refresher(state: AppState) {
let mut tick = tokio::time::interval(REFRESH);
loop {
tick.tick().await;
if let Err(e) = refresh(&state).await {
tracing::warn!(error = %e, "web of trust refresh");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn storage_keys_match_substrate() {
// Well-known key of System.Number (block number) on any Substrate chain.
assert_eq!(
storage_key("System", "Number"),
"0x26aa394eea5630e07c48ae0c9558cef702a5c1b19ab7a04f536c519aca4983ac"
);
}
#[test]
fn scale_u32() {
assert_eq!(decode_u32("0x71160000"), Some(5745));
assert_eq!(decode_u32("0x01"), None);
}
}
+864
View File
@@ -0,0 +1,864 @@
//! End-to-end API scenarios on an in-memory database seeded like production.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
use serde_json::{Value, json};
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
/// The `Set-Cookie` of the last answer, whole.
set_cookie: Option<String>,
}
impl Client {
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None, set_cookie: None }, state)
}
fn with(&self, cookie: Option<String>) -> Self {
Self { router: self.router.clone(), cookie, set_cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
self.set_cookie = res.headers().get(header::SET_COOKIE).map(|v| v.to_str().expect("cookie").to_owned());
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn login(&mut self, name: &str, admin: bool) {
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
}
async fn doc_id(c: &mut Client, group: &str) -> String {
let (s, v) = c.call("GET", &format!("/api/groups/{group}"), None).await;
assert_eq!(s, StatusCode::OK, "{v}");
v["docs"][0]["id"].as_str().expect("doc").to_owned()
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
#[tokio::test]
async fn anonymous_sees_nothing_and_csrf_is_enforced() {
let (mut c, _) = Client::new().await;
let (s, _) = c.call("GET", "/api/groups", None).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
let (s, v) = c.call("GET", "/api/me", None).await;
assert_eq!(s, StatusCode::OK);
assert!(v["me"].is_null());
// A mutating call without the header is refused.
let req = Request::builder()
.method("POST")
.uri("/api/auth/dev")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":"x"}"#))
.expect("request");
let res = c.router.clone().oneshot(req).await.expect("response");
assert_eq!(res.status(), StatusCode::FORBIDDEN);
// No neighbour may frame the product.
assert_eq!(res.headers().get(header::X_FRAME_OPTIONS).map(|v| v.as_bytes()), Some(&b"DENY"[..]));
assert!(res.headers().contains_key(header::X_CONTENT_TYPE_OPTIONS));
}
#[tokio::test]
async fn the_seed_a_minima_and_the_open_proposals() {
let (mut c, _) = Client::new().await;
c.login("Chiara", false).await;
let (_, groups) = c.call("GET", "/api/groups", None).await;
let slugs: Vec<&str> =
groups["groups"].as_array().expect("groups").iter().filter_map(|g| g["slug"].as_str()).collect();
assert_eq!(slugs, ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois", "essai"]);
let id = doc_id(&mut c, "monnaie-libre").await;
let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
assert_eq!(s, StatusCode::OK);
let in_force: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| !u["inForce"].is_null())
.filter_map(|u| u["code"].as_str())
.collect();
assert_eq!(in_force, ["I1", "E1", "E2"]);
let e1 = &unit(&doc, "E1")["inForce"];
assert_eq!(e1["seedMinimal"], true);
assert_eq!(e1["tally"]["for"], 0);
assert_eq!(doc["doc"]["voteFloor"], 50.0);
assert_eq!(doc["doc"]["electorate"], 5745);
// Every other article is an open proposal at zero votes, standard inertia.
let e3 = unit(&doc, "E3");
assert!(e3["inForce"].is_null());
assert_eq!(e3["proposals"].as_array().expect("p").len(), 2, "E3 and its 0.4.0 variant");
assert_eq!(e3["inertia"], "standard");
// Translations seeded.
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
}
#[tokio::test]
async fn one_vote_never_adopts_in_monnaie_libre() {
let (mut c, _) = Client::new().await;
c.login("Yvv", false).await;
let id = doc_id(&mut c, "monnaie-libre").await;
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["entered"].as_array().expect("e").len(), 0);
let (_, d) = c.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
assert_eq!(d["proposal"]["evaluation"]["wellRated"], true);
assert_eq!(d["proposal"]["myVote"], "for");
}
#[tokio::test]
async fn adoption_in_the_test_group_and_publication_queued_only_where_published() {
let (mut a, state) = Client::new().await;
a.login("Yvv", false).await;
let mut b = a.with(None);
b.login("Chiara", false).await;
// Dev identities are members of every group: W(essai) = 2, C = 1.
let id = doc_id(&mut a, "essai").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e3 = unit(&doc, "E3");
let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned();
let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
assert!(v["entered"].as_array().expect("e").is_empty(), "one of two is not enough");
let (_, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(v["entered"][0]["code"], "E3");
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e3 = unit(&doc, "E3");
assert_eq!(e3["inForce"]["id"], p.as_str());
assert!(e3["inForce"]["enteredAt"].is_string());
// The test group publishes nothing.
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE doc_id = ?")
.bind(&id)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(n, 0);
}
#[tokio::test]
async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let mut b = a.with(None);
b.login("Chiara", false).await;
let id = doc_id(&mut a, "essai").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e1 = unit(&doc, "E1");
let (uid, seed_id) =
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
let (s, v) = a
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
"unitId": uid, "title": "Unicité du compte",
"body": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie Ğ1.", "submit": true,
})))
.await;
assert_eq!(s, StatusCode::OK, "{v}");
let rival = v["id"].as_str().expect("id").to_owned();
a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(v["entered"][0]["kind"], "text");
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e1 = unit(&doc, "E1");
assert_eq!(e1["inForce"]["id"], rival.as_str());
assert!(
e1["proposals"].as_array().expect("p").iter().any(|p| p["id"] == seed_id.as_str() && p["status"] == "open")
);
}
#[tokio::test]
async fn editing_is_not_voting() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let mut b = a.with(None);
b.login("Chiara", false).await;
let id = doc_id(&mut a, "monnaie-libre").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let i1 = unit(&doc, "I1")["inForce"].clone();
let pid = i1["id"].as_str().expect("id").to_owned();
// Anyone may fix the glued word of the preamble in force, without a vote.
let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
let (s, v) =
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["edit"], "typo");
// A change of meaning in force is refused: it goes through a proposal.
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{pid}"),
Some(json!({ "title": i1["title"], "body": "Tout autre chose." })),
)
.await;
assert_eq!(s, StatusCode::CONFLICT);
assert_eq!(v["error"], "substantive_in_force");
// Flipping a relation in a seed text is not a typo, even with the same letters.
let k1 = unit(&doc, "K1")["proposals"][0].clone();
let flipped = k1["body"].as_str().expect("body").replace(">= 5 certifications", "<= 5 certifications");
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{}", k1["id"].as_str().expect("id")),
Some(json!({ "title": k1["title"], "body": flipped })),
)
.await;
assert_eq!(s, StatusCode::CONFLICT, "{v}");
assert_eq!(v["error"], "substantive_not_author");
// One word turned around in a text in force, and an invisible bidi override: refused.
let e2 = unit(&doc, "E2")["inForce"].clone();
let e2_id = e2["id"].as_str().expect("id").to_owned();
let body = e2["body"].as_str().expect("body").to_owned();
assert!(body.contains("ces deux présents"));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": e2["title"], "body": body.replacen("deux", "dix", 1) })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": e2["title"], "body": format!("\u{202e}{body}") })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
// A title is one line.
let (s, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(json!({ "title": "Récipro\ncité", "body": body })))
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("title")));
// The title never absorbs the start of the body.
let title = e2["title"].as_str().expect("title");
let (head, rest) = body.split_once(' ').expect("two words");
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{e2_id}"),
Some(json!({ "title": format!("{title} {head}"), "body": rest })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
// Typo fixes are measured against the voted text: slips never add up.
let patch = |body: String| json!({ "title": e2["title"], "body": body });
let (_, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
.await;
assert_eq!(v["edit"], "typo", "one slip is a typo");
let (s, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
.await;
assert_eq!(
(s, v["error"].as_str()),
(StatusCode::CONFLICT, Some("substantive_in_force")),
"a second slip on top is not"
);
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
assert_eq!(v["edit"], "typo", "back to the voted text");
// An address is compared exactly.
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
let x11_body = x11["body"].as_str().expect("body");
assert!(x11_body.contains("git.duniter.org"));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{}", x11["id"].as_str().expect("id")),
Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
// A member proposal: typo keeps votes, substance asks to reconfirm.
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
let (_, v) = a
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true,
})))
.await;
let p = v["id"].as_str().expect("id").to_owned();
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
// A misspelt word fixed: the vote stays.
let (_, v) = a
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces engagements.",
})))
.await;
assert_eq!(v["edit"], "typo");
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
// Words added: a new revision, the vote waits for reconfirmation.
let (_, v) = a
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces deux engagements fondamentaux.",
})))
.await;
assert_eq!(v["edit"], "substantive");
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["myVoteStale"], true);
assert_eq!(d["proposal"]["tally"]["for"], 0);
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["myVoteStale"], false);
assert_eq!(d["proposal"]["tally"]["for"], 1);
assert_eq!(d["history"].as_array().expect("h").len(), 3);
// Someone else cannot change the substance.
let (s, _) = b
.call(
"PATCH",
&format!("/api/proposals/{p}"),
Some(json!({ "title": "Réciprocité", "body": "Autre chose entièrement." })),
)
.await;
assert_eq!(s, StatusCode::CONFLICT);
}
#[tokio::test]
async fn a_contradiction_signal_blocks_entering_into_force() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let mut b = a.with(None);
b.login("Chiara", false).await;
let id = doc_id(&mut a, "essai").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
let (s, v) = b
.call(
"POST",
&format!("/api/proposals/{k1}/signals"),
Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
let sid = v["id"].as_str().expect("sid").to_owned();
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands");
// Only its author lifts it; then the pass lets the text in.
let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
assert_eq!(v["entered"][0]["code"], "K1");
}
#[tokio::test]
async fn inertia_change_is_voted_one_level_up() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let id = doc_id(&mut a, "monnaie-libre").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned();
let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
assert_eq!(again["id"], p.as_str());
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["votedAtInertia"], "high");
}
#[tokio::test]
async fn previews_and_assistant() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let id = doc_id(&mut a, "monnaie-libre").await;
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
assert_eq!(v["units"].as_array().expect("u").len(), 33);
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await;
assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes");
let (_, v) = a
.call("POST", &format!("/api/docs/{id}/assist"), Some(json!({
"text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.",
})))
.await;
let kinds: Vec<&str> = v["hints"].as_array().expect("h").iter().filter_map(|h| h["kind"].as_str()).collect();
assert!(kinds.contains(&"split"), "{v}");
}
#[tokio::test]
async fn split_a_draft() {
let (mut a, _) = Client::new().await;
a.login("Yvv", false).await;
let id = doc_id(&mut a, "essai").await;
let (_, v) = a
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" })))
.await;
let p = v["id"].as_str().expect("id").to_owned();
let (s, v) = a
.call(
"POST",
&format!("/api/proposals/{p}/split"),
Some(json!({ "parts": ["Première.", "Au \u{202e}01\u{202c}."] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
let (s, v) =
a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["drafts"].as_array().expect("d").len(), 2);
let (s, _) = a.call("POST", &format!("/api/proposals/{p}/submit"), None).await;
assert_eq!(s, StatusCode::OK);
}
#[tokio::test]
async fn access_management_is_for_administrators_only() {
let (mut user, _) = Client::new().await;
user.login("Chiara", false).await;
let (s, _) = user.call("GET", "/api/access", None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let mut admin = user.with(None);
admin.login("Yvv", true).await;
let (s, v) =
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara", "groups": ["essai"] }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let link = v["link"].as_str().expect("link").to_owned();
let token = link.split('#').nth(1).expect("token").to_owned();
let mut invited = user.with(None);
let (s, v) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["me"]["kind"], "invite");
let (_, g) = invited.call("GET", "/api/groups", None).await;
let open: Vec<&str> = g["groups"]
.as_array()
.expect("g")
.iter()
.filter(|g| g["open"] == true)
.filter_map(|g| g["slug"].as_str())
.collect();
assert_eq!(open, ["essai"]);
// Single use.
let mut again = user.with(None);
let (s, _) = again.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// Monnaie libre is seen but closed to an invited identity.
let (s, _) = invited.call("GET", "/api/groups/axiom", None).await;
assert_eq!(s, StatusCode::OK);
let (_, ov) = admin.call("GET", "/api/access", None).await;
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
// « Publier maintenant » is for administrators, and needs a configured publication.
let (s, _) = user.call("POST", "/api/access/publication/retry", None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (s, v) = admin.call("POST", "/api/access/publication/retry", None).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("publication_off")));
}
#[tokio::test]
async fn a_session_lasts_until_it_is_closed() {
const FOUR_HUNDRED_DAYS: &str = "Max-Age=34560000";
let (mut chiara, state) = Client::new().await;
chiara.login("Chiara", false).await;
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
// Renewed once a day at most: a visit right after changes nothing.
chiara.call("GET", "/api/me", None).await;
assert_eq!(chiara.set_cookie, None);
// Ten days left: the next visit starts the 400 days again, here and in the browser.
sqlx::query("UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+10 days')")
.execute(&state.db)
.await
.expect("age the session");
sqlx::query("UPDATE identities SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-10 days')")
.execute(&state.db)
.await
.expect("and the last visit");
let (_, v) = chiara.call("GET", "/api/me", None).await;
assert_eq!(v["me"]["displayName"], "Chiara");
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
let far: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sessions WHERE expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+399 days')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(far, 1);
let seen: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM identities WHERE id = 'dev:chiara' AND last_seen_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 minute')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(seen, 1, "the renewal says when the person was last seen");
// A session already over does not come back.
let mut old = chiara.with(None);
old.login("Chiara", false).await;
let old_cookie = old.cookie.clone();
sqlx::query(
"UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')
WHERE rowid = (SELECT MAX(rowid) FROM sessions)",
)
.execute(&state.db)
.await
.expect("end the session");
let (_, v) = old.call("GET", "/api/me", None).await;
assert_eq!((v["me"].clone(), old.set_cookie.clone()), (Value::Null, None));
assert_eq!(old.cookie, old_cookie);
// Until an administrator closes them: a lost device, a person who left.
let mut phone = chiara.with(None);
phone.login("Chiara", false).await;
let (s, _) = chiara.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let mut admin = chiara.with(None);
admin.login("Yvv", true).await;
let sessions_of = |ov: &Value, id: &str| {
ov["identities"].as_array().expect("i").iter().find(|i| i["id"] == id).expect("identity")["sessions"].clone()
};
// A sign-in link sent to the lost device goes with the sessions.
sqlx::query(
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
VALUES ('waiting', 'Chiara', 0, '[]', 'dev:yvv', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+14 days'), 'dev:chiara')",
)
.execute(&state.db)
.await
.expect("a sign-in link waiting");
let (_, ov) = admin.call("GET", "/api/access", None).await;
assert_eq!(sessions_of(&ov, "dev:chiara"), 2, "the session already over is not counted");
assert_eq!(sessions_of(&ov, "dev:yvv"), 0, "the session this page is read with is not counted");
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
assert_eq!((s, v["closed"].as_u64(), v["links"].as_u64()), (StatusCode::OK, Some(2), Some(1)));
for c in [&mut chiara, &mut phone] {
let (_, v) = c.call("GET", "/api/me", None).await;
assert_eq!(v["me"], Value::Null);
}
let waiting: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM invitations WHERE token_hash = 'waiting' AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
)
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(waiting, 0);
// An administrator's own: the others close, this one stays open.
let mut laptop = admin.with(None);
laptop.login("Yvv", true).await;
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:yvv/sessions", None).await;
assert_eq!((s, v["closed"].as_u64()), (StatusCode::OK, Some(1)));
let (_, v) = admin.call("GET", "/api/me", None).await;
assert_eq!(v["me"]["displayName"], "Yvv");
let (_, v) = laptop.call("GET", "/api/me", None).await;
assert_eq!(v["me"], Value::Null);
let (s, _) = admin.call("DELETE", "/api/access/identities/nobody/sessions", None).await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_team_link_makes_one_identity_per_opening() {
let (mut admin, _) = Client::new().await;
admin.login("Yvv", true).await;
let mut user = admin.with(None);
user.login("Chiara", false).await;
let team = |groups: Value, days: i64, max: i64| json!({ "label": "Équipe dev", "groups": groups, "days": days, "maxUses": max });
let (s, _) = user.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
assert_eq!(s, StatusCode::FORBIDDEN, "administrators only");
for (body, code) in [
(team(json!([]), 30, 2), "groups"),
(team(json!(["nope"]), 30, 2), "groups"),
(team(json!(["essai"]), 0, 2), "team_limits"),
(team(json!(["essai"]), 30, 501), "team_limits"),
] {
let (s, v) = admin.call("POST", "/api/access/team-links", Some(body)).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)));
}
let (s, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let link = v["link"].as_str().expect("link").to_owned();
assert!(link.contains("/equipe#"), "{link}");
let token = link.split('#').nth(1).expect("token").to_owned();
let mut first = admin.with(None);
let (s, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["label"], "Équipe dev");
assert_eq!(v["groups"].as_array().expect("g").len(), 1);
assert_eq!(v["signedInAs"], Value::Null);
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lu\u{202e}na" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": " Luna " }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!((v["me"]["kind"].as_str(), v["me"]["admin"].as_bool()), (Some("invite"), Some(false)));
assert_eq!(v["me"]["displayName"], "Luna");
let luna = v["me"]["id"].as_str().expect("id").to_owned();
let (_, g) = first.call("GET", "/api/groups", None).await;
assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true));
// What an invited person writes says so next to their name.
let essai = doc_id(&mut first, "essai").await;
let (_, doc) = first.call("GET", &format!("/api/docs/{essai}"), None).await;
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
let (_, v) = first
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes connues.", "submit": true,
})))
.await;
let (_, d) = admin.call("GET", &format!("/api/proposals/{}", v["id"].as_str().expect("id")), None).await;
assert_eq!(
(d["proposal"]["author"].as_str(), d["proposal"]["authorInvited"].as_bool()),
(Some("Luna"), Some(true))
);
assert_eq!(d["history"][0]["authorInvited"], true);
// Nobody joins under a name already there; the refusal does not count as an opening.
let mut second = admin.with(None);
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
}
// Nor under the name an invitation still waits for.
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara Rossi", "groups": ["essai"] }))).await;
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "chiara rossi" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
// Look-alike letters from other alphabets are not a name.
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
// Two openings allowed: the third is refused.
let mut third = admin.with(None);
let (s, v) = third.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Zoé" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invitation_invalid")));
// Someone signed in keeps their identity and receives the groups.
let (_, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["monnaie-libre"]), 7, 5))).await;
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
assert_eq!(v["signedInAs"], "Luna");
for _ in 0..3 {
// Back again with the session open: no place taken once the groups are there.
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
}
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
assert_eq!(s, StatusCode::OK);
// Administrators see the links still open and where each identity came from; a link revokes.
let (_, ov) = admin.call("GET", "/api/access", None).await;
let links = ov["teamLinks"].as_array().expect("links");
assert_eq!(links.len(), 2, "{ov}");
assert!(
ov["identities"]
.as_array()
.expect("i")
.iter()
.any(|i| i["displayName"] == "Paul" && i["teamLink"] == "Équipe dev")
);
let open = links.iter().find(|l| l["uses"] == 1).expect("second link");
let (s, _) =
admin.call("DELETE", &format!("/api/access/team-links/{}", open["id"].as_str().expect("id")), None).await;
assert_eq!(s, StatusCode::OK);
let mut late = admin.with(None);
let (s, _) = late.call("POST", "/api/auth/team", Some(json!({ "token": token2, "name": "Tardif" }))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn in_monnaie_libre_only_web_of_trust_members_vote() {
let (mut admin, _) = Client::new().await;
admin.login("Yvv", true).await;
let (_, v) = admin
.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": ["monnaie-libre"] })))
.await;
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
let mut invited = admin.with(None);
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
assert_eq!(s, StatusCode::OK);
// She reads the text and may propose, but her vote would not be a web-of-trust vote.
let id = doc_id(&mut invited, "monnaie-libre").await;
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(doc["canVote"], false);
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(s, StatusCode::CONFLICT, "{v}");
assert_eq!(v["error"], "wot_members_only");
// In the test group, every member votes.
let essai = doc_id(&mut admin, "essai").await;
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
assert_eq!(d["canVote"], true);
// A new link may only target an invitation identity, never another kind.
let (_, me) = admin.call("GET", "/api/me", None).await;
let dev_id = me["me"]["id"].as_str().expect("id").to_owned();
let (s, _) =
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Yvv", "identityId": dev_id }))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn feedback_reaches_administrators() {
let (mut user, _) = Client::new().await;
user.login("Chiara", false).await;
let (s, _) = user
.call("POST", "/api/feedback", Some(json!({ "route": "/g/essai", "body": "Le bouton est trop discret." })))
.await;
assert_eq!(s, StatusCode::OK);
let mut admin = user.with(None);
admin.login("Yvv", true).await;
let (_, v) = admin.call("GET", "/api/access/feedback", None).await;
assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret.");
}
#[tokio::test]
async fn publication_writes_the_texts_in_force_to_the_repository() {
use std::process::Command;
let dir = std::env::temp_dir().join(format!("ld-pub-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let remote = dir.join("textes.git");
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
let mut cfg = Config::for_tests();
cfg.data_dir = dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: remote.display().to_string(),
branch: "main".into(),
key: dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
});
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
seed::ensure(&state).await.expect("seed");
ld_server::publish::pass(&state).await.expect("pass");
let show = |path: &str| {
let out = Command::new("git")
.arg("-C")
.arg(&remote)
.args(["show", &format!("main:{path}")])
.output()
.expect("git show");
String::from_utf8(out.stdout).expect("utf8")
};
let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}");
assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
let json: Value =
serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 3);
assert_eq!(json["units"][0]["status"], "seed-minimal");
assert!(show("README.md").contains("libreDecision"));
let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(done, 0);
// A restart queues the document again; nothing changed, so nothing is committed.
let head = || {
let out = Command::new("git").arg("-C").arg(&remote).args(["rev-parse", "main"]).output().expect("rev-parse");
String::from_utf8(out.stdout).expect("utf8")
};
let before = head();
ld_server::publish::resync(&state).await.expect("resync");
ld_server::publish::pass(&state).await.expect("pass");
assert_eq!(head(), before, "an unchanged document produces no commit");
let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&state.db)
.await
.expect("count");
assert_eq!(pending, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives_it() {
// Tenth independent review (850f9c4), 2026-10-21: a first push refused (a key
// without write access) left the commit in the server's clone, and the next pass,
// finding nothing to commit, marked the queue done with the repository still empty.
use std::process::Command;
let dir = std::env::temp_dir().join(format!("ld-pub-retry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("dir");
let bare = |name: &str| {
let path = dir.join(name);
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&path).status().expect("git").success());
path
};
let config = |remote: &std::path::Path| {
let mut cfg = Config::for_tests();
cfg.data_dir = dir.join("data");
cfg.publish = Some(ld_server::config::Publish {
remote: remote.display().to_string(),
branch: "main".into(),
key: dir.join("data/keys/publish_ed25519"),
author_name: "libreDecision".into(),
author_email: "decision@example.org".into(),
});
cfg
};
let has_main = |remote: &std::path::Path| {
Command::new("git")
.arg("-C")
.arg(remote)
.args(["rev-parse", "--verify", "-q", "main"])
.output()
.expect("git")
.status
.success()
};
let pending = |db: &sqlx::SqlitePool| {
let db = db.clone();
async move {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&db)
.await
.expect("count")
}
};
let first = bare("first.git");
let hook = first.join("hooks/pre-receive");
std::fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("hook");
assert!(Command::new("chmod").arg("755").arg(&hook).status().expect("chmod").success());
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(config(&first), db.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
ld_server::publish::pass(&state).await.expect("pass");
assert!(!has_main(&first), "the push was refused");
assert_eq!(pending(&db).await, 1, "the document still waits");
// The key gets its write access: the next start sends what waited, at once.
std::fs::remove_file(&hook).expect("unhook");
ld_server::publish::resync(&state).await.expect("resync");
ld_server::publish::pass(&state).await.expect("pass");
assert!(has_main(&first), "the commit kept in the clone went out");
assert_eq!(pending(&db).await, 0);
// A change of remote to a new, empty repository: it receives the history.
let second = bare("second.git");
let moved = AppState::new(config(&second), db.clone()).await.expect("state");
ld_server::publish::resync(&moved).await.expect("resync");
ld_server::publish::pass(&moved).await.expect("pass");
assert!(has_main(&second), "the new repository received the texts");
assert_eq!(pending(&db).await, 0);
let _ = std::fs::remove_dir_all(&dir);
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "trustin-access"
description = "Access core of trustinWallet as libreDecision uses it today: identities, groups, who sees and who opens what."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
[lints]
workspace = true
+219
View File
@@ -0,0 +1,219 @@
//! trustin-access — the access core of trustinWallet, as libreDecision uses it
//! today.
//!
//! trustinWallet (formerly trustWallet, a name already taken) is the identity
//! and rights layer of the ecosystem. Until it runs as its own service, this
//! crate holds its rules so that libreDecision never decides access by itself:
//! the server asks `sees` / `opens` / `manages_access`, and stores grants.
//!
//! Rules (Yvv, 2026-10-21):
//! - anyone signed in with a Ğ1 key sees the groups that use the same key —
//! Monnaie libre, Axiom, Librodrome, the local group Vallée Drôme Crestois;
//! - a group opens only to its members; Monnaie libre's members are the
//! members of the Ğ1 web of trust, the others are granted one by one;
//! - everything is open to a member except access management, which belongs
//! to trustinWallet's administrators;
//! - where the electorate is the web of trust (Monnaie libre), only its members
//! vote (2026-10-21, independent review: one person, one vote).
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IdentityKind {
/// Signed in through connect.monnaie-libre.fr: a Ğ1 identity (`idty:<index>`).
G1,
/// Signed in with an invitation link (tests, before Ğ1 sign-in is wired).
Invite,
/// Local development only.
Dev,
}
impl IdentityKind {
pub fn as_str(self) -> &'static str {
match self {
Self::G1 => "g1",
Self::Invite => "invite",
Self::Dev => "dev",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::G1, Self::Invite, Self::Dev].into_iter().find(|k| k.as_str() == raw)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Identity {
pub id: String,
pub kind: IdentityKind,
pub display_name: String,
/// Member of the Ğ1 web of trust at the last sign-in.
pub g1_member: bool,
/// Administrator of access (trustinWallet).
pub admin: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GroupPolicy {
/// Open to every member of the Ğ1 web of trust, plus explicit grants.
G1Members,
/// Open to explicit grants only.
Managed,
}
impl GroupPolicy {
pub fn as_str(self) -> &'static str {
match self {
Self::G1Members => "g1-members",
Self::Managed => "managed",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::G1Members, Self::Managed].into_iter().find(|p| p.as_str() == raw)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Group {
pub slug: String,
pub name: String,
pub parent: Option<String>,
pub policy: GroupPolicy,
/// Hidden groups are seen by their members only (the test group).
pub hidden: bool,
}
/// Does this identity belong to the group? `granted` = an explicit grant exists.
pub fn is_member(identity: &Identity, group: &Group, granted: bool) -> bool {
granted || (group.policy == GroupPolicy::G1Members && identity.kind == IdentityKind::G1 && identity.g1_member)
}
/// Is the group listed for this identity?
pub fn sees(identity: &Identity, group: &Group, granted: bool) -> bool {
!group.hidden || is_member(identity, group, granted) || identity.admin
}
/// Can this identity open the group and use its tools (read, propose, vote)?
pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool {
is_member(identity, group, granted)
}
/// Can this identity vote in the group? Where the electorate is the Ğ1 web of
/// trust (policy `G1Members`), only its members vote: the threshold is computed
/// on the size of the web, and an invitation identity next to a Ğ1 identity
/// would give one person two votes. Development identities stand in for members
/// (local only: the dev login is never enabled in production).
pub fn votes_in(identity: &Identity, group: &Group, granted: bool) -> bool {
opens(identity, group, granted)
&& (group.policy != GroupPolicy::G1Members
|| identity.kind == IdentityKind::Dev
|| (identity.kind == IdentityKind::G1 && identity.g1_member))
}
/// Access management stays with trustinWallet's administrators.
pub fn manages_access(identity: &Identity) -> bool {
identity.admin
}
/// The groups a fresh deployment starts with. Ordered as displayed.
pub fn default_groups() -> Vec<Group> {
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
slug: slug.into(),
name: name.into(),
parent: parent.map(Into::into),
policy,
hidden,
};
vec![
g("monnaie-libre", "Monnaie libre", None, GroupPolicy::G1Members, false),
g("axiom", "Axiom", Some("monnaie-libre"), GroupPolicy::Managed, false),
g("librodrome", "Librodrome", Some("monnaie-libre"), GroupPolicy::Managed, false),
g(
"vallee-drome-crestois",
"Groupe local Vallée Drôme Crestois",
Some("monnaie-libre"),
GroupPolicy::Managed,
false,
),
g("essai", "Groupe d'essai", None, GroupPolicy::Managed, true),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn who(kind: IdentityKind, g1_member: bool, admin: bool) -> Identity {
Identity { id: "x".into(), kind, display_name: "x".into(), g1_member, admin }
}
fn group(slug: &str) -> Group {
default_groups().into_iter().find(|g| g.slug == slug).expect("default group")
}
#[test]
fn a_wot_member_sees_all_four_and_opens_monnaie_libre_only() {
let member = who(IdentityKind::G1, true, false);
for slug in ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois"] {
assert!(sees(&member, &group(slug), false), "{slug}");
}
assert!(opens(&member, &group("monnaie-libre"), false));
assert!(!opens(&member, &group("axiom"), false));
assert!(opens(&member, &group("axiom"), true));
assert!(!manages_access(&member));
}
#[test]
fn the_test_group_is_hidden_from_non_members() {
let member = who(IdentityKind::G1, true, false);
assert!(!sees(&member, &group("essai"), false));
assert!(sees(&member, &group("essai"), true));
}
#[test]
fn a_non_member_key_or_an_invitation_does_not_open_monnaie_libre_by_itself() {
let former = who(IdentityKind::G1, false, false);
assert!(!opens(&former, &group("monnaie-libre"), false));
let invited = who(IdentityKind::Invite, false, false);
assert!(!opens(&invited, &group("monnaie-libre"), false));
assert!(opens(&invited, &group("monnaie-libre"), true));
}
#[test]
fn administrators_manage_access_and_see_hidden_groups_but_open_nothing_by_right() {
let admin = who(IdentityKind::Invite, false, true);
assert!(manages_access(&admin));
assert!(sees(&admin, &group("essai"), false));
assert!(!opens(&admin, &group("essai"), false));
}
#[test]
fn only_wot_members_vote_where_the_electorate_is_the_web_of_trust() {
let ml = group("monnaie-libre");
assert!(votes_in(&who(IdentityKind::G1, true, false), &ml, false));
let invited = who(IdentityKind::Invite, false, true);
assert!(opens(&invited, &ml, true), "an invitation may open Monnaie libre to read and propose");
assert!(!votes_in(&invited, &ml, true), "but its votes would not be web-of-trust votes");
assert!(!votes_in(&who(IdentityKind::G1, false, false), &ml, true), "a former member reads, does not vote");
assert!(votes_in(&invited, &group("essai"), true), "managed groups: every member votes");
assert!(!votes_in(&invited, &group("essai"), false));
}
#[test]
fn no_atelier_du_canal() {
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));
}
#[test]
fn round_trips() {
for k in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] {
assert_eq!(IdentityKind::parse(k.as_str()), Some(k));
}
for p in [GroupPolicy::G1Members, GroupPolicy::Managed] {
assert_eq!(GroupPolicy::parse(p.as_str()), Some(p));
}
}
}
+37
View File
@@ -0,0 +1,37 @@
# libreDecision v3 on sonic — decision.librodrome.org.
# Runtime data lives in the named volume `data` (SQLite, the server's clone of
# the texts repository, its deploy key): never `down -v`, never recreated.
# Settings that are not secret are here; secrets, if any, in /opt/libredecision-app/secrets.env.
name: lpk-libredecision-app
services:
app:
image: libredecision-app:latest
container_name: lpk-libredecision-app
restart: always
env_file:
- path: /opt/libredecision-app/secrets.env
required: false
environment:
LD_PUBLIC_URL: https://${APP_DOMAIN:-decision.librodrome.org}
LD_ADMIN_G1_IDS: ${LD_ADMIN_G1_IDS:-idty:8352}
LD_BOOTSTRAP_ADMIN: "1"
LD_PUBLISH_REMOTE: ${LD_PUBLISH_REMOTE:-gitea@git.open.us.org:yvv/decision-depot-textes-reference.git}
LD_PUBLISH_BRANCH: main
RUST_LOG: info,sqlx=warn,tower_http=warn
volumes:
- data:/data
labels:
# Registrator → Consul → Fabio.
- SERVICE_8002_NAME=lpk-libredecision-app-8002
- SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/*
- SERVICE_8002_CHECK_TCP=true
networks:
- sonic
volumes:
data:
networks:
sonic:
external: true
+56
View File
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
# libreDecision v3 — one image: the Rust server serves the API and the compiled
# Svelte interface. Every gate of the loop runs inside the build: a red check
# means no image.
# --- Interface -----------------------------------------------------------------
FROM node:22-bookworm-slim AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY web/ ./
ARG LD_BUILD=dev
ENV LD_BUILD=${LD_BUILD}
RUN npx svelte-kit sync \
&& npx svelte-check --tsconfig ./tsconfig.json --fail-on-warnings \
&& npx vitest run \
&& npx vite build
# --- Server --------------------------------------------------------------------
FROM rust:1.99-slim-bookworm AS server
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY rust-toolchain.toml ./
RUN rustup show active-toolchain || rustup toolchain install
COPY Cargo.toml Cargo.lock rustfmt.toml ./
COPY crates crates
COPY seeds seeds
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo fmt --all --check \
&& cargo clippy --workspace --all-targets --locked -- -D warnings \
&& cargo test --workspace --locked \
&& cargo build --release --locked -p ld-server \
&& cp target/release/ld-server /usr/local/bin/ld-server
# --- Runtime -------------------------------------------------------------------
FROM debian:bookworm-slim AS production
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git openssh-client tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --create-home --home-dir /home/app app \
&& mkdir -p /data && chown app:app /data
COPY --from=server /usr/local/bin/ld-server /usr/local/bin/ld-server
COPY --from=web /src/web/build /app/web
COPY seeds /app/seeds
ARG LD_BUILD=dev
ENV LD_BUILD=${LD_BUILD} \
LD_BIND=0.0.0.0:8002 \
LD_DATA_DIR=/data \
LD_WEB_DIR=/app/web \
LD_SEED_DIR=/app/seeds
USER app
EXPOSE 8002
VOLUME /data
ENTRYPOINT ["tini", "--"]
CMD ["ld-server"]
-85
View File
@@ -1,85 +0,0 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision} -d ${POSTGRES_DB:-glibredecision}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- glibredecision
backend:
build:
context: ../
dockerfile: docker/backend.Dockerfile
target: production
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-glibredecision}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production-with-a-real-secret-key}
DEBUG: "false"
CORS_ORIGINS: '["https://${DOMAIN:-glibredecision.org}"]'
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
IPFS_API_URL: http://ipfs:5001
IPFS_GATEWAY_URL: http://ipfs:8080
labels:
- "traefik.enable=true"
- "traefik.http.routers.glibredecision-api.rule=Host(`${DOMAIN:-glibredecision.org}`) && PathPrefix(`/api`)"
- "traefik.http.routers.glibredecision-api.entrypoints=websecure"
- "traefik.http.routers.glibredecision-api.tls.certresolver=letsencrypt"
- "traefik.http.services.glibredecision-api.loadbalancer.server.port=8002"
networks:
- glibredecision
- traefik
frontend:
build:
context: ../
dockerfile: docker/frontend.Dockerfile
target: production
restart: unless-stopped
depends_on:
- backend
environment:
NUXT_PUBLIC_API_BASE: https://${DOMAIN:-glibredecision.org}/api/v1
labels:
- "traefik.enable=true"
- "traefik.http.routers.glibredecision-front.rule=Host(`${DOMAIN:-glibredecision.org}`)"
- "traefik.http.routers.glibredecision-front.entrypoints=websecure"
- "traefik.http.routers.glibredecision-front.tls.certresolver=letsencrypt"
- "traefik.http.services.glibredecision-front.loadbalancer.server.port=3000"
networks:
- glibredecision
- traefik
ipfs:
image: ipfs/kubo:latest
restart: unless-stopped
volumes:
- ipfs-data:/data/ipfs
networks:
- glibredecision
volumes:
postgres-data:
ipfs-data:
networks:
glibredecision:
driver: bridge
traefik:
external: true
+19
View File
@@ -0,0 +1,19 @@
# 0001 — Rust + Svelte, une image, SQLite
**Contexte.** La v2 (Nuxt local-first + FastAPI) a servi de maquette. Yvv veut « un dev rust -
svelte pour la longue durée, la performance, la modularité et la compilation de haute qualité ».
Le premier chantier a besoin d'un serveur (votes partagés, publication), pas d'un stockage
navigateur.
**Décision.**
- Serveur en Rust : `ld-engine` (règles pures, sans I/O), `trustin-access` (accès),
`ld-server` (axum + sqlx). Toolchain épinglée, `clippy -D warnings`, `unsafe` interdit.
- Interface SvelteKit / Svelte 5 en SPA statique, compilée et servie par le même binaire.
- SQLite (WAL) dans un volume nommé : une instance, des milliers de votants, aucune base à
administrer. Migrations additives seulement.
- Une seule image ; sa construction rejoue toute la boucle.
- La maquette v2 reste dans `maquette/`, intacte, déployée à part.
**Coût.** Deux langages dans le dépôt ; une instance unique (pas de réplication). Si la
synchronisation entre instances devient nécessaire, elle passera par le dépôt des textes et une
API, pas par la base.
+20
View File
@@ -0,0 +1,20 @@
# 0002 — Vote permanent : marge du texte en place et plancher C
**Contexte.** Chaque article est sous vote permanent ; « dès qu'une proposition atteint le score,
elle remonte en vigueur ». La formule inertielle Ğ1 donne un seuil faible quand peu de gens
votent : sans garde-fou, une seule voix ferait passer un texte. Sans règle de départage, deux
versions qui atteignent leur seuil s'échangeraient la place à chaque vote.
**Décision.**
- Plancher **C = nombre minimal de votants « pour »** (Monnaie libre : 50 ; essai : 1), dans
la formule (`R = C + …`) — choix d'Yvv.
- Une proposition entre en vigueur si elle atteint son seuil **et** tient une marge
(pour − requis) strictement plus large que la version en place. La version déplacée redevient
une proposition avec ses votes : rien n'est perdu, tout peut revenir.
- Les textes de départ sont des *seeds a minima* (0 vote, marge −C) : visibles, non adoptés.
- Changer l'inertie d'un article est une proposition votée à l'inertie du cran au-dessus.
- Le balayage tourne après chaque geste et toutes les 5 minutes, sous un verrou global.
**Coût.** Une version en place très soutenue est difficile à déloger — c'est voulu. La taille
de la toile (W) bouge : un texte peut passer ou repasser sans nouveau vote ; chaque entrée en
vigueur est datée et chiffrée dans `adoptions`.
+48
View File
@@ -0,0 +1,48 @@
# 0003 — Corriger n'est pas voter
**Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté — et tout
membre peut corriger un texte en vigueur, qui est republié aussitôt.
**Décision.** Une coquille touche **les lettres et les espaces, rien d'autre**. Le moteur
(`ld-engine/src/edit.rs`) la juge toujours **contre le texte voté** (dernière révision initiale
ou de fond), jamais contre la correction précédente :
- la **forme** du texte — tout ce qui n'est ni lettre latine ni espace, dans l'ordre, avec les
espaces qui touchent un chiffre — reste identique (apostrophes et guillemets de tous styles
comptent pour un même signe) ;
- les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves
restent identiques à la lettre ;
- les espaces (espace, insécable, fine insécable) ne font que coller ou couper des mots, deux
au plus, jamais en touchant une négation ou une quantité (ses morceaux ou le mot entier), jamais en déplaçant
une frontière ; sauts de ligne et indentation font partie de la forme ;
- le titre et le corps sont jugés chacun pour soi, puis ensemble ;
- au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre
ajoutée, retirée ou deux voisines inversées ; jamais aux deux premières ni aux deux dernières
lettres ; jamais une lettre remplacée ; jamais sur un mot de négation, de quantité ou de
position) ;
- les caractères invisibles (ensemble Unicode des ignorables, contrôles autres que saut de
ligne et tabulation) sont refusés partout où l'on écrit ; un titre tient sur une ligne.
Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes
antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ.
L'écriture ne vaut que sur le texte jugé : un texte entré en vigueur ou changé entre-temps
donne un conflit. Les brouillons s'éditent librement.
**Historique.** La première version tolérait la ponctuation et deux mots « à deux lettres
près ». Six relectures indépendantes (2026-10-21) ont trouvé, l'une après l'autre, des
coquilles qui changeaient le sens : mots retournés (« valide » → « invalide »), caractères
invisibles, sosies de chiffres, corrections enchaînées, adresse typosquattée, signe moins
devant un nombre en gras. D'où le passage d'une liste noire à cette règle. La septième
(`fa8e8dc : ok`) a laissé trois points, corrigés aussitôt : mots collés ou coupés sans limite
(« Si non » → « Sinon »), titre jugé avec le corps (le titre en absorbait le début), caractères
invisibles non contrôlés au découpage d'un brouillon ; et deux remarques : tabulation et retour
chariot ne sont plus des espaces libres, les lettres latines rares font partie de la forme.
La huitième (`c3d6bf8 : ok`) a laissé deux points, corrigés ensuite : une négation coupée en
morceaux qui n'en sont pas (« ja mais »), des invisibles hors de la liste (trait d'union
conditionnel, étiquettes) ; et deux remarques : un saut de ligne dans un titre, une liste
Markdown fusionnée par les espaces. La neuvième (`fd19dbf : ok`) : un mot de quantité collé
(« sur tout » → « surtout », « par tout » → « partout ») — corrigé.
**Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une
proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
publié vaut plus qu'un point final.
+14
View File
@@ -0,0 +1,14 @@
# 0004 — Contradictions : signalées par les personnes, bloquantes
**Contexte.** « Il faut que 2 unités modulaires ne puissent pas passer en vigueur si elles se
contredisent. » La contradiction est une question de sens : aucune règle ne la détecte sans
se tromper, et l'IA est écartée pour l'instant.
**Décision.** Tout membre signale une contradiction entre deux propositions, avec un motif.
Tant que le signalement tient, celle qui n'est pas en vigueur ne peut pas y entrer. Il se lève
par son auteur ou un administrateur, et tombe si la proposition signalée change sur le fond.
L'assistant, lui, prévient (sans bloquer) quand un texte porte plusieurs décisions ou ressemble
à une proposition existante.
**Coût.** Un signalement abusif bloque une proposition jusqu'à sa levée. Les signalements sont
publics, motivés, et l'administration peut les lever.
+23
View File
@@ -0,0 +1,23 @@
# 0005 — Publication par file d'envoi vers un dépôt git
**Contexte.** « Le tout est automatiquement poussé sur le dépôt git exploité par les applis
clientes. » Le réseau ou la forge peuvent être indisponibles ; un vote ne doit jamais échouer
pour autant.
**Décision.** Chaque entrée en vigueur (ou correction de coquille, ou traduction d'un texte en
vigueur) ajoute une ligne à une file d'envoi en base ; au démarrage, chaque document publié y
est remis une fois (rien ne se perd sur un redémarrage, un document inchangé ne fait pas de
commit : sa date est celle du dernier changement, jamais « maintenant »). Un travailleur rend `fr.md`, `en.md`,
`es.md` et `document.json` dans le clone du serveur, commit, pousse avec une clé de déploiement
générée par le serveur (sa partie publique s'affiche dans trustinWallet), et réessaie avec un
délai croissant. Dépôt : `git.open.us.org/yvv/decision-depot-textes-reference`, un dossier par groupe et
par document — provisoire (Yvv, 2026-10-21) : viendront un dépôt pour l'ouverture, par
transfert, puis le dépôt officiel pour les textes en vigueur ; changer de dépôt tient en une
ligne (`LD_PUBLISH_REMOTE`) : le serveur réaligne son clone, et un dépôt neuf et vide reçoit
l'historique. Un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans droit
d'écriture) repart au passage suivant ; au démarrage, ce qui attendait repart aussitôt ;
trustinWallet montre l'état (en attente, dernier échec, dernier commit) et offre « Publier
maintenant ». Le français fait foi ; les traductions sont marquées éditoriales.
**Coût.** La publication est asynchrone (quelques secondes, plus en cas de panne) ; l'état
de la file se lit dans trustinWallet.
@@ -0,0 +1,47 @@
# 0006 — Accès : connect.monnaie-libre.fr, invitations, trustinWallet
**Contexte.** « Le même accès que pour le forum Monnaie libre », « toute personne avec une clé
Duniter peut se loger et tout faire — sauf la gestion des accès », qui revient à trustinWallet
(nouveau nom de trustWallet, déjà pris).
**Décision.**
- Connexion Ğ1 : libreDecision est client OAuth2 de connect.monnaie-libre.fr (comme le forum).
Le compte est la clé d'identité (`idty:N`) ; on garde le nom affiché et le statut de membre.
- En attendant l'inscription du client : liens d'invitation à usage unique (14 jours), le
secret dans le fragment de l'URL (jamais dans les journaux du serveur). Un administrateur
peut redonner un lien à une personne invitée (autre appareil, session perdue).
- **Liens d'équipe** (Yvv, 2026-10-21 : « je vais l'utiliser plein de fois, pendant tout le
dev, et la partager avec une équipe ») : un lien réutilisable jusqu'à une date (90 jours au
plus) et pour un nombre de personnes (500 au plus), révocable. Chaque ouverture demande un
nom et crée **sa propre** identité d'invitation, jamais administratrice. Un nom déjà pris est
refusé, quelle que soit sa tenue (casse, accents, ponctuation : `ld_engine::names`, index
unique) ; un nom s'écrit en lettres latines, chiffres, espace, trait d'union, apostrophe ou
point (pas de sosie cyrillique, grec ou pleine chasse). Une personne déjà connectée garde son
identité et reçoit les groupes ; elle ne prend une place que si le lien lui en ajoute un ; sur
un navigateur partagé, « Ce n'est pas moi » la déconnecte. Un nom qu'une invitation attend
est protégé aussi.
- Tout auteur venu par invitation porte la mention **« sur invitation »** à côté de son nom
(propositions, révisions, signalements) : un nom pris avant l'arrivée de son titulaire Ğ1, ou
une lettre sosie, ne passe jamais pour un membre de la toile.
trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même
personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie
libre (seule la toile vote), à surveiller dans le groupe d'essai.
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https (`__Host-`), jeton haché en base ;
en-tête anti-CSRF sur toute écriture. Une session dure **jusqu'à ce qu'on la ferme** : 400 jours
(ce qu'un navigateur garde au plus), recomptés à chaque visite, une fois par jour au plus. On la
ferme en se déconnectant. Un administrateur peut aussi, dans trustinWallet, fermer les sessions
d'une identité (appareil perdu, départ), sauf celle d'où il agit ; il annule du même geste le
lien de connexion encore en attente pour cette identité. Une personne non administratrice qui
perd un appareil le demande à un administrateur. Jusqu'au 2026-10-23, les sessions duraient
30 jours fixes ; on a changé parce qu'une personne invitée n'a pas de clé Ğ1 pour revenir, et
qu'une session expirée lui coûtait un nouveau lien.
- Les règles (qui voit, qui ouvre, qui administre) vivent dans `trustin-access`, futur cœur du
service trustinWallet ; le serveur ne décide jamais seul.
- **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre),
seuls ses membres votent ; une identité d'invitation y lit et propose. Un nouveau lien ne
vise jamais une identité Ğ1 (un administrateur ouvrirait une session à sa place).
- Premier administrateur : un lien d'invitation écrit au premier démarrage dans le volume de
données (0600), jamais dans les journaux.
**Coût.** Tant que connect n'est pas branché, chaque testeuse a besoin d'un lien. Quand
trustinWallet deviendra un service, `trustin-access` deviendra son client.
+13
View File
@@ -0,0 +1,13 @@
# Décisions d'architecture
Une décision durable par fichier : le contexte, la décision, ce qu'elle coûte. On n'efface
pas une décision : on en écrit une nouvelle qui la remplace, et on le note dans l'ancienne.
| # | Décision | Date |
|---|---|---|
| [0001](0001-rust-svelte.md) | Rust + Svelte, une image, SQLite | 2026-10-21 |
| [0002](0002-vote-permanent.md) | Vote permanent : marge du texte en place et plancher C | 2026-10-21 |
| [0003](0003-corriger-n-est-pas-voter.md) | Corriger n'est pas voter | 2026-10-21 |
| [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes | 2026-10-21 |
| [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 |
| [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 |
+20
View File
@@ -0,0 +1,20 @@
# Les chantiers
Un chantier à la fois, **livré complet et en production** avant d'ouvrir le suivant. Chaque
chantier a sa fiche (`CHANTIER.md` : ce qu'on construit et pourquoi), ses contrôles d'usage
(`CONTROLES.md`) et sa branche commune `chantier/<nom>`.
| # | Chantier | Outil | Ce qu'il ajoute | État |
|---|---|---|---|---|
| 1 | [Acte d'engagement Certification](acte-certification/CHANTIER.md) | Docs de référence | le texte sous vote permanent, l'édition distinguée du vote, les contradictions, l'inertie votée, la publication git, l'accès Ğ1 + trustinWallet, le trilingue | **en cours** — binôme Yvv · Chiara |
| 2 | Acte d'engagement forgeron | Docs de référence | la **qualification d'une compétence** (au-delà de l'engagement personnel) | à ouvrir après la mise en production du 1 |
| 3 | Runtime Upgrade | Protocoles | un **protocole** : décision on-chain d'une mise à jour du runtime Duniter | après le 2 |
Les outils Décisions et Mandats restent grisés tant qu'aucun chantier ne les ouvre.
## Ce qu'un chantier doit laisser derrière lui
- la boucle verte (Rust, interface, e2e) et l'image déployée sur decision.librodrome.org ;
- les contrôles d'usage passés par Chiara (ses retours valent contrôles) ;
- la fiche à jour : décisions prises, questions ouvertes, ce qui reste ;
- les décisions d'architecture durables consignées dans `docs/adr/`.
@@ -0,0 +1,220 @@
# Chantier 1 — Acte d'engagement Certification
> Binôme : **Yvv** (porteur) · **Chiara** (testeuse et codeuse — ses tests d'usage valent
> contrôles). Branche commune : `chantier/acte-certification`. Ouvert le 2026-10-21.
> Production : https://decision.librodrome.org
## Le besoin, en une phrase
La communauté Monnaie libre tient son Acte d'engagement Certification comme un **texte vivant** :
lisible d'abord, modifiable par article, chaque modification passant par un vote dont le seuil
suit la formule inertielle de la Ğ1 — sans qu'une coquille demande un vote, sans que deux
articles contradictoires soient en vigueur ensemble, et avec une publication automatique que
les applications clientes (Cesium, Ğecko…) peuvent suivre.
## Ce que voit la personne
1. **Accueil sobre** : « Rendre possible la décision collective » / « en fonction du contexte,
processus et modalités, boîte à outils », les quatre outils en une ligne chacun, puis la
connexion. Le menu complet à gauche : Décisions · Docs de référence · Mandats · Protocoles
(seul Docs de référence est actif).
2. **Connexion Ğ1** — la même que le forum Monnaie libre (connect.monnaie-libre.fr). En attendant
l'inscription du client OAuth2, des **liens d'invitation** à usage unique (une personne),
et des **liens d'équipe** réutilisables jusqu'à une date (chaque ouverture crée sa propre
identité, jamais administratrice).
3. **Groupes** : avec une clé Ğ1, on voit Monnaie libre, Axiom, Librodrome et le Groupe local
Vallée Drôme Crestois — pas l'Atelier du canal. Un groupe et son contenu ne s'ouvrent qu'à
ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres
groupes sur accord dans trustinWallet.
4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ le
Préambule et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
*seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets :
**Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils
ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL.
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
vote, inertie standard — rangées par section (introduction, fondamental, technique,
conclusion, annexes, formule, inertie, ordonnancement), les variantes signalées.
6. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
7. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
une traduction devenue ancienne (le français a changé depuis) est signalée.
## Les règles
### Le seuil — formule inertielle Ğ1
```
R = C + B^W + (M + (1 − M)·(1 − (T/W)^G)) · max(0, T − C)
```
W = électorat (Monnaie libre : taille de la toile, lue sur la chaîne toutes les 6 h ; un groupe
géré : ses membres), T = votes exprimés, M = majorité, G = gradient, B = 0,1, **C = plancher de
votants « pour »** (Monnaie libre : **50** ; groupe d'essai : 1). Adoptée si « pour » ≥ ⌈R⌉.
| Inertie | M | G |
|---|---|---|
| basse | 50 % | 0,1 |
| standard (au départ, partout) | 50 % | 0,2 |
| haute | 60 % | 0,4 |
| maximale | 66 % | 0,6 |
Moteur : `crates/ld-engine/src/threshold.rs` (testé : 7224 / 120 votants → 94 voix requises).
### Le vote permanent
- Toute version d'un article (proposition ou texte en vigueur) se vote à tout moment.
- Une proposition **remonte en vigueur** dès qu'elle atteint son seuil **et** tient une marge
(pour − requis) plus large que la version en place. La version déplacée redevient une
proposition, avec ses votes. Un *seed a minima* a pour marge −C : la première proposition qui
atteint son seuil le remplace.
- Retirer un article est une proposition comme une autre (« retirer cet article »).
- Le balayage tourne à chaque vote et toutes les 5 minutes (la taille de la toile bouge).
### L'inertie se vote aussi
Chaque article porte son inertie. La changer est une proposition, **votée elle-même à
l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande plus que modifier.
### Corriger n'est pas voter
**Une coquille touche les lettres et les espaces, rien d'autre.** Accents, majuscules, le style
des apostrophes et des guillemets, un mot collé ou coupé (« latransmission » → « la
transmission »), ou au plus deux mots mal tapés — chacun un seul glissement de clavier au cœur du
mot (« certifcation » → « certification »). Tout le reste passe par une proposition : la
ponctuation, les chiffres, les signes, l'emphase, les formules, les adresses, et tout changement
de sens.
| Geste | Qui | Effet sur les votes |
|---|---|---|
| Corriger une **coquille** | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour |
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent |
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version |
Pourquoi si strict : six relectures indépendantes ont montré qu'une ponctuation laissée libre
finit toujours par porter un sens — un signe moins devant un nombre en gras, deux conditions
d'une liste fusionnées en une, une virgule glissée dans « 50 », une parenthèse déplacée qui
change la portée d'une exception. La règle est donc construite à l'envers d'une liste noire :
- la **forme** du texte (tout ce qui n'est ni lettre latine ni espace, dans l'ordre : ponctuation,
chiffres de toute écriture, signes, emphase, parenthèses, sosies, marques superposées, autres
alphabets) ne bouge pas ; une espace à côté d'un chiffre en fait partie (« 1 5 » n'est pas
« 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ; « lettre
latine » s'entend de celles du français, de l'anglais et de l'espagnol (les lettres latines
rares qui imitent un signe, « ǃ » ou « Ʒ », font partie de la forme) ; « espace » s'entend de
l'espace, de l'insécable et de la fine insécable entre deux mots ; le **saut de ligne** et
l'**indentation** qui ouvre une ligne font partie de la forme (en Markdown, ils font les
listes et les paragraphes) ; un titre tient sur une ligne ;
- les **adresses** (liens, domaines, courriels) et les **identifiants qui contiennent un
chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu
entre accents graves aussi ;
- les espaces ne font que **coller ou couper** des mots, deux au plus, jamais en touchant une
négation ou une quantité — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne
certifier » → « necertifier », « jamais » → « ja mais », « sur tout » → « surtout » sont du
fond) —, et jamais déplacer une frontière (« les avoir » → « le savoir » est du fond) ;
- le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du
corps), puis ensemble (deux mots au plus en tout) ;
- un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux
premières ni aux deux dernières lettres (préfixes « valide » → « invalide », terminaisons
« certifierai » → « certifierais »), jamais une lettre remplacée (« deux » → « dix »), jamais sur
un mot de négation, de quantité ou de position (ne, jamais, deux, majorité, tous, dessus…) ;
- chaque correction est mesurée contre le **texte voté**, jamais contre la correction
précédente : les glissements ne s'additionnent pas ;
- les caractères invisibles (tout l'ensemble Unicode des caractères ignorables : inversion du
sens d'écriture, largeur nulle, trait d'union conditionnel, sélecteurs de variante,
étiquettes… ; et les caractères de contrôle autres que saut de ligne et tabulation) sont
refusés partout où l'on écrit ;
- une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur
ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »).
Moteur : `crates/ld-engine/src/edit.rs` ; règle d'écriture : `crates/ld-server/src/proposals.rs`.
### Deux articles qui se contredisent
Tout membre peut **signaler une contradiction** entre deux propositions, motif à l'appui. Tant
que le signalement tient, celle des deux qui n'est pas en vigueur **ne peut pas entrer en
vigueur**. Il se lève par son auteur (ou un administrateur), ou tombe quand la proposition
signalée est révisée sur le fond.
### Une décision par proposition
L'éditeur a un **assistant à règles** (pas d'IA pour l'instant ; plus tard, une IA locale, sans
abonnement) : il repère un texte qui porte plusieurs décisions et propose de le **découper en
brouillons**, signale une proposition très proche d'une existante, et un texte long. Moteur :
`crates/ld-engine/src/assist.rs`.
### Brouillon → proposition
On écrit en brouillon (libre, invisible des autres), on soumet ; la proposition est alors
ouverte au vote. Son auteur peut la retirer.
### Publication
Chaque entrée en vigueur (et la publication initiale) passe par une file d'envoi : le serveur
écrit `fr.md`, `en.md`, `es.md` (front matter : document, langue, source, mise à jour ; les
traductions marquées éditoriales) et `document.json`, commit et pousse sur
`git.open.us.org/yvv/decision-depot-textes-reference`, chemin `monnaie-libre/acte-engagement-certification/`.
Échec → nouvel essai avec délai croissant. La clé de publication est générée par le serveur ;
sa partie publique s'affiche dans trustinWallet, à ajouter comme clé de déploiement (écriture)
du dépôt. Le groupe d'essai ne publie jamais.
### Accès — trustinWallet
Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter,
corriger, signaler) **sauf la gestion des accès**, réservée aux administrateurs de trustinWallet
(`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication.
Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`.
**Une personne, une voix.** Dans Monnaie libre, dont l'électorat W est la toile de confiance,
**seuls les membres de la toile votent** (connexion Ğ1). Une identité d'invitation y lit,
propose, corrige et signale, mais ne vote pas : sinon la même personne, invitée puis connectée
avec sa clé, voterait deux fois. Dans les groupes gérés (essai, Axiom…), tout membre vote.
## Le groupe d'essai
Caché, plancher C = 1, sans publication : on y éprouve tout (adoption, coquilles, découpage)
sans toucher au texte de la communauté. Chiara et Yvv en sont membres.
## Décisions prises (2026-10-21, avec Yvv)
| Question | Décision |
|---|---|
| Stack | Rust (serveur, moteur) + Svelte (interface), pour la durée, la performance, la modularité |
| Domaine | le produit à la racine `decision.librodrome.org` ; la maquette sur `maquette.decision.librodrome.org` |
| Connexion | client OAuth2 de connect.monnaie-libre.fr (demande à poka) ; liens d'invitation en attendant |
| Dépôt des textes | `git.open.us.org/yvv/decision-depot-textes-reference` pour l'instant (2026-10-21) ; puis un dépôt pour l'ouverture, par transfert, et le dépôt officiel pour les textes en vigueur |
| Garde-fou du vote permanent | nombre minimal de votants « pour » (C) |
| Langues | fr, en, es ; le français est voté, les traductions sont éditoriales |
| Chiara | testeuse et codeuse |
| Assistant | à règles d'abord ; IA locale plus tard, sans dépendance d'abonnement |
Les raisons longues sont dans `docs/adr/`.
## Limites connues (assumées pour ce premier chantier)
- Le droit de vote se vérifie **au moment du vote** : un membre sorti de la toile, ou dont
l'accès est révoqué, garde les voix déjà exprimées.
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
(pour la dépanner) — jamais pour une identité Ğ1.
- Une identité d'invitation peut porter le nom d'un membre Ğ1 qui ne s'est pas encore
connecté (la connexion Ğ1 ne vérifie pas les noms) : la mention « sur invitation » à côté de
chaque auteur qui n'est pas venu avec sa clé Ğ1 les distingue.
- Des accents ou une ponctuation qui portent du sens restent des coquilles (« ou » / « où »,
« a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible
dans l'historique de l'article.
- Les traductions (en, es) sont éditoriales : tout membre les corrige sans vote, et elles sont
republiées — le français fait foi.
## Questions ouvertes (à Yvv)
- **Licence** du code — non choisie.
- **Texte « Paramètres par défaut » du seed** (F1.2) : il annonce C = 0 et une durée de 30 jours,
alors que le vote est permanent avec C = 50. À reformuler par proposition (c'est leur rôle),
ou à corriger dans le seed avant la première publication si c'est une erreur de transcription.
- Anglais : l'interface est en anglais américain, le seed en anglais britannique.
## Ce qui reste après la livraison
- L'inscription du client OAuth2 (poka) : `DEMANDE-CONNECT.md`, puis les deux secrets dans la CI.
- Les retours de Chiara → contrôles → corrections, sur la branche du chantier.
- Ensuite : chantier 2, l'Acte d'engagement forgeron (qualification d'une compétence).
@@ -0,0 +1,49 @@
# Contrôles d'usage — Acte d'engagement Certification
Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e/`, rejoué par
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe d'essai**
(caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté.
| # | Contrôle | Geste humain | Attendu |
|---|---|---|---|
| C1 | L'accueil | ouvrir `/` sans être connectée | « Rendre possible la décision collective », la phrase d'outils, les quatre sections ; le menu à gauche avec les quatre outils, trois grisés ; « Se connecter avec sa clé Ğ1 » |
| C2 | Les groupes | se connecter, ouvrir « Groupes » | Monnaie libre, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal |
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Préambule, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (32 lignes) |
| C4 | Gestes contextuels | survoler le Préambule → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme |
| C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles |
| C6 | Coquille sans vote | groupe d'essai : Préambule → « Le vote » → « Corriger une coquille », remplacer « latransmission » par « la transmission » | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
## Au-delà de la liste
Ce que l'automate ne voit pas et que Chiara regarde :
- **la lecture** : le texte est-il beau, aéré, sans bruit ? lit-on un article sans effort ?
- **la fatigue** : un geste de trop, une étape inutile, un mot qui arrête ;
- **le vocabulaire** : un libellé qui parle du système au lieu de parler de toi ou du groupe ;
- **les deux ambiances** (pierre / nuit) et le clavier seul (Tab, Entrée, Échap) ;
- **les langues** : une traduction maladroite, une chaîne restée en français.
Chaque remarque passe par « Faire un retour » (menu de gauche) : elle arrive datée, avec la
page, la langue et la taille de l'écran. Les retours se traitent dans trustinWallet
(Vu → Traité) et, s'ils changent une règle, dans `CHANTIER.md`.
## Journal des passages
| Date | Qui | Où | Contrôles | Résultat |
|---|---|---|---|---|
| 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts |
| 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts |
| 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts |
| 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts |
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
@@ -0,0 +1,42 @@
# Demande à poka — client OAuth2 sur connect.monnaie-libre.fr
Brouillon prêt à envoyer (par Yvv, en message privé à poka sur forum.monnaie-libre.fr ou par
le canal habituel). Rien de secret dedans ; la réponse, elle, contiendra un secret.
---
Bonjour poka,
On ouvre libreDecision en production sur **https://decision.librodrome.org** : la communauté
y tient l'**Acte d'engagement Certification** comme un texte vivant, voté article par article
(formule inertielle Ğ1). On voudrait la même connexion que le forum : connect.monnaie-libre.fr.
Peux-tu inscrire un client OAuth2 ?
- **Nom affiché** : libreDecision
- **Site** : https://decision.librodrome.org
- **URI de redirection** : `https://decision.librodrome.org/api/auth/g1/callback`
- **Flux** : authorization code (`/oauth/authorize` → `/oauth/token` en basic auth →
`/oauth/userinfo`), sans scope particulier
- **Ce qu'on lit de `/oauth/userinfo`** : `id` (idty:N), `username`, `address`, `member` —
rien d'autre n'est stocké ; seuls les membres de la toile ouvrent le groupe Monnaie libre.
Il nous faudrait en retour le `client_id` et le `client_secret` (le secret par un canal privé,
pas sur le forum).
Merci !
Yvv
---
## À la réponse
1. Ne pas coller le secret dans une conversation : protocole `/tmp/pwd.txt` (l'assistant crée
et ouvre le fichier, Yvv colle).
2. Ajouter dans Woodpecker (dépôt LPK/decision) deux secrets : `connect_client_id`,
`connect_client_secret`.
3. **Ensuite seulement** (un `from_secret` absent fait refuser tout le pipeline) : dans
`.woodpecker/app.yml`, étape `deploy`, écrire `/opt/libredecision-app/secrets.env` avec
`LD_CONNECT_CLIENT_ID` et `LD_CONNECT_CLIENT_SECRET` (chmod 600) — le compose le lit déjà.
4. Contrôle : « Se connecter avec sa clé Ğ1 » mène à connect.monnaie-libre.fr et revient
connecté ; un membre de la toile ouvre Monnaie libre.
@@ -0,0 +1,77 @@
# Bienvenue sur le chantier — Chiara
Tu es **testeuse et codeuse** sur ce chantier : tes tests d'usage sont nos contrôles (une
remarque de toi = un contrôle à repasser), et tu peux corriger ou construire toi-même.
## 1. Ce qu'on construit
Lis d'abord `CHANTIER.md` (10 minutes) : le texte vivant, le vote permanent, coquille ≠ vote,
les contradictions, l'inertie votée, la publication. Puis `CONTROLES.md` : ce que tu vérifies.
## 2. Tes accès (préparés par Yvv)
| Quoi | Pour quoi | Qui le fait |
|---|---|---|
| Un **lien d'invitation** à decision.librodrome.org (groupes Monnaie libre + groupe d'essai) | tester en production avant la connexion Ğ1 | Yvv, dans trustinWallet (`/acces`) |
| Ta **clé Ğ1** | une fois connect.monnaie-libre.fr branché, tu te connectes comme sur le forum | — |
| Un compte sur **git.open.us.org**, collaboratrice de `yvv/decision` | pousser tes branches, ouvrir des demandes de fusion | Yvv |
Le lien d'invitation s'ouvre **une seule fois** et expire en 14 jours ; il ouvre ta session sur
ce navigateur. Elle reste ouverte tant que tu reviens au moins une fois par an, jusqu'à ce que
tu te déconnectes ou qu'un administrateur la ferme (appareil perdu, par exemple). Sur un autre
appareil, demande à Yvv un nouveau lien de connexion. Si Yvv t'envoie plutôt un **lien
d'équipe** (`/equipe#…`), il te demande ton nom et te crée ta propre identité ; ne l'ouvre qu'une fois, puis reviens par ta session.
## 3. Tester sans rien installer
1. Ouvre ton lien d'invitation → tu arrives connectée.
2. Les contrôles qui écrivent (voter, corriger, découper) se font dans le **groupe d'essai** :
il est caché, son plancher est d'une voix, et rien n'en sort. À deux membres (Yvv et toi),
une proposition passe avec vos deux voix « pour » — la formule demande la majorité de
l'électorat quand il est si petit. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis,
tu proposes, tu signales ; le vote y est réservé aux membres de la toile (avec ta clé Ğ1).
3. Passe les contrôles C1–C12 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne.
4. Chaque remarque : **« Faire un retour »** dans le menu de gauche. Écris ce que tu as fait, ce
que tu attendais, ce qui s'est passé. La page, la langue et la taille de l'écran sont jointes.
## 4. Coder en local
Prérequis : git, Node 22, Rust (rustup — la version 1.99 s'installe toute seule grâce à
`rust-toolchain.toml`).
```bash
git clone gitea@git.open.us.org:yvv/decision.git libreDecision && cd libreDecision
git switch chantier/acte-certification
curl --proto '=https' -sSf https://sh.rustup.rs | sh -s -- -y # si Rust n'est pas installé
cd web && npm ci && npm run build && cd ..
LD_DEV_LOGIN=1 cargo run -p ld-server # http://127.0.0.1:8002 — API + interface
# dans un second terminal, pour l'interface à chaud :
cd web && npm run dev # http://127.0.0.1:3002
```
`LD_DEV_LOGIN=1` affiche une connexion « développement » : tu tapes un nom, tu es membre de
tous les groupes (y compris l'essai). La base locale vit dans `data/` — supprime ce dossier pour
repartir du texte de départ.
Où sont les choses :
- les **règles** (seuil, coquille, découpage, aperçus) : `crates/ld-engine/` — pur, testé ;
- les **accès** : `crates/trustin-access/` ;
- l'**API** : `crates/ld-server/src/` (une route = une fonction ; scénarios dans `tests/api.rs`) ;
- l'**interface** : `web/src/routes/` (pages) et `web/src/lib/components/` ;
- les **textes affichés** : `web/src/lib/i18n/fr.ts` (puis `en.ts`, `es.ts`) — jamais en dur ;
- le **texte de départ** : `seeds/acte-engagement-certification.json`.
## 5. Ta boucle avant de pousser
```bash
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cd web && npm run check && npm test && npm run build && npm run e2e
```
Tout vert, puis : `git switch -c chiara/<sujet>`, commits, `git push origin chiara/<sujet>`, et une
demande de fusion vers `chantier/acte-certification`. Le détail : `CONTRIBUTING.md`.
## 6. Si tu bloques
Écris à Yvv, ou laisse un retour dans l'outil en commençant par « BLOQUÉE : ».
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+144
View File
@@ -0,0 +1,144 @@
# Méthode de travail TDD — libreDecision
## Principe fondamental
**Tu décris la règle métier. Claude traduit en test. Tu valides. Claude implémente.**
Jamais l'inverse. Le test est la source de vérité ; l'implémentation n'est que le moyen de le faire passer.
---
## Workflow par itération
```
1. Tu décris une règle en français naturel
→ "Si scope=personal, la décision est toujours individuelle"
2. Claude écrit le(s) test(s) — RED (le test échoue avant l'implémentation)
→ Tu valides que le test capture bien l'intention
3. Claude implémente le minimum pour que le test passe — GREEN
→ Rien de plus que ce que le test exige
4. Claude refactorise si nécessaire — REFACTOR
→ Sans casser les tests existants
5. Répétition avec la règle suivante
```
---
## Commandes de prompt
| Commande | Action |
|---|---|
| `+test` | Écrire le(s) test(s) sans implémenter |
| `+impl` | Implémenter pour faire passer les tests en attente |
| `+test+impl` | Test + implémentation d'un coup (règle simple) |
| `+règle` | Ajouter une règle au moteur existant |
| `+règle remplace` | Une nouvelle règle remplace une précédente (précise laquelle) |
| `+régression` | Vérifier qu'aucun test existant n'est cassé après un changement |
| `+résumé` | Afficher l'état des règles implémentées et en attente |
---
## Format d'une règle métier
Pour être efficace, une règle doit préciser :
```
ENTRÉES : les variables concernées et leurs valeurs
RÉSULTAT : ce que le système doit retourner ou faire
EXCEPTIONS : cas qui brisent la règle générale (si aucune, dire "aucune")
```
**Exemple :**
```
ENTRÉES : scope = "personal"
RÉSULTAT : decision_type = "individual", recommend_onchain = False
EXCEPTIONS : aucune — même si stakes = "critical", une décision personnelle reste individuelle
```
---
## Structure des tests dans ce projet
```
backend/app/tests/
test_qualifier.py ← moteur de qualification (tunnel Décider)
test_middleware.py ← rate limiter, CORS, headers
test_threshold.py ← formules WoT existantes
test_votes.py ← logique de vote
test_decisions.py ← service décisions
...
```
Chaque fichier de test correspond à un module ou un bloc fonctionnel.
Les tests d'intégration (qui touchent la DB) sont marqués `@pytest.mark.integration`.
---
## Les 4 blocs algorithmiques
```
┌─────────────────────────────────────────────────────────┐
│ TUNNEL "DÉCIDER" │
│ │
│ 1. QUALIFIER → nature / enjeu / réversibilité │
│ ↓ │
│ 2. ROUTEUR → individual / collective / delegated │
│ ↓ ↓ │
│ 3. PROTOCOLE → sélection formule WoT + paramètres │
│ ↓ │
│ 4. GRAVURE → recommandation on-chain (IPFS+remark) │
└─────────────────────────────────────────────────────────┘
```
Les blocs sont testés indépendamment puis en intégration.
Un changement dans le bloc 1 ne doit jamais casser silencieusement le bloc 4.
---
## Invariants fondamentaux (ne jamais casser)
Ces règles doivent avoir un test dédié et rester vertes en permanence :
1. Une décision `individual` ne génère jamais de session de vote
2. Une décision `on_chain` implique toujours `recommend_onchain = True`
3. `recommend_onchain = True` requiert `reversibility = "impossible"` **ou** `stakes = "critical"`
4. Le qualificateur ne retourne jamais un type inconnu (enum strict)
5. Un protocole WoT sélectionné doit exister en base (slug valide)
---
## Règles de régression
- Après chaque implémentation : `pytest backend/app/tests/ -v --tb=short`
- Avant tout commit : zéro test rouge
- Si un test existant casse après un nouveau changement → **stop, analyser, ne pas contourner**
- `RATE_LIMIT_AUTH` en dev = 60/min minimum (pas de blocage en développement)
---
## Où sont les règles métier documentées
| Source | Contenu |
|---|---|
| `docs/dev/tdd-methode.md` | Cette méthode |
| `docs/dev/qualifier-rules.md` | Règles du moteur de qualification (créé au fil des itérations) |
| `backend/app/engine/qualifier.py` | Implémentation du qualificateur |
| `backend/app/tests/test_qualifier.py` | Tests — source de vérité exécutable |
---
## À propos de la mémoire de contexte
Entre les sessions, Claude peut perdre le contexte des règles en cours.
Pour reprendre efficacement :
```
"Résume les règles du qualificateur implémentées jusqu'ici"
→ Claude lit test_qualifier.py et qualifier.py et synthétise
```
Les tests sont leur propre documentation. Ne pas dupliquer les règles en commentaires.
@@ -1,4 +1,4 @@
# Recherche Forum Duniter -- Donnees de reference pour Glibredecision
# Recherche Forum Duniter -- Donnees de reference pour libreDecision
Date de recherche : 2026-02-28
-620
View File
@@ -1,620 +0,0 @@
<script setup lang="ts">
const auth = useAuthStore()
const route = useRoute()
const { initMood } = useMood()
const navigationItems = [
{
label: 'Documents',
icon: 'i-lucide-book-open',
to: '/documents',
},
{
label: 'Decisions',
icon: 'i-lucide-scale',
to: '/decisions',
},
{
label: 'Mandats',
icon: 'i-lucide-user-check',
to: '/mandates',
},
{
label: 'Protocoles',
icon: 'i-lucide-settings',
to: '/protocols',
},
{
label: 'Sanctuaire',
icon: 'i-lucide-archive',
to: '/sanctuary',
},
]
/** Mobile drawer state. */
const mobileMenuOpen = ref(false)
/** Close mobile menu on route change. */
watch(() => route.path, () => {
mobileMenuOpen.value = false
})
/** WebSocket connection and notifications. */
const ws = useWebSocket()
const { setupWsNotifications } = useNotifications()
onMounted(async () => {
initMood()
auth.hydrateFromStorage()
if (auth.token) {
try {
await auth.fetchMe()
} catch {
auth.logout()
}
}
ws.connect()
setupWsNotifications(ws)
})
onUnmounted(() => {
ws.disconnect()
})
function isActive(to: string) {
return route.path === to || route.path.startsWith(to + '/')
}
</script>
<template>
<UApp>
<OfflineBanner />
<div
class="app-shell"
:style="{
backgroundColor: 'var(--mood-bg)',
color: 'var(--mood-text)',
}"
>
<!-- Header -->
<header class="app-header">
<div class="app-header__inner">
<!-- Left: Hamburger (mobile) + Logo -->
<div class="app-header__left">
<button
class="app-header__menu-btn md:hidden"
aria-label="Ouvrir le menu"
@click="mobileMenuOpen = true"
>
<UIcon name="i-lucide-menu" class="text-xl" />
</button>
<NuxtLink to="/" class="app-header__logo">
<span class="app-header__logo-stamp">
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
</span>
<span class="app-header__logo-text">
<span class="app-header__logo-g">ğ</span><span class="app-header__logo-paren">(</span><span class="app-header__logo-word">Decision</span><span class="app-header__logo-paren">)</span>
</span>
</NuxtLink>
</div>
<!-- Center: Mood switcher (desktop) -->
<MoodSwitcher class="hidden sm:flex" />
<!-- Right: Auth -->
<div class="app-header__right">
<template v-if="auth.isAuthenticated">
<span class="app-header__identity">
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 10) + '...' }}
</span>
<span
v-if="auth.identity?.is_smith"
class="app-header__role app-header__role--smith"
>
Forgeron
</span>
<span
v-if="auth.identity?.is_techcomm"
class="app-header__role app-header__role--tech"
>
CoTec
</span>
<button
class="app-header__icon-btn"
aria-label="Se deconnecter"
@click="auth.logout()"
>
<UIcon name="i-lucide-log-out" />
</button>
</template>
<template v-else>
<NuxtLink to="/login" class="app-header__connect-btn">
<UIcon name="i-lucide-log-in" />
<span>Connexion</span>
</NuxtLink>
</template>
</div>
</div>
</header>
<!-- Mobile navigation drawer -->
<USlideover
v-model:open="mobileMenuOpen"
side="left"
title="Navigation"
:ui="{ width: 'max-w-xs' }"
>
<template #body>
<nav class="app-mobile-nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-mobile-nav__link"
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
@click="mobileMenuOpen = false"
>
<UIcon :name="item.icon" class="text-lg" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
<!-- Mood switcher in mobile drawer -->
<div class="app-mobile-mood">
<span class="app-mobile-mood__label">Ambiance</span>
<MoodSwitcher />
</div>
</template>
</USlideover>
<!-- Main content with sidebar -->
<div class="app-body">
<!-- Desktop sidebar -->
<aside class="app-sidebar">
<nav class="app-sidebar__nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-sidebar__link"
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
>
<UIcon :name="item.icon" class="text-lg" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
</aside>
<!-- Page content -->
<main class="app-main">
<ErrorBoundary>
<NuxtPage />
</ErrorBoundary>
<!-- 井 Tsing — sceau (proportions calées sur avatars Yvv) -->
<svg class="app-seal" viewBox="0 0 130 100" fill="currentColor" aria-hidden="true">
<!-- Line 6 (top) — yin -->
<rect x="5" y="5" width="49" height="5" rx="1"/>
<rect x="76" y="5" width="49" height="5" rx="1"/>
<!-- Line 5 — yang -->
<rect x="5" y="22" width="120" height="5" rx="1"/>
<!-- Line 4 — yin -->
<rect x="5" y="39" width="49" height="5" rx="1"/>
<rect x="76" y="39" width="49" height="5" rx="1"/>
<!-- Line 3 — yang -->
<rect x="5" y="56" width="120" height="5" rx="1"/>
<!-- Line 2 — yang -->
<rect x="5" y="73" width="120" height="5" rx="1"/>
<!-- Line 1 (bottom) — yin -->
<rect x="5" y="90" width="49" height="5" rx="1"/>
<rect x="76" y="90" width="49" height="5" rx="1"/>
</svg>
</main>
</div>
<!-- WebSocket error banner -->
<Transition name="slide-up">
<div
v-if="ws.error.value"
class="app-ws-banner"
role="alert"
>
<UIcon name="i-lucide-plug-zap" class="text-lg" />
<span>{{ ws.error.value }}</span>
<button class="app-ws-banner__btn" @click="ws.disconnect(); ws.connect()">
Reconnecter
</button>
</div>
</Transition>
<!-- Footer -->
<footer class="app-footer">
<span>ğ(Decision) v0.1.0</span>
<span class="app-footer__sep">·</span>
<span>Licence libre</span>
</footer>
</div>
</UApp>
</template>
<style scoped>
/* === Shell === */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* === Header === */
.app-header {
position: sticky;
top: 0;
z-index: 30;
background: var(--mood-surface);
}
.app-header__inner {
max-width: 80rem;
margin: 0 auto;
padding: 0 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
}
.app-header__left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: 12px;
}
.app-header__menu-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__logo {
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__logo-stamp {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--mood-accent);
color: var(--mood-accent-text);
border-radius: 8px;
transform: rotate(-10deg);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.app-header__logo:hover .app-header__logo-stamp {
transform: rotate(-16deg) scale(1.08);
}
.app-header__logo-icon {
font-size: 1.125rem;
transform: scaleX(-1);
}
.app-header__logo-text {
display: flex;
align-items: baseline;
gap: 0;
}
.app-header__logo-g {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-accent);
line-height: 1;
font-style: italic;
}
.app-header__logo-paren {
font-size: 1.125rem;
font-weight: 300;
color: var(--mood-text-muted);
opacity: 0.5;
}
.app-header__logo-word {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
.app-header__right {
display: flex;
align-items: center;
gap: 0.625rem;
}
.app-header__identity {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
max-width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 640px) {
.app-header__identity {
max-width: 12rem;
}
}
.app-header__role {
font-size: 0.75rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
display: none;
}
@media (min-width: 640px) {
.app-header__role {
display: inline;
}
}
.app-header__role--smith {
background: rgba(24, 132, 59, 0.15);
color: var(--mood-success);
}
.app-header__role--tech {
background: rgba(24, 86, 168, 0.15);
color: var(--mood-status-vote, #1856a8);
}
.app-header__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: 12px;
font-size: 1rem;
}
.app-header__icon-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__connect-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 24px;
text-decoration: none;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.app-header__connect-btn {
gap: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.9375rem;
}
}
.app-header__connect-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
/* === Sidebar === */
.app-body {
display: flex;
flex: 1;
}
.app-sidebar {
width: 14rem;
flex-shrink: 0;
background: var(--mood-surface);
display: none;
}
@media (min-width: 768px) {
.app-sidebar {
display: block;
}
}
.app-sidebar__nav {
position: sticky;
top: 3.5rem;
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-sidebar__link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: 12px;
transition: all 0.12s ease;
}
.app-sidebar__link:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
/* === Mobile nav === */
.app-mobile-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.75rem;
}
.app-mobile-nav__link {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem 1.25rem;
font-size: 1.0625rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: 14px;
min-height: 3rem;
}
.app-mobile-nav__link:hover,
.app-mobile-nav__link:active {
background: var(--mood-accent-soft);
color: var(--mood-text);
}
.app-mobile-nav__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.app-mobile-mood {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
margin-top: 0.5rem;
border-top: 1px solid var(--mood-accent-soft);
}
.app-mobile-mood__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
}
/* === Main === */
.app-main {
flex: 1;
min-width: 0;
padding: 1.5rem 1.25rem;
}
@media (min-width: 640px) {
.app-main {
padding: 2rem 1.75rem;
}
}
@media (min-width: 1024px) {
.app-main {
padding: 2rem 2.5rem;
}
}
/* === WS Banner === */
.app-ws-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
padding: 0.625rem 1rem;
background: var(--mood-error);
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.app-ws-banner__btn {
margin-left: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgba(255,255,255,0.2);
border-radius: 20px;
color: white;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
}
.app-ws-banner__btn:hover {
background: rgba(255,255,255,0.3);
}
/* === Footer === */
.app-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.app-footer__sep {
opacity: 0.3;
}
/* === Seal — 井 Tsing === */
.app-seal {
display: block;
width: 44px;
margin: 1.5rem 0 0.5rem auto;
color: var(--mood-accent);
opacity: 0.28;
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
}
/* === Transitions === */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>
-248
View File
@@ -1,248 +0,0 @@
/* ==========================================================================
ğ(Decision) — Mood / Ambiance System
Palettes harmoniques variees, colores en lite, lumineux en dark.
========================================================================== */
/* --------------------------------------------------------------------------
Peps — Chaud, colore, tonique (Light)
Palette: corail vif / ocre / indigo / vert franc
-------------------------------------------------------------------------- */
.mood-peps {
--mood-bg: #faf8f5;
--mood-surface: #ffffff;
--mood-surface-hover: #fdf2ec;
--mood-text: #1e1410;
--mood-text-muted: #7a5e48;
--mood-accent: #d44a10;
--mood-accent-soft: rgba(212, 74, 16, 0.08);
--mood-accent-text: #ffffff;
--mood-border: #e8d8c8;
--mood-secondary: #4a5ec0;
--mood-tertiary: #c07820;
--mood-success: #1a8c3e;
--mood-warning: #c47d0a;
--mood-error: #c23028;
--mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%);
--mood-shadow: rgba(180, 80, 20, 0.07);
--mood-input-bg: #ffffff;
--mood-input-border: #d4c0aa;
--mood-input-focus: #d44a10;
--mood-status-prepa: #b8600e;
--mood-status-prepa-bg: rgba(184, 96, 14, 0.12);
--mood-status-vote: #4a5ec0;
--mood-status-vote-bg: rgba(74, 94, 192, 0.10);
--mood-status-vigueur: #1a8c3e;
--mood-status-vigueur-bg: rgba(26, 140, 62, 0.10);
--mood-status-clos: #6e5844;
--mood-status-clos-bg: rgba(110, 88, 68, 0.08);
}
/* --------------------------------------------------------------------------
Zen — Vegetal lumineux, nature vivante (Light)
Palette: vert franc / terre sienne / bleu canard / bois chaud
-------------------------------------------------------------------------- */
.mood-zen {
--mood-bg: #f4f6f0;
--mood-surface: #fbfcf8;
--mood-surface-hover: #eaf0e4;
--mood-text: #1a2418;
--mood-text-muted: #5a7050;
--mood-accent: #2e8b48;
--mood-accent-soft: rgba(46, 139, 72, 0.09);
--mood-accent-text: #ffffff;
--mood-border: #b8d0a8;
--mood-secondary: #8a6030;
--mood-tertiary: #2a7888;
--mood-success: #268040;
--mood-warning: #a87818;
--mood-error: #b83838;
--mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%);
--mood-shadow: rgba(40, 100, 50, 0.07);
--mood-input-bg: #fbfcf8;
--mood-input-border: #a8c898;
--mood-input-focus: #2e8b48;
--mood-status-prepa: #a87818;
--mood-status-prepa-bg: rgba(168, 120, 24, 0.11);
--mood-status-vote: #2a7888;
--mood-status-vote-bg: rgba(42, 120, 136, 0.10);
--mood-status-vigueur: #268040;
--mood-status-vigueur-bg: rgba(38, 128, 64, 0.10);
--mood-status-clos: #607858;
--mood-status-clos-bg: rgba(96, 120, 88, 0.08);
}
/* --------------------------------------------------------------------------
Chagrine — Nuit profonde, reflets varies (Dark)
Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau
-------------------------------------------------------------------------- */
.mood-chagrine {
--mood-bg: #0e1018;
--mood-surface: #151a28;
--mood-surface-hover: #1c2640;
--mood-text: #dce0ec;
--mood-text-muted: #8898b8;
--mood-accent: #6488d8;
--mood-accent-soft: rgba(100, 136, 216, 0.12);
--mood-accent-text: #ffffff;
--mood-border: #222e48;
--mood-secondary: #d0a040;
--mood-tertiary: #c87090;
--mood-success: #48c87a;
--mood-warning: #d8a838;
--mood-error: #d86060;
--mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%);
--mood-shadow: rgba(60, 100, 180, 0.12);
--mood-input-bg: #151a28;
--mood-input-border: #2a3858;
--mood-input-focus: #6488d8;
--mood-status-prepa: #d8a838;
--mood-status-prepa-bg: rgba(216, 168, 56, 0.14);
--mood-status-vote: #6488d8;
--mood-status-vote-bg: rgba(100, 136, 216, 0.14);
--mood-status-vigueur: #48c87a;
--mood-status-vigueur-bg: rgba(72, 200, 122, 0.14);
--mood-status-clos: #7888a8;
--mood-status-clos-bg: rgba(120, 136, 168, 0.10);
}
/* --------------------------------------------------------------------------
Grave — Ambre mineral, ocre lumineux (Dark)
Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse
-------------------------------------------------------------------------- */
.mood-grave {
--mood-bg: #131210;
--mood-surface: #1c1a16;
--mood-surface-hover: #28241c;
--mood-text: #ece4d4;
--mood-text-muted: #a89870;
--mood-accent: #d8a030;
--mood-accent-soft: rgba(216, 160, 48, 0.11);
--mood-accent-text: #131210;
--mood-border: #342e24;
--mood-secondary: #6898b8;
--mood-tertiary: #c07040;
--mood-success: #58c870;
--mood-warning: #d0a030;
--mood-error: #d05050;
--mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%);
--mood-shadow: rgba(180, 130, 40, 0.10);
--mood-input-bg: #1c1a16;
--mood-input-border: #3c3428;
--mood-input-focus: #d8a030;
--mood-status-prepa: #c07040;
--mood-status-prepa-bg: rgba(192, 112, 64, 0.15);
--mood-status-vote: #6898b8;
--mood-status-vote-bg: rgba(104, 152, 184, 0.14);
--mood-status-vigueur: #58c870;
--mood-status-vigueur-bg: rgba(88, 200, 112, 0.14);
--mood-status-clos: #807860;
--mood-status-clos-bg: rgba(128, 120, 96, 0.12);
}
/* ==========================================================================
Global design tokens — Nunito, rounded, borderless
========================================================================== */
*,
*::before,
*::after {
border-color: transparent;
}
html {
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
font-size: 16px;
}
body {
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* --- No borders anywhere --- */
button, input, select, textarea {
border: none !important;
outline: none;
font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none;
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
/* --- Status pills --- */
.status-pill {
display: inline-flex;
align-items: center;
padding: 5px 14px;
border-radius: 20px;
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
border: none !important;
}
.status-pill:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.status-pill.active {
box-shadow: 0 0 0 2.5px var(--mood-accent);
}
.status-prepa {
background: var(--mood-status-prepa-bg);
color: var(--mood-status-prepa);
}
.status-vote {
background: var(--mood-status-vote-bg);
color: var(--mood-status-vote);
}
.status-vigueur {
background: var(--mood-status-vigueur-bg);
color: var(--mood-status-vigueur);
}
.status-clos {
background: var(--mood-status-clos-bg);
color: var(--mood-status-clos);
}
/* ==========================================================================
Nuxt UI overrides — borderless everything
========================================================================== */
:root .mood-peps,
:root .mood-zen,
:root .mood-chagrine,
:root .mood-grave {
--ui-border: transparent;
--ui-bg: var(--mood-input-bg);
--ui-text-highlighted: var(--mood-accent);
}
:root button,
:root [class*="UButton"],
:root [class*="u-button"],
:root [data-variant] {
border: none !important;
font-family: 'Nunito', system-ui, sans-serif !important;
}
:root input,
:root textarea,
:root select,
:root [class*="UInput"],
:root [class*="USelect"],
:root [class*="UTextarea"] {
border: none !important;
font-family: 'Nunito', system-ui, sans-serif !important;
}
@@ -1,51 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
diff: string
}>()
interface DiffLine {
text: string
type: 'added' | 'removed' | 'header' | 'context'
}
const parsedLines = computed((): DiffLine[] => {
if (!props.diff) return []
return props.diff.split('\n').map((line) => {
if (line.startsWith('@@')) {
return { text: line, type: 'header' as const }
}
if (line.startsWith('+')) {
return { text: line, type: 'added' as const }
}
if (line.startsWith('-')) {
return { text: line, type: 'removed' as const }
}
return { text: line, type: 'context' as const }
})
})
function lineClass(type: DiffLine['type']): string {
switch (type) {
case 'added':
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
case 'removed':
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
case 'header':
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
default:
return 'text-gray-700 dark:text-gray-300'
}
}
</script>
<template>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
v-for="(line, index) in parsedLines"
:key="index"
><div
:class="['px-4 py-0.5', lineClass(line.type)]"
>{{ line.text }}</div></template></pre>
</div>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
/**
* Error boundary component.
*
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
* error message in French when child components crash.
* Logs error details to console and emits error event for monitoring.
*/
const emit = defineEmits<{
error: [error: any]
}>()
const hasError = ref(false)
const errorDetails = ref<string | null>(null)
function handleError(error: any) {
hasError.value = true
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
// Log to console for debugging
console.error('[ErrorBoundary] Erreur capturee:', error)
// Emit for external monitoring
emit('error', error)
}
function retry() {
hasError.value = false
errorDetails.value = null
}
</script>
<template>
<NuxtErrorBoundary @error="handleError">
<template v-if="!hasError">
<slot />
</template>
<template v-else>
<div class="flex flex-col items-center justify-center p-8 text-center">
<div class="max-w-md space-y-4">
<UIcon
name="i-lucide-alert-triangle"
class="text-5xl text-warning mx-auto"
/>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Une erreur est survenue
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Un probleme inattendu s'est produit lors du chargement de ce contenu.
Vous pouvez essayer de recharger cette section.
</p>
<p
v-if="errorDetails"
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
>
{{ errorDetails }}
</p>
<UButton
icon="i-lucide-refresh-cw"
label="Reessayer"
color="primary"
variant="soft"
@click="retry"
/>
</div>
</div>
</template>
</NuxtErrorBoundary>
</template>
@@ -1,81 +0,0 @@
<script setup lang="ts">
/**
* Reusable skeleton loader component.
*
* Provides multiple skeleton variants for loading states:
* - Card: card layout with title and content lines
* - List: multiple rows with optional avatar
* - Detail: detailed view with mixed content
*
* Uses Nuxt UI USkeleton components.
*/
const props = withDefaults(
defineProps<{
/** Number of skeleton lines to display (default: 3). */
lines?: number
/** Show an avatar circle placeholder. */
avatar?: boolean
/** Render as a card skeleton with border and padding. */
card?: boolean
}>(),
{
lines: 3,
avatar: false,
card: false,
},
)
/** Generate varying line widths for a natural appearance. */
const lineWidths = computed(() => {
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
})
</script>
<template>
<!-- Card variant -->
<UCard v-if="card" class="animate-pulse">
<div class="space-y-4">
<!-- Optional avatar row -->
<div v-if="avatar" class="flex items-center gap-3">
<USkeleton class="h-10 w-10 rounded-full" />
<div class="flex-1 space-y-2">
<USkeleton class="h-4 w-1/3" />
<USkeleton class="h-3 w-1/4" />
</div>
</div>
<!-- Title line -->
<USkeleton class="h-5 w-2/3" />
<!-- Content lines -->
<div class="space-y-2">
<USkeleton
v-for="(width, i) in lineWidths"
:key="i"
class="h-3"
:class="width"
/>
</div>
</div>
</UCard>
<!-- List / default variant -->
<div v-else class="space-y-3 animate-pulse">
<div
v-for="(width, i) in lineWidths"
:key="i"
class="flex items-center gap-3"
>
<!-- Optional avatar per line -->
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
<!-- Line content -->
<div class="flex-1 space-y-1">
<USkeleton class="h-3" :class="width" />
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
</div>
</div>
</div>
</template>
@@ -1,53 +0,0 @@
<script setup lang="ts">
const { currentMood, moods, setMood } = useMood()
</script>
<template>
<div class="mood-switcher" role="radiogroup" aria-label="Ambiance visuelle">
<UTooltip
v-for="mood in moods"
:key="mood.id"
:text="mood.label"
>
<button
type="button"
role="radio"
:aria-checked="currentMood === mood.id"
:aria-label="`Ambiance ${mood.label}`"
class="mood-dot"
:class="{ 'mood-dot--active': currentMood === mood.id }"
:style="{ '--dot-color': mood.color }"
@click="setMood(mood.id)"
/>
</UTooltip>
</div>
</template>
<style scoped>
.mood-switcher {
display: flex;
align-items: center;
gap: 0.375rem;
}
.mood-dot {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid transparent;
background: var(--dot-color, var(--mood-text-muted));
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.mood-dot:hover {
transform: scale(1.25);
}
.mood-dot--active {
border-color: var(--mood-text);
box-shadow: 0 0 0 2px var(--mood-bg), 0 0 0 3px var(--mood-text-muted);
transform: scale(1.15);
}
</style>
@@ -1,95 +0,0 @@
<script setup lang="ts">
/**
* Offline detection banner.
*
* Uses navigator.onLine and online/offline events to detect
* network connectivity changes. Shows a warning banner when
* offline and a brief success message when back online.
*/
const isOnline = ref(true)
const showReconnected = ref(false)
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
function handleOffline() {
isOnline.value = false
showReconnected.value = false
if (reconnectedTimer) {
clearTimeout(reconnectedTimer)
reconnectedTimer = null
}
}
function handleOnline() {
isOnline.value = true
showReconnected.value = true
// Show "reconnected" message briefly then hide
reconnectedTimer = setTimeout(() => {
showReconnected.value = false
reconnectedTimer = null
}, 3000)
}
onMounted(() => {
isOnline.value = navigator.onLine
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
if (reconnectedTimer) {
clearTimeout(reconnectedTimer)
}
})
</script>
<template>
<Transition name="slide-down">
<div
v-if="!isOnline"
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
role="alert"
>
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
</div>
</div>
</Transition>
<Transition name="slide-down">
<div
v-if="showReconnected && isOnline"
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
role="status"
>
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
<span>Connexion retablie</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
transform: translateY(-100%);
opacity: 0;
}
.slide-down-enter-to,
.slide-down-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>
@@ -1,318 +0,0 @@
<script setup lang="ts">
/**
* SectionLayout — Mise en page pour sections.
*
* Status pills inside the content block (not header).
* Toolbox sidebar with condensed content.
*/
export interface StatusFilter {
id: string
label: string
count: number
cssClass?: string
}
export interface ToolboxItem {
title: string
description: string
actions: Array<{
label: string
to?: string
onClick?: () => void
}>
}
const props = withDefaults(
defineProps<{
title: string
subtitle?: string
statuses: StatusFilter[]
toolboxItems?: ToolboxItem[]
activeStatus?: string | null
}>(),
{
subtitle: undefined,
toolboxItems: undefined,
activeStatus: null,
},
)
const emit = defineEmits<{
'update:activeStatus': [status: string | null]
}>()
const toolboxOpen = ref(false)
const statusCssMap: Record<string, string> = {
draft: 'status-prepa',
qualification: 'status-prepa',
candidacy: 'status-prepa',
voting: 'status-vote',
review: 'status-vote',
active: 'status-vigueur',
executed: 'status-vigueur',
completed: 'status-vigueur',
closed: 'status-clos',
archived: 'status-clos',
revoked: 'status-clos',
reporting: 'status-vote',
}
function getStatusClass(status: StatusFilter): string {
return status.cssClass || statusCssMap[status.id] || 'status-prepa'
}
function toggleStatus(statusId: string) {
if (props.activeStatus === statusId) {
emit('update:activeStatus', null)
}
else {
emit('update:activeStatus', statusId)
}
}
</script>
<template>
<div class="section">
<!-- Header: just title -->
<div class="section__header">
<h1 class="section__title">{{ title }}</h1>
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
</div>
<!-- Body: content + toolbox -->
<div class="section__body">
<div class="section__main">
<!-- Status pills INSIDE the list block -->
<div v-if="statuses.length > 0" class="section__pills">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-pill"
:class="[getStatusClass(status), { active: activeStatus === status.id }]"
@click="toggleStatus(status.id)"
>
{{ status.label }}
<span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
</button>
</div>
<div v-if="$slots.search" class="section__search">
<slot name="search" />
</div>
<div class="section__content">
<slot />
</div>
</div>
<aside class="section__toolbox">
<button class="section__toolbox-head" @click="toolboxOpen = !toolboxOpen">
<div class="section__toolbox-head-left">
<UIcon name="i-lucide-wrench" />
<span>Boite a outils</span>
</div>
<UIcon
:name="toolboxOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="section__toolbox-toggle"
/>
</button>
<div class="section__toolbox-content" :class="{ 'section__toolbox-content--open': toolboxOpen }">
<div v-if="$slots.toolbox" class="section__toolbox-body">
<slot name="toolbox" />
</div>
<div v-else-if="toolboxItems && toolboxItems.length > 0" class="section__toolbox-body">
<ToolboxVignette
v-for="(item, idx) in toolboxItems"
:key="idx"
:title="item.title"
/>
</div>
<div v-else class="section__toolbox-empty">
Aucun outil disponible
</div>
</div>
</aside>
</div>
</div>
</template>
<style scoped>
.section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section__header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.section__title {
font-size: 1.375rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
}
@media (min-width: 640px) {
.section__title {
font-size: 1.75rem;
}
}
.section__subtitle {
font-size: 0.875rem;
color: var(--mood-text-muted);
font-weight: 500;
}
@media (min-width: 640px) {
.section__subtitle {
font-size: 1rem;
}
}
.section__body {
display: grid;
grid-template-columns: 1fr 16rem;
gap: 1.5rem;
align-items: start;
}
.section__main {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.section__pills {
display: flex;
gap: 0.5rem;
align-items: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 2px;
}
.section__pills::-webkit-scrollbar {
display: none;
}
@media (min-width: 640px) {
.section__pills {
flex-wrap: wrap;
overflow-x: visible;
}
}
.section__pill-count {
margin-left: 0.25rem;
font-size: 0.6875rem;
font-weight: 800;
opacity: 0.7;
}
.section__search {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
}
@media (max-width: 639px) {
.section__search {
flex-direction: column;
}
}
.section__content {
min-height: 12rem;
}
.section__toolbox {
position: sticky;
top: 4.5rem;
display: flex;
flex-direction: column;
background: var(--mood-surface);
border-radius: 16px;
overflow: hidden;
}
.section__toolbox-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem;
cursor: pointer;
background: none;
}
.section__toolbox-head-left {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 800;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section__toolbox-toggle {
color: var(--mood-text-muted);
font-size: 0.875rem;
}
.section__toolbox-content {
display: none;
padding: 0 1rem 1rem;
}
.section__toolbox-content--open {
display: block;
}
.section__toolbox-body {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.section__toolbox-empty {
font-size: 0.875rem;
color: var(--mood-text-muted);
text-align: center;
padding: 1rem 0;
}
/* Desktop: toolbox always open, no toggle */
@media (min-width: 1024px) {
.section__toolbox-head {
cursor: default;
}
.section__toolbox-toggle {
display: none;
}
.section__toolbox-content {
display: block;
}
}
@media (max-width: 1023px) {
.section__body {
grid-template-columns: 1fr;
}
.section__toolbox {
position: static;
order: 2;
}
}
</style>
@@ -1,73 +0,0 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
status: string
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
clickable?: boolean
active?: boolean
}>(), {
clickable: true,
active: false,
})
const emit = defineEmits<{
click: []
}>()
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
// Universal statuses
draft: { label: 'En prepa', cssClass: 'status-prepa' },
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
closed: { label: 'Clos', cssClass: 'status-clos' },
// Decision/vote specific
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
review: { label: 'En prepa', cssClass: 'status-prepa' },
voting: { label: 'En vote', cssClass: 'status-vote' },
open: { label: 'En vote', cssClass: 'status-vote' },
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
// Version specific
pending: { label: 'En prepa', cssClass: 'status-prepa' },
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
rejected: { label: 'Clos', cssClass: 'status-clos' },
// Mandate specific
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
investiture: { label: 'En vote', cssClass: 'status-vote' },
revoked: { label: 'Clos', cssClass: 'status-clos' },
completed: { label: 'Clos', cssClass: 'status-clos' },
archived: { label: 'Clos', cssClass: 'status-clos' },
reporting: { label: 'En vote', cssClass: 'status-vote' },
}
const resolved = computed(() => {
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
})
function handleClick() {
if (props.clickable) {
emit('click')
}
}
</script>
<template>
<button
v-if="clickable"
type="button"
class="status-pill"
:class="[resolved.cssClass, { active: active }]"
@click="handleClick"
>
{{ resolved.label }}
</button>
<span
v-else
class="status-pill"
:class="[resolved.cssClass]"
>
{{ resolved.label }}
</span>
</template>
@@ -1,140 +0,0 @@
<script setup lang="ts">
/**
* ToolboxVignette — Carte compacte, bullet points, bouton Demarrer.
*/
export interface ToolboxAction {
label: string
icon?: string
to?: string
emit?: string
primary?: boolean
}
const props = withDefaults(
defineProps<{
title: string
bullets?: string[]
actions?: ToolboxAction[]
}>(),
{
bullets: undefined,
actions: undefined,
},
)
const emit = defineEmits<{
action: [actionEmit: string]
}>()
const defaultActions: ToolboxAction[] = [
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'demarrer', primary: true },
]
const resolvedActions = computed(() => props.actions ?? defaultActions)
function handleAction(action: ToolboxAction) {
if (action.to) {
navigateTo(action.to)
}
else if (action.emit) {
emit('action', action.emit)
}
}
</script>
<template>
<div class="vignette">
<h4 class="vignette__title">{{ title }}</h4>
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
</ul>
<div class="vignette__actions">
<button
v-for="action in resolvedActions"
:key="action.label"
class="vignette__btn"
:class="{ 'vignette__btn--primary': action.primary }"
@click="handleAction(action)"
>
<UIcon v-if="action.icon" :name="action.icon" />
<span>{{ action.label }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.vignette {
background: var(--mood-accent-soft);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.vignette__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0;
}
.vignette__bullets {
margin: 0;
padding: 0 0 0 1rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
list-style-type: disc;
}
.vignette__bullets li::marker {
color: var(--mood-accent);
}
.vignette__actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.125rem;
}
.vignette__btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-surface);
border-radius: 20px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
min-height: 2.25rem;
}
@media (min-width: 640px) {
.vignette__btn {
padding: 0.375rem 0.75rem;
min-height: auto;
}
}
.vignette__btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.vignette__btn:active {
transform: translateY(0);
}
.vignette__btn--primary {
color: var(--mood-accent-text);
background: var(--mood-accent);
}
</style>
@@ -1,123 +0,0 @@
<script setup lang="ts">
/**
* Cadrage form component for creating or editing a decision.
*
* Provides all fields needed for the initial decision setup:
* title, description, context, decision type, and voting protocol.
*/
import type { DecisionCreate } from '~/stores/decisions'
const props = defineProps<{
modelValue: DecisionCreate
submitting?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: DecisionCreate]
'submit': []
}>()
const decisionTypeOptions = [
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
{ label: 'Modification de document', value: 'document_change' },
{ label: 'Vote de mandat', value: 'mandate_vote' },
{ label: 'Changement de parametre', value: 'parameter_change' },
{ label: 'Autre', value: 'other' },
]
function updateField<K extends keyof DecisionCreate>(field: K, value: DecisionCreate[K]) {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
const isValid = computed(() => {
return props.modelValue.title?.trim() && props.modelValue.decision_type
})
function onSubmit() {
if (isValid.value) {
emit('submit')
}
}
</script>
<template>
<form class="space-y-6" @submit.prevent="onSubmit">
<!-- Titre -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Titre <span class="text-red-500">*</span>
</label>
<UInput
:model-value="modelValue.title"
placeholder="Titre de la decision..."
required
@update:model-value="updateField('title', $event as string)"
/>
</div>
<!-- Description -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span class="text-red-500">*</span>
</label>
<UTextarea
:model-value="modelValue.description ?? ''"
placeholder="Decrivez l'objet de cette decision..."
:rows="4"
@update:model-value="updateField('description', $event as string)"
/>
</div>
<!-- Contexte -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Contexte
</label>
<UTextarea
:model-value="modelValue.context ?? ''"
placeholder="Contexte, motivations, liens utiles..."
:rows="3"
@update:model-value="updateField('context', $event as string)"
/>
</div>
<!-- Type de decision -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type de decision <span class="text-red-500">*</span>
</label>
<USelect
:model-value="modelValue.decision_type"
:items="decisionTypeOptions"
placeholder="Selectionnez un type..."
@update:model-value="updateField('decision_type', $event as string)"
/>
</div>
<!-- Protocole de vote -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Protocole de vote
</label>
<ProtocolPicker
:model-value="modelValue.voting_protocol_id ?? null"
@update:model-value="updateField('voting_protocol_id', $event)"
/>
<p class="text-xs text-gray-500">
Optionnel. Peut etre defini ulterieurement pour chaque etape.
</p>
</div>
<!-- Submit -->
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
type="submit"
label="Creer la decision"
icon="i-lucide-plus"
color="primary"
:loading="submitting"
:disabled="!isValid"
/>
</div>
</form>
</template>
@@ -1,71 +0,0 @@
<script setup lang="ts">
/**
* Card component for displaying a decision in a list.
*
* Shows title, type badge, status badge, step count, and creation date.
* Navigates to the decision detail page on click.
*/
import type { Decision } from '~/stores/decisions'
const props = defineProps<{
decision: Decision
}>()
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modif. document'
case 'mandate_vote': return 'Vote de mandat'
case 'parameter_change': return 'Param. change'
case 'other': return 'Autre'
default: return decisionType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function navigate() {
navigateTo(`/decisions/${props.decision.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
@click="navigate"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-scale" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ decision.title }}
</h3>
</div>
<StatusBadge :status="decision.status" type="decision" />
</div>
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ decision.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(decision.decision_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ decision.steps.length }} etape(s)
</span>
<span class="text-xs text-gray-500">
{{ formatDate(decision.created_at) }}
</span>
</div>
</div>
</UCard>
</template>
@@ -1,147 +0,0 @@
<script setup lang="ts">
/**
* Visual stepper/timeline showing the decision workflow.
*
* Displays each step with its type icon, status badge, and dates.
* The active step is highlighted, completed steps show a checkmark.
*/
import type { DecisionStep } from '~/stores/decisions'
const props = defineProps<{
steps: DecisionStep[]
currentStatus: string
}>()
const emit = defineEmits<{
'create-vote-session': [step: DecisionStep]
}>()
const sortedSteps = computed(() => {
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
})
const stepTypeLabel = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'vote': return 'Vote'
case 'execution': return 'Execution'
case 'reporting': return 'Compte rendu'
default: return stepType
}
}
const stepTypeIcon = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'i-lucide-check-square'
case 'review': return 'i-lucide-eye'
case 'vote': return 'i-lucide-vote'
case 'execution': return 'i-lucide-play'
case 'reporting': return 'i-lucide-file-text'
default: return 'i-lucide-circle'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div>
<div v-if="sortedSteps.length === 0" class="text-center py-8">
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
</div>
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
<!-- Steps -->
<div class="space-y-4">
<div
v-for="step in sortedSteps"
:key="step.id"
class="relative pl-12"
>
<!-- Timeline dot -->
<div
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="{
'bg-green-500 border-green-500': step.status === 'completed',
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
'bg-yellow-400 border-yellow-400': step.status === 'pending',
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
}"
>
<UIcon
v-if="step.status === 'completed'"
name="i-lucide-check"
class="text-white text-xs"
/>
</div>
<UCard>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<StatusBadge :status="step.status" type="decision" />
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
{{ step.title }}
</h3>
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
{{ step.description }}
</p>
<div class="text-xs text-gray-500">
Cree le {{ formatDate(step.created_at) }}
</div>
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
<UIcon name="i-lucide-flag" class="text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
Resultat : {{ step.outcome }}
</span>
</div>
<!-- Vote session actions -->
<div class="flex items-center gap-2 mt-2">
<UButton
v-if="step.vote_session_id"
size="xs"
variant="soft"
color="primary"
icon="i-lucide-vote"
label="Voir la session de vote"
/>
<UButton
v-else-if="step.step_type === 'vote' && (step.status === 'active' || step.status === 'pending')"
size="xs"
variant="soft"
color="primary"
icon="i-lucide-plus"
label="Creer une session de vote"
@click.stop="emit('create-vote-session', step)"
/>
</div>
</div>
</UCard>
</div>
</div>
</div>
</div>
</template>
@@ -1,88 +0,0 @@
<script setup lang="ts">
import type { Document } from '~/stores/documents'
const props = withDefaults(defineProps<{
documents: Document[]
loading?: boolean
}>(), {
loading: false,
})
const typeLabel = (docType: string): string => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Reglement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<!-- Loading state -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
</div>
<!-- Empty state -->
<UCard v-else-if="documents.length === 0">
<div class="text-center py-8">
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun document de reference pour le moment</p>
</div>
</UCard>
<!-- Document grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<UCard
v-for="doc in documents"
:key="doc.id"
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<div class="space-y-3">
<!-- Header -->
<div class="flex items-start justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
{{ doc.title }}
</h3>
<StatusBadge :status="doc.status" type="document" />
</div>
<!-- Type + Version -->
<div class="flex items-center gap-2">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(doc.doc_type) }}
</UBadge>
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
</div>
<!-- Description -->
<p
v-if="doc.description"
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
>
{{ doc.description }}
</p>
<!-- Footer -->
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-1 text-xs text-gray-500">
<UIcon name="i-lucide-list" class="text-sm" />
<span>{{ doc.items_count }} items</span>
</div>
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
</div>
</div>
</UCard>
</div>
</template>
@@ -1,126 +0,0 @@
<script setup lang="ts">
/**
* DocumentTuto — Quick tutorial overlay explaining how the document works.
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
*/
const open = ref(false)
const steps = [
{
icon: 'i-lucide-infinity',
title: 'Vote permanent',
text: 'Chaque engagement est sous vote permanent. À tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
},
{
icon: 'i-lucide-sliders-horizontal',
title: 'Inertie variable',
text: 'Les engagements fondamentaux ont une inertie standard (difficulté de remplacement modérée). Les annexes sont plus faciles à modifier. La formule et ses réglages sont très protégés.',
},
{
icon: 'i-lucide-scale',
title: 'Seuil adaptatif',
text: 'La formule WoT adapte le seuil à la participation : peu de votants = quasi-unanimité requise ; beaucoup de votants = majorité simple suffit.',
},
{
icon: 'i-lucide-pen-line',
title: 'Contre-propositions',
text: 'Cliquez sur « Proposer une alternative » pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
},
{
icon: 'i-lucide-git-branch',
title: 'Dépôt automatique',
text: 'Quand une alternative est adoptée, le document officiel est mis à jour, ancré sur IPFS et on-chain, puis déployé dans les applications (Cesium, Gecko).',
},
]
</script>
<template>
<div>
<UButton
icon="i-lucide-circle-help"
variant="ghost"
color="neutral"
size="sm"
@click="open = true"
/>
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
<template #content>
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
Comment ça marche ?
</h2>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
size="xs"
@click="open = false"
/>
</div>
<div class="flex flex-col gap-4">
<div
v-for="(step, idx) in steps"
:key="idx"
class="tuto-step"
>
<div class="tuto-step__icon">
<UIcon :name="step.icon" class="text-base" />
</div>
<div class="tuto-step__content">
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
{{ step.title }}
</h4>
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
{{ step.text }}
</p>
</div>
</div>
</div>
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
Référence : formule g1vote —
<a
href="https://g1vote-view-237903.pages.duniter.org/"
target="_blank"
rel="noopener"
style="color: var(--mood-accent)"
>
g1vote-view
</a>
</p>
</div>
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.tuto-step {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.tuto-step__icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
color: var(--mood-accent);
flex-shrink: 0;
}
.tuto-step__content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
</style>
@@ -1,303 +0,0 @@
<script setup lang="ts">
/**
* EngagementCard — Enhanced item card with inline mini vote board,
* inertia indicator, and action buttons.
*
* Replaces the basic ItemCard for the document detail view.
*/
import type { DocumentItem } from '~/stores/documents'
const props = withDefaults(defineProps<{
item: DocumentItem
documentSlug: string
showActions?: boolean
showVoteBoard?: boolean
}>(), {
showActions: false,
showVoteBoard: true,
})
const emit = defineEmits<{
propose: [item: DocumentItem]
}>()
const isSection = computed(() => props.item.item_type === 'section')
const isPreamble = computed(() => props.item.item_type === 'preamble')
const itemTypeIcon = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'i-lucide-shield-check'
case 'rule': return 'i-lucide-scale'
case 'verification': return 'i-lucide-check-circle'
case 'preamble': return 'i-lucide-scroll-text'
case 'section': return 'i-lucide-layout-list'
default: return 'i-lucide-file-text'
}
})
const itemTypeLabel = computed(() => {
switch (props.item.item_type) {
case 'clause': return 'Engagement'
case 'rule': return 'Variables'
case 'verification': return 'Application'
case 'preamble': return 'Préambule'
case 'section': return 'Titre'
default: return props.item.item_type
}
})
// Mock vote data varies by item for demo — items in "bonnes pratiques" (E8-E11) get lower/mixed votes
const mockVotes = computed(() => {
const order = props.item.sort_order
const pos = props.item.position
// Conseils et bonnes pratiques: varied votes, some non-adopted
if (pos === 'E8') return { votesFor: 4, votesAgainst: 3 } // contested
if (pos === 'E9') return { votesFor: 2, votesAgainst: 5 } // rejected
if (pos === 'E10') return { votesFor: 6, votesAgainst: 2 } // borderline
if (pos === 'E11') return { votesFor: 3, votesAgainst: 4 } // rejected
// Default: well-adopted items
const base = ((order * 7 + 13) % 5) + 8 // 8-12
const against = (order % 3) // 0-2
return { votesFor: base, votesAgainst: against }
})
function navigateToItem() {
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
}
</script>
<template>
<!-- Section header (visual separator, not a card) -->
<div v-if="isSection" class="engagement-section">
<div class="engagement-section__line" />
<div class="engagement-section__content">
<h3 class="engagement-section__title">
{{ item.title }}
</h3>
<p class="engagement-section__text">
{{ item.current_text }}
</p>
<InertiaSlider
:preset="item.inertia_preset"
compact
class="mt-2 max-w-xs"
/>
</div>
</div>
<!-- Regular item card -->
<div
v-else
class="engagement-card"
:class="{
'engagement-card--preamble': isPreamble,
}"
>
<!-- Card header -->
<div class="engagement-card__header" @click="navigateToItem">
<div class="flex items-center gap-2.5 min-w-0">
<div class="engagement-card__position">
{{ item.position }}
</div>
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
<span v-if="item.title" class="engagement-card__title">
{{ item.title }}
</span>
<span class="engagement-card__type-label">
{{ itemTypeLabel }}
</span>
</div>
</div>
<!-- Item text -->
<div class="engagement-card__body" @click="navigateToItem">
<MarkdownRenderer :content="item.current_text" />
</div>
<!-- Mini vote board -->
<div v-if="showVoteBoard" class="engagement-card__vote">
<MiniVoteBoard
:votes-for="mockVotes.votesFor"
:votes-against="mockVotes.votesAgainst"
:wot-size="7224"
:is-permanent="item.is_permanent_vote"
:inertia-preset="item.inertia_preset"
/>
</div>
<!-- Inertia indicator -->
<div class="engagement-card__inertia">
<InertiaSlider
:preset="item.inertia_preset"
compact
/>
</div>
<!-- Actions -->
<div v-if="showActions" class="engagement-card__actions">
<UButton
label="Proposer une alternative"
icon="i-lucide-pen-line"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('propose', item)"
/>
<UButton
label="Voter"
icon="i-lucide-vote"
variant="soft"
color="success"
size="xs"
@click.stop="navigateToItem"
/>
</div>
</div>
</template>
<style scoped>
/* Section separator */
.engagement-section {
display: flex;
gap: 1rem;
padding: 1.5rem 0 0.5rem;
}
.engagement-section__line {
width: 4px;
background: var(--mood-accent);
border-radius: 2px;
flex-shrink: 0;
}
.engagement-section__content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.engagement-section__title {
font-size: 1.125rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
.engagement-section__text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
/* Card */
.engagement-card {
display: flex;
flex-direction: column;
background: var(--mood-surface);
border-radius: 14px;
overflow: hidden;
transition: box-shadow 0.15s, transform 0.15s;
}
.engagement-card:hover {
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
transform: translateY(-1px);
}
.engagement-card--preamble {
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
}
.engagement-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem 0;
cursor: pointer;
}
.engagement-card__position {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.625rem;
padding: 0 0.5rem;
border-radius: 8px;
background: var(--mood-accent);
color: white;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.engagement-card__title {
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.engagement-card__type-label {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-accent);
opacity: 0.5;
flex-shrink: 0;
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
}
.engagement-card__body {
padding: 0.5rem 1rem 0.625rem;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.5;
color: var(--mood-text);
}
@media (min-width: 640px) {
.engagement-card__body {
font-size: 0.9375rem;
line-height: 1.55;
}
}
.engagement-card__vote {
padding: 0 1rem;
opacity: 0.7;
transform: scale(0.92);
transform-origin: left center;
transition: opacity 0.2s;
}
.engagement-card:hover .engagement-card__vote {
opacity: 1;
}
.engagement-card__inertia {
padding: 0.25rem 1rem 0.5rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.engagement-card:hover .engagement-card__inertia {
opacity: 1;
}
.engagement-card__actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
}
</style>
@@ -1,489 +0,0 @@
<script setup lang="ts">
/**
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
* for a reference document. Main block collapsible, each sub-section independently collapsible.
*/
const props = defineProps<{
genesisJson: string
}>()
const expanded = ref(false)
// Individual section toggles
const sectionOpen = reactive<Record<string, boolean>>({
source: true,
tools: false,
forum: true,
process: false,
contributors: false,
})
function toggleSection(key: string) {
sectionOpen[key] = !sectionOpen[key]
}
interface GenesisData {
source_document: {
title: string
url: string
repo: string
}
reference_tools: Record<string, string>
forum_synthesis: Array<{
title: string
url: string
status: string
posts?: number
}>
formula_trigger: string
contributors: Array<{
name: string
role: string
}>
}
const genesis = computed((): GenesisData | null => {
try {
return JSON.parse(props.genesisJson)
} catch {
return null
}
})
const statusLabel = (status: string) => {
switch (status) {
case 'rejected': return 'Rejetée'
case 'in_progress': return 'En cours'
case 'reference': return 'Référence'
default: return status
}
}
const statusClass = (status: string) => {
switch (status) {
case 'rejected': return 'genesis-status--rejected'
case 'in_progress': return 'genesis-status--progress'
case 'reference': return 'genesis-status--reference'
default: return 'genesis-status--default'
}
}
</script>
<template>
<div v-if="genesis" class="genesis-block">
<!-- Header (always visible) -->
<button
class="genesis-block__header"
@click="expanded = !expanded"
>
<div class="flex items-center gap-3">
<div class="genesis-block__icon">
<UIcon name="i-lucide-file-archive" class="text-lg" />
</div>
<div class="text-left">
<h3 class="text-sm font-bold uppercase tracking-wide genesis-accent">
Bloc de genèse
</h3>
<p class="text-xs genesis-text-muted">
Sources, références et formule de dépôt
</p>
</div>
</div>
<UIcon
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-lg genesis-muted-icon"
/>
</button>
<!-- Expandable content -->
<Transition name="genesis-expand">
<div v-if="expanded" class="genesis-block__body">
<!-- Source document -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('source')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-file-text" />
Document source
</h4>
<UIcon
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.source" class="genesis-section__content">
<div class="genesis-card">
<p class="font-medium text-sm genesis-text">
{{ genesis.source_document.title }}
</p>
<div class="flex flex-col gap-1 mt-2">
<a
:href="genesis.source_document.url"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-external-link" class="text-xs" />
Texte officiel
</a>
<a
:href="genesis.source_document.repo"
target="_blank"
rel="noopener"
class="genesis-link"
>
<UIcon name="i-lucide-git-branch" class="text-xs" />
Dépôt git
</a>
</div>
</div>
</div>
</div>
<!-- Reference tools -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('tools')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-wrench" />
Outils de référence
</h4>
<UIcon
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.tools" class="genesis-section__content">
<div class="grid grid-cols-2 gap-2">
<a
v-for="(url, name) in genesis.reference_tools"
:key="name"
:href="url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--tool"
>
<span class="text-xs font-semibold capitalize genesis-text">
{{ name.replace(/_/g, ' ') }}
</span>
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
</a>
</div>
</div>
</div>
<!-- Forum synthesis -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('forum')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-messages-square" />
Synthèse des discussions
</h4>
<UIcon
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.forum" class="genesis-section__content">
<div class="flex flex-col gap-2">
<a
v-for="topic in genesis.forum_synthesis"
:key="topic.url"
:href="topic.url"
target="_blank"
rel="noopener"
class="genesis-card genesis-card--forum"
>
<div class="flex items-start justify-between gap-2">
<span class="text-xs font-medium genesis-text">
{{ topic.title }}
</span>
<span
class="genesis-status shrink-0"
:class="statusClass(topic.status)"
>
{{ statusLabel(topic.status) }}
</span>
</div>
<span v-if="topic.posts" class="text-xs genesis-text-muted">
{{ topic.posts }} messages
</span>
</a>
</div>
</div>
</div>
<!-- Formula trigger -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('process')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-zap" />
Processus de dépôt
</h4>
<UIcon
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.process" class="genesis-section__content">
<div class="genesis-card">
<p class="text-xs leading-relaxed genesis-text">
{{ genesis.formula_trigger }}
</p>
</div>
</div>
</div>
<!-- Contributors -->
<div class="genesis-section">
<button class="genesis-section__toggle" @click="toggleSection('contributors')">
<h4 class="genesis-section__title">
<UIcon name="i-lucide-users" />
Contributeurs
</h4>
<UIcon
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-sm genesis-muted-icon"
/>
</button>
<div v-if="sectionOpen.contributors" class="genesis-section__content">
<div class="flex flex-wrap gap-2">
<div
v-for="c in genesis.contributors"
:key="c.name"
class="genesis-contributor"
>
<span class="font-semibold text-xs genesis-text">{{ c.name }}</span>
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.genesis-block {
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-surface));
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
border-radius: 16px;
overflow: hidden;
}
.genesis-block__header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 1.25rem;
cursor: pointer;
background: none;
transition: background 0.15s;
}
.genesis-block__header:hover {
background: color-mix(in srgb, var(--mood-accent) 4%, transparent);
}
.genesis-block__header h3 {
color: var(--mood-accent) !important;
}
.genesis-block__header p {
color: var(--mood-text-muted) !important;
}
.genesis-block__icon {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
color: var(--mood-accent);
}
.genesis-block__body {
padding: 0 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.genesis-section {
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
}
.genesis-section__toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
cursor: pointer;
background: none;
border: none;
transition: background 0.15s;
}
.genesis-section__toggle:hover {
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
}
.genesis-section__title {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-accent);
margin: 0;
}
.genesis-section__toggle .text-sm {
color: var(--mood-text-muted) !important;
}
.genesis-section__content {
padding: 0 0.75rem 0.75rem;
}
.genesis-card {
padding: 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
}
.genesis-card .font-medium,
.genesis-card .text-xs,
.genesis-text {
color: var(--mood-text) !important;
}
.genesis-card--tool {
display: flex;
align-items: center;
justify-content: space-between;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--tool .text-xs {
color: var(--mood-text) !important;
}
.genesis-card--tool:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.genesis-card--forum {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-decoration: none;
transition: background 0.15s;
}
.genesis-card--forum:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
}
.genesis-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--mood-accent);
text-decoration: none;
font-weight: 500;
}
.genesis-link:hover {
text-decoration: underline;
}
.genesis-contributor {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
}
.genesis-contributor .font-semibold {
color: var(--mood-text) !important;
}
.genesis-contributor .text-xs:not(.font-semibold) {
color: var(--mood-text-muted) !important;
}
/* Status badges — palette-aware */
.genesis-status {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 20px;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.genesis-status--reference {
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
color: var(--mood-accent);
}
.genesis-status--progress {
background: color-mix(in srgb, var(--mood-warning) 20%, transparent);
color: var(--mood-warning);
}
.genesis-status--rejected {
background: color-mix(in srgb, var(--mood-error) 20%, transparent);
color: var(--mood-error);
}
.genesis-status--default {
background: color-mix(in srgb, var(--mood-text) 8%, transparent);
color: var(--mood-text-muted);
}
/* Genesis-context text utilities */
.genesis-accent {
color: var(--mood-accent) !important;
}
.genesis-text {
color: var(--mood-text) !important;
}
.genesis-text-muted {
color: var(--mood-text-muted) !important;
}
.genesis-muted-icon {
color: var(--mood-text-muted) !important;
}
/* Expand/collapse transition */
.genesis-expand-enter-active,
.genesis-expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.genesis-expand-enter-from,
.genesis-expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
</style>
@@ -1,419 +0,0 @@
<script setup lang="ts">
/**
* Inertia slider — displays the inertia preset level for a section.
* Read-only indicator (voting on the preset uses the standard vote flow).
* In full mode: shows formula diagram with simplified curve visualization.
*/
const props = withDefaults(defineProps<{
preset: string
compact?: boolean
mini?: boolean
}>(), {
compact: false,
mini: false,
})
interface InertiaLevel {
label: string
gradient: number
majority: number
color: string
position: number // 0-100 for slider position
description: string
}
const LEVELS: Record<string, InertiaLevel> = {
low: {
label: 'Remplacement facile',
gradient: 0.1,
majority: 50,
color: '#22c55e',
position: 10,
description: 'Majorité simple suffit, même à faible participation',
},
standard: {
label: 'Inertie pour le remplacement',
gradient: 0.2,
majority: 50,
color: '#3b82f6',
position: 37,
description: 'Équilibre : consensus croissant avec la participation',
},
high: {
label: 'Remplacement difficile',
gradient: 0.4,
majority: 60,
color: '#f59e0b',
position: 63,
description: 'Forte mobilisation et super-majorité requises',
},
very_high: {
label: 'Remplacement très difficile',
gradient: 0.6,
majority: 66,
color: '#ef4444',
position: 90,
description: 'Quasi-unanimité requise à toute participation',
},
}
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
// Generate SVG curve points for the inertia function
// Formula simplified: Seuil% = M + (1-M) × (1 - (T/W)^G)
// Where T/W = participation rate, so Seuil% goes from ~100% at low participation to M at full participation
const curvePath = computed(() => {
const G = level.value.gradient
const M = level.value.majority / 100
const points: string[] = []
const steps = 40
for (let i = 0; i <= steps; i++) {
const participation = i / steps // T/W ratio 0..1
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
// SVG coordinates: x = participation (0..200), y = threshold inverted (0=100%, 80=20%)
const x = 30 + participation * 170
const y = 10 + (1 - threshold) * 70
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return `M ${points.join(' L ')}`
})
// The 4 curve paths for the diagram overlay
const allCurves = computed(() => {
return Object.entries(LEVELS).map(([key, lvl]) => {
const G = lvl.gradient
const M = lvl.majority / 100
const points: string[] = []
const steps = 40
for (let i = 0; i <= steps; i++) {
const participation = i / steps
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
const x = 30 + participation * 170
const y = 10 + (1 - threshold) * 70
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return {
key,
color: lvl.color,
path: `M ${points.join(' L ')}`,
active: key === props.preset,
}
})
})
</script>
<template>
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
<!-- Slider track -->
<div class="inertia__track">
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
<div
class="inertia__thumb"
:style="{ left: `${level.position}%`, borderColor: level.color }"
/>
<!-- Level marks -->
<div
v-for="(lvl, key) in LEVELS"
:key="key"
class="inertia__mark"
:class="{ 'inertia__mark--active': key === preset }"
:style="{ left: `${lvl.position}%` }"
/>
</div>
<!-- Label row -->
<div v-if="mini" class="inertia__info">
<span class="inertia__label inertia__label--mini" :style="{ color: level.color }">
Inertie
</span>
</div>
<div v-else class="inertia__info">
<span class="inertia__label" :style="{ color: level.color }">
{{ level.label }}
</span>
<span v-if="!compact" class="inertia__params">
G={{ level.gradient }} M={{ level.majority }}%
</span>
</div>
<!-- Description (not in compact mode) -->
<p v-if="!compact" class="inertia__desc">
{{ level.description }}
</p>
<!-- Formula diagram (not in compact mode) -->
<div v-if="!compact" class="inertia__diagram">
<svg viewBox="0 0 220 100" class="inertia__svg">
<!-- Grid -->
<line x1="30" y1="10" x2="30" y2="80" class="inertia__axis" />
<line x1="30" y1="80" x2="200" y2="80" class="inertia__axis" />
<!-- Grid lines -->
<line x1="30" y1="10" x2="200" y2="10" class="inertia__grid" />
<line x1="30" y1="45" x2="200" y2="45" class="inertia__grid" />
<!-- Majority line M -->
<line
x1="30"
:y1="10 + (1 - level.majority / 100) * 70"
x2="200"
:y2="10 + (1 - level.majority / 100) * 70"
class="inertia__majority-line"
/>
<text
x="203"
:y="13 + (1 - level.majority / 100) * 70"
class="inertia__axis-label"
style="fill: var(--mood-accent)"
>M={{ level.majority }}%</text>
<!-- Background curves (ghosted) -->
<path
v-for="curve in allCurves"
:key="curve.key"
:d="curve.path"
fill="none"
:stroke="curve.color"
:stroke-width="curve.active ? 0 : 1"
:opacity="curve.active ? 0 : 0.15"
stroke-dasharray="3 3"
/>
<!-- Active curve -->
<path
:d="curvePath"
fill="none"
:stroke="level.color"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- Axis labels -->
<text x="15" y="14" class="inertia__axis-label">100%</text>
<text x="15" y="49" class="inertia__axis-label">50%</text>
<text x="15" y="84" class="inertia__axis-label">0%</text>
<text x="28" y="95" class="inertia__axis-label">0%</text>
<text x="105" y="95" class="inertia__axis-label">50%</text>
<text x="185" y="95" class="inertia__axis-label">100%</text>
<!-- Axis titles -->
<text x="3" y="50" class="inertia__axis-title" transform="rotate(-90, 6, 50)">Seuil</text>
<text x="110" y="100" class="inertia__axis-title">Participation (T/W)</text>
</svg>
<!-- Simplified formula -->
<div class="inertia__formula">
<span class="inertia__formula-label">Formule :</span>
<code class="inertia__formula-code">Seuil = M + (1-M) × (1 - (T/W)<sup>G</sup>)</code>
</div>
<div class="inertia__formula-legend">
<span><strong>T</strong> = votes exprimés</span>
<span><strong>W</strong> = taille WoT</span>
<span><strong>M</strong> = majorité cible</span>
<span><strong>G</strong> = gradient d'inertie</span>
</div>
</div>
</div>
</template>
<style scoped>
.inertia {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.inertia--compact {
gap: 0.25rem;
width: fit-content;
}
.inertia--mini {
gap: 0.125rem;
width: fit-content;
min-width: 3rem;
}
.inertia--mini .inertia__track {
height: 3px;
}
.inertia--mini .inertia__thumb {
width: 8px;
height: 8px;
border-width: 2px;
}
.inertia__track {
position: relative;
height: 6px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
border-radius: 3px;
}
.inertia--compact .inertia__track {
height: 4px;
}
.inertia__fill {
position: absolute;
inset: 0;
right: auto;
border-radius: 3px;
transition: width 0.3s ease;
}
.inertia__thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--mood-bg);
border: 3px solid;
transition: left 0.3s ease;
z-index: 2;
}
.inertia--compact .inertia__thumb {
width: 10px;
height: 10px;
border-width: 2px;
}
.inertia__mark {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
z-index: 1;
}
.inertia__mark--active {
background: transparent;
}
.inertia__info {
display: flex;
align-items: center;
justify-content: space-between;
}
.inertia__label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inertia--compact .inertia__label {
font-size: 0.625rem;
}
.inertia__label--mini {
font-size: 0.5625rem;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
.inertia__params {
font-size: 0.625rem;
font-family: monospace;
color: var(--mood-text-muted);
}
.inertia__desc {
font-size: 0.6875rem;
color: var(--mood-text-muted);
line-height: 1.3;
}
/* Diagram */
.inertia__diagram {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inertia__svg {
width: 100%;
max-width: 320px;
height: auto;
}
.inertia__axis {
stroke: color-mix(in srgb, var(--mood-text) 25%, transparent);
stroke-width: 1;
}
.inertia__grid {
stroke: color-mix(in srgb, var(--mood-text) 8%, transparent);
stroke-width: 0.5;
stroke-dasharray: 2 4;
}
.inertia__majority-line {
stroke: var(--mood-accent);
stroke-width: 0.75;
stroke-dasharray: 4 3;
opacity: 0.5;
}
.inertia__axis-label {
font-size: 5px;
fill: var(--mood-text-muted);
font-family: monospace;
}
.inertia__axis-title {
font-size: 5px;
fill: var(--mood-text-muted);
font-weight: 600;
}
.inertia__formula {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.inertia__formula-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-text-muted);
}
.inertia__formula-code {
font-size: 0.6875rem;
font-family: monospace;
color: var(--mood-text);
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: color-mix(in srgb, var(--mood-accent) 6%, var(--mood-bg));
}
.inertia__formula-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.5625rem;
color: var(--mood-text-muted);
}
.inertia__formula-legend strong {
color: var(--mood-text);
font-weight: 700;
}
</style>
@@ -1,89 +0,0 @@
<script setup lang="ts">
import type { DocumentItem } from '~/stores/documents'
const props = withDefaults(defineProps<{
item: DocumentItem
documentSlug: string
showActions?: boolean
}>(), {
showActions: false,
})
const emit = defineEmits<{
propose: [item: DocumentItem]
}>()
const itemTypeLabel = (itemType: string): string => {
switch (itemType) {
case 'clause': return 'Clause'
case 'rule': return 'Regle'
case 'verification': return 'Verification'
case 'preamble': return 'Preambule'
case 'section': return 'Section'
default: return itemType
}
}
function navigateToItem() {
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateToItem"
>
<div class="space-y-3">
<!-- Item header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UBadge variant="solid" color="primary" size="xs">
{{ item.position }}
</UBadge>
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
{{ item.title }}
</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ itemTypeLabel(item.item_type) }}
</UBadge>
</div>
<div class="flex items-center gap-2">
<UBadge
v-if="item.voting_protocol_id"
color="info"
variant="subtle"
size="xs"
>
Sous vote
</UBadge>
<UBadge
v-else
color="neutral"
variant="subtle"
size="xs"
>
Pas de vote
</UBadge>
</div>
</div>
<!-- Item text -->
<div class="pl-2">
<MarkdownRenderer :content="item.current_text" />
</div>
<!-- Actions -->
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
<UButton
label="Proposer une modification"
icon="i-lucide-pen-line"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('propose', item)"
/>
</div>
</div>
</UCard>
</template>
@@ -1,95 +0,0 @@
<script setup lang="ts">
import type { ItemVersion } from '~/stores/documents'
const props = defineProps<{
version: ItemVersion
}>()
const emit = defineEmits<{
accept: [versionId: string]
reject: [versionId: string]
}>()
const auth = useAuthStore()
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncateAddress(address: string | null): string {
if (!address) return 'Inconnu'
if (address.length <= 16) return address
return address.slice(0, 8) + '...' + address.slice(-6)
}
</script>
<template>
<UCard>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<StatusBadge :status="version.status" type="version" />
<span class="text-sm text-gray-500">
Propose par
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
{{ truncateAddress(version.proposed_by) }}
</span>
</span>
</div>
<span class="text-xs text-gray-400">
{{ formatDate(version.created_at) }}
</span>
</div>
<!-- Rationale -->
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
</div>
<!-- Diff view -->
<div v-if="version.diff">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
<DiffView :diff="version.diff" />
</div>
<!-- Proposed text (fallback if no diff) -->
<div v-else-if="version.proposed_text">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
<MarkdownRenderer :content="version.proposed_text" />
</div>
</div>
<!-- Actions for authenticated users -->
<div
v-if="auth.isAuthenticated && version.status === 'proposed'"
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
>
<UButton
label="Accepter"
icon="i-lucide-check"
color="success"
variant="soft"
size="xs"
@click="emit('accept', version.id)"
/>
<UButton
label="Rejeter"
icon="i-lucide-x"
color="error"
variant="soft"
size="xs"
@click="emit('reject', version.id)"
/>
</div>
</div>
</UCard>
</template>
@@ -1,240 +0,0 @@
<script setup lang="ts">
/**
* MiniVoteBoard — compact inline vote status for an engagement item.
*
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
* Uses mock data when no vote session is linked (dev mode).
*/
import { useVoteFormula } from '~/composables/useVoteFormula'
const props = withDefaults(defineProps<{
votesFor?: number
votesAgainst?: number
wotSize?: number
isPermanent?: boolean
inertiaPreset?: string
startsAt?: string | null
endsAt?: string | null
}>(), {
votesFor: 0,
votesAgainst: 0,
wotSize: 7224,
isPermanent: true,
inertiaPreset: 'standard',
startsAt: null,
endsAt: null,
})
const { computeThreshold } = useVoteFormula()
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
}
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
const threshold = computed(() => {
if (totalVotes.value === 0) return 1
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
})
const isPassing = computed(() => props.votesFor >= threshold.value)
const forPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesFor / totalVotes.value) * 100
})
const againstPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesAgainst / totalVotes.value) * 100
})
const thresholdPct = computed(() => {
if (totalVotes.value === 0) return 50
return Math.min((threshold.value / totalVotes.value) * 100, 100)
})
const participationRate = computed(() => {
if (props.wotSize === 0) return 0
return (totalVotes.value / props.wotSize) * 100
})
const remaining = computed(() => {
const diff = threshold.value - props.votesFor
return diff > 0 ? diff : 0
})
function formatDate(d: string): string {
return new Date(d).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>
<template>
<div class="mini-board">
<!-- Vote type + status on same line -->
<div class="mini-board__header">
<div class="flex items-center gap-2 flex-wrap">
<template v-if="isPermanent">
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
</template>
<template v-else>
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
</span>
</template>
<UBadge
:color="isPassing ? 'success' : 'warning'"
:variant="isPassing ? 'solid' : 'subtle'"
size="xs"
>
{{ isPassing ? 'Adopté' : 'En attente' }}
</UBadge>
</div>
</div>
<!-- Progress bar -->
<div class="mini-board__bar">
<div
class="mini-board__bar-for"
:style="{ width: `${forPct}%` }"
/>
<div
class="mini-board__bar-against"
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
/>
<!-- Threshold marker -->
<div
v-if="totalVotes > 0"
class="mini-board__bar-threshold"
:style="{ left: `${thresholdPct}%` }"
/>
</div>
<!-- Stats row -->
<div class="mini-board__stats">
<div class="flex items-center gap-3">
<span class="mini-board__stat mini-board__stat--for">
{{ votesFor }} pour
</span>
<span class="mini-board__stat mini-board__stat--against">
{{ votesAgainst }} contre
</span>
</div>
<div class="flex items-center gap-3">
<span class="mini-board__stat">
{{ votesFor }}/{{ threshold }} requis
</span>
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
</span>
</div>
</div>
<!-- Participation -->
<div class="mini-board__footer">
<span class="text-xs" style="color: var(--mood-text-muted)">
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
({{ participationRate.toFixed(2) }}%)
</span>
</div>
</div>
</template>
<style scoped>
.mini-board {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
}
.mini-board__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mini-board__bar {
position: relative;
height: 6px;
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
border-radius: 3px;
overflow: visible;
}
.mini-board__bar-for {
position: absolute;
inset: 0;
right: auto;
background: #22c55e;
border-radius: 3px 0 0 3px;
transition: width 0.4s ease;
}
.mini-board__bar-against {
position: absolute;
inset: 0;
right: auto;
background: #ef4444;
transition: width 0.4s ease, left 0.4s ease;
}
.mini-board__bar-threshold {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
background: #facc15;
border-radius: 1px;
transform: translateX(-50%);
transition: left 0.4s ease;
z-index: 2;
}
.mini-board__stats {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.25rem;
}
.mini-board__stat {
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-text-muted);
}
.mini-board__stat--for {
color: #22c55e;
}
.mini-board__stat--against {
color: #ef4444;
}
.mini-board__stat--remaining {
color: #f59e0b;
}
.mini-board__footer {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
@@ -1,82 +0,0 @@
<script setup lang="ts">
/**
* Card component for displaying a mandate in a list.
*
* Shows title, type badge, status badge, mandatee, date range.
* Navigates to the mandate detail page on click.
*/
import type { Mandate } from '~/stores/mandates'
const props = defineProps<{
mandate: Mandate
}>()
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function navigate() {
navigateTo(`/mandates/${props.mandate.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
@click="navigate"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-user-check" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ mandate.title }}
</h3>
</div>
<StatusBadge :status="mandate.status" type="mandate" />
</div>
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ mandate.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(mandate.mandate_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ mandate.steps.length }} etape(s)
</span>
<span v-if="mandate.mandatee_id" class="text-xs text-gray-500 flex items-center gap-1">
<UIcon name="i-lucide-user" class="text-xs" />
{{ mandate.mandatee_id.slice(0, 8) }}...
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
<div>
<span class="block font-medium">Debut</span>
{{ formatDate(mandate.starts_at) }}
</div>
<div>
<span class="block font-medium">Fin</span>
{{ formatDate(mandate.ends_at) }}
</div>
</div>
</div>
</UCard>
</template>
@@ -1,138 +0,0 @@
<script setup lang="ts">
/**
* Visual timeline for mandate lifecycle steps.
*
* Displays each step with its type, status, and visual indicators.
* Similar pattern to DecisionWorkflow but with mandate-specific step types.
*/
import type { MandateStep } from '~/stores/mandates'
const props = defineProps<{
steps: MandateStep[]
currentStatus: string
}>()
const sortedSteps = computed(() => {
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
})
const stepTypeLabel = (stepType: string) => {
switch (stepType) {
case 'candidacy': return 'Candidature'
case 'voting': return 'Vote'
case 'active': return 'Actif'
case 'reporting': return 'Rapport'
case 'completed': return 'Termine'
default: return stepType
}
}
const stepTypeIcon = (stepType: string) => {
switch (stepType) {
case 'candidacy': return 'i-lucide-user-plus'
case 'voting': return 'i-lucide-vote'
case 'active': return 'i-lucide-shield-check'
case 'reporting': return 'i-lucide-file-text'
case 'completed': return 'i-lucide-check-circle'
default: return 'i-lucide-circle'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div>
<div v-if="sortedSteps.length === 0" class="text-center py-8">
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune etape definie pour ce mandat</p>
</div>
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
<!-- Steps -->
<div class="space-y-4">
<div
v-for="step in sortedSteps"
:key="step.id"
class="relative pl-12"
>
<!-- Timeline dot -->
<div
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="{
'bg-green-500 border-green-500': step.status === 'completed',
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
'bg-yellow-400 border-yellow-400': step.status === 'pending',
'bg-red-500 border-red-500': step.status === 'revoked',
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
}"
>
<UIcon
v-if="step.status === 'completed'"
name="i-lucide-check"
class="text-white text-xs"
/>
<UIcon
v-else-if="step.status === 'revoked'"
name="i-lucide-x"
class="text-white text-xs"
/>
</div>
<UCard>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<StatusBadge :status="step.status" type="mandate" />
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
{{ step.title }}
</h3>
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
{{ step.description }}
</p>
<div class="text-xs text-gray-500">
Cree le {{ formatDate(step.created_at) }}
</div>
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
<UIcon name="i-lucide-flag" class="text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
Resultat : {{ step.outcome }}
</span>
</div>
<div v-if="step.vote_session_id" class="mt-2">
<UButton
size="xs"
variant="soft"
color="primary"
icon="i-lucide-vote"
label="Voir la session de vote"
/>
</div>
</div>
</UCard>
</div>
</div>
</div>
</div>
</template>
@@ -1,243 +0,0 @@
<script setup lang="ts">
/**
* Interactive formula parameter editor.
*
* Provides sliders and inputs for adjusting all formula parameters,
* emitting the updated config on each change.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
modelValue: FormulaConfig
}>()
const emit = defineEmits<{
'update:modelValue': [value: FormulaConfig]
}>()
/** Local reactive copy to avoid direct prop mutation. */
const local = reactive({ ...props.modelValue })
/** Sync incoming prop changes. */
watch(() => props.modelValue, (newVal) => {
Object.assign(local, newVal)
}, { deep: true })
/** Emit on any local change. */
watch(local, () => {
emit('update:modelValue', { ...local })
}, { deep: true })
/** Track optional fields. */
const showSmith = ref(local.smith_exponent !== null)
const showTechcomm = ref(local.techcomm_exponent !== null)
const showNuancedMin = ref(local.nuanced_min_participants !== null)
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
watch(showSmith, (v) => {
local.smith_exponent = v ? 0.5 : null
})
watch(showTechcomm, (v) => {
local.techcomm_exponent = v ? 0.5 : null
})
watch(showNuancedMin, (v) => {
local.nuanced_min_participants = v ? 10 : null
})
watch(showNuancedThreshold, (v) => {
local.nuanced_threshold_pct = v ? 66 : null
})
interface ParamDef {
key: string
label: string
description: string
type: 'input' | 'slider'
min: number
max: number
step: number
}
const mainParams: ParamDef[] = [
{
key: 'duration_days',
label: 'Duree (jours)',
description: 'Duree du vote en jours',
type: 'input',
min: 1,
max: 365,
step: 1,
},
{
key: 'majority_pct',
label: 'Majorite (%)',
description: 'Ratio de majorite cible a haute participation',
type: 'slider',
min: 0,
max: 100,
step: 1,
},
{
key: 'base_exponent',
label: 'Exposant de base (B)',
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
type: 'slider',
min: 0.01,
max: 1.0,
step: 0.01,
},
{
key: 'gradient_exponent',
label: 'Gradient d\'inertie (G)',
description: 'Controle la vitesse de transition vers la majorite simple',
type: 'slider',
min: 0.01,
max: 2.0,
step: 0.01,
},
{
key: 'constant_base',
label: 'Constante de base (C)',
description: 'Plancher fixe de votes requis',
type: 'input',
min: 0,
max: 100,
step: 1,
},
]
</script>
<template>
<div class="space-y-6">
<!-- Main parameters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ param.label }}
</label>
<span class="text-sm font-mono font-bold text-primary">
{{ (local as any)[param.key] }}
</span>
</div>
<template v-if="param.type === 'slider'">
<URange
v-model="(local as any)[param.key]"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<template v-else>
<UInput
v-model.number="(local as any)[param.key]"
type="number"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<p class="text-xs text-gray-500">{{ param.description }}</p>
</div>
</div>
<!-- Optional parameters -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
Parametres optionnels
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Smith exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showSmith" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere Smith (S)
</label>
</div>
<template v-if="showSmith && local.smith_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant Smith</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
</div>
<URange
v-model="local.smith_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
</template>
</div>
<!-- TechComm exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showTechcomm" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere TechComm (T)
</label>
</div>
<template v-if="showTechcomm && local.techcomm_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant TechComm</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
</div>
<URange
v-model="local.techcomm_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
</template>
</div>
<!-- Nuanced min participants -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedMin" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Participants minimum (nuance)
</label>
</div>
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
<UInput
v-model.number="local.nuanced_min_participants"
type="number"
:min="1"
:max="1000"
:step="1"
/>
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
</template>
</div>
<!-- Nuanced threshold pct -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedThreshold" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Seuil nuance (%)
</label>
</div>
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
</div>
<URange
v-model="local.nuanced_threshold_pct"
:min="50"
:max="100"
:step="1"
/>
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -1,61 +0,0 @@
<script setup lang="ts">
/**
* Display decoded mode params string as labeled badges/chips.
*
* Parses the compact mode params string and renders each parameter
* as a human-readable chip.
*/
const props = defineProps<{
modeParams: string
}>()
interface ParamChip {
code: string
label: string
value: string
color: string
}
const chips = computed((): ParamChip[] => {
if (!props.modeParams) return []
try {
const parsed = parseModeParams(props.modeParams)
const result: ParamChip[] = []
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
if (parsed.constant_base > 0) {
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
}
if (parsed.smith_exponent !== null) {
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
}
if (parsed.techcomm_exponent !== null) {
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
}
return result
} catch {
return []
}
})
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
<UBadge
v-for="chip in chips"
:key="chip.code"
:color="(chip.color as any)"
variant="subtle"
size="xs"
>
{{ chip.label }}: {{ chip.value }}
</UBadge>
</div>
</template>
@@ -1,64 +0,0 @@
<script setup lang="ts">
/**
* Dropdown/select to pick a voting protocol.
*
* Fetches protocols from the store and renders them in a USelect
* with protocol name, mode params, and vote type badge.
*/
import type { VotingProtocol } from '~/stores/protocols'
const props = defineProps<{
modelValue: string | null
voteType?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const protocols = useProtocolsStore()
onMounted(async () => {
if (protocols.protocols.length === 0) {
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
}
})
const filteredProtocols = computed(() => {
if (!props.voteType) return protocols.protocols
return protocols.protocols.filter(p => p.vote_type === props.voteType)
})
const options = computed(() => {
return filteredProtocols.value.map(p => ({
label: buildLabel(p),
value: p.id,
}))
})
function buildLabel(p: VotingProtocol): string {
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
const params = p.mode_params ? ` [${p.mode_params}]` : ''
return `${p.name} - ${typeLabel}${params}`
}
function onSelect(value: string) {
emit('update:modelValue', value || null)
}
</script>
<template>
<div>
<USelect
:model-value="modelValue ?? undefined"
:items="options"
placeholder="Selectionnez un protocole..."
:loading="protocols.loading"
value-key="value"
@update:model-value="onSelect"
/>
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
{{ protocols.error }}
</p>
</div>
</template>
@@ -1,33 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
txHash: string | null
block: number | null
}>()
const truncatedHash = computed(() => {
if (!props.txHash) return null
if (props.txHash.length <= 20) return props.txHash
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
})
</script>
<template>
<template v-if="txHash">
<div class="inline-flex items-center gap-2">
<div class="flex items-center gap-1.5">
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
{{ truncatedHash }}
</span>
</div>
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
Bloc #{{ block.toLocaleString('fr-FR') }}
</UBadge>
</div>
</template>
<template v-else>
<UBadge color="warning" variant="subtle" size="xs">
Non ancre
</UBadge>
</template>
</template>
@@ -1,38 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
cid: string | null
}>()
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
const truncatedCid = computed(() => {
if (!props.cid) return null
if (props.cid.length <= 20) return props.cid
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
})
const gatewayUrl = computed(() => {
if (!props.cid) return null
return `${IPFS_GATEWAY}${props.cid}`
})
</script>
<template>
<template v-if="cid">
<a
:href="gatewayUrl!"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
>
<UIcon name="i-lucide-hard-drive" class="text-sm" />
<span>{{ truncatedCid }}</span>
<UIcon name="i-lucide-external-link" class="text-sm" />
</a>
</template>
<template v-else>
<UBadge color="neutral" variant="subtle" size="xs">
Non disponible
</UBadge>
</template>
</template>
@@ -1,171 +0,0 @@
<script setup lang="ts">
export interface SanctuaryEntryOut {
id: string
entry_type: string
reference_id: string
title: string | null
content_hash: string
ipfs_cid: string | null
chain_tx_hash: string | null
chain_block: number | null
metadata_json: string | null
created_at: string
}
const props = defineProps<{
entry: SanctuaryEntryOut
}>()
const emit = defineEmits<{
verify: [id: string]
}>()
const typeLabel = (entryType: string): string => {
switch (entryType) {
case 'document': return 'Document'
case 'decision': return 'Decision'
case 'vote_result': return 'Resultat de vote'
default: return entryType
}
}
const typeColor = (entryType: string): string => {
switch (entryType) {
case 'document': return 'primary'
case 'decision': return 'success'
case 'vote_result': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncateHash(hash: string | null, length: number = 16): string {
if (!hash) return '-'
if (hash.length <= length * 2) return hash
return hash.slice(0, length) + '...' + hash.slice(-8)
}
const copied = ref(false)
async function copyHash() {
try {
await navigator.clipboard.writeText(props.entry.content_hash)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// Clipboard API not available
}
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
@click="navigateTo(`/sanctuary/${entry.id}`)"
>
<div class="space-y-4">
<!-- Entry header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ entry.title || 'Entree sans titre' }}
</h3>
<p class="text-xs text-gray-500">
{{ formatDate(entry.created_at) }}
</p>
</div>
</div>
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
{{ typeLabel(entry.entry_type) }}
</UBadge>
</div>
<!-- Hashes and anchors -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Content hash -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
</div>
<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
variant="ghost"
color="neutral"
size="xs"
class="p-0"
@click.stop="copyHash"
/>
</div>
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
{{ truncateHash(entry.content_hash) }}
</p>
</div>
<!-- IPFS CID -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
</div>
<div @click.stop>
<IPFSLink :cid="entry.ipfs_cid" />
</div>
</div>
<!-- Chain anchor -->
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
</div>
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
</div>
</div>
<!-- Verification status indicators -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center gap-1">
<UIcon
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
/>
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
</span>
</div>
<div class="flex items-center gap-1">
<UIcon
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
/>
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
</span>
</div>
</div>
<UButton
label="Verifier"
icon="i-lucide-shield-check"
variant="soft"
color="primary"
size="xs"
@click.stop="emit('verify', entry.id)"
/>
</div>
</div>
</UCard>
</template>
@@ -1,110 +0,0 @@
<script setup lang="ts">
/**
* Display vote formula with KaTeX rendering.
*
* Renders the WoT threshold formula using KaTeX when available,
* falling back to a code display. Shows parameter values and
* optional Smith/TechComm criteria formulas.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
formulaConfig: FormulaConfig
showExplanation?: boolean
}>()
const showExplain = ref(props.showExplanation ?? false)
/**
* Render a LaTeX string to HTML using KaTeX, with code fallback.
*/
function renderFormula(tex: string): string {
if (typeof window !== 'undefined' && (window as any).katex) {
return (window as any).katex.renderToString(tex, { throwOnError: false, displayMode: true })
}
return `<code class="text-sm font-mono">${tex}</code>`
}
/** Main threshold formula in LaTeX. */
const mainFormulaTeX = 'Seuil = C + B^W + \\left(M + (1-M) \\cdot \\left(1 - \\left(\\frac{T}{W}\\right)^G\\right)\\right) \\cdot \\max(0,\\, T - C)'
/** Smith criterion formula. */
const smithFormulaTeX = computed(() => {
if (props.formulaConfig.smith_exponent === null) return null
return `Seuil_{Smith} = \\lceil W_{Smith}^{${props.formulaConfig.smith_exponent}} \\rceil`
})
/** TechComm criterion formula. */
const techcommFormulaTeX = computed(() => {
if (props.formulaConfig.techcomm_exponent === null) return null
return `Seuil_{TechComm} = \\lceil W_{TechComm}^{${props.formulaConfig.techcomm_exponent}} \\rceil`
})
const mainFormulaHtml = computed(() => renderFormula(mainFormulaTeX))
const smithFormulaHtml = computed(() => smithFormulaTeX.value ? renderFormula(smithFormulaTeX.value) : null)
const techcommFormulaHtml = computed(() => techcommFormulaTeX.value ? renderFormula(techcommFormulaTeX.value) : null)
const parameters = computed(() => [
{ label: 'Duree', code: 'D', value: `${props.formulaConfig.duration_days} jours`, description: 'Duree du vote en jours' },
{ label: 'Majorite', code: 'M', value: `${props.formulaConfig.majority_pct}%`, description: 'Ratio de majorite cible a haute participation' },
{ label: 'Base', code: 'B', value: String(props.formulaConfig.base_exponent), description: 'Exposant de base (B^W tend vers 0 si B < 1)' },
{ label: 'Gradient', code: 'G', value: String(props.formulaConfig.gradient_exponent), description: 'Exposant du gradient d\'inertie' },
{ label: 'Constante', code: 'C', value: String(props.formulaConfig.constant_base), description: 'Plancher fixe de votes requis' },
...(props.formulaConfig.smith_exponent !== null ? [{
label: 'Smith', code: 'S', value: String(props.formulaConfig.smith_exponent), description: 'Exposant du critere Smith',
}] : []),
...(props.formulaConfig.techcomm_exponent !== null ? [{
label: 'TechComm', code: 'T', value: String(props.formulaConfig.techcomm_exponent), description: 'Exposant du critere TechComm',
}] : []),
])
</script>
<template>
<div class="space-y-4">
<!-- Main formula -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg overflow-x-auto">
<div v-html="mainFormulaHtml" class="text-center" />
</div>
<!-- Smith criterion -->
<div v-if="smithFormulaHtml" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">Critere Smith</p>
<div v-html="smithFormulaHtml" class="text-center" />
</div>
<!-- TechComm criterion -->
<div v-if="techcommFormulaHtml" class="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">Critere TechComm</p>
<div v-html="techcommFormulaHtml" class="text-center" />
</div>
<!-- Parameters grid -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div
v-for="param in parameters"
:key="param.code"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center gap-2 mb-1">
<span class="font-mono font-bold text-primary text-sm">{{ param.code }}</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ param.label }}</span>
</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ param.value }}</div>
<p v-if="showExplain" class="text-xs text-gray-500 mt-1">{{ param.description }}</p>
</div>
</div>
<!-- Explanation toggle -->
<div class="flex justify-end">
<UButton
variant="ghost"
color="neutral"
size="xs"
:icon="showExplain ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showExplain = !showExplain"
>
{{ showExplain ? 'Masquer les explications' : 'Afficher les explications' }}
</UButton>
</div>
</div>
</template>
@@ -1,114 +0,0 @@
<script setup lang="ts">
/**
* Visual gauge showing votes vs threshold.
*
* Displays a horizontal progress bar with green (pour) and red (contre) fills,
* a vertical threshold marker, participation statistics, and a pass/fail badge.
*/
const props = defineProps<{
votesFor: number
votesAgainst: number
threshold: number
wotSize: number
}>()
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
/** Percentage of "pour" votes relative to total votes. */
const forPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesFor / totalVotes.value) * 100
})
/** Percentage of "contre" votes relative to total votes. */
const againstPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesAgainst / totalVotes.value) * 100
})
/** Position of the threshold marker as a percentage of total votes. */
const thresholdPosition = computed(() => {
if (totalVotes.value === 0) return 50
// Threshold as a percentage of total votes
const pct = (props.threshold / totalVotes.value) * 100
return Math.min(pct, 100)
})
/** Whether the vote passes (votes_for >= threshold). */
const isPassing = computed(() => props.votesFor >= props.threshold)
/** Participation rate. */
const participationRate = computed(() => {
if (props.wotSize === 0) return 0
return (totalVotes.value / props.wotSize) * 100
})
</script>
<template>
<div class="space-y-3">
<!-- Progress bar -->
<div class="relative h-8 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<!-- Green fill (pour) -->
<div
class="absolute inset-y-0 left-0 bg-green-500 transition-all duration-500"
:style="{ width: `${forPct}%` }"
/>
<!-- Red fill (contre) -->
<div
class="absolute inset-y-0 bg-red-500 transition-all duration-500"
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
/>
<!-- Threshold marker -->
<div
v-if="totalVotes > 0"
class="absolute inset-y-0 w-0.5 bg-yellow-400 z-10"
:style="{ left: `${thresholdPosition}%` }"
>
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-yellow-600 dark:text-yellow-400 whitespace-nowrap">
Seuil
</div>
</div>
<!-- Percentage labels inside the bar -->
<div class="absolute inset-0 flex items-center px-3 text-xs font-bold text-white">
<span v-if="forPct > 10">{{ forPct.toFixed(1) }}%</span>
<span class="flex-1" />
<span v-if="againstPct > 10">{{ againstPct.toFixed(1) }}%</span>
</div>
</div>
<!-- Counts and threshold text -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<span class="text-green-600 dark:text-green-400 font-medium">
{{ votesFor }} pour
</span>
<span class="text-red-600 dark:text-red-400 font-medium">
{{ votesAgainst }} contre
</span>
</div>
<div class="font-medium" :class="isPassing ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'">
{{ votesFor }} / {{ threshold }} requis
</div>
</div>
<!-- Participation rate -->
<div class="flex items-center justify-between text-xs text-gray-500">
<span>
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} sur {{ wotSize }} membres
({{ participationRate.toFixed(2) }}%)
</span>
<!-- Pass/fail badge -->
<UBadge
:color="isPassing ? 'success' : 'error'"
variant="subtle"
size="xs"
>
{{ isPassing ? 'Adopte' : 'Non adopte' }}
</UBadge>
</div>
</div>
</template>
@@ -1,142 +0,0 @@
<script setup lang="ts">
/**
* Binary vote component: Pour / Contre.
*
* Displays two large buttons for binary voting with confirmation modal.
* Integrates with the votes store and auth store for submission and access control.
*/
const props = defineProps<{
sessionId: string
disabled?: boolean
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const submitting = ref(false)
const pendingVote = ref<'pour' | 'contre' | null>(null)
const showConfirm = ref(false)
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
})
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
})
function requestVote(value: 'pour' | 'contre') {
if (isDisabled.value) return
pendingVote.value = value
showConfirm.value = true
}
async function confirmVote() {
if (!pendingVote.value) return
showConfirm.value = false
submitting.value = true
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: pendingVote.value,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
pendingVote.value = null
}
}
function cancelVote() {
showConfirm.value = false
pendingVote.value = null
}
const confirmLabel = computed(() => {
return pendingVote.value === 'pour'
? 'Confirmer le vote POUR'
: 'Confirmer le vote CONTRE'
})
</script>
<template>
<div class="space-y-4">
<!-- Vote buttons -->
<div class="flex gap-4">
<UButton
size="xl"
:color="userVote?.vote_value === 'pour' ? 'success' : 'neutral'"
:variant="userVote?.vote_value === 'pour' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'pour'"
icon="i-lucide-thumbs-up"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('pour')"
>
Pour
</UButton>
<UButton
size="xl"
:color="userVote?.vote_value === 'contre' ? 'error' : 'neutral'"
:variant="userVote?.vote_value === 'contre' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'contre'"
icon="i-lucide-thumbs-down"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('contre')"
>
Contre
</UButton>
</div>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ userVote.vote_value === 'pour' ? 'Pour' : 'Contre' }}
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter
<strong :class="pendingVote === 'pour' ? 'text-green-600' : 'text-red-600'">
{{ pendingVote === 'pour' ? 'POUR' : 'CONTRE' }}
</strong>.
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton
:color="pendingVote === 'pour' ? 'success' : 'error'"
@click="confirmVote"
>
{{ confirmLabel }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
@@ -1,122 +0,0 @@
<script setup lang="ts">
/**
* Vote history list for a session.
*
* Displays a table of all votes cast in a session, sorted by date descending.
* For nuanced votes, shows the level label and color. Shows Smith/TechComm badges.
*/
import type { Vote } from '~/stores/votes'
const props = defineProps<{
votes: Vote[]
}>()
/** Nuanced level labels matching VoteNuanced component. */
const nuancedLabels: Record<number, { label: string; color: string }> = {
0: { label: 'CONTRE', color: 'error' },
1: { label: 'PAS DU TOUT D\'ACCORD', color: 'warning' },
2: { label: 'PAS D\'ACCORD', color: 'warning' },
3: { label: 'NEUTRE', color: 'neutral' },
4: { label: 'D\'ACCORD', color: 'success' },
5: { label: 'TOUT A FAIT D\'ACCORD', color: 'success' },
}
/** Sorted votes by date descending. */
const sortedVotes = computed(() => {
return [...props.votes].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
function truncateAddress(address: string): string {
if (address.length <= 16) return address
return `${address.slice(0, 8)}...${address.slice(-6)}`
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function voteLabel(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.label ?? `Niveau ${vote.nuanced_level}`
}
return vote.vote_value === 'pour' ? 'Pour' : 'Contre'
}
function voteColor(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.color ?? 'neutral'
}
return vote.vote_value === 'pour' ? 'success' : 'error'
}
</script>
<template>
<div>
<div v-if="sortedVotes.length === 0" class="text-center py-8">
<UIcon name="i-lucide-vote" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun vote enregistre</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votant</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Vote</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Statut</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="vote in sortedVotes"
:key="vote.id"
class="border-b border-gray-100 dark:border-gray-800"
>
<!-- Voter address -->
<td class="px-4 py-3">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
{{ truncateAddress(vote.voter_id) }}
</span>
</td>
<!-- Vote value -->
<td class="px-4 py-3">
<UBadge :color="(voteColor(vote) as any)" variant="subtle" size="xs">
{{ voteLabel(vote) }}
</UBadge>
</td>
<!-- Smith / TechComm badges -->
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<UBadge v-if="vote.voter_is_smith" color="info" variant="subtle" size="xs">
Smith
</UBadge>
<UBadge v-if="vote.voter_is_techcomm" color="purple" variant="subtle" size="xs">
TechComm
</UBadge>
<span v-if="!vote.voter_is_smith && !vote.voter_is_techcomm" class="text-xs text-gray-400">
Membre
</span>
</div>
</td>
<!-- Date -->
<td class="px-4 py-3 text-xs text-gray-500">
{{ formatDate(vote.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -1,187 +0,0 @@
<script setup lang="ts">
/**
* 6-level nuanced vote component.
*
* Displays 6 vote levels from CONTRE (0) to TOUT A FAIT D'ACCORD (5),
* each with a distinctive color. Negative votes (0-2) optionally include
* a comment textarea.
*/
const props = defineProps<{
sessionId: string
disabled?: boolean
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const submitting = ref(false)
const selectedLevel = ref<number | null>(null)
const comment = ref('')
const showConfirm = ref(false)
interface NuancedLevel {
level: number
label: string
color: string
bgClass: string
textClass: string
ringClass: string
}
const levels: NuancedLevel[] = [
{ level: 0, label: 'CONTRE', color: 'red', bgClass: 'bg-red-500', textClass: 'text-red-600 dark:text-red-400', ringClass: 'ring-red-500' },
{ level: 1, label: 'PAS DU TOUT D\'ACCORD', color: 'orange-red', bgClass: 'bg-orange-600', textClass: 'text-orange-700 dark:text-orange-400', ringClass: 'ring-orange-600' },
{ level: 2, label: 'PAS D\'ACCORD', color: 'orange', bgClass: 'bg-orange-400', textClass: 'text-orange-600 dark:text-orange-300', ringClass: 'ring-orange-400' },
{ level: 3, label: 'NEUTRE', color: 'gray', bgClass: 'bg-gray-400', textClass: 'text-gray-600 dark:text-gray-400', ringClass: 'ring-gray-400' },
{ level: 4, label: 'D\'ACCORD', color: 'light-green', bgClass: 'bg-green-400', textClass: 'text-green-600 dark:text-green-400', ringClass: 'ring-green-400' },
{ level: 5, label: 'TOUT A FAIT D\'ACCORD', color: 'green', bgClass: 'bg-green-600', textClass: 'text-green-700 dark:text-green-300', ringClass: 'ring-green-600' },
]
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
})
/** Initialize selected level from existing vote. */
watchEffect(() => {
if (userVote.value?.nuanced_level !== undefined && userVote.value?.nuanced_level !== null) {
selectedLevel.value = userVote.value.nuanced_level
}
})
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
})
/** Whether the comment field should be shown (negative votes). */
const showComment = computed(() => {
return selectedLevel.value !== null && selectedLevel.value <= 2
})
function selectLevel(level: number) {
if (isDisabled.value) return
selectedLevel.value = level
showConfirm.value = true
}
async function confirmVote() {
if (selectedLevel.value === null) return
showConfirm.value = false
submitting.value = true
const voteValue = selectedLevel.value >= 3 ? 'pour' : 'contre'
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: voteValue,
nuanced_level: selectedLevel.value,
comment: showComment.value && comment.value.trim() ? comment.value.trim() : null,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
}
}
function cancelVote() {
showConfirm.value = false
}
function getLevelLabel(level: number): string {
return levels.find(l => l.level === level)?.label ?? ''
}
</script>
<template>
<div class="space-y-4">
<!-- Level buttons -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<button
v-for="lvl in levels"
:key="lvl.level"
:disabled="isDisabled"
class="relative flex flex-col items-center p-4 rounded-lg border-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:class="[
selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level
? `ring-3 ${lvl.ringClass} border-transparent`
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
]"
@click="selectLevel(lvl.level)"
>
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
:class="lvl.bgClass"
>
{{ lvl.level }}
</div>
<span class="text-xs font-medium text-center leading-tight" :class="lvl.textClass">
{{ lvl.label }}
</span>
<!-- Selected indicator -->
<div
v-if="selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level"
class="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-xs"
:class="lvl.bgClass"
>
<UIcon name="i-lucide-check" class="w-3 h-3" />
</div>
</button>
</div>
<!-- Comment for negative votes -->
<div v-if="showComment" class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Commentaire (optionnel pour les votes negatifs)
</label>
<UTextarea
v-model="comment"
placeholder="Expliquez votre position..."
:rows="3"
:disabled="isDisabled"
/>
</div>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ getLevelLabel(userVote.nuanced_level ?? 0) }}
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter :
<strong>{{ getLevelLabel(selectedLevel ?? 0) }}</strong> (niveau {{ selectedLevel }}).
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton color="primary" :loading="submitting" @click="confirmVote">
Confirmer le vote
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
-196
View File
@@ -1,196 +0,0 @@
/**
* Composable for making authenticated API calls to the Glibredecision backend.
*
* Uses the runtime config `apiBase` and automatically injects the Bearer token
* from the auth store when available.
*
* Production-grade features:
* - Error interceptor with user-friendly French messages
* - Retry logic with exponential backoff for 5xx errors
* - Request timeout (30s default)
* - AbortController support for cancellation
*/
/** Map of HTTP status codes to user-friendly French error messages. */
const HTTP_ERROR_MESSAGES: Record<number, string> = {
401: 'Session expirée, veuillez vous reconnecter',
403: 'Accès non autorisé',
404: 'Ressource introuvable',
409: 'Conflit avec une ressource existante',
422: 'Données invalides',
429: 'Trop de requêtes, veuillez patienter',
500: 'Erreur serveur, veuillez réessayer',
502: 'Service temporairement indisponible',
503: 'Service en maintenance, veuillez réessayer',
504: 'Délai de réponse dépassé',
}
/** Default request timeout in milliseconds. */
const DEFAULT_TIMEOUT_MS = 30_000
/** Maximum number of retry attempts for 5xx errors. */
const MAX_RETRIES = 3
/** Base delay for exponential backoff in milliseconds. */
const BASE_BACKOFF_MS = 1_000
export interface ApiOptions extends Record<string, any> {
/** Custom timeout in milliseconds (default: 30000). */
timeout?: number
/** External AbortController for request cancellation. */
signal?: AbortSignal
/** Disable automatic retry for this request. */
noRetry?: boolean
}
export interface ApiError {
status: number
message: string
detail?: string
}
/**
* Resolve a user-friendly error message from an HTTP status code.
*/
function resolveErrorMessage(status: number, fallback?: string): string {
return HTTP_ERROR_MESSAGES[status] || fallback || 'Une erreur inattendue est survenue'
}
/**
* Wait for the specified duration in milliseconds.
*/
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Determine whether an HTTP status code is retryable (5xx server errors).
*/
function isRetryable(status: number): boolean {
return status >= 500 && status < 600
}
export function useApi() {
const config = useRuntimeConfig()
const auth = useAuthStore()
/**
* Perform a typed fetch against the backend API.
*
* @param path - API path relative to apiBase, e.g. "/documents"
* @param options - $fetch options (method, body, query, headers, timeout, signal, noRetry)
* @returns Typed response
* @throws ApiError with status, message, and optional detail
*/
async function $api<T>(path: string, options: ApiOptions = {}): Promise<T> {
const {
timeout = DEFAULT_TIMEOUT_MS,
signal: externalSignal,
noRetry = false,
...fetchOptions
} = options
const headers: Record<string, string> = {}
if (auth.token) {
headers.Authorization = `Bearer ${auth.token}`
}
const maxAttempts = noRetry ? 1 : MAX_RETRIES
let lastError: any = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Create an AbortController for timeout management
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
// If an external signal is provided, forward its abort
let externalAbortHandler: (() => void) | null = null
if (externalSignal) {
if (externalSignal.aborted) {
clearTimeout(timeoutId)
throw _createApiError(0, 'Requête annulée')
}
externalAbortHandler = () => controller.abort()
externalSignal.addEventListener('abort', externalAbortHandler, { once: true })
}
try {
const result = await $fetch<T>(`${config.public.apiBase}${path}`, {
...fetchOptions,
headers: { ...headers, ...fetchOptions.headers },
signal: controller.signal,
})
return result
} catch (err: any) {
lastError = err
const status = err?.response?.status || err?.status || 0
// Handle 401: auto-logout for expired sessions
if (status === 401 && auth.token) {
auth.token = null
auth.identity = null
auth._clearToken()
navigateTo('/login')
}
// Handle abort (timeout or external cancellation)
if (err?.name === 'AbortError' || controller.signal.aborted) {
if (externalSignal?.aborted) {
throw _createApiError(0, 'Requête annulée')
}
throw _createApiError(0, 'Délai de réponse dépassé')
}
// Retry only for 5xx errors and if we have remaining attempts
if (isRetryable(status) && attempt < maxAttempts) {
const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1)
await delay(backoffMs)
continue
}
// Build and throw a structured error
const detail = err?.data?.detail || err?.message || undefined
const message = resolveErrorMessage(status, detail)
throw _createApiError(status, message, detail)
} finally {
clearTimeout(timeoutId)
if (externalAbortHandler && externalSignal) {
externalSignal.removeEventListener('abort', externalAbortHandler)
}
}
}
// Fallback: should not reach here, but handle gracefully
const fallbackStatus = lastError?.response?.status || lastError?.status || 0
const fallbackDetail = lastError?.data?.detail || lastError?.message || undefined
throw _createApiError(fallbackStatus, resolveErrorMessage(fallbackStatus, fallbackDetail), fallbackDetail)
}
/**
* Create a structured ApiError object.
*/
function _createApiError(status: number, message: string, detail?: string): ApiError {
const error: ApiError = { status, message }
if (detail) error.detail = detail
return error
}
/**
* Create an AbortController for manual request cancellation.
* Useful for component unmount cleanup.
*
* @example
* const { controller, signal } = createAbortController()
* await $api('/documents', { signal })
* // On unmount:
* controller.abort()
*/
function createAbortController() {
const controller = new AbortController()
return { controller, signal: controller.signal }
}
return { $api, createAbortController }
}
-66
View File
@@ -1,66 +0,0 @@
import type { Ref } from 'vue'
export interface Mood {
id: string
label: string
description: string
icon: string
color: string
isDark: boolean
}
const STORAGE_KEY = 'glibredecision_mood'
const moods: Mood[] = [
{ id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false },
{ id: 'zen', label: 'Zen', description: 'Nature vivante', icon: 'i-lucide-leaf', color: '#2e8b48', isDark: false },
{ id: 'chagrine', label: 'Chagrine', description: 'Nuit profonde', icon: 'i-lucide-moon', color: '#6488d8', isDark: true },
{ id: 'grave', label: 'Grave', description: 'Ambre mineral', icon: 'i-lucide-shield', color: '#d8a030', isDark: true },
]
const currentMood: Ref<string> = ref('peps')
function applyMood(moodId: string) {
if (import.meta.server) return
const mood = moods.find(m => m.id === moodId)
if (!mood) return
const html = document.documentElement
// Remove all existing mood classes
moods.forEach(m => html.classList.remove(`mood-${m.id}`))
// Add the new mood class
html.classList.add(`mood-${moodId}`)
// Sync color-mode (light/dark) via Nuxt's useColorMode
const colorMode = useColorMode()
colorMode.preference = mood.isDark ? 'dark' : 'light'
// Persist choice
localStorage.setItem(STORAGE_KEY, moodId)
currentMood.value = moodId
}
function setMood(moodId: string) {
applyMood(moodId)
}
function initMood() {
if (import.meta.server) return
const saved = localStorage.getItem(STORAGE_KEY)
const moodId = saved && moods.some(m => m.id === saved) ? saved : 'peps'
applyMood(moodId)
}
export function useMood() {
return {
currentMood: readonly(currentMood),
moods,
setMood,
initMood,
}
}
@@ -1,180 +0,0 @@
/**
* Composable for toast notifications using Nuxt UI.
*
* Provides typed notification helpers with French messages and
* integration with WebSocket events for real-time notifications.
*/
export type NotificationType = 'success' | 'error' | 'warning' | 'info'
interface NotifyOptions {
/** Toast title. */
title: string
/** Toast description (optional). */
description?: string
/** Notification type. */
type?: NotificationType
/** Auto-close duration in milliseconds (default: 5000). */
duration?: number
}
/** Map notification types to Nuxt UI toast color props. */
const TYPE_COLORS: Record<NotificationType, string> = {
success: 'success',
error: 'error',
warning: 'warning',
info: 'info',
}
/** Map notification types to Lucide icon names. */
const TYPE_ICONS: Record<NotificationType, string> = {
success: 'i-lucide-check-circle',
error: 'i-lucide-alert-circle',
warning: 'i-lucide-alert-triangle',
info: 'i-lucide-info',
}
/** Default duration for toasts (ms). */
const DEFAULT_DURATION = 5_000
export function useNotifications() {
const toast = useToast()
/**
* Show a toast notification.
*
* @param options - Notification options (title, description, type, duration)
*/
function notify(options: NotifyOptions): void
function notify(title: string, description?: string, type?: NotificationType): void
function notify(
titleOrOptions: string | NotifyOptions,
description?: string,
type?: NotificationType,
): void {
let opts: NotifyOptions
if (typeof titleOrOptions === 'string') {
opts = {
title: titleOrOptions,
description,
type: type || 'info',
}
} else {
opts = titleOrOptions
}
const notifType = opts.type || 'info'
toast.add({
title: opts.title,
description: opts.description,
icon: TYPE_ICONS[notifType],
color: TYPE_COLORS[notifType] as any,
duration: opts.duration ?? DEFAULT_DURATION,
})
}
/**
* Show a success toast.
*/
function notifySuccess(message: string, description?: string): void {
notify({
title: message,
description,
type: 'success',
})
}
/**
* Show an error toast.
*/
function notifyError(message: string, description?: string): void {
notify({
title: message,
description,
type: 'error',
duration: 8_000,
})
}
/**
* Show a warning toast.
*/
function notifyWarning(message: string, description?: string): void {
notify({
title: message,
description,
type: 'warning',
})
}
/**
* Show an info toast.
*/
function notifyInfo(message: string, description?: string): void {
notify({
title: message,
description,
type: 'info',
})
}
/**
* Setup WebSocket event listeners that auto-show notifications.
* Call this once in app.vue or a layout component.
*/
function setupWsNotifications(wsComposable: ReturnType<typeof useWebSocket>): void {
wsComposable.onVoteSubmitted((data) => {
notifyInfo(
'Nouveau vote enregistre',
data?.session_title || 'Un vote a ete soumis dans une session active.',
)
})
wsComposable.onDecisionAdvanced((data) => {
notifySuccess(
'Decision avancee',
data?.title
? `La decision "${data.title}" est passee a l'etape suivante.`
: 'Une decision a progresse dans son processus.',
)
})
wsComposable.onMandateUpdated((data) => {
notifyInfo(
'Mandat mis a jour',
data?.title
? `Le mandat "${data.title}" a ete modifie.`
: 'Un mandat a ete mis a jour.',
)
})
wsComposable.onDocumentChanged((data) => {
notifyInfo(
'Document modifie',
data?.title
? `Le document "${data.title}" a ete modifie.`
: 'Un document de reference a ete modifie.',
)
})
wsComposable.onSanctuaryArchived((data) => {
notifySuccess(
'Document archive au sanctuaire',
data?.title
? `"${data.title}" a ete ancre sur IPFS.`
: 'Un document a ete archive de maniere immuable.',
)
})
}
return {
notify,
notifySuccess,
notifyError,
notifyWarning,
notifyInfo,
setupWsNotifications,
}
}

Some files were not shown because too many files have changed in this diff Show More