v3 : points de la relecture indépendante — une personne une voix, ordre de déploiement, durcissements

- Dans Monnaie libre (électorat = toile de confiance), seuls les membres de la
  toile votent (trustin_access::votes_in) ; une identité d'invitation y lit,
  propose et signale. L'interface le dit à la place des boutons (fr/en/es).
- Un lien d'invitation ne vise jamais une identité Ğ1 ; le lien est réservé
  avant de créer l'identité (deux acceptations simultanées : une seule passe).
- Un nombre n'est jamais une coquille (« 50 » → « 5,0 », « 1 5 » → « 15 »).
- Redirection après connexion : caractères de contrôle refusés.
- En-têtes : X-Frame-Options DENY, frame-ancestors 'none', nosniff, Referrer-Policy.
- Déploiement : le produit passe en premier ; la maquette attend son succès.
  Les conteneurs, même arrêtés, qui revendiquent encore la racine sortent de
  « restart: always » (ils reviendraient au redémarrage de Docker).
- Tests : 57 Rust (dont 14 scénarios d'API), e2e 12 / 12 avec C11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 04:23:51 +02:00
co-authored by Claude Opus 5.5
parent 1f9c241b41
commit 94907afb20
25 changed files with 229 additions and 49 deletions
+34 -15
View File
@@ -169,7 +169,16 @@ pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
/// 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(),
// Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil".
Some(p)
if p.starts_with('/')
&& !p.starts_with("//")
&& !p.contains('\\')
&& !p.chars().any(char::is_control)
&& p.len() < 512 =>
{
p.to_owned()
}
_ => "/groupes".to_owned(),
}
}
@@ -324,18 +333,33 @@ struct InvitationRow {
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
let hash = util::sha256_hex(body.token.trim());
let now = util::now();
// Reserve the link first: of two simultaneous acceptances, one only goes on.
let reserved =
sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?")
.bind(&now)
.bind(&hash)
.bind(&now)
.execute(&state.db)
.await?;
if reserved.rows_affected() != 1 {
return Err(AppError::Invalid("invitation_invalid"));
}
let inv = sqlx::query_as::<_, InvitationRow>(
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?",
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?",
)
.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,
Some(existing) => {
if load_identity(&state, &existing).await?.kind != "invite" {
return Err(AppError::Invalid("invitation_invalid"));
}
existing
}
None => {
let id = format!("inv:{}", util::id());
sqlx::query(
@@ -355,16 +379,11 @@ pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<I
}
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"));
}
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
.bind(&identity_id)
.bind(&inv.token_hash)
.execute(&state.db)
.await?;
let session = open_session(&state, &identity_id).await?;
let me = load_identity(&state, &identity_id).await?;
let mut res = Json(json!({ "me": me })).into_response();