UpdateUsernameAdmin already skipped the reserved-word check in the availability lookup, but UserStore.UpdateUsername's own write path (replacePeerUsernameTx / CollectibleUsernameStore.SetEditableUsername) enforces the same operator blocklist a second time, independently and unconditionally. That second check is what was still rejecting an admin handing out a word they'd deliberately reserved, with "username occupied". Add UpdateUsernameAdmin/SetEditableUsernameAdmin bypass variants down the write path (postgres and memory) and route users.Service's actual write through them when the availability check was already bypassed.
779 lines
27 KiB
Go
779 lines
27 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// Default page sizes applied when an admin filter leaves the limit unset. The
|
|
// PostgreSQL queries page with LIMIT, so an unset limit has to resolve to a
|
|
// finite page in both backends.
|
|
const (
|
|
defaultCollectibleUsernameListLimit = 50
|
|
defaultCollectibleUsernameTransferLimit = 50
|
|
)
|
|
|
|
// CollectibleUsernameStore is the in-memory implementation of both
|
|
// store.UsernameRegistryStore and store.CollectibleUsernameStore. RPC unit tests
|
|
// run against it, so it reproduces every invariant migration 0150 encodes as an
|
|
// index or CHECK constraint and returns the same domain errors PostgreSQL maps
|
|
// its violations onto:
|
|
//
|
|
// - peer_usernames_peer_editable_idx: exactly one editable row per peer.
|
|
// - peer_usernames_collectible_not_editable_check: a row backed by an asset is
|
|
// never editable, so client-driven username edits cannot move an asset.
|
|
// - peer_usernames.username_lower UNIQUE: global, case-insensitive name
|
|
// uniqueness across users and channels, keyed by lower(username).
|
|
// - collectible_usernames.username_lower UNIQUE: a name is minted at most
|
|
// once. A burn therefore releases the registry row -- the name can be
|
|
// occupied again as a peer username -- while the asset row keeps the name
|
|
// for provenance and blocks a second mint.
|
|
// - the status/owner CHECK pair: owner is populated exactly for status 'owned'.
|
|
// - collectible_username_transfers_command_idx: command keys are globally
|
|
// unique, which is what makes mint/transfer/revoke replay-safe.
|
|
type CollectibleUsernameStore struct {
|
|
mu sync.Mutex
|
|
nextAssetID int64
|
|
nextTransferID int64
|
|
// assets is collectible_usernames keyed by identity.
|
|
assets map[int64]domain.CollectibleUsername
|
|
// assetsByName resolves a name onto the asset it currently stands for. After
|
|
// migration 0152 uniqueness covers live rows only, so one name can accumulate
|
|
// several burned rows plus at most one live row; this index points at the live
|
|
// row when there is one and at the newest burned row otherwise, mirroring the
|
|
// SQL lookup order.
|
|
assetsByName map[string]int64
|
|
// registry is peer_usernames keyed by username_lower, which is exactly how
|
|
// the table enforces global uniqueness.
|
|
registry map[string]collectibleRegistryRow
|
|
// transfers is the append-only provenance log per asset.
|
|
transfers map[int64][]domain.CollectibleUsernameTransfer
|
|
// commands maps a provenance command key onto the asset it touched.
|
|
commands map[string]int64
|
|
// reserved, when set, is the operator blocklist consulted before a name is
|
|
// assigned to an editable slot or minted, mirroring the PostgreSQL checks.
|
|
reserved *ReservedUsernameStore
|
|
}
|
|
|
|
// WithReservedUsernames wires the operator blocklist into the registry so a
|
|
// reserved name is refused, matching PostgreSQL.
|
|
func (s *CollectibleUsernameStore) WithReservedUsernames(reserved *ReservedUsernameStore) *CollectibleUsernameStore {
|
|
s.reserved = reserved
|
|
return s
|
|
}
|
|
|
|
// nameReserved reports whether a name is on the operator blocklist. It touches
|
|
// only s.reserved (its own lock), so it is safe from any context.
|
|
func (s *CollectibleUsernameStore) nameReserved(usernameLower string) bool {
|
|
if s == nil || s.reserved == nil {
|
|
return false
|
|
}
|
|
r, _ := s.reserved.IsReserved(context.Background(), usernameLower)
|
|
return r
|
|
}
|
|
|
|
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
|
|
// projected username shape.
|
|
type collectibleRegistryRow struct {
|
|
peer domain.Peer
|
|
row domain.Username
|
|
}
|
|
|
|
// NewCollectibleUsernameStore creates an empty registry. Asset ids start at 1 so
|
|
// a zero CollectibleID keeps meaning "editable slot", matching the nullable
|
|
// collectible_id column.
|
|
func NewCollectibleUsernameStore() *CollectibleUsernameStore {
|
|
return &CollectibleUsernameStore{
|
|
nextAssetID: 1,
|
|
nextTransferID: 1,
|
|
assets: make(map[int64]domain.CollectibleUsername),
|
|
assetsByName: make(map[string]int64),
|
|
registry: make(map[string]collectibleRegistryRow),
|
|
transfers: make(map[int64][]domain.CollectibleUsernameTransfer),
|
|
commands: make(map[string]int64),
|
|
}
|
|
}
|
|
|
|
// SetEditableUsername writes the peer's editable slot, mirroring the
|
|
// replace-then-insert the PostgreSQL user and channel stores run inside
|
|
// account.updateUsername / channels.updateUsername. The memory backend keeps
|
|
// usernames on the user and channel rows, so tests need this hook to give a peer
|
|
// the editable registry row the projection expects. An empty username clears the
|
|
// slot.
|
|
func (s *CollectibleUsernameStore) SetEditableUsername(ctx context.Context, peer domain.Peer, username string) (bool, error) {
|
|
return s.setEditableUsernameChecked(ctx, peer, username, true)
|
|
}
|
|
|
|
// SetEditableUsernameAdmin is SetEditableUsername without the operator
|
|
// reserved-username blocklist check, for the admin console deliberately
|
|
// assigning a reserved word to a specific account.
|
|
func (s *CollectibleUsernameStore) SetEditableUsernameAdmin(ctx context.Context, peer domain.Peer, username string) (bool, error) {
|
|
return s.setEditableUsernameChecked(ctx, peer, username, false)
|
|
}
|
|
|
|
func (s *CollectibleUsernameStore) setEditableUsernameChecked(_ context.Context, peer domain.Peer, username string, checkReserved bool) (bool, error) {
|
|
if !validCollectibleUsernamePeer(peer) {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
username = domain.NormalizeUsername(username)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if username == "" {
|
|
return s.clearEditableLocked(peer), nil
|
|
}
|
|
// peer_usernames has no length CHECK; the 5..32 editable rule lives in the
|
|
// service layer. Only the character rules are a registry concern.
|
|
if !domain.ValidCollectibleUsername(username) {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
key := strings.ToLower(username)
|
|
if checkReserved && s.nameReserved(key) {
|
|
return false, domain.ErrUsernameOccupied
|
|
}
|
|
if existing, ok := s.registry[key]; ok {
|
|
if existing.peer == peer && existing.row.Editable {
|
|
if existing.row.Username == username {
|
|
return false, nil
|
|
}
|
|
existing.row.Username = username
|
|
s.registry[key] = existing
|
|
return true, nil
|
|
}
|
|
return false, domain.ErrUsernameOccupied
|
|
}
|
|
// A live asset owns its name even while it sits in the vault: only a burn
|
|
// puts the name back into the free pool.
|
|
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
|
|
return false, domain.ErrUsernameOccupied
|
|
}
|
|
s.clearEditableLocked(peer)
|
|
s.registry[key] = collectibleRegistryRow{
|
|
peer: peer,
|
|
row: domain.Username{
|
|
Username: username,
|
|
Active: true,
|
|
Editable: true,
|
|
},
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// PeerUsernames returns the peer's registry rows in projection order.
|
|
func (s *CollectibleUsernameStore) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.peerUsernamesLocked(peer), nil
|
|
}
|
|
|
|
// PeerUsernamesBatch resolves several peers at once; peers holding no username
|
|
// are absent from the result.
|
|
func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
|
out := make(map[domain.Peer][]domain.Username, len(peers))
|
|
if len(peers) == 0 {
|
|
return out, nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, peer := range peers {
|
|
if !validCollectibleUsernamePeer(peer) {
|
|
continue
|
|
}
|
|
if _, done := out[peer]; done {
|
|
continue
|
|
}
|
|
rows := s.peerUsernamesLocked(peer)
|
|
if len(rows) == 0 {
|
|
continue
|
|
}
|
|
out[peer] = rows
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// activeUsernamePeer resolves an active registry name for the memory user and
|
|
// channel stores. Keeping lookup on the same registry that owns toggle/reorder
|
|
// state prevents the test backend from silently falling back to scalar-only
|
|
// behavior.
|
|
func (s *CollectibleUsernameStore) activeUsernamePeer(username string, peerType domain.PeerType) (domain.Peer, bool) {
|
|
key := strings.ToLower(domain.NormalizeUsername(username))
|
|
if key == "" {
|
|
return domain.Peer{}, false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
entry, ok := s.registry[key]
|
|
if !ok || !entry.row.Active || entry.peer.Type != peerType {
|
|
return domain.Peer{}, false
|
|
}
|
|
return entry.peer, true
|
|
}
|
|
|
|
// activeUsernameMatches returns the best username rank for each peer: exact
|
|
// matches precede prefix matches. Inactive rows stay occupied in the registry
|
|
// but are deliberately absent from client search.
|
|
func (s *CollectibleUsernameStore) activeUsernameMatches(query string, peerType domain.PeerType) map[int64]int {
|
|
query = strings.ToLower(domain.NormalizeUsername(query))
|
|
if query == "" {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make(map[int64]int)
|
|
for username, entry := range s.registry {
|
|
if !entry.row.Active || entry.peer.Type != peerType || !strings.HasPrefix(username, query) {
|
|
continue
|
|
}
|
|
rank := 1
|
|
if username == query {
|
|
rank = 0
|
|
}
|
|
if current, ok := out[entry.peer.ID]; !ok || rank < current {
|
|
out[entry.peer.ID] = rank
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *CollectibleUsernameStore) peerHasActiveCollectibleUsername(peer domain.Peer) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, entry := range s.registry {
|
|
if entry.peer == peer && entry.row.Active && !entry.row.Editable {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetUsernameActive toggles one collectible row. The domain validator owns the
|
|
// rules: the editable slot is off limits and a peer that holds usernames must
|
|
// keep at least one active.
|
|
func (s *CollectibleUsernameStore) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
|
if !validCollectibleUsernamePeer(peer) {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
username = domain.NormalizeUsername(username)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
current := s.peerUsernamesLocked(peer)
|
|
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
|
|
return false, err
|
|
}
|
|
key := strings.ToLower(username)
|
|
entry := s.registry[key]
|
|
if entry.row.Active == active {
|
|
return false, nil
|
|
}
|
|
entry.row.Active = active
|
|
s.registry[key] = entry
|
|
return true, nil
|
|
}
|
|
|
|
// ReorderUsernames rewrites the peer's username sort order, editable slot
|
|
// included. Validation and the resulting order both come from the domain helper,
|
|
// so the two backends cannot drift.
|
|
func (s *CollectibleUsernameStore) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) {
|
|
if !validCollectibleUsernamePeer(peer) {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
current := s.peerUsernamesLocked(peer)
|
|
reordered, err := domain.ApplyUsernameReorder(current, order)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
// Renumbering always happens; "changed" is about what a client can see.
|
|
changed := !domain.SameUsernameOrder(current, reordered)
|
|
for _, row := range reordered {
|
|
key := strings.ToLower(row.Username)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
entry := s.registry[key]
|
|
if entry.row.SortOrder == row.SortOrder {
|
|
continue
|
|
}
|
|
entry.row.SortOrder = row.SortOrder
|
|
s.registry[key] = entry
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// DeactivateAllUsernames clears the active flag on every collectible row and
|
|
// leaves the editable slot alone.
|
|
func (s *CollectibleUsernameStore) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
|
|
if !validCollectibleUsernamePeer(peer) {
|
|
return false, domain.ErrUsernameInvalid
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
changed := false
|
|
for key, entry := range s.registry {
|
|
if entry.peer != peer || !entry.row.Collectible() || !entry.row.Active {
|
|
continue
|
|
}
|
|
entry.row.Active = false
|
|
s.registry[key] = entry
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// MintCollectibleUsername creates the asset, optionally assigning it in the same
|
|
// call. A replayed command key returns the recorded asset with created=false.
|
|
func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if asset, ok := s.replayLocked(req.CommandKey); ok {
|
|
return asset, false, nil
|
|
}
|
|
key := strings.ToLower(req.Username)
|
|
// Only a live asset occupies a name. A name whose history is entirely burned
|
|
// is free to be issued again, and the new asset takes over the index entry
|
|
// while the burned rows stay as provenance.
|
|
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
|
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
|
}
|
|
if _, ok := s.registry[key]; ok {
|
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
|
}
|
|
if s.nameReserved(key) {
|
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
|
}
|
|
now := time.Now().UTC()
|
|
purchaseDate := req.PurchaseDate
|
|
if purchaseDate.IsZero() {
|
|
purchaseDate = now
|
|
}
|
|
asset := domain.CollectibleUsername{
|
|
ID: s.nextAssetID,
|
|
Username: req.Username,
|
|
Status: domain.CollectibleUsernameStatusVault,
|
|
PurchaseDate: purchaseDate,
|
|
Currency: req.Currency,
|
|
Amount: req.Amount,
|
|
CryptoCurrency: req.CryptoCurrency,
|
|
CryptoAmount: req.CryptoAmount,
|
|
URL: req.URL,
|
|
Version: 1,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if req.Owner.Type != "" {
|
|
asset.Status = domain.CollectibleUsernameStatusOwned
|
|
asset.Owner = req.Owner
|
|
// The first holder is the original owner and survives every later move.
|
|
asset.OriginalOwner = req.Owner
|
|
}
|
|
if err := asset.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
if asset.Owned() && s.countCollectiblesLocked(req.Owner) >= domain.MaxPeerCollectibleUsernames {
|
|
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
|
|
}
|
|
s.nextAssetID++
|
|
s.assets[asset.ID] = asset
|
|
s.assetsByName[key] = asset.ID
|
|
if asset.Owned() {
|
|
s.attachLocked(asset, req.Owner)
|
|
}
|
|
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
|
|
CollectibleID: asset.ID,
|
|
Kind: domain.CollectibleUsernameKindMint,
|
|
To: req.Owner,
|
|
Currency: req.Currency,
|
|
Amount: req.Amount,
|
|
Actor: req.Actor,
|
|
Reason: req.Reason,
|
|
CommandKey: req.CommandKey,
|
|
CreatedAt: now,
|
|
})
|
|
return asset, true, nil
|
|
}
|
|
|
|
// TransferCollectibleUsername moves the asset out of the vault or between
|
|
// holders. Handing the asset to the peer that already holds it is a no-op.
|
|
func (s *CollectibleUsernameStore) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if asset, ok := s.replayLocked(req.CommandKey); ok {
|
|
return asset, false, nil
|
|
}
|
|
asset, err := s.assetByNameLocked(req.Username)
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
|
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
|
}
|
|
if asset.Owned() && asset.Owner == req.To {
|
|
return asset, false, nil
|
|
}
|
|
key := strings.ToLower(asset.Username)
|
|
// Defensive: the registry row for this name must be the asset's own. A live
|
|
// asset occupies its name globally, so the only way another row can hold it
|
|
// is an inconsistently seeded store.
|
|
if existing, ok := s.registry[key]; ok && existing.row.CollectibleID != asset.ID {
|
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
|
}
|
|
if s.countCollectiblesLocked(req.To) >= domain.MaxPeerCollectibleUsernames {
|
|
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
|
|
}
|
|
from := asset.Owner
|
|
now := time.Now().UTC()
|
|
asset.Status = domain.CollectibleUsernameStatusOwned
|
|
asset.Owner = req.To
|
|
if asset.OriginalOwner.Type == "" {
|
|
asset.OriginalOwner = req.To
|
|
}
|
|
asset.TransferCount++
|
|
asset.Version++
|
|
asset.UpdatedAt = now
|
|
if err := asset.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
s.assets[asset.ID] = asset
|
|
s.detachLocked(asset.ID)
|
|
s.attachLocked(asset, req.To)
|
|
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
|
|
CollectibleID: asset.ID,
|
|
Kind: domain.CollectibleUsernameKindTransfer,
|
|
From: from,
|
|
To: req.To,
|
|
Actor: req.Actor,
|
|
Reason: req.Reason,
|
|
CommandKey: req.CommandKey,
|
|
CreatedAt: now,
|
|
})
|
|
return asset, true, nil
|
|
}
|
|
|
|
// RevokeCollectibleUsername returns the asset to the vault, or burns it.
|
|
//
|
|
// A revoke keeps the name owned by the asset -- nobody else can take it -- while
|
|
// a burn drops the registry row and releases the name back to the free pool. The
|
|
// burned asset row itself survives with its name so provenance stays readable
|
|
// and the name never mints twice.
|
|
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if asset, ok := s.replayLocked(req.CommandKey); ok {
|
|
return asset, false, nil
|
|
}
|
|
asset, err := s.assetByNameLocked(req.Username)
|
|
if err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
if asset.Status == domain.CollectibleUsernameStatusBurned {
|
|
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
|
|
}
|
|
if !req.Burn && !asset.Owned() {
|
|
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotOwned
|
|
}
|
|
from := asset.Owner
|
|
now := time.Now().UTC()
|
|
kind := domain.CollectibleUsernameKindRevoke
|
|
asset.Status = domain.CollectibleUsernameStatusVault
|
|
if req.Burn {
|
|
kind = domain.CollectibleUsernameKindBurn
|
|
asset.Status = domain.CollectibleUsernameStatusBurned
|
|
}
|
|
asset.Owner = domain.Peer{}
|
|
asset.Version++
|
|
asset.UpdatedAt = now
|
|
if err := asset.Validate(); err != nil {
|
|
return domain.CollectibleUsername{}, false, err
|
|
}
|
|
s.assets[asset.ID] = asset
|
|
s.detachLocked(asset.ID)
|
|
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
|
|
CollectibleID: asset.ID,
|
|
Kind: kind,
|
|
From: from,
|
|
Actor: req.Actor,
|
|
Reason: req.Reason,
|
|
CommandKey: req.CommandKey,
|
|
CreatedAt: now,
|
|
})
|
|
return asset, true, nil
|
|
}
|
|
|
|
// CollectibleUsername looks the asset up by name, case-insensitively.
|
|
// DeleteCollectibleUsername removes the live asset for a name completely --
|
|
// registry row, asset and provenance -- and frees the name for any use. Revoke
|
|
// with Burn retires an asset but keeps its history; this is the escape hatch for
|
|
// an asset issued by mistake.
|
|
//
|
|
// A command key cannot make this idempotent: the record it would resolve to is
|
|
// gone. A repeated call therefore reports deleted=false once no live asset is
|
|
// left, which is also what a delete of a burned-only name reports.
|
|
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
|
if s == nil {
|
|
return false, nil
|
|
}
|
|
req.Username = domain.NormalizeUsername(req.Username)
|
|
req.Actor = strings.TrimSpace(req.Actor)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
|
if err := req.Validate(); err != nil {
|
|
return false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
key := strings.ToLower(req.Username)
|
|
id, ok := s.assetsByName[key]
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
asset, ok := s.assets[id]
|
|
if !ok || asset.Status == domain.CollectibleUsernameStatusBurned {
|
|
return false, nil
|
|
}
|
|
s.detachLocked(id)
|
|
delete(s.assets, id)
|
|
delete(s.transfers, id)
|
|
for commandKey, target := range s.commands {
|
|
if target == id {
|
|
delete(s.commands, commandKey)
|
|
}
|
|
}
|
|
s.rebindAssetNameLocked(key)
|
|
return true, nil
|
|
}
|
|
|
|
// rebindAssetNameLocked re-points the name index after a row disappears: the
|
|
// newest remaining row wins, and the entry is dropped when none is left.
|
|
func (s *CollectibleUsernameStore) rebindAssetNameLocked(key string) {
|
|
best := int64(0)
|
|
for id, asset := range s.assets {
|
|
if strings.ToLower(asset.Username) != key {
|
|
continue
|
|
}
|
|
if best == 0 || id > best {
|
|
best = id
|
|
}
|
|
}
|
|
if best == 0 {
|
|
delete(s.assetsByName, key)
|
|
return
|
|
}
|
|
s.assetsByName[key] = best
|
|
}
|
|
|
|
func (s *CollectibleUsernameStore) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.assetByNameLocked(domain.NormalizeUsername(username))
|
|
}
|
|
|
|
// CollectibleUsernameByID looks the asset up by identity.
|
|
func (s *CollectibleUsernameStore) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
asset, ok := s.assets[id]
|
|
if !ok {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
// ListCollectibleUsernames is the admin listing query: newest first, paged by a
|
|
// BeforeID keyset, matching collectible_usernames_status_idx ordering.
|
|
func (s *CollectibleUsernameStore) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
|
if filter.Status != "" && !filter.Status.Valid() {
|
|
return nil, domain.ErrCollectibleUsernameStateInvalid
|
|
}
|
|
limit := filter.Limit
|
|
if limit <= 0 {
|
|
limit = defaultCollectibleUsernameListLimit
|
|
}
|
|
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]domain.CollectibleUsername, 0, len(s.assets))
|
|
for _, asset := range s.assets {
|
|
if filter.Status != "" && asset.Status != filter.Status {
|
|
continue
|
|
}
|
|
if filter.Owner.Type != "" && asset.Owner != filter.Owner {
|
|
continue
|
|
}
|
|
if query != "" && !strings.Contains(strings.ToLower(asset.Username), query) {
|
|
continue
|
|
}
|
|
if filter.BeforeID > 0 && asset.ID >= filter.BeforeID {
|
|
continue
|
|
}
|
|
out = append(out, asset)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CollectibleUsernameTransfers returns the provenance log newest first.
|
|
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
|
if limit <= 0 {
|
|
limit = defaultCollectibleUsernameTransferLimit
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
stored := s.transfers[collectibleID]
|
|
out := make([]domain.CollectibleUsernameTransfer, 0, len(stored))
|
|
for i := len(stored) - 1; i >= 0 && len(out) < limit; i-- {
|
|
out = append(out, stored[i])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// peerUsernamesLocked collects the peer's rows in projection order. The rows are
|
|
// values, so the returned slice cannot be used to mutate stored state.
|
|
func (s *CollectibleUsernameStore) peerUsernamesLocked(peer domain.Peer) []domain.Username {
|
|
rows := make([]domain.Username, 0, 4)
|
|
for _, entry := range s.registry {
|
|
if entry.peer != peer {
|
|
continue
|
|
}
|
|
rows = append(rows, entry.row)
|
|
}
|
|
return domain.SortUsernames(rows)
|
|
}
|
|
|
|
// countCollectiblesLocked counts the peer's collectible registry rows, which is
|
|
// what MaxPeerCollectibleUsernames bounds.
|
|
func (s *CollectibleUsernameStore) countCollectiblesLocked(peer domain.Peer) int {
|
|
count := 0
|
|
for _, entry := range s.registry {
|
|
if entry.peer == peer && entry.row.Collectible() {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// nextSortOrderLocked appends the new collectible after the peer's existing ones,
|
|
// clamped by the registry sort_order CHECK.
|
|
func (s *CollectibleUsernameStore) nextSortOrderLocked(peer domain.Peer) int {
|
|
next := 0
|
|
for _, entry := range s.registry {
|
|
if entry.peer != peer || !entry.row.Collectible() {
|
|
continue
|
|
}
|
|
if entry.row.SortOrder >= next {
|
|
next = entry.row.SortOrder + 1
|
|
}
|
|
}
|
|
if next > domain.MaxUsernameSortOrder {
|
|
next = domain.MaxUsernameSortOrder
|
|
}
|
|
return next
|
|
}
|
|
|
|
// attachLocked projects an owned asset into the registry. Callers check
|
|
// occupancy and the per-peer bound first, exactly like the PostgreSQL path does
|
|
// before it hits the unique index.
|
|
func (s *CollectibleUsernameStore) attachLocked(asset domain.CollectibleUsername, peer domain.Peer) {
|
|
s.registry[strings.ToLower(asset.Username)] = collectibleRegistryRow{
|
|
peer: peer,
|
|
row: domain.Username{
|
|
Username: asset.Username,
|
|
Active: true,
|
|
Editable: false,
|
|
SortOrder: s.nextSortOrderLocked(peer),
|
|
CollectibleID: asset.ID,
|
|
},
|
|
}
|
|
}
|
|
|
|
// detachLocked removes the registry row backed by the asset, leaving the peer's
|
|
// editable slot and its other collectibles untouched.
|
|
func (s *CollectibleUsernameStore) detachLocked(collectibleID int64) {
|
|
for key, entry := range s.registry {
|
|
if entry.row.CollectibleID == collectibleID {
|
|
delete(s.registry, key)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// clearEditableLocked drops the peer's editable row, keeping the one-editable-row
|
|
// index true by construction.
|
|
func (s *CollectibleUsernameStore) clearEditableLocked(peer domain.Peer) bool {
|
|
for key, entry := range s.registry {
|
|
if entry.peer == peer && entry.row.Editable {
|
|
delete(s.registry, key)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *CollectibleUsernameStore) assetByNameLocked(username string) (domain.CollectibleUsername, error) {
|
|
if username == "" {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
id, ok := s.assetsByName[strings.ToLower(username)]
|
|
if !ok {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
asset, ok := s.assets[id]
|
|
if !ok {
|
|
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
|
}
|
|
return asset, nil
|
|
}
|
|
|
|
// replayLocked resolves a command key onto the asset the recorded command
|
|
// touched. The provenance command index is global, so a replayed key is a no-op
|
|
// for every kind, just like the INSERT ... ON CONFLICT DO NOTHING path.
|
|
func (s *CollectibleUsernameStore) replayLocked(commandKey string) (domain.CollectibleUsername, bool) {
|
|
if commandKey == "" {
|
|
return domain.CollectibleUsername{}, false
|
|
}
|
|
id, ok := s.commands[commandKey]
|
|
if !ok {
|
|
return domain.CollectibleUsername{}, false
|
|
}
|
|
asset, ok := s.assets[id]
|
|
return asset, ok
|
|
}
|
|
|
|
func (s *CollectibleUsernameStore) recordTransferLocked(entry domain.CollectibleUsernameTransfer) {
|
|
entry.ID = s.nextTransferID
|
|
s.nextTransferID++
|
|
s.transfers[entry.CollectibleID] = append(s.transfers[entry.CollectibleID], entry)
|
|
if entry.CommandKey != "" {
|
|
s.commands[entry.CommandKey] = entry.CollectibleID
|
|
}
|
|
}
|
|
|
|
// validCollectibleUsernamePeer mirrors the peer_type CHECK: only real user and
|
|
// channel peers can hold a username.
|
|
func validCollectibleUsernamePeer(peer domain.Peer) bool {
|
|
switch peer.Type {
|
|
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
|
return peer.ID > 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|