Allow dot in usernames per NIP-05 local-part spec

Extend usernameRE to [a-z0-9_.-], preserve dots in SanitizeForUsername,
and add tests for validation, sanitization, and nip05 sync precedence.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-05 06:10:14 +00:00
parent 7a1ceb49c3
commit bbfc64733a
4 changed files with 19 additions and 6 deletions

View File

@@ -42,8 +42,9 @@ var (
ErrUsernameTaken = errors.New("username taken") ErrUsernameTaken = errors.New("username taken")
) )
// Username rules: 1-30 chars, [a-z0-9_-], lowercase, must start with alnum. // Username rules: 1-30 chars, [a-z0-9_.-], lowercase, must start with alnum.
var usernameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,29}$`) // Dot is allowed per NIP-05 local-part spec.
var usernameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{0,29}$`)
func ValidateUsername(name string, reserved []string) error { func ValidateUsername(name string, reserved []string) error {
name = strings.ToLower(strings.TrimSpace(name)) name = strings.ToLower(strings.TrimSpace(name))

View File

@@ -10,9 +10,12 @@ func TestValidateUsername(t *testing.T) {
{"alice", true}, {"alice", true},
{"al-ice_42", true}, {"al-ice_42", true},
{"a", true}, {"a", true},
{"alice.bob", true},
{"alice.smith.42", true},
{"", false}, {"", false},
{"-alice", false}, {"-alice", false},
{"_alice", false}, {"_alice", false},
{".alice", false},
{"thisusernameiswaytoolongtobevalid12345", false}, {"thisusernameiswaytoolongtobevalid12345", false},
{"admin", false}, {"admin", false},
} }

View File

@@ -5,7 +5,7 @@ import (
) )
// SanitizeForUsername coerces an arbitrary profile string into a candidate // SanitizeForUsername coerces an arbitrary profile string into a candidate
// that matches usernameRE: lowercase ASCII alphanumerics, `_`, and `-`, // that matches usernameRE: lowercase ASCII alphanumerics, `_`, `-`, and `.`,
// length <= 30, with an alphanumeric first character. Returns "" when no // length <= 30, with an alphanumeric first character. Returns "" when no
// usable handle can be derived. // usable handle can be derived.
func SanitizeForUsername(s string) string { func SanitizeForUsername(s string) string {
@@ -22,7 +22,7 @@ func SanitizeForUsername(s string) string {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r) b.WriteRune(r)
prevSep = false prevSep = false
case r == '-' || r == '_': case r == '-' || r == '_' || r == '.':
if b.Len() == 0 { if b.Len() == 0 {
continue continue
} }
@@ -42,9 +42,9 @@ func SanitizeForUsername(s string) string {
prevSep = true prevSep = true
} }
} }
out := strings.TrimRight(b.String(), "_-") out := strings.TrimRight(b.String(), "_-.")
if len(out) > 30 { if len(out) > 30 {
out = strings.TrimRight(out[:30], "_-") out = strings.TrimRight(out[:30], "_-.")
} }
return out return out
} }

View File

@@ -17,6 +17,10 @@ func TestSanitizeForUsername(t *testing.T) {
{"alice_", "alice"}, {"alice_", "alice"},
{"alice--bob", "alice-bob"}, {"alice--bob", "alice-bob"},
{"alice__bob", "alice_bob"}, {"alice__bob", "alice_bob"},
{"alice.bob", "alice.bob"},
{"alice..bob", "alice.bob"},
{".alice", "alice"},
{"alice.", "alice"},
{" ", ""}, {" ", ""},
{"", ""}, {"", ""},
{"!!!", ""}, {"!!!", ""},
@@ -59,6 +63,11 @@ func TestCandidateFromMetadataPrecedence(t *testing.T) {
name: "alice", nip05: "preferred@azzamo.net", dom: domain, name: "alice", nip05: "preferred@azzamo.net", dom: domain,
want: "preferred", want: "preferred",
}, },
{
desc: "nip05 local part with dot is preserved",
name: "alice", nip05: "alice.smith@azzamo.net", dom: domain,
want: "alice.smith",
},
{ {
desc: "nip05 ignored when domain differs", desc: "nip05 ignored when domain differs",
name: "alice", nip05: "preferred@other.example", dom: domain, name: "alice", nip05: "preferred@other.example", dom: domain,