usernames: operator reserved-username blocklist

A plain blocklist for names like @support - separate from the collectible
system, so a reservation has no owner, no price and no "bought on Fragment"
badge.

- reserved_usernames table + migration.
- Enforced in replacePeerUsernameTx (the single editable-username write point:
  account.updateUsername, channels.updateUsername, @BotFather /setusername) and
  in the collectible mint path; a reserved name returns USERNAME_OCCUPIED.
- admin.Service: ReserveUsername / UnreserveUsername (journalled commands) and
  the ReservedUsernames listing.
- adminapi: /v1/reserved-usernames{,/reserve,/unreserve}.
- telesrv-admin panel + a "Reserved Usernames" page in the web UI (dist rebuilt).
- Postgres and in-memory store implementations; the memory registry gains an
  optional reserved-name check so tests exercise the same rule.
This commit is contained in:
Astra 2026-09-09 19:57:14 +01:00
parent 22846e340f
commit 2bdb1ecf37
21 changed files with 843 additions and 6 deletions

View file

@ -73,6 +73,9 @@ const (
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
// Operator username blocklist.
ActionReserveUsername = "usernames.reserve"
ActionUnreserveUsername = "usernames.unreserve"
// Official platform verification review. Claim/approve/reject act on one
// application; revoke acts on a target, because clearing a badge is not a
// decision on the application that granted it.
@ -404,6 +407,16 @@ type CollectibleUsernamesService interface {
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
}
// ReservedUsernamesService is the operator username blocklist: a plain list of
// names no peer may take. Separate from the collectible lifecycle - a reservation
// has no owner, no price and no Fragment badge.
type ReservedUsernamesService interface {
IsReserved(ctx context.Context, usernameLower string) (bool, error)
ReserveUsername(ctx context.Context, username, reason, actor string) (created bool, err error)
UnreserveUsername(ctx context.Context, username string) (removed bool, err error)
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
}
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
// expose it answer a detail request in one round trip; the keyset fallback in
// CollectibleUsernameByID keeps a service without it correct.
@ -431,6 +444,7 @@ type Dependencies struct {
Emoji EmojiService
Moderation ModerationService
Usernames CollectibleUsernamesService
ReservedUsernames ReservedUsernamesService
Verification VerificationService
// BotVerification is the third-party mechanism, wired separately from
// Verification: the two never read each other's state.
@ -463,6 +477,7 @@ type Service struct {
emoji EmojiService
moderation ModerationService
usernames CollectibleUsernamesService
reservedUsernames ReservedUsernamesService
verification VerificationService
botVerification BotVerificationService
account AccountService
@ -533,6 +548,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Usernames != nil {
s.usernames = deps.Usernames
}
if deps.ReservedUsernames != nil {
s.reservedUsernames = deps.ReservedUsernames
}
if deps.Verification != nil {
s.verification = deps.Verification
}
@ -2217,6 +2235,91 @@ func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteColle
})
}
// ReserveUsernameRequest / UnreserveUsernameRequest add or remove a blocklist
// entry. reservedUsernameFromRequest normalises the name; the reason is a free
// operator note.
type ReserveUsernameRequest struct {
CommandMeta
Username string
}
type UnreserveUsernameRequest struct {
CommandMeta
Username string
}
// ReserveUsername adds a name to the operator blocklist. Journalled and
// replay-safe like every other command.
func (s *Service) ReserveUsername(ctx context.Context, req ReserveUsernameRequest) (CommandResult, error) {
if s == nil || s.reservedUsernames == nil {
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
if len(req.Reason) > domain.MaxReservedUsernameReasonLength {
return CommandResult{}, fmt.Errorf("reason must be <= %d bytes", domain.MaxReservedUsernameReasonLength)
}
return s.runCommand(ctx, req.CommandMeta, ActionReserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username}
if s.usernames != nil {
if asset, err := s.usernames.Collectible(ctx, req.Username); err == nil {
details["existing_collectible_id"] = strconv.FormatInt(asset.ID, 10)
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
}
}
if req.DryRun {
return CommandResult{Message: "username reservation validated", Details: details}, nil
}
created, err := s.reservedUsernames.ReserveUsername(ctx, req.Username, req.Reason, req.Actor)
if err != nil {
return CommandResult{Details: details}, err
}
details["created"] = created
message := "username reserved"
if !created {
message = "username was already reserved"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// UnreserveUsername removes a name from the operator blocklist.
func (s *Service) UnreserveUsername(ctx context.Context, req UnreserveUsernameRequest) (CommandResult, error) {
if s == nil || s.reservedUsernames == nil {
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if strings.TrimSpace(req.Username) == "" {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
return s.runCommand(ctx, req.CommandMeta, ActionUnreserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username}
if req.DryRun {
return CommandResult{Message: "username unreservation validated", Details: details}, nil
}
removed, err := s.reservedUsernames.UnreserveUsername(ctx, req.Username)
if err != nil {
return CommandResult{Details: details}, err
}
details["removed"] = removed
message := "username unreserved"
if !removed {
message = "username was not reserved"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ReservedUsernames is the admin listing read for the blocklist.
func (s *Service) ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
if s == nil || s.reservedUsernames == nil {
return nil, fmt.Errorf("reserved username dependency is not configured")
}
return s.reservedUsernames.ReservedUsernames(ctx, filter)
}
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
if userID < 0 || channelID < 0 {
return domain.Peer{}, fmt.Errorf("owner id must be positive")

View file

@ -12,6 +12,7 @@ import (
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// Compile-time proof that the shipped use-case services satisfy the admin ports.
@ -1427,3 +1428,76 @@ func TestDeleteCollectibleUsernameCommand(t *testing.T) {
t.Fatalf("delete of invalid name = nil error, want rejection")
}
}
func TestReserveAndUnreserveUsername(t *testing.T) {
ctx := context.Background()
reserved := memory.NewReservedUsernameStore()
svc := NewService(Dependencies{
Commands: newMemoryCommandRepo(),
ReservedUsernames: reserved,
Now: fixedNow,
})
dry, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
CommandMeta: CommandMeta{CommandID: "rv-dry", Actor: "ops", Reason: "official handle", DryRun: true},
Username: "@Support",
})
if err != nil {
t.Fatalf("dry-run reserve: %v", err)
}
if got, _ := reserved.IsReserved(ctx, "support"); got {
t.Fatal("dry-run reserved the name")
}
if dry.Details["username"] != "Support" {
t.Fatalf("dry-run details = %+v", dry.Details)
}
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
CommandMeta: CommandMeta{CommandID: "rv-exec", Actor: "ops", Reason: "official handle"},
Username: "support",
}); err != nil {
t.Fatalf("reserve: %v", err)
}
if got, _ := reserved.IsReserved(ctx, "support"); !got {
t.Fatal("name not reserved after exec")
}
if _, err := svc.UnreserveUsername(ctx, UnreserveUsernameRequest{
CommandMeta: CommandMeta{CommandID: "urv-exec", Actor: "ops", Reason: "no longer needed"},
Username: "SUPPORT",
}); err != nil {
t.Fatalf("unreserve: %v", err)
}
if got, _ := reserved.IsReserved(ctx, "support"); got {
t.Fatal("name still reserved after unreserve")
}
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
CommandMeta: CommandMeta{CommandID: "bad", Actor: "ops", Reason: "x"},
Username: "ab",
}); err == nil {
t.Fatal("reserve of a too-short name = nil error, want rejection")
}
}
func TestMemoryRegistryRefusesReservedName(t *testing.T) {
ctx := context.Background()
reserved := memory.NewReservedUsernameStore()
if _, err := reserved.ReserveUsername(ctx, "support", "", "ops"); err != nil {
t.Fatalf("seed reserve: %v", err)
}
registry := memory.NewCollectibleUsernameStore().WithReservedUsernames(reserved)
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "support"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("SetEditableUsername(reserved) err = %v, want ErrUsernameOccupied", err)
}
if _, _, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
Username: "support", Currency: domain.CollectibleCurrencyUSD, Amount: 0, CommandKey: "k1",
}); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("Mint(reserved) err = %v, want ErrUsernameOccupied", err)
}
// A different name is unaffected.
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "freename"); err != nil {
t.Fatalf("SetEditableUsername(free) err = %v", err)
}
}