v3 : dixième relecture — mention « sur invitation », noms attendus protégés

- 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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 09:06:15 +02:00
co-authored by Claude Opus 5.5
parent 753b52202b
commit 8a3216037c
13 changed files with 67 additions and 17 deletions
+1 -1
View File
@@ -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 : - Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`), 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 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, 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 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 conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
+7 -2
View File
@@ -210,6 +210,9 @@ pub struct ProposalRow {
pub label: Option<String>, pub label: Option<String>,
pub author_id: Option<String>, pub author_id: Option<String>,
pub author_name: Option<String>, pub author_name: Option<String>,
/// 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<bool>,
pub revision: i64, pub revision: i64,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
@@ -220,7 +223,7 @@ pub struct ProposalRow {
pub const PROPOSAL_SELECT: &str = pub const PROPOSAL_SELECT: &str =
"SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin, "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 p.entered_at, p.confirmed_at
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id"; FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
@@ -466,7 +469,9 @@ fn proposal_json(
json!({ json!({
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target, "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, "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), "mine": p.author_id.as_deref() == Some(me),
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at, "revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
+11 -5
View File
@@ -551,10 +551,12 @@ pub async fn detail(
title: String, title: String,
body: String, body: String,
author_name: Option<String>, author_name: Option<String>,
author_invited: Option<bool>,
created_at: String, created_at: String,
} }
let revs = sqlx::query_as::<_, Rev>( 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", FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
) )
.bind(&id) .bind(&id)
@@ -567,7 +569,8 @@ pub async fn detail(
let diff = previous let diff = previous
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body))); .map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
history.push(json!({ 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); previous = Some(r);
} }
@@ -580,13 +583,14 @@ pub async fn detail(
reason: String, reason: String,
author_id: String, author_id: String,
author_name: String, author_name: String,
author_invited: bool,
created_at: String, created_at: String,
lifted_at: Option<String>, lifted_at: Option<String>,
lifted_by: Option<String>, lifted_by: Option<String>,
} }
let sigs = sqlx::query_as::<_, Sig>( 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, "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 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", 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, None => None,
}; };
signals.push(json!({ 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, "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 })), "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": { "proposal": {
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body, "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, "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, "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", "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(), "tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(),
+10 -1
View File
@@ -97,7 +97,16 @@ pub struct JoinBody {
/// `name_key` settles two openings at the same instant. /// `name_key` settles two openings at the same instant.
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> { async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
let name = names::check(raw).ok_or(AppError::Invalid("name"))?; let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
let taken: Vec<String> = 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<String> = 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) { if taken.iter().any(|t| names::key(t) == name.key) {
return Err(AppError::Conflict("name_taken")); return Err(AppError::Conflict("name_taken"));
} }
+19
View File
@@ -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 luna = v["me"]["id"].as_str().expect("id").to_owned();
let (_, g) = first.call("GET", "/api/groups", None).await; 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)); 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. // Nobody joins under a name already there; the refusal does not count as an opening.
let mut second = admin.with(None); 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; 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}"); 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. // 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; 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"))); assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
+5 -1
View File
@@ -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 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 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 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 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 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. libre (seule la toile vote), à surveiller dans le groupe d'essai.
@@ -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. **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 - Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
(pour la dépanner) — jamais pour une identité Ğ1. (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ù », - 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 « a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible
dans l'historique de l'article. dans l'historique de l'article.
@@ -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, 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, 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, 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 |
+6 -6
View File
@@ -25,13 +25,13 @@
} = $props(); } = $props();
interface Detail { interface Detail {
proposal: Proposal & { author: string | null }; proposal: Proposal & { author: string | null; authorInvited: boolean };
unit: { id: string; code: string; inertia: Inertia }; unit: { id: string; code: string; inertia: Inertia };
inForce: { id: string; title: string; body: string } | null; inForce: { id: string; title: string; body: string } | null;
diff: DiffPart[] | 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: { 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; other: { id: string; title: string; status: string; code: string | null } | null;
}[]; }[];
electorate: number; electorate: number;
@@ -223,7 +223,7 @@
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span> {#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span> {:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if} {:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/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}
</p> </p>
</header> </header>
@@ -334,7 +334,7 @@
<ol class="history"> <ol class="history">
{#each detail.history.slice(1) as h (h.at + h.revision)} {#each detail.history.slice(1) as h (h.at + h.revision)}
<li> <li>
<p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{/if}</p> <p class="small"><b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}</p>
{#if h.diff}<DiffText parts={h.diff} />{/if} {#if h.diff}<DiffText parts={h.diff} />{/if}
</li> </li>
{/each} {/each}
@@ -349,7 +349,7 @@
<div class="signal" class:lifted={s.liftedAt}> <div class="signal" class:lifted={s.liftedAt}>
<p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p> <p class="small"><b>{s.other?.title ?? ''}</b> — {s.reason}</p>
<p class="faint small"> <p class="faint small">
{t('signal.by', { name: s.author })} · {i18n.date(s.at)} {t('signal.by', { name: s.author })}{#if s.authorInvited} · {t('author.invited')}{/if} · {i18n.date(s.at)}
{#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if} {#if s.liftedAt} · {s.liftedBy === 'revision' ? t('signal.lifted.revision') : t('signal.lifted')}{/if}
</p> </p>
{#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if} {#if s.mine && !s.liftedAt}<button class="btn quiet small" type="button" disabled={busy} onclick={() => lift(s.id)}>{t('signal.lift')}</button>{/if}
+1
View File
@@ -162,6 +162,7 @@ export const en: Messages = {
'panel.inforce': 'In force', 'panel.inforce': 'In force',
'panel.proposal': 'Proposal', 'panel.proposal': 'Proposal',
'panel.by': 'proposed by {name}', 'panel.by': 'proposed by {name}',
'author.invited': 'by invitation',
'panel.seed.origin': 'text from the seed', 'panel.seed.origin': 'text from the seed',
'panel.changes': 'View changes', 'panel.changes': 'View changes',
'panel.text': 'View text', 'panel.text': 'View text',
+1
View File
@@ -162,6 +162,7 @@ export const es: Messages = {
'panel.inforce': 'En vigor', 'panel.inforce': 'En vigor',
'panel.proposal': 'Propuesta', 'panel.proposal': 'Propuesta',
'panel.by': 'propuesto por {name}', 'panel.by': 'propuesto por {name}',
'author.invited': 'por invitación',
'panel.seed.origin': 'texto procedente del seed', 'panel.seed.origin': 'texto procedente del seed',
'panel.changes': 'Ver los cambios', 'panel.changes': 'Ver los cambios',
'panel.text': 'Ver el texto', 'panel.text': 'Ver el texto',
+1
View File
@@ -165,6 +165,7 @@ export const fr = {
'panel.inforce': 'En vigueur', 'panel.inforce': 'En vigueur',
'panel.proposal': 'Proposition', 'panel.proposal': 'Proposition',
'panel.by': 'proposé par {name}', 'panel.by': 'proposé par {name}',
'author.invited': 'sur invitation',
'panel.seed.origin': 'texte issu du seed', 'panel.seed.origin': 'texte issu du seed',
'panel.changes': 'Voir les changements', 'panel.changes': 'Voir les changements',
'panel.text': 'Voir le texte', 'panel.text': 'Voir le texte',
+1 -1
View File
@@ -50,7 +50,7 @@ export interface Proposal {
status: 'draft' | 'open' | 'in_force' | 'superseded' | 'applied' | 'withdrawn'; status: 'draft' | 'open' | 'in_force' | 'superseded' | 'applied' | 'withdrawn';
origin: 'seed' | 'member'; origin: 'seed' | 'member';
label: string | null; label: string | null;
author: { id: string; name: string | null } | null; author: { id: string; name: string | null; invited: boolean } | null;
mine: boolean; mine: boolean;
revision: number; revision: number;
createdAt: string; createdAt: string;