v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+10
@@ -42,3 +42,13 @@ htmlcov/
|
||||
# IPFS
|
||||
ipfs-data/
|
||||
docs/*.pdf
|
||||
|
||||
# v3 — Rust + Svelte
|
||||
/target/
|
||||
/data/
|
||||
web/build/
|
||||
web/.svelte-kit/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
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
|
||||
@@ -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,243 @@
|
||||
//! 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**: accents, case, punctuation, spacing, emphasis, or at most
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no number, no negation
|
||||
//! touched). 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",
|
||||
// en
|
||||
"not", "no", "never", "without", "none", "nor", // es
|
||||
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada",
|
||||
];
|
||||
|
||||
const MAX_TYPO_WORDS: usize = 2;
|
||||
const MAX_TYPO_DISTANCE: 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()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
if old == new {
|
||||
return EditKind::Unchanged;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
return EditKind::Typographic;
|
||||
}
|
||||
let (a, b) = (words(old), words(new));
|
||||
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(|c| c.is_ascii_digit())
|
||||
|| y.chars().any(|c| c.is_ascii_digit())
|
||||
|| NEGATIONS.contains(&x.as_str())
|
||||
|| NEGATIONS.contains(&y.as_str())
|
||||
|| x.chars().count().min(y.chars().count()) < 3
|
||||
|| levenshtein(x, y) > MAX_TYPO_DISTANCE;
|
||||
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_punctuation_emphasis() {
|
||||
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier !"), EditKind::Typographic);
|
||||
assert_eq!(classify("un **être humain**", "un être humain"), EditKind::Typographic);
|
||||
assert_eq!(classify("same", "same"), EditKind::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspelt_words() {
|
||||
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
|
||||
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
|
||||
// « 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,181 @@
|
||||
-- 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,
|
||||
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,243 @@
|
||||
//! 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?;
|
||||
Ok(Json(json!({
|
||||
"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"));
|
||||
}
|
||||
if let Some(id) = &b.identity_id {
|
||||
auth::load_identity(&state, id).await?;
|
||||
}
|
||||
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,185 @@
|
||||
//! 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?;
|
||||
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,516 @@
|
||||
//! 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";
|
||||
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) 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, &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 {
|
||||
Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') && 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, &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, "", "/api/auth/g1", 0);
|
||||
let stash = cookie_value(&headers, OAUTH_COOKIE).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 inv = sqlx::query_as::<_, InvitationRow>(
|
||||
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations
|
||||
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?",
|
||||
)
|
||||
.bind(&hash)
|
||||
.bind(util::now())
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::Invalid("invitation_invalid"))?;
|
||||
|
||||
let identity_id = match inv.identity_id.clone() {
|
||||
Some(existing) => 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?;
|
||||
let used =
|
||||
sqlx::query("UPDATE invitations SET used_at = ?, identity_id = ? WHERE token_hash = ? AND used_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&identity_id)
|
||||
.bind(&inv.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if used.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
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) {
|
||||
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, "", "/", 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(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,618 @@
|
||||
//! 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(())
|
||||
}
|
||||
|
||||
#[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 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, 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, "position": u.position, "inertia": u.inertia,
|
||||
"inForce": in_force, "proposals": proposals,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let publication = publication_status(&state, &id).await?;
|
||||
Ok(Json(json!({
|
||||
"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,182 @@
|
||||
//! 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?;
|
||||
sqlx::migrate!("./migrations").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 {
|
||||
Router::new()
|
||||
.nest("/api", api_router())
|
||||
.fallback_service(web::service(&state.cfg.web_dir))
|
||||
.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()),
|
||||
)
|
||||
.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?;
|
||||
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,797 @@
|
||||
//! 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 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(())
|
||||
}
|
||||
|
||||
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?;
|
||||
sqlx::query("INSERT INTO units (id, doc_id, code, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, 'standard', NULL, ?)")
|
||||
.bind(&id)
|
||||
.bind(&st.doc.id)
|
||||
.bind(format!("N{}", n + 1))
|
||||
.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"));
|
||||
}
|
||||
let kind = edit::classify(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
|
||||
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);
|
||||
}
|
||||
sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
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;
|
||||
sqlx::query("UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(revision)
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
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?;
|
||||
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"));
|
||||
}
|
||||
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 {
|
||||
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(), 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,251 @@
|
||||
//! 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;
|
||||
|
||||
pub async fn worker(state: AppState) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = state.publish_wake.notified() => {}
|
||||
() = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
if state.cfg.publish.is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = pass(&state).await {
|
||||
tracing::warn!(error = %e, "publication pass");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Due {
|
||||
id: i64,
|
||||
doc_id: String,
|
||||
attempts: i64,
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
/// 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 = util::now();
|
||||
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,204 @@
|
||||
//! 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 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, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
|
||||
.bind(&unit_id)
|
||||
.bind(&doc_id)
|
||||
.bind(&unit.code)
|
||||
.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?;
|
||||
}
|
||||
}
|
||||
}
|
||||
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,388 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
#[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").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");
|
||||
|
||||
// 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 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.");
|
||||
}
|
||||
@@ -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,193 @@
|
||||
//! 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.
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user