From 8a3216037c7e8481a358c633f11594ae2a70342f Mon Sep 17 00:00:00 2001
From: Yvv
Date: Wed, 21 Oct 2026 09:06:15 +0200
Subject: [PATCH] =?UTF-8?q?v3=20:=20dixi=C3=A8me=20relecture=20=E2=80=94?=
=?UTF-8?q?=20mention=20=C2=AB=20sur=20invitation=20=C2=BB,=20noms=20atten?=
=?UTF-8?q?dus=20prot=C3=A9g=C3=A9s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Tout auteur venu par invitation porte « sur invitation » à côté de son
nom (propositions, révisions, signalements) : un nom pris avant l'arrivée
de son titulaire Ğ1, ou une lettre sosie, ne passe pas pour un membre de
la toile.
- Un lien d'équipe refuse aussi le nom qu'une invitation attend encore.
Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13.
Co-Authored-By: Claude Opus 5.5
---
HANDOFF.md | 2 +-
crates/ld-server/src/docs.rs | 9 +++++++--
crates/ld-server/src/proposals.rs | 16 +++++++++++-----
crates/ld-server/src/team.rs | 11 ++++++++++-
crates/ld-server/tests/api.rs | 19 +++++++++++++++++++
docs/adr/0006-acces-connect-trustinwallet.md | 6 +++++-
docs/chantiers/acte-certification/CHANTIER.md | 3 +++
.../chantiers/acte-certification/CONTROLES.md | 1 +
web/src/lib/components/VoteView.svelte | 12 ++++++------
web/src/lib/i18n/en.ts | 1 +
web/src/lib/i18n/es.ts | 1 +
web/src/lib/i18n/fr.ts | 1 +
web/src/lib/types.ts | 2 +-
13 files changed, 67 insertions(+), 17 deletions(-)
diff --git a/HANDOFF.md b/HANDOFF.md
index 7616bcc..cbac261 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -22,7 +22,7 @@ pas encore en ligne : les points des septième, huitième et neuvième relecture
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
- `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`. La
+ `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : ok`. La
règle est désormais simple et explicable : **une coquille touche les lettres et les espaces,
rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens
conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs
index baadf6b..d9cd60f 100644
--- a/crates/ld-server/src/docs.rs
+++ b/crates/ld-server/src/docs.rs
@@ -210,6 +210,9 @@ pub struct ProposalRow {
pub label: Option,
pub author_id: Option,
pub author_name: Option,
+ /// The author came by invitation, not with a Ğ1 key: shown next to the name,
+ /// so a name taken ahead of its owner, or a look-alike, does not pass for them.
+ pub author_invited: Option,
pub revision: i64,
pub created_at: String,
pub updated_at: String,
@@ -220,7 +223,7 @@ pub struct ProposalRow {
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.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, 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";
@@ -466,7 +469,9 @@ fn proposal_json(
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 })),
+ "author": p.author_id.as_ref().map(|id| json!({
+ "id": id, "name": p.author_name, "invited": p.author_invited.unwrap_or(false),
+ })),
"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,
diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs
index d582a43..cfabc12 100644
--- a/crates/ld-server/src/proposals.rs
+++ b/crates/ld-server/src/proposals.rs
@@ -551,10 +551,12 @@ pub async fn detail(
title: String,
body: String,
author_name: Option,
+ author_invited: Option,
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
+ "SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
+ i.kind = 'invite' AS author_invited, 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)
@@ -567,7 +569,8 @@ pub async fn detail(
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,
+ "revision": r.revision, "kind": r.kind, "author": r.author_name,
+ "authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff,
}));
previous = Some(r);
}
@@ -580,13 +583,14 @@ pub async fn detail(
reason: String,
author_id: String,
author_name: String,
+ author_invited: bool,
created_at: String,
lifted_at: Option,
lifted_by: Option,
}
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
+ i.kind = 'invite' AS author_invited, 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",
)
@@ -603,7 +607,8 @@ pub async fn detail(
None => None,
};
signals.push(json!({
- "id": s.id, "reason": s.reason, "author": s.author_name, "mine": s.author_id == user.id,
+ "id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
+ "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 })),
}));
@@ -626,7 +631,8 @@ pub async fn detail(
"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()),
+ "author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
+ "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(),
diff --git a/crates/ld-server/src/team.rs b/crates/ld-server/src/team.rs
index 6e961f1..510b9ef 100644
--- a/crates/ld-server/src/team.rs
+++ b/crates/ld-server/src/team.rs
@@ -97,7 +97,16 @@ pub struct JoinBody {
/// `name_key` settles two openings at the same instant.
async fn free_name(state: &AppState, raw: &str) -> AppResult {
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
- let taken: Vec = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
+ // People already there, and those an invitation still waits for.
+ let mut taken: Vec = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
+ taken.extend(
+ sqlx::query_scalar::<_, String>(
+ "SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
+ )
+ .bind(util::now())
+ .fetch_all(&state.db)
+ .await?,
+ );
if taken.iter().any(|t| names::key(t) == name.key) {
return Err(AppError::Conflict("name_taken"));
}
diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs
index 7651339..087c20a 100644
--- a/crates/ld-server/tests/api.rs
+++ b/crates/ld-server/tests/api.rs
@@ -504,6 +504,21 @@ async fn a_team_link_makes_one_identity_per_opening() {
let luna = v["me"]["id"].as_str().expect("id").to_owned();
let (_, g) = first.call("GET", "/api/groups", None).await;
assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true));
+ // What an invited person writes says so next to their name.
+ let essai = doc_id(&mut first, "essai").await;
+ let (_, doc) = first.call("GET", &format!("/api/docs/{essai}"), None).await;
+ let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
+ let (_, v) = first
+ .call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
+ "unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes connues.", "submit": true,
+ })))
+ .await;
+ let (_, d) = admin.call("GET", &format!("/api/proposals/{}", v["id"].as_str().expect("id")), None).await;
+ assert_eq!(
+ (d["proposal"]["author"].as_str(), d["proposal"]["authorInvited"].as_bool()),
+ (Some("Luna"), Some(true))
+ );
+ assert_eq!(d["history"][0]["authorInvited"], true);
// Nobody joins under a name already there; the refusal does not count as an opening.
let mut second = admin.with(None);
@@ -511,6 +526,10 @@ async fn a_team_link_makes_one_identity_per_opening() {
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
}
+ // Nor under the name an invitation still waits for.
+ admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara Rossi", "groups": ["essai"] }))).await;
+ let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "chiara rossi" }))).await;
+ assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
// Look-alike letters from other alphabets are not a name.
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
diff --git a/docs/adr/0006-acces-connect-trustinwallet.md b/docs/adr/0006-acces-connect-trustinwallet.md
index 650fdfe..c3d5f3d 100644
--- a/docs/adr/0006-acces-connect-trustinwallet.md
+++ b/docs/adr/0006-acces-connect-trustinwallet.md
@@ -18,7 +18,11 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
unique) ; un nom s'écrit en lettres latines, chiffres, espace, trait d'union, apostrophe ou
point (pas de sosie cyrillique, grec ou pleine chasse). Une personne déjà connectée garde son
identité et reçoit les groupes ; elle ne prend une place que si le lien lui en ajoute un ; sur
- un navigateur partagé, « Ce n'est pas moi » la déconnecte.
+ un navigateur partagé, « Ce n'est pas moi » la déconnecte. Un nom qu'une invitation attend
+ est protégé aussi.
+- Tout auteur venu par invitation porte la mention **« sur invitation »** à côté de son nom
+ (propositions, révisions, signalements) : un nom pris avant l'arrivée de son titulaire Ğ1, ou
+ une lettre sosie, ne passe jamais pour un membre de la toile.
trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même
personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie
libre (seule la toile vote), à surveiller dans le groupe d'essai.
diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md
index 9f1963c..7970c83 100644
--- a/docs/chantiers/acte-certification/CHANTIER.md
+++ b/docs/chantiers/acte-certification/CHANTIER.md
@@ -196,6 +196,9 @@ Les raisons longues sont dans `docs/adr/`.
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
(pour la dépanner) — jamais pour une identité Ğ1.
+- Une identité d'invitation peut porter le nom d'un membre Ğ1 qui ne s'est pas encore
+ connecté (la connexion Ğ1 ne vérifie pas les noms) : la mention « sur invitation » à côté de
+ chaque auteur qui n'est pas venu avec sa clé Ğ1 les distingue.
- Des accents ou une ponctuation qui portent du sens restent des coquilles (« ou » / « où »,
« a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible
dans l'historique de l'article.
diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md
index 72ab90b..6000f93 100644
--- a/docs/chantiers/acte-certification/CONTROLES.md
+++ b/docs/chantiers/acte-certification/CONTROLES.md
@@ -46,3 +46,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts |
| 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts |
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
+| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
diff --git a/web/src/lib/components/VoteView.svelte b/web/src/lib/components/VoteView.svelte
index 4659fa9..0608251 100644
--- a/web/src/lib/components/VoteView.svelte
+++ b/web/src/lib/components/VoteView.svelte
@@ -25,13 +25,13 @@
} = $props();
interface Detail {
- proposal: Proposal & { author: string | null };
+ proposal: Proposal & { author: string | null; authorInvited: boolean };
unit: { id: string; code: string; inertia: Inertia };
inForce: { id: string; title: string; body: string } | null;
diff: DiffPart[] | null;
- history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; at: string; diff: DiffPart[] | null }[];
+ history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
signals: {
- id: string; reason: string; author: string; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
+ id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
other: { id: string; title: string; status: string; code: string | null } | null;
}[];
electorate: number;
@@ -223,7 +223,7 @@
{#if p.seedMinimal}{t('doc.seed')}
{:else if p.confirmedAt}{t('doc.confirmed')}
{:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if}
- {#if p.author}{t('panel.by', { name: p.author })}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
+ {#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
@@ -334,7 +334,7 @@
{#each detail.history.slice(1) as h (h.at + h.revision)}