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
+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.");
}