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:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
+10
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[workspace]
resolver = "3"
members = ["crates/ld-engine", "crates/trustin-access", "crates/ld-server"]
[workspace.package]
version = "0.3.0"
edition = "2024"
rust-version = "1.90"
repository = "https://git.open.us.org/LPK/decision"
publish = false
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
unwrap_used = "warn"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "ld-engine"
description = "Pure decision engines of libreDecision: inertial threshold, edit classification, editing assistant, document composition."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
unicode-normalization = "0.1"
[dev-dependencies]
serde_json = "1"
[lints]
workspace = true
+232
View File
@@ -0,0 +1,232 @@
//! Rule-based editing assistant — no AI yet (Yvv, 2026-10-21: « D'abord des
//! règles simples, ensuite on mettra de l'IA, mais locale »).
//!
//! It never decides. It says, while the author writes:
//! - « this text seems to carry several decisions — split it? » with the parts;
//! - « this text is close to <unit> — make it a variant instead? »;
//! - « this text is long ».
//!
//! The local AI planned later plugs in behind the same `Hint` type.
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Hint {
/// Several decisions in one text: the suggested parts, in order.
Split { parts: Vec<String> },
/// Close to an existing unit or proposal.
Similar { target: String, score: f64 },
/// Longer than a unit usually is.
Long { chars: usize },
}
pub const LONG_UNIT_CHARS: usize = 700;
pub const SIMILARITY_HINT: f64 = 0.45;
/// Markers of a commitment or a rule, folded (lowercase, no accents).
const DECISION_MARKERS: &[&str] = &[
"je m'engage",
"je m engage",
"j'ai ",
"je suis ",
"je me suis",
"je verifie",
"je ne ",
"doit ",
"doivent ",
"s'engage",
"est tenu",
"est interdit",
"obligatoire",
"il est interdit",
"verifiez",
"ne certifiez",
"contactez",
"assurez",
"i commit",
"i have ",
"must ",
"shall ",
"me comprometo",
"debe ",
];
const STOPWORDS: &[&str] = &[
"le", "la", "les", "un", "une", "des", "de", "du", "d", "l", "et", "ou", "a", "au", "aux", "en", "que", "qui",
"je", "j", "me", "m", "se", "s", "ce", "cet", "cette", "ces", "par", "pour", "sur", "dans", "avec", "est", "sont",
"the", "of", "and", "to", "in", "is", "el", "los", "las", "y", "que", "con", "por", "para",
];
fn fold(s: &str) -> String {
s.nfd()
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
.flat_map(char::to_lowercase)
.map(|c| if c == '’' { '\'' } else { c })
.collect()
}
fn is_decision(segment: &str) -> bool {
let f = fold(segment);
DECISION_MARKERS.iter().any(|m| f.contains(m))
}
/// Paragraphs, list items: the natural cut lines of a modular text.
fn segments(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
let flush = |current: &mut String, out: &mut Vec<String>| {
let t = current.trim();
if !t.is_empty() {
out.push(t.to_owned());
}
current.clear();
};
for line in text.lines() {
let trimmed = line.trim_start();
let is_item = trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed
.split_once(['.', ')'])
.is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
if trimmed.is_empty() || is_item {
flush(&mut current, &mut out);
}
if !trimmed.is_empty() {
if !current.is_empty() {
current.push('\n');
}
current.push_str(line);
}
}
flush(&mut current, &mut out);
out
}
/// Sentences of a single paragraph (split after . ! ? followed by a space).
fn sentences(paragraph: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut chars = paragraph.chars().peekable();
while let Some(c) = chars.next() {
current.push(c);
if matches!(c, '.' | '!' | '?') && chars.peek().is_some_and(|n| n.is_whitespace()) {
out.push(current.trim().to_owned());
current.clear();
}
}
if !current.trim().is_empty() {
out.push(current.trim().to_owned());
}
out
}
/// The parts to propose separately, or `None` when the text holds one decision.
pub fn split_suggestion(text: &str) -> Option<Vec<String>> {
let segs = segments(text);
if segs.len() >= 2 && segs.iter().filter(|s| is_decision(s)).count() >= 2 {
return Some(segs);
}
if segs.len() == 1 {
let sents = sentences(&segs[0]);
let committing = sents.iter().filter(|s| is_decision(s)).count();
if sents.len() >= 2 && committing >= 2 {
return Some(sents);
}
}
None
}
fn content_words(text: &str) -> Vec<String> {
fold(text)
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.chars().count() > 2 && !STOPWORDS.contains(w))
.map(str::to_owned)
.collect()
}
/// Jaccard similarity over content words and their bigrams.
pub fn similarity(a: &str, b: &str) -> f64 {
use std::collections::BTreeSet;
let grams = |t: &str| -> BTreeSet<String> {
let w = content_words(t);
let mut set: BTreeSet<String> = w.iter().cloned().collect();
set.extend(w.windows(2).map(|p| format!("{} {}", p[0], p[1])));
set
};
let (x, y) = (grams(a), grams(b));
if x.is_empty() || y.is_empty() {
return 0.0;
}
let inter = x.intersection(&y).count() as f64;
let union = x.union(&y).count() as f64;
inter / union
}
/// All hints for a draft, given the texts it may collide with (`(id, text)`).
pub fn hints(text: &str, others: &[(String, String)]) -> Vec<Hint> {
let mut out = Vec::new();
if let Some(parts) = split_suggestion(text) {
out.push(Hint::Split { parts });
}
let mut close: Vec<(String, f64)> =
others.iter().map(|(id, t)| (id.clone(), similarity(text, t))).filter(|(_, s)| *s >= SIMILARITY_HINT).collect();
close.sort_by(|a, b| b.1.total_cmp(&a.1));
out.extend(close.into_iter().take(3).map(|(target, score)| Hint::Similar { target, score }));
let chars = text.chars().count();
if chars > LONG_UNIT_CHARS {
out.push(Hint::Long { chars });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_commitments_in_one_text_are_flagged() {
let text = "Je m'engage sur l'honneur à n'avoir qu'un seul compte.\n\nJe m'engage à ne certifier que des personnes qui respectent cet engagement.";
let parts = split_suggestion(text).expect("two decisions");
assert_eq!(parts.len(), 2);
assert!(parts[0].starts_with("Je m'engage sur"));
}
#[test]
fn two_commitments_in_one_paragraph() {
let text = "Je m'engage à vérifier la clé. Je m'engage aussi à rester joignable.";
assert_eq!(split_suggestion(text).map(|p| p.len()), Some(2));
}
#[test]
fn a_list_of_rules_is_flagged_but_one_explained_commitment_is_not() {
let list = "- Vérifiez la clé avec la personne.\n- Contactez les certifieurs existants.";
assert_eq!(split_suggestion(list).map(|p| p.len()), Some(2));
let single = "Je m'engage sur l'honneur à n'avoir qu'un seul compte. C'est le socle de la toile.";
assert_eq!(split_suggestion(single), None);
}
#[test]
fn preamble_like_text_is_not_split() {
let text = "Le présent acte définit les obligations réciproques des membres. Cet acte est l'unique relation contractuelle de notre toile.";
assert_eq!(split_suggestion(text), None);
}
#[test]
fn similarity_finds_a_variant() {
let current = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.";
let variant = "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.";
let other = "J'ai vérifié avec la personne certifiée qu'elle a généré son document de révocation.";
assert!(similarity(current, variant) >= SIMILARITY_HINT);
assert!(similarity(current, other) < 0.2);
let h = hints(variant, &[("E1".into(), current.into()), ("E6".into(), other.into())]);
assert!(matches!(&h[..], [Hint::Similar { target, .. }] if target == "E1"));
}
#[test]
fn long_text() {
let text = "mot ".repeat(200);
assert!(hints(&text, &[]).iter().any(|h| matches!(h, Hint::Long { .. })));
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Composition of a modular document under permanent vote.
//!
//! A document is an ordered list of **units** (preamble, articles, headings).
//! A unit is a slot: it may hold one version in force, or none yet. Every
//! **proposal** targets exactly one unit — one proposal, one decision — and is
//! one of: a text for the unit (new or replacing), its removal, or a change of
//! its inertia. Proposals on the same unit and the same dimension (content or
//! inertia) are rivals: at most one of them enters into force at a time.
//!
//! A rival replaces the version in force only if it reached its threshold
//! **and** holds a wider margin than the version in force (for − required).
//! The displaced version goes back among the proposals with its votes; it can
//! only return by widening its margin again — no oscillation. A seed a minima
//! (in force without any vote) has the margin of zero votes, so the first
//! rival that reaches its threshold replaces it.
//!
//! This module answers three questions, purely:
//! - which proposals enter into force now (`adoptions`);
//! - what the document would read if the well-rated, or all, proposals passed
//! (`preview`);
//! - which formula a proposal is judged by (`params_for`).
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VersionText {
pub id: String,
pub title: String,
pub body: String,
/// Votes still cast on the version in force (permanent vote).
#[serde(default)]
pub tally: Tally,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UnitState {
pub id: String,
pub position: f64,
pub inertia: InertiaPreset,
pub in_force: Option<VersionText>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "inertia", rename_all = "lowercase")]
pub enum ProposalKind {
/// A text for the unit: an addition if the unit is empty, a replacement otherwise.
Text,
/// Remove the unit from the text in force.
Remove,
/// Change the inertia of the unit.
Inertia(InertiaPreset),
}
impl ProposalKind {
fn is_content(self) -> bool {
!matches!(self, Self::Inertia(_))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProposalState {
pub id: String,
pub unit_id: String,
pub kind: ProposalKind,
pub title: String,
pub body: String,
pub tally: Tally,
/// Sortable submission stamp (RFC 3339): the earlier wins a perfect tie.
pub submitted_at: String,
/// An active contradiction signal forbids entering into force.
pub blocked: bool,
}
/// A change of inertia is voted one level above the unit (« inertie + »).
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams {
let preset = match kind {
ProposalKind::Inertia(_) => unit.inertia.raised(),
ProposalKind::Text | ProposalKind::Remove => unit.inertia,
};
FormulaParams::for_inertia(preset, floor)
}
/// A proposal that changes nothing cannot enter into force.
fn is_effective(unit: &UnitState, p: &ProposalState) -> bool {
match p.kind {
ProposalKind::Text => unit.in_force.as_ref().is_none_or(|v| v.title != p.title || v.body != p.body),
ProposalKind::Remove => unit.in_force.is_some(),
ProposalKind::Inertia(target) => target != unit.inertia,
}
}
#[derive(Clone, Debug)]
struct Scored<'a> {
proposal: &'a ProposalState,
margin: i64,
reached: bool,
well_rated: bool,
}
fn score<'a>(
unit: &UnitState,
p: &'a ProposalState,
electorate: u32,
floor: f64,
) -> Result<Scored<'a>, ThresholdError> {
let e = evaluate(p.tally, electorate, &params_for(unit, p.kind, floor))?;
Ok(Scored {
proposal: p,
margin: i64::from(p.tally.for_votes) - i64::from(e.required),
reached: e.reached,
well_rated: e.well_rated,
})
}
/// Best of rivals: widest margin, then most *for* votes, then earliest.
fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
rivals.sort_by(|a, b| {
b.margin
.cmp(&a.margin)
.then(b.proposal.tally.for_votes.cmp(&a.proposal.tally.for_votes))
.then(a.proposal.submitted_at.cmp(&b.proposal.submitted_at))
.then(a.proposal.id.cmp(&b.proposal.id))
});
rivals.first().map(|s| s.proposal)
}
/// Margin of the version in force, judged like a text proposal on its unit.
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> {
let Some(v) = &unit.in_force else { return Ok(None) };
let e = evaluate(v.tally, electorate, &params_for(unit, ProposalKind::Text, floor))?;
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
}
/// The proposals that enter into force now: per unit and dimension, the best
/// of those that reached their threshold, are effective and not blocked — and,
/// for content, hold a wider margin than the version in force.
pub fn adoptions(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
) -> Result<Vec<String>, ThresholdError> {
let mut out = Vec::new();
for unit in units {
let incumbent = in_force_margin(unit, electorate, floor)?;
for content in [true, false] {
let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
if p.blocked || !is_effective(unit, p) {
continue;
}
let s = score(unit, p, electorate, floor)?;
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
if s.reached && beats_incumbent {
rivals.push(s);
}
}
if let Some(winner) = best(rivals) {
out.push(winner.id.clone());
}
}
}
Ok(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewMode {
/// Only the in-force text.
InForce,
/// As if every well-rated proposal passed (« si les bien cotées passent »).
WellRated,
/// As if every proposal passed, whatever its rating (« si tout passe »).
All,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewMark {
Unchanged,
Added,
Replaced,
Removed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewUnit {
pub unit_id: String,
pub title: String,
pub body: String,
pub mark: PreviewMark,
/// The proposal that would bring the change.
pub proposal_id: Option<String>,
}
/// The document as it would read. Removed units are kept with their mark so
/// the screen can show them struck through.
pub fn preview(
units: &[UnitState],
proposals: &[ProposalState],
electorate: u32,
floor: f64,
mode: PreviewMode,
) -> Result<Vec<PreviewUnit>, ThresholdError> {
let mut ordered: Vec<&UnitState> = units.iter().collect();
ordered.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(&b.id)));
let mut out = Vec::new();
for unit in ordered {
let chosen = if mode == PreviewMode::InForce {
None
} else {
let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content()) {
if !is_effective(unit, p) {
continue;
}
let s = score(unit, p, electorate, floor)?;
if mode == PreviewMode::All || s.well_rated {
rivals.push(s);
}
}
if mode == PreviewMode::All {
// Whatever its rating: the most supported, then the earliest.
for r in &mut rivals {
r.margin = 0;
}
}
best(rivals)
};
match (chosen, &unit.in_force) {
(Some(p), current) => {
let mark = match (p.kind, current) {
(ProposalKind::Remove, _) => PreviewMark::Removed,
(_, Some(_)) => PreviewMark::Replaced,
(_, None) => PreviewMark::Added,
};
let (title, body) = match (p.kind, current) {
(ProposalKind::Remove, Some(v)) => (v.title.clone(), v.body.clone()),
_ => (p.title.clone(), p.body.clone()),
};
out.push(PreviewUnit { unit_id: unit.id.clone(), title, body, mark, proposal_id: Some(p.id.clone()) });
}
(None, Some(v)) => out.push(PreviewUnit {
unit_id: unit.id.clone(),
title: v.title.clone(),
body: v.body.clone(),
mark: PreviewMark::Unchanged,
proposal_id: None,
}),
(None, None) => {}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(id: &str, position: f64, in_force: Option<&str>) -> UnitState {
UnitState {
id: id.into(),
position,
inertia: InertiaPreset::Standard,
in_force: in_force.map(|b| VersionText {
id: format!("{id}-v"),
title: id.into(),
body: b.into(),
tally: Tally::default(),
}),
}
}
fn prop(id: &str, unit_id: &str, kind: ProposalKind, body: &str, for_votes: u32, against: u32) -> ProposalState {
ProposalState {
id: id.into(),
unit_id: unit_id.into(),
kind,
title: unit_id.into(),
body: body.into(),
tally: Tally { for_votes, against },
submitted_at: format!("2026-10-21T00:00:0{}Z", id.len() % 10),
blocked: false,
}
}
const W: u32 = 5745;
const C: f64 = 50.0;
#[test]
fn nothing_passes_at_zero_votes() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
assert!(adoptions(&units, &props, W, C).expect("valid").is_empty());
}
#[test]
fn the_floor_holds_then_releases() {
let units = [unit("E3", 7.0, None)];
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty());
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]);
}
#[test]
fn rivals_on_one_unit_yield_one_winner() {
let units = [unit("E3", 7.0, None)];
let props = [
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
];
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]);
}
#[test]
fn inertia_and_content_are_independent_dimensions() {
let units = [unit("E1", 4.0, Some("unicité"))];
let props = [
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0),
];
let won = adoptions(&units, &props, W, C).expect("valid");
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
}
#[test]
fn inertia_change_is_voted_one_level_up() {
let u = unit("E1", 4.0, Some("x"));
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C);
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct());
assert_eq!(p.gradient, InertiaPreset::High.gradient());
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient());
}
#[test]
fn blocked_or_ineffective_never_pass() {
let units = [unit("E1", 4.0, Some("unicité"))];
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
blocked.blocked = true;
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0);
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty());
}
#[test]
fn a_rival_must_beat_the_version_in_force() {
let mut units = [unit("E1", 4.0, Some("unicité"))];
// The seed a minima has no vote: the first rival that reaches its threshold wins.
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits.
if let Some(v) = units[0].in_force.as_mut() {
v.tally = Tally { for_votes: 60, against: 0 };
}
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty());
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]);
// The same rule holds for a removal; inertia does not compete with the text.
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty());
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)];
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]);
}
#[test]
fn previews() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None), unit("E1", 4.0, Some("unicité"))];
let props = [
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
];
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid");
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid");
assert_eq!(
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
);
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
assert_eq!(
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
);
}
#[test]
fn all_mode_prefers_the_most_supported_rival() {
let units = [unit("E3", 7.0, None)];
let props = [
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
];
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
}
}
+243
View File
@@ -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);
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Pure engines of libreDecision — no IO, no clock, no randomness.
//!
//! Everything that decides an outcome lives here so it can be tested in
//! isolation and ported verbatim: the inertial threshold (parity with the
//! Python/TypeScript reference of the v2 maquette), the edit classifier that
//! separates a typo fix from a change of meaning, the rule-based editing
//! assistant, and the composition of a document under permanent vote.
pub mod assist;
pub mod compose;
pub mod edit;
pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally};
+285
View File
@@ -0,0 +1,285 @@
//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula.
//!
//! ```text
//! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C)
//! ```
//!
//! R is the minimum number of *for* votes; a proposal is adopted when
//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for +
//! against). At low participation the formula asks for near-unanimity, at full
//! participation the simple majority M suffices. C is the minimal number of
//! *for* votes below which nothing passes — the guard of a permanent vote
//! (Yvv, 2026-10-21: « Nombre minimal de votants »).
//!
//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the
//! Python reference, with one deliberate guard — the participation ratio is
//! capped at 1, so a stale W can never lower the bar below M.
use serde::{Deserialize, Serialize};
/// The four inertia levels of a modular text. Wiring of the v2 maquette:
/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InertiaPreset {
Low,
Standard,
High,
Max,
}
impl InertiaPreset {
pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max];
pub fn majority_pct(self) -> f64 {
match self {
Self::Low | Self::Standard => 50.0,
Self::High => 60.0,
Self::Max => 66.0,
}
}
pub fn gradient(self) -> f64 {
match self {
Self::Low => 0.1,
Self::Standard => 0.2,
Self::High => 0.4,
Self::Max => 0.6,
}
}
/// « Inertie + » : one level up, capped at max. A change of inertia is
/// itself voted at the raised level of the unit it touches.
pub fn raised(self) -> Self {
match self {
Self::Low => Self::Standard,
Self::Standard => Self::High,
Self::High | Self::Max => Self::Max,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Standard => "standard",
Self::High => "high",
Self::Max => "max",
}
}
pub fn parse(raw: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.as_str() == raw)
}
}
/// The letters of the formula, named.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FormulaParams {
/// M, in percent (0–100).
pub majority_pct: f64,
/// G.
pub gradient: f64,
/// B.
pub base: f64,
/// C — minimal number of *for* votes.
pub constant: f64,
}
impl FormulaParams {
pub const DEFAULT_BASE: f64 = 0.1;
pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self {
Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThresholdError {
EmptyElectorate,
MajorityOutOfRange,
}
/// The required ratio of *for* votes among the votes cast beyond C.
pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 {
let m = params.majority_pct / 100.0;
let participation = (f64::from(cast) / f64::from(electorate.max(1))).min(1.0);
m + (1.0 - m) * (1.0 - participation.powf(params.gradient))
}
/// ⌈R⌉ — the minimum number of *for* votes for adoption.
pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
if electorate == 0 {
return Err(ThresholdError::EmptyElectorate);
}
if !(0.0..=100.0).contains(&params.majority_pct) {
return Err(ThresholdError::MajorityOutOfRange);
}
let c = params.constant;
let offset = params.base.powf(f64::from(electorate));
let raw = if cast == 0 {
c + offset
} else {
c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)
};
// `raw` is finite and non-negative for any valid input; saturate rather than wrap.
Ok(clamp_ceil(raw))
}
fn clamp_ceil(raw: f64) -> u32 {
let ceiled = raw.ceil();
if ceiled <= 0.0 {
0
} else if ceiled >= f64::from(u32::MAX) {
u32::MAX
} else {
// Exact: `ceiled` is an integer in (0, u32::MAX).
ceiled as u32
}
}
/// Votes counted on one proposal.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tally {
#[serde(rename = "for")]
pub for_votes: u32,
pub against: u32,
}
impl Tally {
pub fn cast(&self) -> u32 {
self.for_votes.saturating_add(self.against)
}
}
/// Where a proposal stands, in words a screen can say.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Evaluation {
pub cast: u32,
/// ⌈R⌉ at the current participation.
pub required: u32,
/// `for ≥ ⌈R⌉` with at least one vote cast.
pub reached: bool,
/// *for* votes still missing (0 once reached).
pub missing: u32,
/// The majority the inertia asks for at this participation.
pub required_ratio: f64,
/// « Bien cotée » : it holds the majority its inertia asks for, it only
/// lacks voters (C ignored). Used by the "if the well-rated pass" preview.
pub well_rated: bool,
}
pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> {
let cast = tally.cast();
let required = wot_threshold(electorate, cast, params)?;
let without_floor = FormulaParams { constant: 0.0, ..*params };
let required_without_floor = wot_threshold(electorate, cast, &without_floor)?;
let reached = cast > 0 && tally.for_votes >= required;
Ok(Evaluation {
cast,
required,
reached,
missing: required.saturating_sub(tally.for_votes),
required_ratio: required_ratio(electorate, cast, params),
well_rated: cast > 0 && tally.for_votes >= required_without_floor,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn g1vote(m: f64, g: f64) -> FormulaParams {
FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 }
}
#[test]
fn reference_case_of_the_smith_vote() {
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94.
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
assert!(e.reached);
assert_eq!(e.missing, 0);
}
#[test]
fn parity_with_the_typescript_reference() {
// Values computed by maquette/frontend/app/engine/threshold.ts.
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 0, &p), Ok(0));
assert_eq!(wot_threshold(7224, 1, &p), Ok(1));
assert_eq!(wot_threshold(100, 100, &p), Ok(50));
assert_eq!(wot_threshold(5, 5, &p), Ok(3));
let high = g1vote(60.0, 0.4);
assert_eq!(wot_threshold(7224, 120, &high), Ok(111));
}
#[test]
fn one_vote_passes_without_floor_and_never_with_it() {
// The trap of a permanent vote: a lone "for" meets the inertial bar.
let bare = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0);
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached);
let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid");
assert!(!e.reached);
assert_eq!(e.required, 50);
assert_eq!(e.missing, 49);
assert!(e.well_rated, "unanimous, it only lacks voters");
}
#[test]
fn floor_is_a_minimum_of_for_votes() {
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
// 50 unanimous votes reach it, 49 do not.
assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached);
assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached);
// Beyond the floor the inertial majority applies to the rest.
let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid");
assert!(!e.reached, "near-unanimity is asked of the votes beyond C");
}
#[test]
fn no_vote_is_never_reached() {
let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0);
let e = evaluate(Tally::default(), 3, &p).expect("valid");
assert!(!e.reached);
assert!(!e.well_rated);
}
#[test]
fn small_group_needs_everyone() {
// Test group of two people, C = 1: both must vote for.
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0);
assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
}
#[test]
fn stale_electorate_cannot_lower_the_bar_below_majority() {
let p = g1vote(50.0, 0.2);
// 30 votes cast for an electorate believed to be 10: the reference
// would ask 12 (ratio below M), the cap keeps the simple majority:
// ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up.
let r = wot_threshold(10, 30, &p).expect("valid");
assert_eq!(r, 16);
}
#[test]
fn inertia_levels_rise_and_cap() {
assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard);
assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High);
assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max);
for p in InertiaPreset::ALL {
assert_eq!(InertiaPreset::parse(p.as_str()), Some(p));
}
}
#[test]
fn errors() {
let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate));
let bad = g1vote(120.0, 0.2);
assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange));
}
}
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "ld-server"
description = "libreDecision server: API, Ğ1 sign-in, storage, publication of adopted texts, static web."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
ld-engine = { path = "../ld-engine" }
trustin-access = { path = "../trustin-access" }
anyhow = "1"
axum = { version = "0.8", features = ["macros"] }
base64 = "0.22"
hex = "0.4"
rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
thiserror = "2"
time = { version = "0.3", features = ["formatting", "parsing", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process", "fs"] }
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header", "limit"] }
tower = { version = "0.5", features = ["util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
twox-hash = { version = "2", default-features = false, features = ["xxhash64"] }
url = "2"
[dev-dependencies]
http-body-util = "0.1"
[lints]
workspace = true
+181
View File
@@ -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
);
+243
View File
@@ -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 })))
}
+185
View File
@@ -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(&params).unwrap_or_default())
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
tracing::info!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, kind, "entered into force");
entered.push(Entered { proposal_id: p.id.clone(), unit_id: unit.id.clone(), code: unit.code.clone(), kind });
}
if !entered.is_empty() {
let codes: Vec<&str> = entered.iter().map(|e| e.code.as_str()).collect();
enqueue_publication(state, &st.doc, &format!("entrée en vigueur : {}", codes.join(", "))).await?;
}
Ok(entered)
}
/// The electorate changes (the web of trust grows or shrinks): every few
/// minutes, every document is passed through the engine again.
pub async fn sweeper(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(300));
loop {
tick.tick().await;
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
Ok(ids) => ids,
Err(e) => {
tracing::warn!(error = %e, "sweeper");
continue;
}
};
for id in ids {
if let Err(e) = run(&state, &id).await {
tracing::warn!(doc = %id, error = %e, "sweeper pass");
}
}
}
}
/// Unit states, re-exported for tests.
pub fn unit_states(st: &DocState) -> Vec<UnitState> {
st.unit_states()
}
+516
View File
@@ -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);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Configuration, from the environment only. Secrets never have a default.
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Connect {
/// Base URL of the sso-connect instance (no trailing slash).
pub base_url: String,
pub client_id: String,
pub client_secret: String,
}
#[derive(Clone, Debug)]
pub struct Publish {
/// Remote of the texts repository, e.g. `git@git.duniter.org:Yvv/libredecision-textes.git`.
pub remote: String,
pub branch: String,
/// Private SSH key allowed to push there (deploy key).
pub key: PathBuf,
pub author_name: String,
pub author_email: String,
}
#[derive(Clone, Debug)]
pub struct Config {
pub bind: String,
pub data_dir: PathBuf,
pub web_dir: PathBuf,
pub seed_dir: PathBuf,
/// Public origin, e.g. `https://decision.librodrome.org` — callbacks and cookies.
pub public_url: String,
pub connect: Option<Connect>,
pub admin_g1_ids: Vec<String>,
pub dev_login: bool,
pub bootstrap_admin: bool,
pub publish: Option<Publish>,
pub rpc_endpoints: Vec<String>,
pub indexers: Vec<String>,
pub wot_fallback: u32,
pub build: String,
}
fn var(name: &str) -> Option<String> {
std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
}
fn flag(name: &str) -> bool {
var(name).is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
fn list(name: &str, default: &[&str]) -> Vec<String> {
match var(name) {
Some(v) => v.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(),
None => default.iter().map(|s| (*s).to_owned()).collect(),
}
}
impl Config {
pub fn from_env() -> Self {
let data_dir = PathBuf::from(var("LD_DATA_DIR").unwrap_or_else(|| "data".into()));
let connect = match (var("LD_CONNECT_CLIENT_ID"), var("LD_CONNECT_CLIENT_SECRET")) {
(Some(client_id), Some(client_secret)) => Some(Connect {
base_url: var("LD_CONNECT_URL")
.unwrap_or_else(|| "https://connect.monnaie-libre.fr".into())
.trim_end_matches('/')
.to_owned(),
client_id,
client_secret,
}),
_ => None,
};
let publish = var("LD_PUBLISH_REMOTE").map(|remote| Publish {
remote,
branch: var("LD_PUBLISH_BRANCH").unwrap_or_else(|| "main".into()),
key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")),
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
});
Self {
bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()),
web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())),
seed_dir: PathBuf::from(var("LD_SEED_DIR").unwrap_or_else(|| "seeds".into())),
public_url: var("LD_PUBLIC_URL")
.unwrap_or_else(|| "http://127.0.0.1:3002".into())
.trim_end_matches('/')
.to_owned(),
connect,
admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]),
dev_login: flag("LD_DEV_LOGIN"),
bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"),
publish,
rpc_endpoints: list(
"LD_RPC_ENDPOINTS",
&[
"https://rpc.duniter.org",
"https://g1.axiom-team.fr/ws/",
"https://g1.asycn.io/ws/",
"https://g1.coinduf.eu",
],
),
indexers: list(
"LD_INDEXERS",
&["https://indexer.duniter.org/v1/graphql", "https://g1-squid.axiom-team.fr/v1/graphql"],
),
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
data_dir,
}
}
/// Cookies are `Secure` as soon as the public origin is https.
pub fn secure_cookies(&self) -> bool {
self.public_url.starts_with("https://")
}
pub fn connect_callback(&self) -> String {
format!("{}/api/auth/g1/callback", self.public_url)
}
/// For tests: an in-memory configuration with dev login.
pub fn for_tests() -> Self {
Self {
bind: "127.0.0.1:0".into(),
data_dir: std::env::temp_dir().join("ld-tests"),
web_dir: PathBuf::from("web/build"),
seed_dir: PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../seeds")),
public_url: "http://127.0.0.1:3002".into(),
connect: None,
admin_g1_ids: vec!["idty:8352".into()],
dev_login: true,
bootstrap_admin: false,
publish: None,
rpc_endpoints: vec![],
indexers: vec![],
wot_fallback: 5745,
build: "test".into(),
}
}
}
+618
View File
@@ -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()
}
+50
View File
@@ -0,0 +1,50 @@
//! API errors: a stable machine code the interface translates, never a stack.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("unauthenticated")]
Unauthenticated,
#[error("forbidden")]
Forbidden,
#[error("not found")]
NotFound,
#[error("invalid: {0}")]
Invalid(&'static str),
#[error("conflict: {0}")]
Conflict(&'static str),
#[error("unavailable: {0}")]
Unavailable(&'static str),
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type AppResult<T> = Result<T, AppError>;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
Self::Unauthenticated => (StatusCode::UNAUTHORIZED, "unauthenticated"),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
Self::Invalid(code) => (StatusCode::BAD_REQUEST, *code),
Self::Conflict(code) => (StatusCode::CONFLICT, *code),
Self::Unavailable(code) => (StatusCode::SERVICE_UNAVAILABLE, *code),
Self::Db(e) => {
tracing::error!(error = %e, "database");
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
Self::Other(e) => {
tracing::error!(error = %e, "internal");
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
};
(status, Json(json!({ "error": code }))).into_response()
}
}
+182
View File
@@ -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(())
}
+6
View File
@@ -0,0 +1,6 @@
//! libreDecision server — entry point. Everything else lives in the library.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
ld_server::run().await
}
+797
View File
@@ -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);
}
}
+251
View File
@@ -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)
}
+204
View File
@@ -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(())
}
+63
View File
@@ -0,0 +1,63 @@
//! Small shared helpers: clock, randomness, hashing, language maps.
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// Languages of the interface and of the editorial translations (Yvv, 2026-10-21).
pub const LANGS: [&str; 3] = ["fr", "en", "es"];
pub fn now() -> String {
stamp(OffsetDateTime::now_utc())
}
pub fn stamp(at: OffsetDateTime) -> String {
at.replace_nanosecond(0).unwrap_or(at).format(&Rfc3339).unwrap_or_default()
}
pub fn in_days(days: i64) -> String {
stamp(OffsetDateTime::now_utc() + time::Duration::days(days))
}
pub fn in_seconds(secs: i64) -> String {
stamp(OffsetDateTime::now_utc() + time::Duration::seconds(secs))
}
/// 32 random bytes, hex — session tokens, invitation tokens, OAuth state.
pub fn token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
/// 16 random bytes, hex — row identifiers.
pub fn id() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn sha256_hex(data: &str) -> String {
hex::encode(Sha256::digest(data.as_bytes()))
}
/// The hash a translation is pinned to: it goes stale when the source changes.
pub fn source_hash(title: &str, body: &str) -> String {
sha256_hex(&format!("{title}\n\u{1f}\n{body}"))
}
pub type LangMap = BTreeMap<String, String>;
pub fn lang_json(map: &LangMap) -> String {
serde_json::to_string(map).unwrap_or_else(|_| "{}".into())
}
pub fn parse_lang(raw: &str) -> LangMap {
serde_json::from_str(raw).unwrap_or_default()
}
pub fn is_lang(lang: &str) -> bool {
LANGS.contains(&lang)
}
+30
View File
@@ -0,0 +1,30 @@
//! The compiled Svelte interface: static files, SPA fallback, cache policy.
use axum::body::Body;
use axum::http::Request;
use axum::http::header::{CACHE_CONTROL, HeaderValue};
use axum::response::Response;
use std::path::Path;
use tower::ServiceExt;
use tower::util::BoxCloneSyncService;
use tower_http::services::{ServeDir, ServeFile};
pub type WebService = BoxCloneSyncService<Request<Body>, Response, std::convert::Infallible>;
/// Hashed assets are immutable; everything else (index.html) is revalidated.
pub fn service(dir: &Path) -> WebService {
let index = dir.join("index.html");
let serve = ServeDir::new(dir).precompressed_gzip().fallback(ServeFile::new(index));
let svc = tower::service_fn(move |req: Request<Body>| {
let immutable = req.uri().path().starts_with("/_app/immutable/");
let serve = serve.clone();
async move {
let res = serve.oneshot(req).await.unwrap_or_else(|e| match e {});
let mut res = res.map(Body::new);
let policy = if immutable { "public, max-age=31536000, immutable" } else { "no-cache" };
res.headers_mut().insert(CACHE_CONTROL, HeaderValue::from_static(policy));
Ok::<_, std::convert::Infallible>(res)
}
});
BoxCloneSyncService::new(svc)
}
+128
View File
@@ -0,0 +1,128 @@
//! W — the size of the Ğ1 web of trust, the electorate of Monnaie libre.
//!
//! Read on chain first (`Membership.CounterForMembership`, JSON-RPC
//! `state_getStorage` on a Duniter v2s node), from an indexer otherwise, and
//! kept in `settings`. A failed refresh keeps the last known value; the very
//! first start falls back to `LD_WOT_FALLBACK`.
use crate::AppState;
use crate::util;
use serde_json::json;
use std::time::Duration;
const REFRESH: Duration = Duration::from_secs(6 * 3600);
const PLAUSIBLE: std::ops::RangeInclusive<u32> = 100..=10_000_000;
fn twox_128(data: &[u8]) -> [u8; 16] {
let mut out = [0u8; 16];
out[..8].copy_from_slice(&twox_hash::XxHash64::oneshot(0, data).to_le_bytes());
out[8..].copy_from_slice(&twox_hash::XxHash64::oneshot(1, data).to_le_bytes());
out
}
/// Storage key of a plain storage value: `twox_128(pallet) ++ twox_128(item)`.
pub fn storage_key(pallet: &str, item: &str) -> String {
let mut key = Vec::with_capacity(32);
key.extend_from_slice(&twox_128(pallet.as_bytes()));
key.extend_from_slice(&twox_128(item.as_bytes()));
format!("0x{}", hex::encode(key))
}
/// SCALE `u32`, little endian.
pub fn decode_u32(hex_value: &str) -> Option<u32> {
let bytes = hex::decode(hex_value.trim_start_matches("0x")).ok()?;
let arr: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
Some(u32::from_le_bytes(arr))
}
async fn from_chain(state: &AppState) -> Option<(u32, String)> {
let key = storage_key("Membership", "CounterForMembership");
for node in &state.cfg.rpc_endpoints {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "state_getStorage", "params": [key] });
let res = state.http.post(node).json(&body).send().await;
let value: Option<serde_json::Value> = match res {
Ok(r) if r.status().is_success() => r.json().await.ok(),
_ => None,
};
if let Some(n) = value.as_ref().and_then(|v| v["result"].as_str()).and_then(decode_u32)
&& PLAUSIBLE.contains(&n)
{
return Some((n, format!("chain:{node}")));
}
}
None
}
async fn from_indexer(state: &AppState) -> Option<(u32, String)> {
let query = json!({ "query": "{ identities(filter:{isMember:{equalTo:true}}) { totalCount } }" });
for url in &state.cfg.indexers {
let res = state.http.post(url).json(&query).send().await;
let value: Option<serde_json::Value> = match res {
Ok(r) if r.status().is_success() => r.json().await.ok(),
_ => None,
};
let n = value
.as_ref()
.and_then(|v| v["data"]["identities"]["totalCount"].as_u64())
.and_then(|n| u32::try_from(n).ok());
if let Some(n) = n.filter(|n| PLAUSIBLE.contains(n)) {
return Some((n, format!("indexer:{url}")));
}
}
None
}
pub async fn refresh(state: &AppState) -> anyhow::Result<Option<u32>> {
let found = match from_chain(state).await {
Some(v) => Some(v),
None => from_indexer(state).await,
};
let Some((n, source)) = found else {
tracing::warn!("web of trust size: no node nor indexer answered, keeping the last value");
return Ok(None);
};
let now = util::now();
for (key, value) in [("wot_size", n.to_string()), ("wot_source", source.clone())] {
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(key)
.bind(value)
.bind(&now)
.execute(&state.db)
.await?;
}
tracing::info!(size = n, %source, "web of trust size");
Ok(Some(n))
}
pub async fn refresher(state: AppState) {
let mut tick = tokio::time::interval(REFRESH);
loop {
tick.tick().await;
if let Err(e) = refresh(&state).await {
tracing::warn!(error = %e, "web of trust refresh");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn storage_keys_match_substrate() {
// Well-known key of System.Number (block number) on any Substrate chain.
assert_eq!(
storage_key("System", "Number"),
"0x26aa394eea5630e07c48ae0c9558cef702a5c1b19ab7a04f536c519aca4983ac"
);
}
#[test]
fn scale_u32() {
assert_eq!(decode_u32("0x71160000"), Some(5745));
assert_eq!(decode_u32("0x01"), None);
}
}
+388
View File
@@ -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.");
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "trustin-access"
description = "Access core of trustinWallet as libreDecision uses it today: identities, groups, who sees and who opens what."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
[lints]
workspace = true
+193
View File
@@ -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