Compare commits

...
10 Commits
Author SHA1 Message Date
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
360 changed files with 18867 additions and 283 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=libredecision
POSTGRES_USER=libredecision
POSTGRES_PASSWORD=change-me-in-production
DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision
# 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=git@git.duniter.org:Yvv/libredecision-textes.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=decision.librodrome.org
RUST_LOG=info,sqlx=warn
+11
View File
@@ -42,3 +42,14 @@ 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/
+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
+17 -10
View File
@@ -1,7 +1,15 @@
# 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
@@ -18,7 +26,7 @@ steps:
- name: test-backend
image: python:3.11-slim
commands:
- cd backend
- cd maquette/backend
- pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short
@@ -27,7 +35,7 @@ steps:
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 frontend
- cd maquette/frontend
- npm ci
- npm run build
@@ -39,7 +47,7 @@ steps:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker build -t libredecision-backend:latest -f docker/backend.Dockerfile --target production .
- 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
@@ -50,7 +58,7 @@ steps:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker build -t libredecision-frontend:latest -f docker/frontend.Dockerfile --target production .
- 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
@@ -63,7 +71,8 @@ steps:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
# 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
@@ -116,14 +125,12 @@ steps:
depends_on:
- sbom-publish
environment:
APP_DOMAIN:
from_secret: app_domain
POSTGRES_PASSWORD:
from_secret: postgres_password
SECRET_KEY:
from_secret: secret_key
commands:
- echo "APP_DOMAIN=$APP_DOMAIN" > .env.deploy
- 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
@@ -158,7 +165,7 @@ steps:
commands:
- cp .env.deploy /opt/libredecision/.env
- chmod 600 /opt/libredecision/.env
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
- 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
@@ -249,6 +256,6 @@ steps:
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
- 'echo "ECHEC pipeline #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
+77 -77
View File
@@ -1,100 +1,100 @@
# libreDecision
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal).
**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse.
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`
2. Si l'objectif de la session n'est pas précisé, le demander
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 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
package manager : npm ; port **3002** strict
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro**
— non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config
en prévision
- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker
- **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.
## Structure
```
frontend/
app/
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
# triage, impact — import UNIQUEMENT via le barrel ~/engine
data/
persistence.ts # IndexedDB local-first
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
stores/
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
# onboarding/ common/ (primitives Ld*)
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
# creer (layout bare) · donnees
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
types/domain.ts # le noyau du modèle
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
docker/ # compose + Dockerfiles
docs/dev/BLUEPRINT-V2.md # la référence produit v2
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
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) · 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 · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
# protocoles (à venir)
web/e2e/ # Playwright C1–C11 + 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
## Données runtime — jamais écrasées
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
- `.env` racine : secrets backend — jamais commité
- **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
## Commandes — la boucle (tout vert = fait)
```bash
# Frontend (tout le dev v2)
cd frontend && npm run dev # :3002
npm test # vitest — 342 tests, référence à maintenir
npm run build # zéro erreur exigé avant commit
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 : 12 contrôles
# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par
cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json
# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts
# Docker
docker compose -f docker/docker-compose.yml up
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** (variables, commentaires, docstrings)
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
des identifiants de code, jamais des textes affichés
- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à
autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain
(cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ;
dérogation asymétrique (alourdir libre, alléger motivé)
- **Budget 400 lignes max par page** (`app/pages/`)
- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en
imports explicites (testables sous vitest nu) ; toute mutation d'état passe par
`canTransition(decision, to, ctx)`
- **Formule inertie** : `R = C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)` —
`frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des
lettres W/T/M/B/G/C et de KaTeX
- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()`
dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
- **Sceau** `井` (#48 Tsing) : `LdSeal` dans les layouts ; SVGs dans `public/`
- **CSS `drop-shadow()`** safe pour l'emboss ; `<filter>` SVG inline cause des artefacts
- **Composants** : `pathPrefix: false` — noms de fichiers uniques, auto-import
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
- **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.duniter.org/Yvv/libredecision-textes`.
+52 -80
View File
@@ -1,91 +1,63 @@
# Contribuer à libreDecision
La référence produit est `docs/dev/BLUEPRINT-V2.md` — toute contribution qui touche au
comportement doit s'y conformer (ou proposer un amendement du blueprint, argumenté).
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.
## Environnement
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
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
cd frontend
npm install
npm run dev # http://localhost:3002 — port strict, jamais de fallback
npm test # vitest — 342 tests
npm run build # doit passer sans erreur avant tout commit
# Backend v1 (optionnel — conservé pour la future synchro)
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
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–C11 (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
- **UI en français, code en anglais** (variables, commentaires, docstrings).
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
toujours la personne ou le collectif — jamais la formule ni le système.
- **Test anti-lexique** (`tests/lexicon.spec.ts`) : la liste `FORBIDDEN_UI_TERMS` interdit
certains mots dans toute chaîne visible (lexicon + templates `.vue`), insensible à la casse
et aux accents. « Triage », « verdict », « déléguer »… peuvent exister comme identifiants de
code, jamais à l'écran.
- **CSS** : borderless (profondeur via background + box-shadow), palettes par classes
`.mood-*` sur `<html>`, composants natifs (`<input>`, `<select>`, `<button>` custom).
- **Composants** : `pathPrefix: false` — noms de fichiers uniques et parlants, auto-import.
- **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.
## Invariants doctrinaux (non négociables)
## Signaler
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
de re-vote n'est visible que de son auteur.
- **Jamais de contenu adopté sans geste humain.** Le moteur constate des seuils sur des
contenus formulés par des humains ; la cristallisation paramétrique, la clôture d'un
dossier découpé, le départage d'une égalité sont des gestes datés — jamais des automatismes.
- **Le repli universel est le consentement** : tout collectif a un protocole Consentement
(`protocolByRange.consent`), tout repli de résolution y mène.
- **Dérogation asymétrique** : alourdir libre (1 tap journalisé) ; alléger = note obligatoire
+ fenêtre d'objection.
- **Budget 400 lignes maximum par page** (`app/pages/`). Au-delà, extraire des composants.
- **Moteurs purs** : `app/engine/` ne dépend de rien (ni Nuxt, ni Pinia, ni DOM), s'importe
uniquement via le barrel `~/engine`, et chaque module a ses tests vitest. Les stores
s'écrivent avec des imports explicites (pas d'auto-import Nuxt) pour rester testables sous
vitest pur.
## Ajouter une modalité de vote
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
`engine/index.ts`.
3. `app/engine/state.ts` — la règle de clôture (automatique à `closesAt`, ou geste humain).
4. `app/lexicon.ts` — les libellés (et vérifier l'anti-lexique).
5. `app/components/votes/VoteX.vue` — l'écran de la modalité, branché dans
`pages/decisions/[id]/vote.vue`.
6. Seeds et gabarits — un protocole nommé qui la référence (le modifier = amender le Pacte).
## Ajouter un gabarit de collectif
Dans `app/data/templates.ts` : une entrée `TEMPLATE_CARDS` + le montage dans
`buildTemplateBundle()`. Invariants : protocole Consentement au minimum, clause A1
(« Notre finalité »), préambule Autonomie–Équilibre–Liaison proposé et modifiable, cercles
typés `kind`. Un bundle seed passe par le même `importBundle()` que l'import utilisateur.
## Tests
```bash
cd frontend && npm test
```
- `tests/engine/` — les moteurs purs (seuils, triage, états, médiane, impact…).
- `tests/stores/` — la façade decisions sous Pinia nu.
- `tests/seeds/` — tests de non-perte : le bundle Ğ1 doit conserver 33+59 clauses, décomptes
et provenance exacts ; l'Atelier du Canal doit couvrir tous les états.
- `tests/lexicon.spec.ts` — l'anti-lexique.
Le compteur de référence est **342 tests verts**. Toute PR qui en casse un est refusée ;
toute fonctionnalité de moteur arrive avec les siens.
## Build
`npm run build` doit rester vert (zéro erreur, zéro warning nouveau) avant chaque commit.
Vérifier mobile + desktop (zéro régression CSS).
**« 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
+46
View File
@@ -0,0 +1,46 @@
# HANDOFF — libreDecision
> Dernière mise à jour : 2026-10-21, session orch du chantier 1.
## Où on en est
**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 60 tests dont 14 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
e2e 12 / 12 (C1–C11, 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. 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. **Créer le dépôt des textes** sur git.duniter.org : `Yvv/libredecision-textes`, **public**,
vide ; y ajouter la **clé de déploiement** du serveur, **en écriture** (sa partie publique
s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur fait lui-même le
premier envoi.
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation),
puis créer l'**invitation de Chiara** (groupes Monnaie libre + groupe d'essai).
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).
## Pour reprendre
- 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.
+74 -73
View File
@@ -1,11 +1,10 @@
# libreDecision
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
boîte à outils.
libreDecision fait de la décision un geste quotidien : chaque question tient en une phrase,
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
décidé reste révisable, mesuré, consigné. Le même outil sert un trio d'amis, une association,
une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
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
@@ -24,90 +23,92 @@ une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
> 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 6 routes de décision
## Les quatre outils
Le triage (moteur pur, toujours expliqué en une phrase, toujours contournable) oriente chaque
décision vers l'une de six routes :
| 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 |
| Route | Geste |
|---|---|
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
| **record** | « Déjà tranché — je le consigne. » L'observatoire en un geste, adopté immédiatement |
| **mandate** | « Ton mandat couvre — décide, c'est tracé. » Trace obligatoire + fenêtre d'objection 48 h |
| **transmit** | « Le mandat d'un autre couvre — transmets-lui. » (ou conteste le mandat) |
| **advice** | « Réversible et petit cercle — demande leur avis, puis décide. » 72 h |
| **collective** | « Nous décidons. » Formulation, liste des concernés arrêtée, session de vote |
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.
Dérogation asymétrique : alourdir est libre (1 tap journalisé) ; alléger exige une note motivée
et une fenêtre d'objection.
## Comment marche un doc de référence
## Les 5 modalités
- 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.
Chaque protocole est nommé et référencé par une clause du Pacte — le modifier, c'est amender
le Pacte.
1. **Consentement** — « Ça me va / J'objecte » : zéro objection maintenue = adopté. Le repli
universel : tout collectif en a un (invariant).
2. **Nuancé 6 niveaux** — de « Pas du tout » à « Tout à fait », jamais « contre » ;
histogramme visible, commentaire obligatoire sur les niveaux bas.
3. **Paramétrique** — décider au curseur : faisceau des positions, médiane vivante, carte
« Pour moi » ; la cristallisation finale est un geste humain daté du garant, jamais un
automatisme.
4. **Élection** — réservée aux mandats et aux problématiques d'échelle (6 méthodes de
nomination, vote blanc, jamais de départage par l'outil).
5. **Binaire inertiel** — le seuil `C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)`, héritage
Ğ1 remis à sa place : l'outil de dernier recours des très grands corps. L'Atelier des
formules (`/textes/formules`) reste son école.
## Local-first
- **Aucun serveur requis** : tout l'état vit dans le navigateur (IndexedDB via idb-keyval,
une clé par collectif).
- **Export / import** de bundles JSON — la fédération, c'est l'échange de bundles, jamais une
table.
- **Seeds** livrés et importés par le même chemin que l'import utilisateur :
- `duniter-g1.bundle.json` — la toile Ğ1 : 33 clauses Certification + 59 clauses Forgeron
(textes votés intacts, provenance et décomptes conservés), protocoles, 3 mandats ;
- `atelier-du-canal.bundle.json` — association fictive de 12 membres, le test permanent de
marque blanche : décisions à tous les états, paramétrique vivant, épreuve du réel due.
- **7 gabarits** de collectif à la création (`/creer`) : page blanche, informel, association,
coopérative, commune, monnaie libre, institution symétrique.
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
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
npm ; port **3002**
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
- **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émarrage
## Démarrer
```bash
cd frontend
npm install
npm run dev # http://localhost:3002
npm test # vitest — 342 tests
npm run build # build de production
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)
```
## Structure v2
`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–C11, 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
```
frontend/app/
engine/ # moteurs purs (barrel index.ts — seul point d'import)
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
composables/ # useMood, useFeed, useSearch
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
lexicon.ts # source unique des libellés FR + test anti-lexique
types/domain.ts # le noyau du modèle
docs/dev/BLUEPRINT-V2.md # la référence produit
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–C11, 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. Le fichier LICENSE sera posé avant la première publication.
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"));
}
}
+532
View File
@@ -0,0 +1,532 @@
//! 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, precomposed accents included (after NFC):
/// the only letters a typo fix may touch. Any other script may hold look-alikes
/// (Cyrillic « а ») or reorder the display (Hebrew, Arabic).
fn is_latin_letter(c: char) -> bool {
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF)
}
/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
/// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
/// Source"). A text of reference never needs them; they are refused.
pub fn has_hidden_controls(s: &str) -> bool {
s.chars().any(
|c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF),
)
}
/// 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;
for (i, &c) in cs.iter().enumerate() {
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, words only glued or split: « latransmission » → « la
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »).
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
let (mut i, mut j) = (0, 0);
while i < a.len() && j < b.len() {
if a[i] == b[j] {
(i, j) = (i + 1, j + 1);
} else if j + 1 < b.len() && a[i] == format!("{}{}", 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]) {
(i, j) = (i + 2, j + 1);
} else {
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 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 % »."));
}
#[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);
}
}
+14
View File
@@ -0,0 +1,14 @@
//! 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 threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally};
+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
);
+252
View File
@@ -0,0 +1,252 @@
//! 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 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>, 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?;
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 publication = json!({
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
"publicKey": crate::publish::public_key(&state),
});
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()),
})).collect::<Vec<_>>(),
"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 {
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 })))
}
#[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 })))
}
#[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()
}
+548
View File
@@ -0,0 +1,548 @@
//! 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 }
}
const SESSION_DAYS: i64 = 30;
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),
}
}
}
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)
}
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 }))
}
pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
Json(json!({ "me": viewer }))
}
/// 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");
}
#[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. `git@git.duniter.org:Yvv/libredecision-textes.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(),
}
}
}
+627
View File
@@ -0,0 +1,627 @@
//! 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>,
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, 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 })),
"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()
}
}
+202
View File
@@ -0,0 +1,202 @@
//! 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 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/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/identities/{id}/admin", post(access::set_admin))
.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
}
+873
View File
@@ -0,0 +1,873 @@
//! 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 {
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.
let next = format!("{title}\n{body}");
let kind = if next == format!("{}\n{}", p.title, p.body) {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) {
EditKind::Unchanged => EditKind::Typographic, // back to the voted text
k => k,
}
};
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(&p.body, body) })))
}
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"));
}
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"));
}
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>,
created_at: String,
}
let revs = sqlx::query_as::<_, Rev>(
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name, 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, "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,
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,
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, "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, "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);
}
}
+318
View File
@@ -0,0 +1,318 @@
//! 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.
async fn checkout(state: &AppState) -> anyhow::Result<PathBuf> {
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?;
if has_remote_branch.is_empty() {
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)
}
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 = checkout(state).await?;
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() {
return Ok(None);
}
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?;
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
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.
pub async fn resync(state: &AppState) -> anyhow::Result<()> {
if state.cfg.publish.is_none() {
return Ok(());
}
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(())
}
/// 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(())
}
+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);
}
}
+550
View File
@@ -0,0 +1,550 @@
//! 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>,
}
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 }, state)
}
fn with(&self, cookie: Option<String>) -> Self {
Self { router: self.router.clone(), cookie }
}
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();
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")));
// 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.", "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"));
}
#[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);
}
+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:-git@git.duniter.org:Yvv/libredecision-textes.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"]
+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`.
+35
View File
@@ -0,0 +1,35 @@
# 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 ne font que coller ou couper des mots, jamais déplacer une frontière ;
- 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 de mise en forme sont refusés partout où l'on écrit.
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.
**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.
+17
View File
@@ -0,0 +1,17 @@
# 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.duniter.org/Yvv/libredecision-textes`, un dossier par groupe et
par document. 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,23 @@
# 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).
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https, 30 jours, jeton haché en base ;
en-tête anti-CSRF sur toute écriture.
- 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,203 @@
# 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.
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 ;
- 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, jamais déplacer une frontière (« les
avoir » → « le savoir » est du fond) ;
- 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 (inversion du sens d'écriture, largeur nulle) sont refusés partout ;
- 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.duniter.org/Yvv/libredecision-textes`, 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.duniter.org/Yvv/libredecision-textes` (compte d'Yvv) |
| 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.
- 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,44 @@
# 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à |
| 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 |
@@ -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,74 @@
# 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 pour 30 jours.
## 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–C11 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 : ».
+49
View File
@@ -0,0 +1,49 @@
# PostgreSQL
POSTGRES_DB=libredecision
POSTGRES_USER=libredecision
POSTGRES_PASSWORD=change-me-in-production
DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision
# Backend
SECRET_KEY=change-me-in-production-with-a-real-secret-key
DEBUG=true
# Environment: development, staging, production
ENVIRONMENT=development
# Logging: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
# CORS
CORS_ORIGINS=["http://localhost:3002"]
# Database connection pool
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=10
# 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=decision.librodrome.org
+118
View File
@@ -0,0 +1,118 @@
> **Maquette v2** — conservée telle quelle pour présenter le projet, en ligne sur
> https://maquette.decision.librodrome.org. Le produit (v3, Rust + Svelte) est à la racine du
> dépôt : voir `../README.md`. Les chemins ci-dessous sont relatifs à `maquette/` ; la référence
> produit de la maquette reste `../docs/dev/BLUEPRINT-V2.md`.
# libreDecision
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
libreDecision fait de la décision un geste quotidien : chaque question tient en une phrase,
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
décidé reste révisable, mesuré, consigné. Le même outil sert un trio d'amis, une association,
une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
## 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 6 routes de décision
Le triage (moteur pur, toujours expliqué en une phrase, toujours contournable) oriente chaque
décision vers l'une de six routes :
| Route | Geste |
|---|---|
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
| **record** | « Déjà tranché — je le consigne. » L'observatoire en un geste, adopté immédiatement |
| **mandate** | « Ton mandat couvre — décide, c'est tracé. » Trace obligatoire + fenêtre d'objection 48 h |
| **transmit** | « Le mandat d'un autre couvre — transmets-lui. » (ou conteste le mandat) |
| **advice** | « Réversible et petit cercle — demande leur avis, puis décide. » 72 h |
| **collective** | « Nous décidons. » Formulation, liste des concernés arrêtée, session de vote |
Dérogation asymétrique : alourdir est libre (1 tap journalisé) ; alléger exige une note motivée
et une fenêtre d'objection.
## Les 5 modalités
Chaque protocole est nommé et référencé par une clause du Pacte — le modifier, c'est amender
le Pacte.
1. **Consentement** — « Ça me va / J'objecte » : zéro objection maintenue = adopté. Le repli
universel : tout collectif en a un (invariant).
2. **Nuancé 6 niveaux** — de « Pas du tout » à « Tout à fait », jamais « contre » ;
histogramme visible, commentaire obligatoire sur les niveaux bas.
3. **Paramétrique** — décider au curseur : faisceau des positions, médiane vivante, carte
« Pour moi » ; la cristallisation finale est un geste humain daté du garant, jamais un
automatisme.
4. **Élection** — réservée aux mandats et aux problématiques d'échelle (6 méthodes de
nomination, vote blanc, jamais de départage par l'outil).
5. **Binaire inertiel** — le seuil `C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)`, héritage
Ğ1 remis à sa place : l'outil de dernier recours des très grands corps. L'Atelier des
formules (`/textes/formules`) reste son école.
## Local-first
- **Aucun serveur requis** : tout l'état vit dans le navigateur (IndexedDB via idb-keyval,
une clé par collectif).
- **Export / import** de bundles JSON — la fédération, c'est l'échange de bundles, jamais une
table.
- **Seeds** livrés et importés par le même chemin que l'import utilisateur :
- `duniter-g1.bundle.json` — la toile Ğ1 : 33 clauses Certification + 59 clauses Forgeron
(textes votés intacts, provenance et décomptes conservés), protocoles, 3 mandats ;
- `atelier-du-canal.bundle.json` — association fictive de 12 membres, le test permanent de
marque blanche : décisions à tous les états, paramétrique vivant, épreuve du réel due.
- **7 gabarits** de collectif à la création (`/creer`) : page blanche, informel, association,
coopérative, commune, monnaie libre, institution symétrique.
## Stack
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
npm ; port **3002**
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
## Démarrage
```bash
cd frontend
npm install
npm run dev # http://localhost:3002
npm test # vitest — 342 tests
npm run build # build de production
```
## Structure v2
```
frontend/app/
engine/ # moteurs purs (barrel index.ts — seul point d'import)
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
composables/ # useMood, useFeed, useSearch
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
lexicon.ts # source unique des libellés FR + test anti-lexique
types/domain.ts # le noyau du modèle
docs/dev/BLUEPRINT-V2.md # la référence produit
```
## Licence
Logiciel libre. Le fichier LICENSE sera posé avant la première publication.

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