Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8e8dc1f0 | ||
|
|
6c0754a5c3 | ||
|
|
1eac6f856a | ||
|
|
9203fd2501 | ||
|
|
ac9c904e7e | ||
|
|
94907afb20 | ||
|
|
1f9c241b41 | ||
|
|
c2669a3d0b | ||
|
|
7a022dd35e | ||
|
|
67ddee28b2 |
@@ -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
@@ -1,49 +1,34 @@
|
|||||||
# PostgreSQL
|
# libreDecision v3 — variables du serveur (ld-server). Toutes facultatives en dev.
|
||||||
POSTGRES_DB=libredecision
|
# Le serveur lit son environnement, pas de fichier : `set -a; . ./.env; set +a` avant
|
||||||
POSTGRES_USER=libredecision
|
# `cargo run -p ld-server`. Le .env n'est jamais commité.
|
||||||
POSTGRES_PASSWORD=change-me-in-production
|
|
||||||
DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision
|
|
||||||
|
|
||||||
# Backend
|
# Écoute et dossiers
|
||||||
SECRET_KEY=change-me-in-production-with-a-real-secret-key
|
LD_BIND=0.0.0.0:8002
|
||||||
DEBUG=true
|
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
|
# Connexion Ğ1 — client OAuth2 de connect.monnaie-libre.fr (les deux sont requis, sinon désactivée)
|
||||||
ENVIRONMENT=development
|
# LD_CONNECT_URL=https://connect.monnaie-libre.fr
|
||||||
|
# LD_CONNECT_CLIENT_ID=
|
||||||
|
# LD_CONNECT_CLIENT_SECRET=
|
||||||
|
|
||||||
# Logging: DEBUG, INFO, WARNING, ERROR
|
# Accès (trustinWallet)
|
||||||
LOG_LEVEL=INFO
|
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
|
# Publication des textes en vigueur (dépôt git lu par les applis clientes)
|
||||||
CORS_ORIGINS=["http://localhost:3002"]
|
# 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
|
# Taille de la toile de confiance (W) — lue sur la chaîne, repli sur l'indexeur puis sur la valeur fixe
|
||||||
DATABASE_POOL_SIZE=20
|
# LD_RPC_ENDPOINTS=https://rpc.duniter.org,https://g1.axiom-team.fr/ws/
|
||||||
DATABASE_MAX_OVERFLOW=10
|
# LD_INDEXERS=https://indexer.duniter.org/v1/graphql
|
||||||
|
# LD_WOT_FALLBACK=5745
|
||||||
|
|
||||||
# Auth / Sessions
|
RUST_LOG=info,sqlx=warn
|
||||||
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
|
|
||||||
|
|||||||
+11
@@ -42,3 +42,14 @@ htmlcov/
|
|||||||
# IPFS
|
# IPFS
|
||||||
ipfs-data/
|
ipfs-data/
|
||||||
docs/*.pdf
|
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/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: security-check
|
- name: security-check
|
||||||
@@ -18,7 +26,7 @@ steps:
|
|||||||
- name: test-backend
|
- name: test-backend
|
||||||
image: python:3.11-slim
|
image: python:3.11-slim
|
||||||
commands:
|
commands:
|
||||||
- cd backend
|
- cd maquette/backend
|
||||||
- pip install --no-cache-dir -r requirements.txt
|
- pip install --no-cache-dir -r requirements.txt
|
||||||
- pytest app/tests/ -v --tb=short
|
- pytest app/tests/ -v --tb=short
|
||||||
|
|
||||||
@@ -27,7 +35,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
# git requis : dépendance @yvv/nuxt-base en git+https (node:20-slim n'a pas git)
|
# 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
|
- apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||||
- cd frontend
|
- cd maquette/frontend
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm run build
|
- npm run build
|
||||||
|
|
||||||
@@ -39,7 +47,7 @@ steps:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
commands:
|
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"
|
- echo "Image backend construite"
|
||||||
|
|
||||||
# NOTE: volumes + pas de from_secret : compatible
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
@@ -50,7 +58,7 @@ steps:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
commands:
|
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"
|
- echo "Image frontend construite"
|
||||||
|
|
||||||
# NOTE: volumes + pas de from_secret : compatible
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
@@ -63,7 +71,8 @@ steps:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
commands:
|
commands:
|
||||||
- apk add --no-cache curl
|
- 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
|
- mkdir -p .reports
|
||||||
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
||||||
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
|
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
|
||||||
@@ -116,14 +125,12 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- sbom-publish
|
- sbom-publish
|
||||||
environment:
|
environment:
|
||||||
APP_DOMAIN:
|
|
||||||
from_secret: app_domain
|
|
||||||
POSTGRES_PASSWORD:
|
POSTGRES_PASSWORD:
|
||||||
from_secret: postgres_password
|
from_secret: postgres_password
|
||||||
SECRET_KEY:
|
SECRET_KEY:
|
||||||
from_secret: secret_key
|
from_secret: secret_key
|
||||||
commands:
|
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 "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> .env.deploy
|
||||||
- echo "SECRET_KEY=$SECRET_KEY" >> .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
|
- 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:
|
commands:
|
||||||
- cp .env.deploy /opt/libredecision/.env
|
- cp .env.deploy /opt/libredecision/.env
|
||||||
- chmod 600 /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
|
# 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,
|
# 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
|
# on arrete donc aussi l'ancienne stack (ehv-decision-main-*) pour eviter la
|
||||||
@@ -249,6 +256,6 @@ steps:
|
|||||||
- name: notify-failure
|
- name: notify-failure
|
||||||
image: alpine:3.20
|
image: alpine:3.20
|
||||||
commands:
|
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:
|
when:
|
||||||
- status: failure
|
- status: failure
|
||||||
@@ -1,100 +1,100 @@
|
|||||||
# libreDecision
|
# 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,
|
||||||
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
|
boîte à outils. **v3 : Rust + Svelte**, un chantier à la fois, chacun livré en production avant
|
||||||
observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal).
|
le suivant (`docs/chantiers/README.md`).
|
||||||
**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse.
|
**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
|
## Protocole de début de session
|
||||||
|
|
||||||
1. `git pull --rebase origin main`
|
1. `git pull --rebase origin main` (puis la branche du chantier : `chantier/acte-certification`)
|
||||||
2. Si l'objectif de la session n'est pas précisé, le demander
|
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
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
|
- **Serveur** : Rust edition 2024, toolchain **1.99** épinglée (`rust-toolchain.toml`) ;
|
||||||
package manager : npm ; port **3002** strict
|
rustup installé sans PATH → `export PATH=$HOME/.cargo/bin:$PATH`. axum 0.8, sqlx 0.8
|
||||||
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
|
(SQLite, requêtes runtime + `migrate!`), tokio, reqwest rustls.
|
||||||
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
|
- **Interface** : SvelteKit 2 / Svelte 5 (runes), adapter-static SPA, vite 7, vitest,
|
||||||
- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro**
|
Playwright ; npm. Polices Literata (textes) + Nunito (interface), fontsource.
|
||||||
— non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config
|
- **Ports** : dev interface **3002** (strict, proxy `/api` → 8002) · serveur **8002** ·
|
||||||
en prévision
|
serveur jetable des e2e **8092**.
|
||||||
- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker
|
- **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
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
crates/
|
||||||
app/
|
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle, presets), edit (coquille vs fond,
|
||||||
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
|
# diff), assist (découpage, similarité, longueur), compose (adoption, aperçus)
|
||||||
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
|
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut
|
||||||
# triage, impact — import UNIQUEMENT via le barrel ~/engine
|
ld-server/
|
||||||
data/
|
migrations/ # schéma SQLite — additif seulement
|
||||||
persistence.ts # IndexedDB local-first
|
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
||||||
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
|
# dev, bootstrap) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
|
||||||
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
|
# traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W
|
||||||
stores/
|
# sur la chaîne) · seed.rs · web.rs (sert web/build)
|
||||||
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
|
tests/api.rs # scénarios bout en bout
|
||||||
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
|
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur)
|
||||||
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
|
web/src/
|
||||||
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
|
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
||||||
# onboarding/ common/ (primitives Ld*)
|
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp…
|
||||||
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
|
routes/ # / · connexion · invitation · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
|
||||||
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
|
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
|
||||||
# creer (layout bare) · donnees
|
# protocoles (à venir)
|
||||||
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
|
web/e2e/ # Playwright C1–C11 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
||||||
types/domain.ts # le noyau du modèle
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
|
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
|
||||||
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
|
maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v1)
|
||||||
docker/ # compose + Dockerfiles
|
|
||||||
docs/dev/BLUEPRINT-V2.md # la référence produit v2
|
|
||||||
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Données runtime
|
## Données runtime — jamais écrasées
|
||||||
|
|
||||||
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
|
- **Production** : volume `lpk-libredecision-app_data` (`/data` : `libredecision.db`, `textes/`
|
||||||
- `.env` racine : secrets backend — jamais commité
|
= 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
|
```bash
|
||||||
# Frontend (tout le dev v2)
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cd frontend && npm run dev # :3002
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
npm test # vitest — 342 tests, référence à maintenir
|
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 12 contrôles
|
||||||
npm run build # zéro erreur exigé avant commit
|
|
||||||
|
|
||||||
# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json
|
cd web && npm run dev # :3002 à chaud
|
||||||
# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts
|
docker build -f docker/app.Dockerfile --target production . # rejoue toute la boucle
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker compose -f docker/docker-compose.yml up
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions / pièges
|
## Conventions / pièges
|
||||||
|
|
||||||
- **UI français, code anglais** (variables, commentaires, docstrings)
|
- **UI français, code anglais** ; **trilingue** : aucune chaîne affichée en dur, tout passe par
|
||||||
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
|
`web/src/lib/i18n/fr.ts` puis en/es (test de parité + `FORBIDDEN_UI_TERMS`). Tutoyer ; le sujet
|
||||||
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
|
est la personne ou le groupe, jamais le système. Le français du texte voté fait foi.
|
||||||
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
|
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
|
||||||
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
|
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
|
||||||
des identifiants de code, jamais des textes affichés
|
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
|
||||||
- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à
|
une coquille ne demande pas de vote, un changement de fond si ; un signalement de
|
||||||
autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain
|
contradiction bloque ; l'inertie se vote au cran au-dessus ; le groupe d'essai ne publie jamais.
|
||||||
(cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ;
|
- **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie
|
||||||
dérogation asymétrique (alourdir libre, alléger motivé)
|
`ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`).
|
||||||
- **Budget 400 lignes max par page** (`app/pages/`)
|
- **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ;
|
||||||
- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en
|
un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio
|
||||||
imports explicites (testables sous vitest nu) ; toute mutation d'état passe par
|
masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
|
||||||
`canTransition(decision, to, ctx)`
|
`fr-FR` (sinon l'interface part en anglais).
|
||||||
- **Formule inertie** : `R = C + B^W + (M + (1−M)(1−(T/W)^G))·max(0, T−C)` —
|
- **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit`
|
||||||
`frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des
|
(`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` —
|
||||||
lettres W/T/M/B/G/C et de KaTeX
|
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
|
||||||
- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()`
|
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.duniter.org/Yvv/libredecision-textes`.
|
||||||
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)
|
|
||||||
|
|||||||
+52
-80
@@ -1,91 +1,63 @@
|
|||||||
# Contribuer à libreDecision
|
# Contribuer à libreDecision
|
||||||
|
|
||||||
La référence produit est `docs/dev/BLUEPRINT-V2.md` — toute contribution qui touche au
|
On travaille **un chantier à la fois**, livré complet et en production avant le suivant
|
||||||
comportement doit s'y conformer (ou proposer un amendement du blueprint, argumenté).
|
(`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
|
```bash
|
||||||
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cd frontend
|
cargo fmt --all --check
|
||||||
npm install
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
npm run dev # http://localhost:3002 — port strict, jamais de fallback
|
cargo test --workspace
|
||||||
npm test # vitest — 342 tests
|
cd web
|
||||||
npm run build # doit passer sans erreur avant tout commit
|
npm run check # svelte-check, zéro avertissement
|
||||||
|
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
|
||||||
# Backend v1 (optionnel — conservé pour la future synchro)
|
npm run build
|
||||||
cd backend
|
npm run e2e # Playwright : C1–C11 (bureau) + M1 (mobile), serveur jetable sur :8092
|
||||||
python -m venv venv && source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **UI en français, code en anglais** (variables, commentaires, docstrings).
|
- **Interface en français, code en anglais** (identifiants, commentaires, messages de log).
|
||||||
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
|
- **Trilingue dès le départ** : toute chaîne affichée vit dans `web/src/lib/i18n/fr.ts`
|
||||||
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
|
(la source), puis `en.ts` et `es.ts`. Le test de parité refuse une clé manquante ou un
|
||||||
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
|
paramètre `{x}` qui diffère. Le texte voté est le français ; les traductions sont éditoriales.
|
||||||
toujours la personne ou le collectif — jamais la formule ni le système.
|
- **Ton** : tutoyer la personne ; le sujet est la personne ou le collectif, jamais le système.
|
||||||
- **Test anti-lexique** (`tests/lexicon.spec.ts`) : la liste `FORBIDDEN_UI_TERMS` interdit
|
Mots interdits à l'écran (`FORBIDDEN_UI_TERMS`) : « triage », « verdict », « déléguer »…
|
||||||
certains mots dans toute chaîne visible (lexicon + templates `.vue`), insensible à la casse
|
- **Moteur pur** : `crates/ld-engine` n'a ni I/O, ni base, ni réseau. Toute règle de vote,
|
||||||
et aux accents. « Triage », « verdict », « déléguer »… peuvent exister comme identifiants de
|
d'édition ou de composition s'y écrit et s'y teste d'abord.
|
||||||
code, jamais à l'écran.
|
- **Accès** : le serveur ne décide jamais seul qui voit quoi — il demande à `trustin-access`.
|
||||||
- **CSS** : borderless (profondeur via background + box-shadow), palettes par classes
|
- **Données runtime** (`data/`, volume `lpk-libredecision-app_data`) : jamais écrasées, jamais
|
||||||
`.mood-*` sur `<html>`, composants natifs (`<input>`, `<select>`, `<button>` custom).
|
réinitialisées ; une migration ajoute, elle ne réécrit pas.
|
||||||
- **Composants** : `pathPrefix: false` — noms de fichiers uniques et parlants, auto-import.
|
- **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
|
**« Faire un retour »** (dans le menu de gauche, sur chaque page) envoie une note datée avec la
|
||||||
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
|
page, la langue et la taille de l'écran ; elles se lisent dans trustinWallet (`/acces`).
|
||||||
de re-vote n'est visible que de son auteur.
|
Pour un bug reproductible, une issue sur la forge avec les étapes.
|
||||||
- **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).
|
|
||||||
|
|||||||
Generated
+2742
File diff suppressed because it is too large
Load Diff
+22
@@ -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
@@ -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.
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
# libreDecision
|
# 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,
|
En ligne : **https://decision.librodrome.org** (le produit, v3) · la maquette v2, conservée pour
|
||||||
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
|
présenter le projet : https://maquette.decision.librodrome.org (`maquette/`).
|
||||||
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
|
## 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,
|
> 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.
|
> 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
|
| Outil | Ce qu'il fait | État |
|
||||||
décision vers l'une de six routes :
|
|---|---|---|
|
||||||
|
| **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 |
|
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).
|
||||||
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
|
Chantier en cours : **Acte d'engagement Certification** (Monnaie libre), en binôme Yvv + Chiara.
|
||||||
| **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
|
## Comment marche un doc de référence
|
||||||
et une fenêtre d'objection.
|
|
||||||
|
|
||||||
## 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 détail, les décisions et leurs raisons : [`docs/chantiers/acte-certification/CHANTIER.md`](docs/chantiers/acte-certification/CHANTIER.md)
|
||||||
le Pacte.
|
et [`docs/adr/`](docs/adr/).
|
||||||
|
|
||||||
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
|
## Stack
|
||||||
|
|
||||||
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
|
- **Serveur** : Rust (edition 2024, toolchain 1.99 épinglée) — axum 0.8, sqlx/SQLite, tokio.
|
||||||
npm ; port **3002**
|
Trois crates : `ld-engine` (moteur pur : seuils, édition, assistant, composition),
|
||||||
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
|
`trustin-access` (règles d'accès de trustinWallet), `ld-server` (API + publication + service
|
||||||
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
|
de l'interface).
|
||||||
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
|
- **Interface** : SvelteKit 2 / Svelte 5 (runes), SPA statique servie par le serveur ;
|
||||||
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
|
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
|
```bash
|
||||||
cd frontend
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
npm install
|
cd web && npm ci && npm run build && cd .. # l'interface compilée
|
||||||
npm run dev # http://localhost:3002
|
LD_DEV_LOGIN=1 cargo run -p ld-server # API + interface sur :8002
|
||||||
npm test # vitest — 342 tests
|
cd web && npm run dev # interface à chaud sur :3002 (proxy /api → :8002)
|
||||||
npm run build # build de production
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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/
|
crates/
|
||||||
engine/ # moteurs purs (barrel index.ts — seul point d'import)
|
ld-engine/ # moteur pur, sans I/O : threshold, edit, assist, compose
|
||||||
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
|
trustin-access/ # qui voit, qui ouvre, qui gère les accès
|
||||||
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
|
ld-server/ # axum : auth Ğ1 + invitations, docs, propositions, votes, adoption, publication
|
||||||
composables/ # useMood, useFeed, useSearch
|
migrations/ # schéma SQLite (données runtime : jamais réécrites)
|
||||||
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
|
tests/api.rs # scénarios bout en bout de l'API
|
||||||
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
|
seeds/ # documents de départ (JSON trilingue), instanciés une seule fois par groupe
|
||||||
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
|
web/
|
||||||
lexicon.ts # source unique des libellés FR + test anti-lexique
|
src/lib/ # api, session, i18n (fr source, en, es), composants
|
||||||
types/domain.ts # le noyau du modèle
|
src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)…
|
||||||
docs/dev/BLUEPRINT-V2.md # la référence produit
|
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
|
## 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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ¶ms_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, ¶ms_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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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(¶ms.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
-- libreDecision v3 — initial schema. Runtime data: never dropped, never rewritten.
|
||||||
|
-- Dates are RFC 3339 UTC strings (sortable).
|
||||||
|
|
||||||
|
CREATE TABLE identities (
|
||||||
|
id TEXT PRIMARY KEY, -- idty:<index> | inv:<hex> | dev:<name>
|
||||||
|
kind TEXT NOT NULL, -- g1 | invite | dev
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
address TEXT, -- SS58 seen at the last Ğ1 sign-in, display only
|
||||||
|
g1_member INTEGER NOT NULL DEFAULT 0,
|
||||||
|
admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE invitations (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on acceptance
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
identity_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE groups (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent TEXT,
|
||||||
|
policy TEXT NOT NULL, -- g1-members | managed
|
||||||
|
hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE memberships (
|
||||||
|
group_slug TEXT NOT NULL REFERENCES groups(slug),
|
||||||
|
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
granted_by TEXT,
|
||||||
|
granted_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (group_slug, identity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_slug TEXT NOT NULL REFERENCES groups(slug),
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL, -- JSON {lang: text}
|
||||||
|
summary TEXT NOT NULL, -- JSON {lang: text}
|
||||||
|
source_lang TEXT NOT NULL,
|
||||||
|
vote_floor REAL NOT NULL, -- C, minimal number of "for" votes
|
||||||
|
publish_path TEXT, -- path in the texts repository; NULL = never published
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (group_slug, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE units (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
section TEXT, -- grouping key of the seed (introduction, technique…)
|
||||||
|
position REAL NOT NULL,
|
||||||
|
inertia TEXT NOT NULL, -- low | standard | high | max
|
||||||
|
in_force_id TEXT, -- proposals.id of the version in force, NULL = empty slot
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX units_doc ON units(doc_id);
|
||||||
|
|
||||||
|
-- A proposal is a version of a unit: proposed, in force, or past.
|
||||||
|
CREATE TABLE proposals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||||
|
kind TEXT NOT NULL, -- text | remove | inertia
|
||||||
|
inertia_target TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- draft | open | in_force | superseded | withdrawn
|
||||||
|
origin TEXT NOT NULL, -- seed | member
|
||||||
|
label TEXT,
|
||||||
|
author_id TEXT REFERENCES identities(id),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
submitted_at TEXT,
|
||||||
|
entered_at TEXT, -- entered into force by vote; NULL for a seed a minima
|
||||||
|
left_at TEXT,
|
||||||
|
confirmed_at TEXT -- a seed a minima that later reached its threshold
|
||||||
|
);
|
||||||
|
CREATE INDEX proposals_unit ON proposals(unit_id);
|
||||||
|
|
||||||
|
CREATE TABLE revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- initial | typo | substantive
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author_id TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX revisions_proposal ON revisions(proposal_id);
|
||||||
|
|
||||||
|
CREATE TABLE votes (
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
choice TEXT NOT NULL, -- for | against
|
||||||
|
revision INTEGER NOT NULL, -- counted only while equal to proposals.revision
|
||||||
|
cast_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (proposal_id, identity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE signals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
target_proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
author_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
lifted_at TEXT,
|
||||||
|
lifted_by TEXT -- identity id, or 'revision' when the text changed
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE translations (
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
lang TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
source_hash TEXT NOT NULL, -- hash of the source title + body it translates
|
||||||
|
origin TEXT NOT NULL, -- seed | member
|
||||||
|
updated_by TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (proposal_id, lang)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE adoptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
proposal_id TEXT NOT NULL,
|
||||||
|
unit_id TEXT NOT NULL,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
votes_for INTEGER NOT NULL,
|
||||||
|
votes_against INTEGER NOT NULL,
|
||||||
|
electorate INTEGER NOT NULL,
|
||||||
|
required INTEGER NOT NULL,
|
||||||
|
params TEXT NOT NULL,
|
||||||
|
at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outbox (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
next_at TEXT NOT NULL,
|
||||||
|
done_at TEXT,
|
||||||
|
commit_sha TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
identity_id TEXT,
|
||||||
|
route TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
context TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'new'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,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 })))
|
||||||
|
}
|
||||||
@@ -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(¶ms).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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -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 : ».
|
||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user