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 d2ffaa92bf
commit a83aa45fb8
23 changed files with 874 additions and 39 deletions

View file

@ -12,6 +12,7 @@ import (
"io/fs" "io/fs"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"path" "path"
"strconv" "strconv"
"strings" "strings"
@ -74,6 +75,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI))) mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI))) mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI))) mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("GET /api/reserved-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleReservedUsernamesAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI))) mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI))) mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI))) mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
@ -128,6 +130,8 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI))) mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI))) mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI))) mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/reserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleReserveUsernameAPI)))
mux.Handle("POST /api/actions/unreserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleUnreserveUsernameAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI))) mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI))) mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI))) mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
@ -2239,6 +2243,69 @@ type mintCollectibleUsernameAPIRequest struct {
PurchaseDate flexUnix `json:"purchase_date"` 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) { func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body mintCollectibleUsernameAPIRequest var body mintCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) { if !decodeAction(w, r, &body) {

View file

@ -23,7 +23,7 @@
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script> <script type="module" crossorigin src="/assets/index-BWYHyok0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css"> <link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
</head> </head>
<body> <body>

View file

@ -22,6 +22,7 @@ import type {
ChannelListResponse, ChannelListResponse,
CollectibleUsernameDetail, CollectibleUsernameDetail,
CollectibleUsernameListResponse, CollectibleUsernameListResponse,
ReservedUsernameListResponse,
CommandResult, CommandResult,
GroupMessageDetail, GroupMessageDetail,
GroupMessageListResponse, GroupMessageListResponse,
@ -163,6 +164,8 @@ export const api = {
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`), request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
collectibleUsername: (id: string) => collectibleUsername: (id: string) =>
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`), request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
reservedUsernames: (params: URLSearchParams) =>
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
dashboard: () => request<DashboardResponse>("/api/dashboard"), dashboard: () => request<DashboardResponse>("/api/dashboard"),
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"), storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
storageAccounts: (params: URLSearchParams) => storageAccounts: (params: URLSearchParams) =>

View file

@ -1,6 +1,7 @@
import { import {
AtSign, AtSign,
BadgeCheck, BadgeCheck,
Ban,
Bot, Bot,
ChevronDown, ChevronDown,
Database, Database,
@ -99,6 +100,7 @@ export function Shell({
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink> <NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
)} )}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink> <NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink> <NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink> <NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink> <NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>

View file

@ -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<ReservedUsernameRow[]>([]);
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 (
<PageFrame
title={"Reserved usernames"}
eyebrow={"Usernames / Blocklist"}
actions={
<button className="btn icon-text" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Reserved names"} value={String(rows.length)} />
</div>
<QueryPanel>
<div className="toolbar">
<label className="field-inline">
<span>{"Reserve"}</span>
<input
value={newName}
onChange={(event) => setNewName(event.target.value)}
placeholder={"support"}
/>
</label>
<ActionButton
disabled={cleanNew.length < 5}
label={"Reserve username"}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/reserve-username"
payload={() => ({ username: cleanNew })}
onDone={() => {
setNewName("");
void load();
}}
/>
</div>
<form
className="toolbar"
onSubmit={(event) => {
event.preventDefault();
void load();
}}
>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Username"}</th>
<th>{"Reason"}</th>
<th>{"Reserved by"}</th>
<th>{"Reserved (UTC)"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.username}>
<td>
<strong>
<AtSign size={13} />
{row.username}
</strong>
</td>
<td>{row.reason || "-"}</td>
<td>{row.actor || "-"}</td>
<td>{formatUnix(row.created_at) || "-"}</td>
<td>
<ActionButton
compact
label={"Unreserve"}
icon={<Trash2 size={13} />}
tone="danger"
path="/api/actions/unreserve-username"
payload={() => ({ username: row.username })}
onDone={() => void load()}
/>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={5} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -4,6 +4,7 @@ import { AccountsPage } from "./AccountsPage";
import { SharedDevicesPage } from "./SharedDevicesPage"; import { SharedDevicesPage } from "./SharedDevicesPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage"; import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage"; import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage"; import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage"; import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage"; import { BotDetailPage } from "./BotDetailPage";
@ -82,6 +83,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/collectible-usernames") { if (route.path === "/collectible-usernames") {
return <CollectibleUsernamesPage navigate={navigate} />; return <CollectibleUsernamesPage navigate={navigate} />;
} }
if (route.path === "/reserved-usernames") {
return <ReservedUsernamesPage />;
}
if (route.path === "/storage") { if (route.path === "/storage") {
return <StoragePage navigate={navigate} />; return <StoragePage navigate={navigate} />;
} }

View file

@ -20,6 +20,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/bot-verification")) return "Third-party verification"; if (pathname.startsWith("/bot-verification")) return "Third-party verification";
if (pathname.startsWith("/verification")) return "Official Verification"; if (pathname.startsWith("/verification")) return "Official Verification";
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames"; 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("/storage")) return "Storage";
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices"; if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
if (pathname.startsWith("/accounts")) return "Accounts"; if (pathname.startsWith("/accounts")) return "Accounts";

View file

@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
transfers: CollectibleUsernameTransferRow[] | null; 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 // Official platform verification. Every int64 the backend tags `,string` stays a
// decimal string here: application ids, peer ids and the optimistic-locking // 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 // version all outgrow the exact range of a JSON number, and a rounded version

View file

@ -1203,6 +1203,7 @@ func run(logger *zap.Logger) error {
// Collectible (NFT) usernames are an optional read model projected at the // Collectible (NFT) usernames are an optional read model projected at the
// protocol edge. // protocol edge.
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool) collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
usernamesService := usernamesapp.NewService( usernamesService := usernamesapp.NewService(
usernamesapp.WithRegistryStore(collectibleUsernameStore), usernamesapp.WithRegistryStore(collectibleUsernameStore),
usernamesapp.WithCollectibleStore(collectibleUsernameStore), usernamesapp.WithCollectibleStore(collectibleUsernameStore),
@ -1390,6 +1391,7 @@ func run(logger *zap.Logger) error {
Emoji: filesService, Emoji: filesService,
Moderation: moderationService, Moderation: moderationService,
Usernames: usernamesService, Usernames: usernamesService,
ReservedUsernames: reservedUsernameStore,
Verification: verificationService, Verification: verificationService,
BotVerification: botVerificationService, BotVerification: botVerificationService,
Account: accountService, Account: accountService,

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.reserved_usernames;

View file

@ -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);

View file

@ -66,6 +66,9 @@ const (
ActionTransferCollectibleUsername = "usernames.collectible.transfer" ActionTransferCollectibleUsername = "usernames.collectible.transfer"
ActionRevokeCollectibleUsername = "usernames.collectible.revoke" ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
ActionDeleteCollectibleUsername = "usernames.collectible.delete" ActionDeleteCollectibleUsername = "usernames.collectible.delete"
// Operator username blocklist.
ActionReserveUsername = "usernames.reserve"
ActionUnreserveUsername = "usernames.unreserve"
// Official platform verification review. Claim/approve/reject act on one // Official platform verification review. Claim/approve/reject act on one
// application; revoke acts on a target, because clearing a badge is not a // application; revoke acts on a target, because clearing a badge is not a
// decision on the application that granted it. // decision on the application that granted it.
@ -363,6 +366,16 @@ type CollectibleUsernamesService interface {
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) 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 // collectibleUsernameByIDLookup is the optional by-identity read. Stores that
// expose it answer a detail request in one round trip; the keyset fallback in // expose it answer a detail request in one round trip; the keyset fallback in
// CollectibleUsernameByID keeps a service without it correct. // CollectibleUsernameByID keeps a service without it correct.
@ -389,6 +402,7 @@ type Dependencies struct {
Emoji EmojiService Emoji EmojiService
Moderation ModerationService Moderation ModerationService
Usernames CollectibleUsernamesService Usernames CollectibleUsernamesService
ReservedUsernames ReservedUsernamesService
Verification VerificationService Verification VerificationService
// BotVerification is the third-party mechanism, wired separately from // BotVerification is the third-party mechanism, wired separately from
// Verification: the two never read each other's state. // Verification: the two never read each other's state.
@ -420,6 +434,7 @@ type Service struct {
emoji EmojiService emoji EmojiService
moderation ModerationService moderation ModerationService
usernames CollectibleUsernamesService usernames CollectibleUsernamesService
reservedUsernames ReservedUsernamesService
verification VerificationService verification VerificationService
botVerification BotVerificationService botVerification BotVerificationService
account AccountService account AccountService
@ -487,6 +502,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Usernames != nil { if deps.Usernames != nil {
s.usernames = deps.Usernames s.usernames = deps.Usernames
} }
if deps.ReservedUsernames != nil {
s.reservedUsernames = deps.ReservedUsernames
}
if deps.Verification != nil { if deps.Verification != nil {
s.verification = deps.Verification s.verification = deps.Verification
} }
@ -2059,6 +2077,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) { func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
if userID < 0 || channelID < 0 { if userID < 0 || channelID < 0 {
return domain.Peer{}, fmt.Errorf("owner id must be positive") return domain.Peer{}, fmt.Errorf("owner id must be positive")

View file

@ -12,6 +12,7 @@ import (
usernamesapp "telesrv/internal/app/usernames" usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store/memory"
) )
// Compile-time proof that the shipped use-case services satisfy the admin ports. // 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") 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)
}
}

View file

@ -98,6 +98,9 @@ type Service interface {
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, 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) ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
@ -234,6 +237,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername)) 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", s.authenticated(s.handleCollectibleUsernames))
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername)) 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 // Official platform verification. Unlike every route above, these carry a
// named permission, so a scoped token can be given the review surface and // named permission, so a scoped token can be given the review surface and
// nothing else. Revocation additionally requires verification.revoke. // nothing else. Revocation additionally requires verification.revoke.
@ -1186,6 +1192,54 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
writeCommandResult(w, result, err) 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) { func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
filter := domain.CollectibleUsernameFilter{ filter := domain.CollectibleUsernameFilter{

View file

@ -510,12 +510,30 @@ func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDeci
type captureCollectibleUsernameService struct { type captureCollectibleUsernameService struct {
fakeService fakeService
mint admin.MintCollectibleUsernameRequest mint admin.MintCollectibleUsernameRequest
transfer admin.TransferCollectibleUsernameRequest transfer admin.TransferCollectibleUsernameRequest
revoke admin.RevokeCollectibleUsernameRequest revoke admin.RevokeCollectibleUsernameRequest
del admin.DeleteCollectibleUsernameRequest del admin.DeleteCollectibleUsernameRequest
filter domain.CollectibleUsernameFilter reserve admin.ReserveUsernameRequest
assetID int64 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) { func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
@ -731,3 +749,49 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) { func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
return nil, nil 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)
}
}

View file

@ -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
}

View file

@ -55,6 +55,24 @@ type CollectibleUsernameStore struct {
transfers map[int64][]domain.CollectibleUsernameTransfer transfers map[int64][]domain.CollectibleUsernameTransfer
// commands maps a provenance command key onto the asset it touched. // commands maps a provenance command key onto the asset it touched.
commands map[string]int64 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 // 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 return false, domain.ErrUsernameInvalid
} }
key := strings.ToLower(username) key := strings.ToLower(username)
if s.nameReservedLocked(key) {
return false, domain.ErrUsernameOccupied
}
if existing, ok := s.registry[key]; ok { if existing, ok := s.registry[key]; ok {
if existing.peer == peer && existing.row.Editable { if existing.peer == peer && existing.row.Editable {
if existing.row.Username == username { if existing.row.Username == username {
@ -313,6 +334,9 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re
if _, ok := s.registry[key]; ok { if _, ok := s.registry[key]; ok {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
} }
if s.nameReservedLocked(key) {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
now := time.Now().UTC() now := time.Now().UTC()
purchaseDate := req.PurchaseDate purchaseDate := req.PurchaseDate
if purchaseDate.IsZero() { if purchaseDate.IsZero() {

View file

@ -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
}

View file

@ -206,6 +206,11 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context,
} else if found { } else if found {
return domain.ErrUsernameOccupied return domain.ErrUsernameOccupied
} }
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
return err
} else if reserved {
return domain.ErrUsernameOccupied
}
var existing int64 var existing int64
switch err := tx.QueryRow(ctx, ` switch err := tx.QueryRow(ctx, `
SELECT id FROM collectible_usernames SELECT id FROM collectible_usernames

View file

@ -61,6 +61,21 @@ func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower st
return owner, true, nil 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) { func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) {
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false) owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
if err != nil || !found { if err != nil || !found {
@ -111,6 +126,11 @@ WHERE peer_type = $1
// otherwise account.updateUsername would silently release a minted asset. // 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 { func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
if usernameLower != "" { 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) owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
if err != nil { if err != nil {
return err return err

View file

@ -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()
}

View file

@ -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)
}