aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py14
-rw-r--r--packages/meshbay-hub/tests/test_username_case.py24
3 files changed, 38 insertions, 5 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index e07f03b..3625698 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -213,7 +213,10 @@ design; reading what *other* operators host is not, and does not follow (§3.2).
An account lives on the hub: a username, an encrypted email address, a status and
a role. A username is 8 to 64 characters, checked at registration only — accounts
-created under the older 3-character floor keep signing in. The passphrase never leaves the client. It derives **two independent
+created under the older 3-character floor keep signing in — and **unique whatever
+its case**: invitations and member management name people by username, and
+"Alice" beside "alice" is one person to whoever reads the list. Sign-in takes the
+name as stored. The passphrase never leaves the client. It derives **two independent
values**, both salted by the trimmed username:
| Value | Derivation | Consumer |
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index fb53076..9326bfb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -185,12 +185,18 @@ async def register(
):
eh = hash_email_blind(body.email)
- existing = await db.execute(
- select(User).where(User.username == body.username))
- found = existing.scalar_one_or_none()
+ # Unique regardless of case: invitations and member management name people
+ # by username, and "Alice" beside "alice" is one person to whoever reads it.
+ # Accounts that already differ only by case (made before this) keep their
+ # names; the exact match is the one a retry means.
+ same = (await db.execute(
+ select(User).where(func.lower(User.username) == body.username.lower())
+ )).scalars().all()
+ found = next((u for u in same if u.username == body.username), same[0] if same else None)
if found:
- if found.status == "pending" and found.email_hash == eh:
+ if (found.username == body.username and found.status == "pending"
+ and found.email_hash == eh):
# Same person retrying before validation — resend a code.
# No captcha: the initial registration already passed it.
#
diff --git a/packages/meshbay-hub/tests/test_username_case.py b/packages/meshbay-hub/tests/test_username_case.py
new file mode 100644
index 0000000..42f4815
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_username_case.py
@@ -0,0 +1,24 @@
+"""
+A username is unique whatever its case.
+
+Invitations and member management name people by username, so "Alice" beside
+"alice" is one person to whoever reads the list — and a second account under
+the other spelling is the way to be mistaken for them.
+"""
+
+import pytest
+from test_bundle_pepper import KEY
+
+
+async def _register(client, username, email):
+ return await client.post("/v1/users/register", json={
+ "username": username, "auth_key": KEY, "email": email})
+
+
+@pytest.mark.asyncio
+async def test_a_name_differing_only_by_case_is_taken(client):
+ assert (await _register(client, "alice_case", "a1@example.invalid")).status_code == 201
+ for other in ("Alice_case", "ALICE_CASE", "alice_CASE"):
+ r = await _register(client, other, "a2@example.invalid")
+ assert r.status_code == 409, (other, r.text)
+