summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 17:01:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 17:01:07 +0200
commitcd85808c13926c89a97987d320ac26391eae3267 (patch)
treee3e1b12a8ceb8a5ed5b9c9790b6971a2fa2d9d00
parent3d8c1acf785ff7389a68cc515a5c9324dee8de41 (diff)
downloadmeshbay-cd85808c13926c89a97987d320ac26391eae3267.tar.gz
feat(hub): make mailing an invitation a remembered choice
A "Send the invitation by e-mail" box under the Invite member field, checked by default and stored as the invite_email preference. Unchecked, invite-notify is never called and the hub never sees the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md13
-rw-r--r--docs/QUICKSTART.md4
-rw-r--r--docs/USERGUIDE.md14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js69
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/tests/test_invite_email_choice.py67
16 files changed, 158 insertions, 22 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index a47cea0..f42fa69 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -371,7 +371,8 @@ Four properties, each load-bearing:
1. **No public key is ever fetched from a directory.** The joiner's keys arrive
from the joiner, both signed together in one transcript (`meshbay:join:v1`), so
the identity key vouches for the encryption key.
-2. **The code binds a key to an account**, and the hub never sees it. 40 bits,
+2. **The code binds a key to an account**, and the hub never sees it — unless
+ the inviter ticks *Send the invitation by e-mail* (below). 40 bits,
Crockford base32 rendered `XXXX-XXXX`, single use, valid for exactly one
account in one group, stored only as `sha256(code)`. A password KDF over 40
uniformly random bits would buy nothing. Guessing is bounded by 5 attempts per
@@ -419,6 +420,16 @@ being bob on his first connection:
| Safety-number comparison | Nothing — but it needs two humans reading digits at the worst moment | correct, unusable as a default |
| **One-time pairing code** | **Nothing: the code never reaches the hub** | **adopted** |
+**Mailing the code is the inviter's choice, and it gives up the row above.**
+The Members tab can ask the hub to mail the code to the invitee's address on file
+(`invite-notify`); that request carries the code, so an active hub holds it and
+can join in the invitee's place. It is offered because a code that arrives on its
+own is worth more to most groups than the property, and it is stated rather than
+hidden: the box reads *"Send the invitation by e-mail (may land in spam)"*, is
+checked by default, and is **remembered per account** (the `invite_email`
+preference), so an operator who unticks it once is not asked to again. Unticked,
+the hub is never called and the table above holds exactly. The CLI mails nothing.
+
**Delegation is designed and deferred.** `invite_create` is authorised as a *role*
check against the roster rather than an equality test against the operator, and
the `delegate` role value is reserved, so a group admin who does not run the node
diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md
index 92a5cad..4da1eff 100644
--- a/docs/QUICKSTART.md
+++ b/docs/QUICKSTART.md
@@ -319,7 +319,9 @@ works once, for that account only, for that group only, and the hub never sees
it. You do not need to be online when they use it.
You can do the same thing from the **Members** tab of the group, from the
-browser you paired in Step 8.
+browser you paired in Step 8. There, **Send the invitation by e-mail** is ticked
+by default and has the hub mail the code for you — which means the hub reads it.
+Untick it and share the code yourself when that matters; the box remembers.
---
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index 0dd5819..d7c3430 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -220,9 +220,10 @@ Someone who runs a node invites you. You need an account on the same hub first.
You will not be asked again on that browser, or on that machine.
-**The code never passes through the hub**, and that is the whole reason it
-exists: it proves the invitation came from the person running the group, and
-not from the service in the middle.
+**The code never passes through the hub** — unless whoever invited you asked
+the hub to mail it to you — and that is the whole reason it exists: it proves
+the invitation came from the person running the group, and not from the
+service in the middle.
### Leaving
@@ -548,6 +549,13 @@ membership on the hub and produces a code that never goes near it. Send the
code out of band; they enter it the first time they open the group. You do not
need to be online then.
+The Members tab offers **Send the invitation by e-mail**, ticked by default: the
+hub mails the code to the address on their account, so nobody has to copy it.
+That is a trade: **the hub then holds the code**, and a hub that wanted to could
+use it to join in their place. For a group where that matters, untick it and
+send the code yourself — the box remembers your choice. The CLI never mails
+anything.
+
### Seeing who is in
```bash
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 2666e86..9b9a189 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1208,6 +1208,9 @@ ALLOWED_PREF_KEYS = frozenset([
"default_tab",
"music_keep_screen_on",
"media_page_size",
+ # Whether the Members tab asks the hub to mail an invitation. Remembered
+ # because unticking it on every invitation is what nobody would keep doing.
+ "invite_email",
])
# `default_tab:<group_id>`, which is what the SPA writes (group-page.js). The
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 5f811e8..64ea3fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -9,6 +9,9 @@ import { hubFetch, navigate } from './hub-client.js';
import { availableApps, configurableApps } from './apps.js';
import * as platform from './platform.js';
+// The account preference behind the "send by e-mail" box. The hub's
+// ALLOWED_PREF_KEYS must list it, or every toggle snaps back.
+export const INVITE_EMAIL_PREF = 'invite_email';
// ── Shared Directories Table ────────────────────────────────────────────
@@ -410,6 +413,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const [loading, setLoading] = useState(true);
const [inviteUser, setInviteUser] = useState('');
const [inviting, setInviting] = useState(false);
+ // Whether the hub mails the invitation. Checked, the hub is handed the code
+ // to write it into the mail — so it is the inviter's choice, remembered per
+ // account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it.
+ const [inviteByEmail, setInviteByEmail] = useState(true);
const [error, setError] = useState('');
// Node loopback state (Electron-only)
@@ -801,6 +808,23 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
useEffect(() => { loadMembers(); }, [loadMembers]);
+ useEffect(() => {
+ hubFetch('/v1/users/me/preferences', { token })
+ .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false'))
+ .catch(() => {});
+ }, [token]);
+
+ const toggleInviteByEmail = useCallback(async (next) => {
+ setInviteByEmail(next);
+ try {
+ await hubFetch(`/v1/users/me/preferences/${INVITE_EMAIL_PREF}`, {
+ method: 'PUT', token, body: { value: next ? 'true' : 'false' },
+ });
+ } catch {
+ setInviteByEmail(!next);
+ }
+ }, [token]);
+
const isAdmin = group && group.is_admin;
const doInvite = useCallback(async (e) => {
@@ -838,23 +862,27 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
method: 'POST', token, body: {},
});
- // Send an email notification to the invitee with the code.
- // The hub decrypts their email server-side — the inviter never sees it.
- let emailStatus = 'no_email';
- try {
- const notif = await hubFetch(`/v1/groups/${groupId}/invite-notify`, {
- method: 'POST', token,
- // No group_name: the hub reads it from the group row it has already
- // loaded. Sending one offered a second answer to a settled question,
- // and that answer was the subject line of an email the hub signs.
- body: { username, code: result.code },
- });
- emailStatus = notif.status;
- } catch { /* best effort */ }
+ // Send an email notification to the invitee with the code, if the inviter
+ // asked for it. The hub decrypts their email server-side — the inviter
+ // never sees it — and so the hub reads the code; without the box ticked
+ // nothing below runs and the code is shown on this page and nowhere else.
+ let emailStatus = inviteByEmail ? 'no_email' : 'not_requested';
+ if (inviteByEmail) {
+ try {
+ const notif = await hubFetch(`/v1/groups/${groupId}/invite-notify`, {
+ method: 'POST', token,
+ // No group_name: the hub reads it from the group row it has already
+ // loaded. Sending one offered a second answer to a settled question,
+ // and that answer was the subject line of an email the hub signs.
+ body: { username, code: result.code },
+ });
+ emailStatus = notif.status;
+ } catch { /* best effort */ }
+ }
setInviteCode({
username, code: result.code, expires: result.expires_at,
- emailSent: emailStatus === 'sent',
+ emailStatus,
});
setInviteUser('');
loadMembers();
@@ -863,7 +891,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
} finally {
setInviting(false);
}
- }, [groupId, token, inviteUser, loadMembers, transportRef]);
+ }, [groupId, token, inviteUser, inviteByEmail, loadMembers, transportRef]);
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
@@ -893,9 +921,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<div class="success-msg" style="margin-bottom:8px">
<p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
<p class="code-display">${inviteCode.code}</p>
- ${inviteCode.emailSent
+ ${inviteCode.emailStatus === 'sent'
? html`<p style="color:var(--success)">${t('members.invite_email_sent')}</p>`
- : html`<p style="color:var(--text-dim)">${t('members.invite_email_failed')}</p>`
+ : inviteCode.emailStatus !== 'not_requested'
+ && html`<p style="color:var(--text-dim)">${t('members.invite_email_failed')}</p>`
}
<p>${t('members.invite_code_hint')}</p>
</div>
@@ -909,6 +938,12 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${inviting ? '...' : t('members.invite_btn')}
</button>
</div>
+ <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ font-size:0.88em; color:var(--text-secondary)">
+ <input type="checkbox" checked=${inviteByEmail}
+ onChange=${e => toggleInviteByEmail(e.target.checked)} />
+ <span>${t('members.invite_email_opt')}</span>
+ </label>
</form>
</div>
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 730c75b..81a6689 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -685,6 +685,7 @@ export default {
'members.invite_code_ready': 'Einladungscode für {user}:',
'members.invite_email_sent': 'Eine E-Mail mit dem Code wurde an dieses Mitglied gesendet.',
'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.',
+ 'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)',
'members.invite_code_hint': 'Sie können diesen Code auch über einen anderen Kanal teilen (z. B. SMS). '
+ 'Die eingeladene Person gibt ihn ein, wenn sie diese Gruppe zum ersten Mal öffnet.',
'transfers.title': 'Übertragungen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index daa378b..4dee9f3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -801,6 +801,7 @@ export default {
'members.invite_code_ready': 'Invitation code for {user}:',
'members.invite_email_sent': 'An email with the code has been sent to this member.',
'members.invite_email_failed': 'Could not send the email — share the code manually.',
+ 'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)',
'members.invite_code_hint': 'You can also share this code via another channel (e.g. SMS). '
+ 'They enter it the first time they open this group.',
'transfers.title': 'Transfers',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 3f8261c..567838d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -680,6 +680,7 @@ export default {
'members.invite_code_ready': 'Código de invitación para {user}:',
'members.invite_email_sent': 'Se ha enviado un correo con el código a este miembro.',
'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.',
+ 'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)',
'members.invite_code_hint': 'También puede compartir este código por otro canal (ej. SMS). '
+ 'Lo introduce la primera vez que abre este grupo.',
'transfers.title': 'Transferencias',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 7a0209e..0cc176f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -683,6 +683,7 @@ export default {
'members.invite_code_ready': "Code d’invitation pour {user} :",
'members.invite_email_sent': "Un e-mail avec le code a été envoyé à ce membre.",
'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.",
+ 'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)',
'members.invite_code_hint': "Vous pouvez aussi transmettre ce code par un autre canal (ex. SMS). "
+ "La personne le saisit la première fois qu’elle ouvre ce groupe.",
'transfers.title': 'Transferts',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 5b99839..03a1762 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -683,6 +683,7 @@ export default {
'members.invite_code_ready': 'Codice di invito per {user}:',
'members.invite_email_sent': "Un'e-mail con il codice è stata inviata a questo membro.",
'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.",
+ 'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)',
'members.invite_code_hint': 'Può anche condividere questo codice tramite un altro canale (es. SMS). '
+ 'Lo inserirà la prima volta che aprirà questo gruppo.',
'transfers.title': 'Trasferimenti',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index b825412..f5447bf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -675,6 +675,7 @@ export default {
'members.invite_code_ready': '{user} 宛ての招待コード:',
'members.invite_email_sent': 'このメンバーにコード付きのメールが送信されました。',
'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。',
+ 'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)',
'members.invite_code_hint': 'このコードは別の手段(SMSなど)でも共有できます。'
+ '相手がこのグループを初めて開いたときに入力します。',
'transfers.title': '転送',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 9b6070b..b051a87 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -684,6 +684,7 @@ export default {
'members.invite_code_ready': 'Uitnodigingscode voor {user}:',
'members.invite_email_sent': 'Er is een e-mail met de code naar dit lid gestuurd.',
'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.',
+ 'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)',
'members.invite_code_hint': 'U kunt deze code ook via een ander kanaal delen (bijv. sms). '
+ 'De code werkt één keer en gaat nooit via de hub.',
'transfers.title': 'Overdrachten',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 20e721f..1e29669 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -696,6 +696,7 @@ export default {
'members.invite_code_ready': 'Kod zaproszenia dla użytkownika {user}:',
'members.invite_email_sent': 'E-mail z kodem został wysłany do tego członka.',
'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.',
+ 'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)',
'members.invite_code_hint': 'Możesz też przekazać ten kod innym kanałem (np. SMS). '
+ 'Kod działa jednorazowo i nigdy nie przechodzi przez hub.',
'transfers.title': 'Transfery',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 5506b60..a0ae557 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -682,6 +682,7 @@ export default {
'members.invite_code_ready': 'Código de convite para {user}:',
'members.invite_email_sent': 'Um e-mail com o código foi enviado a este membro.',
'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.',
+ 'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)',
'members.invite_code_hint': 'Você também pode compartilhar este código por outro canal (ex: SMS). '
+ 'O código funciona uma única vez e nunca passa pelo hub.',
'transfers.title': 'Transferências',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 21ccd04..8d3e426 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -664,6 +664,7 @@ export default {
'members.invite_code_ready': '给 {user} 的邀请码:',
'members.invite_email_sent': '包含验证码的邮件已发送给该成员。',
'members.invite_email_failed': '无法发送邮件——请手动分享验证码。',
+ 'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)',
'members.invite_code_hint': '您也可以通过其他渠道(如短信)分享此验证码。'
+ '验证码仅可使用一次,且绝不会经过 hub。',
'transfers.title': '传输',
diff --git a/packages/meshbay-hub/tests/test_invite_email_choice.py b/packages/meshbay-hub/tests/test_invite_email_choice.py
new file mode 100644
index 0000000..e04c31f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_invite_email_choice.py
@@ -0,0 +1,67 @@
+"""
+Whether the hub mails an invitation is the inviter's choice, and it is remembered.
+
+Mailing it hands the hub the code — `invite-notify` writes it into the message —
+which is exactly what §3.4 says the code is for not doing. So the Members tab
+offers it as a box, checked by default, and an unchecked box must mean the hub
+is never asked. The choice lives in an account preference; a key the hub does
+not list is refused, and the box would snap back on every click with nothing on
+screen to say why.
+"""
+
+import base64
+import re
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub"
+SETTINGS = ROOT / "static" / "group-settings.js"
+USERS = ROOT / "api" / "users.py"
+
+
+def _pref_key() -> str:
+ m = re.search(r"^export const INVITE_EMAIL_PREF = '([^']+)';",
+ SETTINGS.read_text(encoding="utf-8"), re.M)
+ assert m, "group-settings.js no longer declares INVITE_EMAIL_PREF"
+ return m.group(1)
+
+
+def test_hub_accepts_the_key_the_client_writes():
+ allowed = re.search(r"ALLOWED_PREF_KEYS = frozenset\(\[(.*?)\]\)",
+ USERS.read_text(encoding="utf-8"), re.S).group(1)
+ assert f'"{_pref_key()}"' in allowed
+
+
+def test_an_unchecked_box_never_reaches_invite_notify():
+ """The only call to `invite-notify` sits inside the branch the box opens."""
+ src = SETTINGS.read_text(encoding="utf-8")
+ calls = [m.start() for m in re.finditer(r"/invite-notify`", src)]
+ assert len(calls) == 1, "expected exactly one invite-notify call in group-settings.js"
+ guard = src.rfind("if (inviteByEmail) {", 0, calls[0])
+ assert guard != -1, "invite-notify is called without checking the box"
+ # The guarded block must still be open where the call is: no closing brace
+ # at the guard's own indentation between the two.
+ indent = src[src.rfind("\n", 0, guard) + 1:guard]
+ assert f"\n{indent}}}" not in src[guard:calls[0]], (
+ "the box's branch closes before the invite-notify call")
+
+
+@pytest.mark.asyncio
+async def test_the_choice_is_remembered(client):
+ auth_key = base64.b64encode(b"k" * 32).decode()
+ r = await client.post("/v1/users/register", json={
+ "username": "invite_mailer", "email": "invite_mailer@example.test",
+ "auth_key": auth_key})
+ assert r.status_code == 201, r.text
+ r = await client.post("/v1/users/login", json={
+ "username": "invite_mailer", "auth_key": auth_key})
+ assert r.status_code == 200, r.text
+ headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+ key = _pref_key()
+ r = await client.put(f"/v1/users/me/preferences/{key}",
+ headers=headers, json={"value": "false"})
+ assert r.status_code == 200, r.text
+ r = await client.get("/v1/users/me/preferences", headers=headers)
+ assert r.json().get(key) == "false"