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