Compare commits

...

2 commits

Author SHA1 Message Date
be8afdd7d9 Merge branch 'feat/reserved-usernames' 2026-09-09 19:57:14 +01:00
a83aa45fb8 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.
2026-09-09 19:57:14 +01:00
23 changed files with 874 additions and 39 deletions

View file

@ -12,6 +12,7 @@ import (
"io/fs"
"mime/multipart"
"net/http"
"net/url"
"path"
"strconv"
"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/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
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/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
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/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/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/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
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"`
}
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) {

View file

@ -23,7 +23,7 @@
})();
</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">
</head>
<body>

View file

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

View file

@ -1,6 +1,7 @@
import {
AtSign,
BadgeCheck,
Ban,
Bot,
ChevronDown,
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={<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={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</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 { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
@ -82,6 +83,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/collectible-usernames") {
return <CollectibleUsernamesPage navigate={navigate} />;
}
if (route.path === "/reserved-usernames") {
return <ReservedUsernamesPage />;
}
if (route.path === "/storage") {
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("/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";

View file

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

View file

@ -1203,6 +1203,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),
@ -1390,6 +1391,7 @@ func run(logger *zap.Logger) error {
Emoji: filesService,
Moderation: moderationService,
Usernames: usernamesService,
ReservedUsernames: reservedUsernameStore,
Verification: verificationService,
BotVerification: botVerificationService,
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"
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.
@ -363,6 +366,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.
@ -389,6 +402,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.
@ -420,6 +434,7 @@ type Service struct {
emoji EmojiService
moderation ModerationService
usernames CollectibleUsernamesService
reservedUsernames ReservedUsernamesService
verification VerificationService
botVerification BotVerificationService
account AccountService
@ -487,6 +502,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
}
@ -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) {
if userID < 0 || channelID < 0 {
return domain.Peer{}, fmt.Errorf("owner id must be positive")

View file

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

View file

@ -98,6 +98,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)
@ -234,6 +237,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.
@ -1186,6 +1192,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{

View file

@ -514,10 +514,28 @@ type captureCollectibleUsernameService struct {
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) {
s.mint = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
@ -731,3 +749,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)
}
}

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
// 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() {

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 {
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

View file

@ -61,6 +61,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 {
@ -111,6 +126,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

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