diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go index 0b16c52f..bb212b57 100644 --- a/cmd/telesrv-admin/server.go +++ b/cmd/telesrv-admin/server.go @@ -12,6 +12,7 @@ import ( "io/fs" "mime/multipart" "net/http" + "net/url" "path" "strconv" "strings" @@ -100,6 +101,7 @@ func (s *server) routes() http.Handler { mux.Handle("GET /api/messages/groups", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessagesAPI))) mux.Handle("GET /api/messages/groups/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessageDetailAPI))) mux.Handle("GET /api/collectible-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernamesAPI))) + mux.Handle("GET /api/reserved-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleReservedUsernamesAPI))) mux.Handle("GET /api/collectible-usernames/{id}", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernameDetailAPI))) mux.Handle("GET /api/storage/stats", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageStatsAPI))) mux.Handle("GET /api/storage/accounts", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageAccountsAPI))) @@ -156,6 +158,8 @@ func (s *server) routes() http.Handler { mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI))) mux.Handle("POST /api/actions/storage-manual-purge", s.scopedRoute(permissionStorageManage, http.HandlerFunc(s.handleStorageManualPurgeAPI))) mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI))) + mux.Handle("POST /api/actions/reserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleReserveUsernameAPI))) + mux.Handle("POST /api/actions/unreserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleUnreserveUsernameAPI))) mux.Handle("POST /api/actions/mint-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleMintCollectibleUsernameAPI))) mux.Handle("POST /api/actions/transfer-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleTransferCollectibleUsernameAPI))) mux.Handle("POST /api/actions/revoke-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI))) @@ -2438,6 +2442,69 @@ type mintCollectibleUsernameAPIRequest struct { PurchaseDate flexUnix `json:"purchase_date"` } +type reserveUsernameAPIRequest struct { + CommandID string `json:"command_id"` + Reason string `json:"reason"` + Confirm bool `json:"confirm"` + Username string `json:"username"` +} + +func (s *server) handleReserveUsernameAPI(w http.ResponseWriter, r *http.Request) { + var body reserveUsernameAPIRequest + if !decodeAction(w, r, &body) { + return + } + req := admin.ReserveUsernameRequest{ + CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reserve-username"), + Username: body.Username, + } + result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/reserve", req) + writeCommandResultAPI(w, result, err) +} + +func (s *server) handleUnreserveUsernameAPI(w http.ResponseWriter, r *http.Request) { + var body reserveUsernameAPIRequest + if !decodeAction(w, r, &body) { + return + } + req := admin.UnreserveUsernameRequest{ + CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "unreserve-username"), + Username: body.Username, + } + result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/unreserve", req) + writeCommandResultAPI(w, result, err) +} + +func (s *server) handleReservedUsernamesAPI(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + params := url.Values{} + for _, name := range []string{"q", "limit", "offset"} { + if v := strings.TrimSpace(q.Get(name)); v != "" { + params.Set(name, v) + } + } + apiPath := "/v1/reserved-usernames" + if enc := params.Encode(); enc != "" { + apiPath += "?" + enc + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "request build failed") + return + } + req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + writeAPIError(w, http.StatusBadGateway, "admin api unreachable") + return + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(data) +} + func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) { var body mintCollectibleUsernameAPIRequest if !decodeAction(w, r, &body) { diff --git a/cmd/telesrv-admin/web/src/api.ts b/cmd/telesrv-admin/web/src/api.ts index eb6f291b..cd6181a7 100644 --- a/cmd/telesrv-admin/web/src/api.ts +++ b/cmd/telesrv-admin/web/src/api.ts @@ -24,6 +24,7 @@ import type { ChannelListResponse, CollectibleUsernameDetail, CollectibleUsernameListResponse, + ReservedUsernameListResponse, CommandResult, DockerService, EnvGroup, @@ -177,6 +178,8 @@ export const api = { request(`/api/collectible-usernames?${params.toString()}`), collectibleUsername: (id: string) => request(`/api/collectible-usernames/${encodeURIComponent(id)}`), + reservedUsernames: (params: URLSearchParams) => + request(`/api/reserved-usernames?${params.toString()}`), dashboard: () => request("/api/dashboard"), storageStats: () => request("/api/storage/stats"), storageAccounts: (params: URLSearchParams) => diff --git a/cmd/telesrv-admin/web/src/components/Layout.tsx b/cmd/telesrv-admin/web/src/components/Layout.tsx index a93f07bd..a0793ada 100644 --- a/cmd/telesrv-admin/web/src/components/Layout.tsx +++ b/cmd/telesrv-admin/web/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { AtSign, BadgeCheck, + Ban, Bot, ChevronDown, Database, @@ -288,6 +289,9 @@ export function Shell({ {canReadUsernames && ( } href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"} )} + {canReadUsernames && ( + } href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"} + )} {canReadStorage && ( } href="/storage" route={route} navigate={navigate}>{"Storage"} )} diff --git a/cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx b/cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx new file mode 100644 index 00000000..ff4029c4 --- /dev/null +++ b/cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx @@ -0,0 +1,138 @@ +import { AtSign, Loader2, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { api, errorMessage } from "../api"; +import { ActionButton } from "../components/ActionButton"; +import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; +import { formatUnix } from "../lib/format"; +import type { ReservedUsernameRow } from "../types"; + +// Reserved usernames are a plain operator blocklist: a name listed here cannot be +// taken as an editable username by any peer and cannot be minted as a +// collectible. No owner, no price, no Fragment badge - that is the collectible +// tab's job. +export function ReservedUsernamesPage() { + const [q, setQ] = useState(""); + const [rows, setRows] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [newName, setNewName] = useState(""); + + async function load() { + setBusy(true); + setError(""); + const params = new URLSearchParams({ limit: "200" }); + if (q.trim()) params.set("q", q.trim().replace(/^@/, "")); + try { + const result = await api.reservedUsernames(params); + setRows(result.reserved ?? []); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void load(); + }, []); + + const cleanNew = newName.trim().replace(/^@/, ""); + + return ( + load()} disabled={busy}> + {"Refresh"} + + } + > + {error && {error}} +
+ +
+ + +
+ + } + tone="neutral" + path="/api/actions/reserve-username" + payload={() => ({ username: cleanNew })} + onDone={() => { + setNewName(""); + void load(); + }} + /> +
+
{ + event.preventDefault(); + void load(); + }} + > + + +
+
+ +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + {rows.length === 0 && } + +
{"Username"}{"Reason"}{"Reserved by"}{"Reserved (UTC)"}
+ + + {row.username} + + {row.reason || "-"}{row.actor || "-"}{formatUnix(row.created_at) || "-"} + } + tone="danger" + path="/api/actions/unreserve-username" + payload={() => ({ username: row.username })} + onDone={() => void load()} + /> +
+
+
+ ); +} diff --git a/cmd/telesrv-admin/web/src/pages/Routes.tsx b/cmd/telesrv-admin/web/src/pages/Routes.tsx index 7a8afd3f..3b3c6c52 100644 --- a/cmd/telesrv-admin/web/src/pages/Routes.tsx +++ b/cmd/telesrv-admin/web/src/pages/Routes.tsx @@ -5,6 +5,7 @@ import { AccountsPage } from "./AccountsPage"; import { SharedDevicesPage } from "./SharedDevicesPage"; import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage"; import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage"; +import { ReservedUsernamesPage } from "./ReservedUsernamesPage"; import { ChannelDetailPage } from "./ChannelDetailPage"; import { ChannelsPage } from "./ChannelsPage"; import { BotDetailPage } from "./BotDetailPage"; @@ -103,6 +104,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig if (route.path === "/collectible-usernames") { return gate(permissionUsernamesRead, ); } + if (route.path === "/reserved-usernames") { + return ; + } if (route.path === "/storage") { return gate(permissionStorageRead, ); } diff --git a/cmd/telesrv-admin/web/src/routing.ts b/cmd/telesrv-admin/web/src/routing.ts index 133d2b13..58626cae 100644 --- a/cmd/telesrv-admin/web/src/routing.ts +++ b/cmd/telesrv-admin/web/src/routing.ts @@ -20,6 +20,7 @@ export function routeTitle(pathname: string): string { if (pathname.startsWith("/bot-verification")) return "Third-party verification"; if (pathname.startsWith("/verification")) return "Official Verification"; if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames"; + if (pathname.startsWith("/reserved-usernames")) return "Reserved Usernames"; if (pathname.startsWith("/storage")) return "Storage"; if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices"; if (pathname.startsWith("/accounts")) return "Accounts"; diff --git a/cmd/telesrv-admin/web/src/types.ts b/cmd/telesrv-admin/web/src/types.ts index c6c2bc11..7bc48cec 100644 --- a/cmd/telesrv-admin/web/src/types.ts +++ b/cmd/telesrv-admin/web/src/types.ts @@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = { transfers: CollectibleUsernameTransferRow[] | null; }; +export type ReservedUsernameRow = { + username: string; + reason: string; + actor: string; + created_at: number; +}; + +export type ReservedUsernameListResponse = { + reserved: ReservedUsernameRow[] | null; +}; + // Official platform verification. Every int64 the backend tags `,string` stays a // decimal string here: application ids, peer ids and the optimistic-locking // version all outgrow the exact range of a JSON number, and a rounded version diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 9c0af9bb..fdbd97ee 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -1491,6 +1491,7 @@ func run(logger *zap.Logger) error { // Collectible (NFT) usernames are an optional read model projected at the // protocol edge. collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool) + reservedUsernameStore := postgres.NewReservedUsernameStore(pool) usernamesService := usernamesapp.NewService( usernamesapp.WithRegistryStore(collectibleUsernameStore), usernamesapp.WithCollectibleStore(collectibleUsernameStore), @@ -1704,6 +1705,7 @@ func run(logger *zap.Logger) error { Emoji: filesService, Moderation: moderationService, Usernames: usernamesService, + ReservedUsernames: reservedUsernameStore, Verification: verificationService, BotVerification: botVerificationService, Account: accountService, diff --git a/deploy/migrations/20260909190000_reserved_usernames.down.sql b/deploy/migrations/20260909190000_reserved_usernames.down.sql new file mode 100644 index 00000000..b3edb27a --- /dev/null +++ b/deploy/migrations/20260909190000_reserved_usernames.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.reserved_usernames; diff --git a/deploy/migrations/20260909190000_reserved_usernames.up.sql b/deploy/migrations/20260909190000_reserved_usernames.up.sql new file mode 100644 index 00000000..6e157737 --- /dev/null +++ b/deploy/migrations/20260909190000_reserved_usernames.up.sql @@ -0,0 +1,17 @@ +-- Operator-maintained username blocklist. A name listed here cannot be taken as +-- an editable username by any peer (account.updateUsername, channels.updateUsername, +-- @BotFather /setusername, or the admin set-username actions). It is a plain +-- blocklist: no owner, no price, no Fragment collectible badge. + +CREATE TABLE public.reserved_usernames ( + username_lower text PRIMARY KEY CHECK ( + username_lower <> '' AND username_lower = lower(username_lower) + ), + username text NOT NULL, + reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512), + actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 256), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX reserved_usernames_created_at_idx + ON public.reserved_usernames (created_at DESC, username_lower); diff --git a/internal/admin/service.go b/internal/admin/service.go index 326287bd..93fa8838 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -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") diff --git a/internal/admin/service_test.go b/internal/admin/service_test.go index 06699284..c9e564f7 100644 --- a/internal/admin/service_test.go +++ b/internal/admin/service_test.go @@ -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) + } +} diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go index f80af86c..8d99b626 100644 --- a/internal/adminapi/server.go +++ b/internal/adminapi/server.go @@ -100,6 +100,9 @@ type Service interface { CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) + ReserveUsername(ctx context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) + UnreserveUsername(ctx context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) + ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) @@ -238,6 +241,9 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername)) mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames)) mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername)) + mux.HandleFunc("POST /v1/reserved-usernames/reserve", s.authenticated(s.handleReserveUsername)) + mux.HandleFunc("POST /v1/reserved-usernames/unreserve", s.authenticated(s.handleUnreserveUsername)) + mux.HandleFunc("GET /v1/reserved-usernames", s.authenticated(s.handleReservedUsernames)) // Official platform verification. Unlike every route above, these carry a // named permission, so a scoped token can be given the review surface and // nothing else. Revocation additionally requires verification.revoke. @@ -1208,6 +1214,54 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http. writeCommandResult(w, result, err) } +func (s *Server) handleReserveUsername(w http.ResponseWriter, r *http.Request) { + var req admin.ReserveUsernameRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.ReserveUsername(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleUnreserveUsername(w http.ResponseWriter, r *http.Request) { + var req admin.UnreserveUsernameRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.UnreserveUsername(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleReservedUsernames(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + filter := domain.ReservedUsernameFilter{Query: query.Get("q")} + limit, ok := optionalQueryInt(w, query, "limit") + if !ok { + return + } + filter.Limit = limit + offset, ok := optionalQueryInt(w, query, "offset") + if !ok { + return + } + filter.Offset = offset + items, err := s.svc.ReservedUsernames(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, "list failed") + return + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + out = append(out, map[string]any{ + "username": item.Username, + "reason": item.Reason, + "actor": item.Actor, + "created_at": item.CreatedAt.Unix(), + }) + } + writeJSON(w, http.StatusOK, map[string]any{"reserved": out}) +} + func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() filter := domain.CollectibleUsernameFilter{ diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go index b69a7ad9..379ae372 100644 --- a/internal/adminapi/server_test.go +++ b/internal/adminapi/server_test.go @@ -518,12 +518,30 @@ func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDeci type captureCollectibleUsernameService struct { fakeService - mint admin.MintCollectibleUsernameRequest - transfer admin.TransferCollectibleUsernameRequest - revoke admin.RevokeCollectibleUsernameRequest - del admin.DeleteCollectibleUsernameRequest - filter domain.CollectibleUsernameFilter - assetID int64 + mint admin.MintCollectibleUsernameRequest + transfer admin.TransferCollectibleUsernameRequest + revoke admin.RevokeCollectibleUsernameRequest + del admin.DeleteCollectibleUsernameRequest + reserve admin.ReserveUsernameRequest + unreserve admin.UnreserveUsernameRequest + resFilter domain.ReservedUsernameFilter + filter domain.CollectibleUsernameFilter + assetID int64 +} + +func (s *captureCollectibleUsernameService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) { + s.reserve = req + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (s *captureCollectibleUsernameService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) { + s.unreserve = req + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (s *captureCollectibleUsernameService) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) { + s.resFilter = filter + return []domain.ReservedUsername{{Username: "support", Reason: "official", Actor: "ops"}}, nil } func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) { @@ -739,3 +757,49 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) { return nil, nil } + +func (fakeService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) ReservedUsernames(context.Context, domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) { + return nil, nil +} + +func TestAdminAPIReservedUsernames(t *testing.T) { + svc := &captureCollectibleUsernameService{} + srv := &Server{token: "secret", svc: svc} + + reserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/reserve", strings.NewReader( + `{"command_id":"r-1","actor":"ops","reason":"official","username":"support"}`)) + reserve.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + srv.routes().ServeHTTP(rec, reserve) + if rec.Code != http.StatusOK || svc.reserve.Username != "support" || svc.reserve.CommandID != "r-1" { + t.Fatalf("reserve status=%d req=%+v", rec.Code, svc.reserve) + } + + unreserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/unreserve", strings.NewReader( + `{"command_id":"u-1","actor":"ops","reason":"done","username":"support"}`)) + unreserve.Header.Set("Authorization", "Bearer secret") + rec = httptest.NewRecorder() + srv.routes().ServeHTTP(rec, unreserve) + if rec.Code != http.StatusOK || svc.unreserve.Username != "support" { + t.Fatalf("unreserve status=%d req=%+v", rec.Code, svc.unreserve) + } + + list := httptest.NewRequest(http.MethodGet, "/v1/reserved-usernames?q=sup&limit=10", nil) + list.Header.Set("Authorization", "Bearer secret") + rec = httptest.NewRecorder() + srv.routes().ServeHTTP(rec, list) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"username":"support"`) { + t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String()) + } + if svc.resFilter.Query != "sup" || svc.resFilter.Limit != 10 { + t.Fatalf("list filter = %+v", svc.resFilter) + } +} diff --git a/internal/domain/reserved_username.go b/internal/domain/reserved_username.go new file mode 100644 index 00000000..69f6b13d --- /dev/null +++ b/internal/domain/reserved_username.go @@ -0,0 +1,24 @@ +package domain + +import "time" + +// MaxReservedUsernameReasonLength bounds the operator note on a reservation. +const MaxReservedUsernameReasonLength = 512 + +// ReservedUsername is one entry in the operator username blocklist. A reserved +// name cannot be taken as an editable username by any peer and cannot be minted +// as a collectible. +type ReservedUsername struct { + Username string // display form (original case at reservation time) + Reason string + Actor string + CreatedAt time.Time +} + +// ReservedUsernameFilter pages the blocklist. Query matches a username prefix +// (case-insensitive); an empty query lists everything. +type ReservedUsernameFilter struct { + Query string + Limit int + Offset int +} diff --git a/internal/store/memory/collectible_username.go b/internal/store/memory/collectible_username.go index 6f0052c8..e53850f6 100644 --- a/internal/store/memory/collectible_username.go +++ b/internal/store/memory/collectible_username.go @@ -55,6 +55,24 @@ type CollectibleUsernameStore struct { 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 +} + +func (s *CollectibleUsernameStore) nameReservedLocked(usernameLower string) bool { + if 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 @@ -101,6 +119,9 @@ func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer d return false, domain.ErrUsernameInvalid } key := strings.ToLower(username) + if s.nameReservedLocked(key) { + return false, domain.ErrUsernameOccupied + } if existing, ok := s.registry[key]; ok { if existing.peer == peer && existing.row.Editable { if existing.row.Username == username { @@ -313,6 +334,9 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re if _, ok := s.registry[key]; ok { return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied } + if s.nameReservedLocked(key) { + return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied + } now := time.Now().UTC() purchaseDate := req.PurchaseDate if purchaseDate.IsZero() { diff --git a/internal/store/memory/reserved_username.go b/internal/store/memory/reserved_username.go new file mode 100644 index 00000000..9da2e108 --- /dev/null +++ b/internal/store/memory/reserved_username.go @@ -0,0 +1,100 @@ +package memory + +import ( + "context" + "sort" + "strings" + "sync" + "time" + + "telesrv/internal/domain" +) + +// ReservedUsernameStore is the in-memory operator username blocklist. +type ReservedUsernameStore struct { + mu sync.Mutex + entries map[string]domain.ReservedUsername // keyed by username_lower +} + +// NewReservedUsernameStore creates an empty blocklist. +func NewReservedUsernameStore() *ReservedUsernameStore { + return &ReservedUsernameStore{entries: make(map[string]domain.ReservedUsername)} +} + +func (s *ReservedUsernameStore) IsReserved(_ context.Context, usernameLower string) (bool, error) { + if s == nil { + return false, nil + } + usernameLower = strings.ToLower(strings.TrimSpace(usernameLower)) + if usernameLower == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.entries[usernameLower] + return ok, nil +} + +func (s *ReservedUsernameStore) ReserveUsername(_ context.Context, username, reason, actor string) (bool, error) { + username = strings.TrimSpace(username) + lower := strings.ToLower(username) + if lower == "" { + return false, domain.ErrUsernameInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.entries[lower]; ok { + return false, nil + } + s.entries[lower] = domain.ReservedUsername{Username: username, Reason: reason, Actor: actor, CreatedAt: time.Now().UTC()} + return true, nil +} + +func (s *ReservedUsernameStore) UnreserveUsername(_ context.Context, username string) (bool, error) { + lower := strings.ToLower(strings.TrimSpace(username)) + if lower == "" { + return false, domain.ErrUsernameInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.entries[lower]; !ok { + return false, nil + } + delete(s.entries, lower) + return true, nil +} + +func (s *ReservedUsernameStore) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) { + s.mu.Lock() + defer s.mu.Unlock() + q := strings.ToLower(strings.TrimSpace(filter.Query)) + out := make([]domain.ReservedUsername, 0, len(s.entries)) + for key, entry := range s.entries { + if q != "" && !strings.HasPrefix(key, q) { + continue + } + out = append(out, entry) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].CreatedAt.After(out[j].CreatedAt) + } + return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username) + }) + limit := filter.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + if offset >= len(out) { + return []domain.ReservedUsername{}, nil + } + end := offset + limit + if end > len(out) { + end = len(out) + } + return out[offset:end], nil +} diff --git a/internal/store/postgres/collectible_username.go b/internal/store/postgres/collectible_username.go index db26c0d2..c5547074 100644 --- a/internal/store/postgres/collectible_username.go +++ b/internal/store/postgres/collectible_username.go @@ -206,6 +206,11 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context, } else if found { return domain.ErrUsernameOccupied } + if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil { + return err + } else if reserved { + return domain.ErrUsernameOccupied + } var existing int64 switch err := tx.QueryRow(ctx, ` SELECT id FROM collectible_usernames diff --git a/internal/store/postgres/peer_username.go b/internal/store/postgres/peer_username.go index aa21e73d..7341f998 100644 --- a/internal/store/postgres/peer_username.go +++ b/internal/store/postgres/peer_username.go @@ -65,6 +65,21 @@ func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower st return owner, true, nil } +// usernameReservedTx reports whether a name is on the operator blocklist. It is +// consulted before every editable-username write and before a collectible mint. +func usernameReservedTx(ctx context.Context, db sqlcgen.DBTX, usernameLower string) (bool, error) { + if usernameLower == "" { + return false, nil + } + var exists bool + if err := db.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM reserved_usernames WHERE username_lower = $1)`, + usernameLower).Scan(&exists); err != nil { + return false, fmt.Errorf("check reserved username: %w", err) + } + return exists, nil +} + func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) { owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false) if err != nil || !found { @@ -115,6 +130,11 @@ WHERE peer_type = $1 // otherwise account.updateUsername would silently release a minted asset. func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error { if usernameLower != "" { + if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil { + return err + } else if reserved { + return domain.ErrUsernameOccupied + } owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true) if err != nil { return err diff --git a/internal/store/postgres/reserved_username.go b/internal/store/postgres/reserved_username.go new file mode 100644 index 00000000..37782bfe --- /dev/null +++ b/internal/store/postgres/reserved_username.go @@ -0,0 +1,99 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// ReservedUsernameStore is the operator username blocklist backed by the +// reserved_usernames table. +type ReservedUsernameStore struct { + db sqlcgen.DBTX +} + +// NewReservedUsernameStore builds the store on a pgx pool or transaction. +func NewReservedUsernameStore(db sqlcgen.DBTX) *ReservedUsernameStore { + return &ReservedUsernameStore{db: db} +} + +func (s *ReservedUsernameStore) IsReserved(ctx context.Context, usernameLower string) (bool, error) { + usernameLower = strings.ToLower(strings.TrimSpace(usernameLower)) + if usernameLower == "" { + return false, nil + } + var exists bool + if err := s.db.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM reserved_usernames WHERE username_lower = $1)`, + usernameLower).Scan(&exists); err != nil { + return false, fmt.Errorf("check reserved username: %w", err) + } + return exists, nil +} + +func (s *ReservedUsernameStore) ReserveUsername(ctx context.Context, username, reason, actor string) (bool, error) { + username = strings.TrimSpace(username) + lower := strings.ToLower(username) + if lower == "" { + return false, domain.ErrUsernameInvalid + } + tag, err := s.db.Exec(ctx, ` +INSERT INTO reserved_usernames (username_lower, username, reason, actor) +VALUES ($1, $2, $3, $4) +ON CONFLICT (username_lower) DO NOTHING`, lower, username, reason, actor) + if err != nil { + return false, fmt.Errorf("reserve username: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *ReservedUsernameStore) UnreserveUsername(ctx context.Context, username string) (bool, error) { + lower := strings.ToLower(strings.TrimSpace(username)) + if lower == "" { + return false, domain.ErrUsernameInvalid + } + tag, err := s.db.Exec(ctx, `DELETE FROM reserved_usernames WHERE username_lower = $1`, lower) + if err != nil { + return false, fmt.Errorf("unreserve username: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *ReservedUsernameStore) ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) { + limit := filter.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + args := []any{limit, offset} + where := "" + if q := strings.ToLower(strings.TrimSpace(filter.Query)); q != "" { + args = append(args, q+"%") + where = "WHERE username_lower LIKE $3" + } + rows, err := s.db.Query(ctx, ` +SELECT username, reason, actor, created_at +FROM reserved_usernames +`+where+` +ORDER BY created_at DESC, username_lower +LIMIT $1 OFFSET $2`, args...) + if err != nil { + return nil, fmt.Errorf("list reserved usernames: %w", err) + } + defer rows.Close() + out := make([]domain.ReservedUsername, 0, limit) + for rows.Next() { + var item domain.ReservedUsername + if err := rows.Scan(&item.Username, &item.Reason, &item.Actor, &item.CreatedAt); err != nil { + return nil, fmt.Errorf("scan reserved username: %w", err) + } + out = append(out, item) + } + return out, rows.Err() +} diff --git a/internal/store/reserved_username.go b/internal/store/reserved_username.go new file mode 100644 index 00000000..8da8c2a9 --- /dev/null +++ b/internal/store/reserved_username.go @@ -0,0 +1,22 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ReservedUsernameStore owns the operator username blocklist. IsReserved is the +// hot path consulted on every editable-username write; the rest are the admin +// lifecycle. +type ReservedUsernameStore interface { + // IsReserved reports whether usernameLower (already lowercased) is blocked. + IsReserved(ctx context.Context, usernameLower string) (bool, error) + // ReserveUsername adds an entry. Returns created=false if it already existed + // (the existing reason/actor are kept). + ReserveUsername(ctx context.Context, username, reason, actor string) (created bool, err error) + // UnreserveUsername removes an entry. Returns removed=false if absent. + UnreserveUsername(ctx context.Context, username string) (removed bool, err error) + // ReservedUsernames pages the blocklist, newest first. + ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) +}