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:
parent
22846e340f
commit
2bdb1ecf37
21 changed files with 843 additions and 6 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue