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 22846e340f
commit 2bdb1ecf37
21 changed files with 843 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import (
"io/fs"
"mime/multipart"
"net/http"
"net/url"
"path"
"strconv"
"strings"
@ -100,6 +101,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/groups", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/collectible-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("GET /api/reserved-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleReservedUsernamesAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("GET /api/storage/stats", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageStatsAPI)))
mux.Handle("GET /api/storage/accounts", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageAccountsAPI)))
@ -156,6 +158,8 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
mux.Handle("POST /api/actions/storage-manual-purge", s.scopedRoute(permissionStorageManage, http.HandlerFunc(s.handleStorageManualPurgeAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/reserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleReserveUsernameAPI)))
mux.Handle("POST /api/actions/unreserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleUnreserveUsernameAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
@ -2438,6 +2442,69 @@ type mintCollectibleUsernameAPIRequest struct {
PurchaseDate flexUnix `json:"purchase_date"`
}
type reserveUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
}
func (s *server) handleReserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body reserveUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ReserveUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reserve-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/reserve", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleUnreserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body reserveUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.UnreserveUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "unreserve-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/unreserve", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleReservedUsernamesAPI(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
params := url.Values{}
for _, name := range []string{"q", "limit", "offset"} {
if v := strings.TrimSpace(q.Get(name)); v != "" {
params.Set(name, v)
}
}
apiPath := "/v1/reserved-usernames"
if enc := params.Encode(); enc != "" {
apiPath += "?" + enc
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "request build failed")
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, "admin api unreachable")
return
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(data)
}
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body mintCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {

View file

@ -24,6 +24,7 @@ import type {
ChannelListResponse,
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
ReservedUsernameListResponse,
CommandResult,
DockerService,
EnvGroup,
@ -177,6 +178,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,
@ -288,6 +289,9 @@ export function Shell({
{canReadUsernames && (
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
)}
{canReadUsernames && (
<NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
)}
{canReadStorage && (
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</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

@ -5,6 +5,7 @@ import { AccountsPage } from "./AccountsPage";
import { SharedDevicesPage } from "./SharedDevicesPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
@ -103,6 +104,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/collectible-usernames") {
return gate(permissionUsernamesRead, <CollectibleUsernamesPage navigate={navigate} />);
}
if (route.path === "/reserved-usernames") {
return <ReservedUsernamesPage />;
}
if (route.path === "/storage") {
return gate(permissionStorageRead, <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