Files
xarmian b1357799a9 feat: username validation, reserved words, and registration flow
Add username support to registration with validation, reserved words,
and real-time availability checking (TASK-409).

Backend:
- ValidateUsername() with format/length/reserved word checks
- 35+ reserved usernames (route conflicts, system terms)
- GET /auth/check-username endpoint for real-time validation
- handleBootstrap auto-generates username from name (D1)
- handleRegister accepts optional username, auto-generates if omitted

Frontend:
- Register page: username field with auto-generation from name
- Join/invite page: same username field in register mode
- Debounced availability checking (400ms) via /auth/check-username
- Inline status indicators (checking/available/taken)
- API client: register() accepts username, new checkUsername() method
2026-04-10 23:12:59 +00:00

95 lines
3.1 KiB
Go

package server
import (
"fmt"
"regexp"
"strings"
)
var usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
var usernameShortRe = regexp.MustCompile(`^[a-z0-9]$`)
// reservedUsernames contains words that cannot be used as usernames because they
// conflict with existing or planned URL routes, system paths, or common terms.
// Decision D11: "be generous — better to reserve too many than too few."
var reservedUsernames = map[string]bool{
// Auth routes
"login": true, "register": true, "join": true, "signup": true, "signin": true,
"forgot-password": true, "reset-password": true,
"logout": true, "auth": true, "oauth": true, "sso": true,
// App routes
"settings": true, "admin": true, "console": true,
"dashboard": true, "api": true, "health": true,
"new": true, "create": true, "edit": true, "delete": true,
// Share/special routes
"s": true, "share": true, "shared": true,
"invite": true, "invitations": true,
// System
"app": true, "www": true, "mail": true, "ftp": true, "smtp": true,
"static": true, "assets": true, "public": true, "cdn": true,
"pad": true, "system": true, "root": true, "server": true,
"null": true, "undefined": true, "nan": true, "true": true, "false": true,
// Common reserved
"about": true, "help": true, "support": true, "contact": true,
"docs": true, "blog": true, "pricing": true, "plans": true,
"terms": true, "privacy": true, "security": true, "legal": true,
"status": true, "changelog": true, "updates": true,
"home": true, "index": true, "search": true, "explore": true,
"notifications": true, "messages": true, "feed": true,
"account": true, "profile": true, "user": true, "users": true,
"org": true, "orgs": true, "team": true, "teams": true,
"workspace": true, "workspaces": true,
"project": true, "projects": true,
}
// ValidateUsername checks a username for format, length, and reserved word violations.
// It does NOT check uniqueness (that requires a store call).
func ValidateUsername(username string) error {
if username == "" {
return fmt.Errorf("username is required")
}
// Must be lowercase
if username != strings.ToLower(username) {
return fmt.Errorf("username must be lowercase")
}
// Length check
if len(username) < 3 {
return fmt.Errorf("username must be at least 3 characters")
}
if len(username) > 39 {
return fmt.Errorf("username must be at most 39 characters")
}
// Format check
if len(username) == 1 {
if !usernameShortRe.MatchString(username) {
return fmt.Errorf("username must contain only lowercase letters, numbers, and hyphens")
}
} else if !usernameRe.MatchString(username) {
return fmt.Errorf("username must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen")
}
// No consecutive hyphens
if strings.Contains(username, "--") {
return fmt.Errorf("username cannot contain consecutive hyphens")
}
// Reserved words
if reservedUsernames[username] {
return fmt.Errorf("username %q is reserved", username)
}
return nil
}
// IsReservedUsername checks if a username is in the reserved list.
func IsReservedUsername(username string) bool {
return reservedUsernames[strings.ToLower(username)]
}