feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -125,6 +125,21 @@ TELESRV_ADMIN_API_ADDR=
# Admin UI 监听地址默认值通常无需修改RTMP ingest 保留 2400。
TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600
# Permissions granted to an Admin UI session that logged in with
# TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every
# permission and is the default, so enabling RBAC never locks an operator out of
# a panel that worked before. Names are letters/digits/._:- and may end in
# "namespace.*" to grant a whole namespace.
TELESRV_ADMIN_UI_PERMISSIONS=*
# Additional Admin API bearer tokens with a bounded permission set each, so an
# integration gets exactly the rights it needs instead of the unrestricted
# TELESRV_ADMIN_API_TOKEN. Format: "name:token:perm1,perm2" entries separated by
# ';'. A token may not contain ':' or whitespace, names and tokens must be
# unique, and reusing TELESRV_ADMIN_API_TOKEN here is refused; any malformed
# entry fails startup rather than silently granting or dropping rights.
TELESRV_ADMIN_SCOPED_TOKENS=
TELESRV_POSTGRES_DSN=postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable
TELESRV_REDIS_ADDR=127.0.0.1:6399
TELESRV_REDIS_PASSWORD=
@ -167,6 +182,90 @@ TELESRV_STARGIFT_TRANSFER_DELAY=0s
TELESRV_STARGIFT_RESELL_DELAY=0s
TELESRV_STARGIFT_CRAFT_DELAY=0s
TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250
# Local admin-only composite account rating. It is not projected into Telegram's
# userFull.stars_rating fields. Disabling it refuses local rating writes.
TELESRV_RATING_ENABLED=true
# A local rating increase is parked for this long before it becomes the visible
# admin level; a decrease always applies immediately. 0 applies every change at once.
TELESRV_RATING_PENDING_DELAY=24h
# Background recompute worker: the rating derives from signals owned by other
# subsystems, so freshness is a worker property rather than a write-path one.
TELESRV_RATING_RECOMPUTE_INTERVAL=15m
TELESRV_RATING_RECOMPUTE_BATCH=500
TELESRV_RATING_STALE_AFTER=6h
# Integer composite weights; the defaults below are exactly the shipped domain
# formula. Penalties are magnitudes that the formula subtracts, so every value is
# non-negative and a negative one fails startup.
TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE=1000
TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE=250
TELESRV_RATING_WEIGHT_MESSAGE_SENT=1
TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY=2
TELESRV_RATING_WEIGHT_GIFT_RECEIVED=25
TELESRV_RATING_WEIGHT_MODERATION_CASE=150
TELESRV_RATING_WEIGHT_SCAM_PENALTY=5000
TELESRV_RATING_WEIGHT_FAKE_PENALTY=5000
# Upper bound of the activity component so activity alone cannot outweigh Stars
# and moderation; 0 leaves it uncapped.
TELESRV_RATING_ACTIVITY_CAP=5000
# Landing URL recorded on a minted collectible (NFT) username when the mint
# command carries no explicit URL. Empty derives
# <TELESRV_PUBLIC_BASE_URL>/nft/username/<username>. A template may carry the
# {username} placeholder; without it the name is appended as the last path
# segment. No external marketplace is contacted.
TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE=
# Official platform verification: applications filed through the built-in
# @verifybot and decided in the admin panel. An approval flips the platform
# verified flag on the target peer and nothing else; it is not the third-party
# bot verification icon. Disabling refuses every verification use case, while
# peers already carrying the badge keep it.
TELESRV_VERIFICATION_ENABLED=true
# Plain user accounts as verification subjects. Off by default: the official
# process verifies a public presence (bot, public channel, public supergroup).
TELESRV_VERIFICATION_ALLOW_USER_TARGETS=false
# How long an applicant must wait before filing the same target again after a
# rejection, measured from the decision so a slow review never shortens it.
# 0 disables the cooldown; must be 0..8760h.
TELESRV_VERIFICATION_REJECT_COOLDOWN=720h
# Applications one applicant may create per window. Either value 0 disables the
# budget; a positive limit requires a positive window.
TELESRV_VERIFICATION_APPLY_RATE_LIMIT=3
TELESRV_VERIFICATION_APPLY_RATE_WINDOW=24h
# @verifybot dialog rate per applicant, independent of how many applications are
# actually created. Either value 0 disables it.
TELESRV_VERIFICATION_BOT_RATE_LIMIT=30
TELESRV_VERIFICATION_BOT_RATE_WINDOW=1m
# Applicant notification worker. A decision commits with its outbox row, never
# with a message send, so delivery is a separate retrying cycle over durable
# rows. Interval must be positive; batch must be 1..500.
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
TELESRV_VERIFICATION_NOTIFY_BATCH=50
# Applications one applicant may keep open at once; 0 disables the cap, maximum
# is 50.
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
# Third-party bot verification (core.telegram.org/api/bots/verification): a
# verifier bot marks peers with its OWN icon and description, which clients render
# before the name. This is NOT the platform checkmark above: the operator grants
# verifier status to a bot, and the two mechanisms never read each other's state.
# Disabling refuses every third-party mutation (grants, revocations, applications,
# icon catalogue edits) while the marks already granted keep rendering -- blanking
# one verifier's badges is what its per-verifier kill switch is for.
TELESRV_BOT_VERIFICATION_ENABLED=true
# Peers one verifier bot may mark. Verifier status is granted per deployment rather
# than earned per peer, so an unbounded verifier would be an unbounded badge
# printer. 0 disables the service bound and leaves only the storage bound, which is
# also the maximum accepted here (10000).
TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER=10000
# Verification applications one applicant may file per window, across all verifier
# bots. Either value 0 disables the budget; a positive limit requires a positive
# window. Looser than the official budget on purpose: a deployment can run several
# verifier companies, and filing with a second one is not a retry of the first.
TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT=5
TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW=24h
TELESRV_BLOB_DIR=data/blobs
TELESRV_STICKER_SEED_DIR=data/sticker-seed
# Optional Premium feature-preview media export. Missing directory keeps the

View file

@ -0,0 +1,569 @@
package main
import (
"errors"
"net/http"
"strconv"
"strings"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// Third-party bot verification in the panel BFF
// (core.telegram.org/api/bots/verification).
//
// This is NOT the official platform badge (see verification.go): third-party
// verification is an attributed mark granted by a verifier bot, carrying that
// verifier's own custom emoji icon and description. The two mechanisms own
// separate tables (verification_icons / bot_verifier_settings /
// custom_verifications / custom_verification_requests vs
// verification_applications), separate permissions (botverification.* vs
// verification.*) and separate routes, and neither reads the other's state.
//
// Reads come straight from PostgreSQL, like every other table view, so the tables
// page without a hop through the admin API and peers can be resolved by a join.
// Every mutation goes the other way -- always through the admin API, so the command
// journal, the status machine and the optimistic lock are enforced in one place and
// a panel action is indistinguishable from an API one in the audit trail.
// botVerificationRead mounts a route behind a session and botverification.review.
func (s *server) botVerificationRead(handler http.HandlerFunc) http.Handler {
return s.requireAuthAPI(s.requirePermission(permissionBotVerificationReview, handler))
}
// botVerificationManage mounts a route behind a session and botverification.manage.
//
// The manage right is checked on its own rather than on top of review: appointing
// a verifier and working its queue are different jobs, so an operator may hold
// either without the other.
func (s *server) botVerificationManage(handler http.HandlerFunc) http.Handler {
return s.requireAuthAPI(s.requirePermission(permissionBotVerificationManage, handler))
}
// ---------------------------------------------------------------------------
// Reads
// ---------------------------------------------------------------------------
func (s *server) handleBotVerifiersAPI(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, err := s.read.ListBotVerifiers(r.Context(), queryFlag(query.Get("enabled_only")), limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
func (s *server) handleVerificationIconsAPI(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, err := s.read.ListVerificationIcons(r.Context(), queryFlag(query.Get("active_only")), limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
// handleCustomVerificationsAPI pages granted marks. The filter is validated before
// the read store is consulted: a malformed query is a 400 whether or not the
// database happens to be reachable.
func (s *server) handleCustomVerificationsAPI(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
peerType := strings.TrimSpace(query.Get("peer_type"))
if !validMarkablePeerType(peerType) {
writeAPIError(w, http.StatusBadRequest, "invalid peer_type")
return
}
verifierBotID, err := parseInt64(query.Get("verifier_bot_id"))
if err != nil || verifierBotID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid verifier_bot_id")
return
}
beforeID, err := parseInt64(query.Get("before_id"))
if err != nil || beforeID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, hasMore, err := s.read.ListCustomVerifications(r.Context(), verifierBotID, peerType, query.Get("q"), beforeID, limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := ""
if hasMore && len(rows) > 0 {
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleCustomVerificationRequestsAPI(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
status := strings.TrimSpace(query.Get("status"))
if status != "" && !domain.CustomVerificationRequestStatus(status).Valid() {
writeAPIError(w, http.StatusBadRequest, "invalid status")
return
}
peerType := strings.TrimSpace(query.Get("peer_type"))
if !validMarkablePeerType(peerType) {
writeAPIError(w, http.StatusBadRequest, "invalid peer_type")
return
}
verifierBotID, err := parseInt64(query.Get("verifier_bot_id"))
if err != nil || verifierBotID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid verifier_bot_id")
return
}
beforeID, err := parseInt64(query.Get("before_id"))
if err != nil || beforeID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, hasMore, err := s.read.ListCustomVerificationRequests(
r.Context(), status, verifierBotID, peerType, query.Get("q"), beforeID, limit,
)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := ""
if hasMore && len(rows) > 0 {
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleCustomVerificationRequestDetailAPI(w http.ResponseWriter, r *http.Request) {
id, ok := botVerificationPathID(w, r)
if !ok {
return
}
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
detail, err := s.read.CustomVerificationRequestDetail(r.Context(), id)
if err != nil {
if errors.Is(err, errReadNotFound) {
writeAPIError(w, http.StatusNotFound, "custom verification request not found")
return
}
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"request": detail.Request,
"verifier": detail.Verifier,
// mark_active describes the peer as it is now, not as the status implies: a
// reviewer has to see that an approved mark was since stripped by the
// operator before deciding anything else about it.
"mark_active": detail.MarkActive,
})
}
func (s *server) handleCustomVerificationCountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
counts, err := s.read.CustomVerificationRequestCounts(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"counts": counts})
}
// ---------------------------------------------------------------------------
// Queue decisions
// ---------------------------------------------------------------------------
// botVerificationDecisionAPIRequest is the decision payload shared by the three
// per-application actions. version is the optimistic-locking token the reviewer
// read; internal_note is operator-only and is not part of what the applicant is
// told. It is optional everywhere, so one panel form can drive all three actions
// without tripping the strict decoder.
type botVerificationDecisionAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Version flexInt64 `json:"version"`
InternalNote string `json:"internal_note"`
}
func (s *server) handleApproveBotVerificationAPI(w http.ResponseWriter, r *http.Request) {
id, ok := botVerificationPathID(w, r)
if !ok {
return
}
var body botVerificationDecisionAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ApproveBotVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "approve-bot-verification"),
RequestID: id,
Version: body.Version.Int64(),
InternalNote: body.InternalNote,
}
result, status, err := s.callAdminCommand(r.Context(), botVerificationDecisionPath(id, "approve"), req)
writeBotVerificationResultAPI(w, result, status, err)
}
func (s *server) handleRejectBotVerificationAPI(w http.ResponseWriter, r *http.Request) {
id, ok := botVerificationPathID(w, r)
if !ok {
return
}
var body botVerificationDecisionAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RejectBotVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reject-bot-verification"),
RequestID: id,
Version: body.Version.Int64(),
InternalNote: body.InternalNote,
}
result, status, err := s.callAdminCommand(r.Context(), botVerificationDecisionPath(id, "reject"), req)
writeBotVerificationResultAPI(w, result, status, err)
}
func (s *server) handleRevokeBotVerificationAPI(w http.ResponseWriter, r *http.Request) {
id, ok := botVerificationPathID(w, r)
if !ok {
return
}
var body botVerificationDecisionAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RevokeBotVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-bot-verification"),
RequestID: id,
Version: body.Version.Int64(),
InternalNote: body.InternalNote,
}
result, status, err := s.callAdminCommand(r.Context(), botVerificationDecisionPath(id, "revoke"), req)
writeBotVerificationResultAPI(w, result, status, err)
}
// ---------------------------------------------------------------------------
// Operator actions
// ---------------------------------------------------------------------------
// grantBotVerifierAPIRequest appoints a bot as a verifier or reconfigures one.
// version is 0 for a new grant and the token the operator read for an update, so
// two operators editing the same verifier cannot clobber each other. enabled is
// deliberately absent: the kill switch is its own action.
type grantBotVerifierAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
BotID flexInt64 `json:"bot_id"`
IconDocumentID flexInt64 `json:"icon_document_id"`
CompanyName string `json:"company_name"`
DefaultDescription string `json:"default_description"`
CanModifyCustomDescription bool `json:"can_modify_custom_description"`
Version flexInt64 `json:"version"`
}
func (s *server) handleGrantBotVerifierAPI(w http.ResponseWriter, r *http.Request) {
var body grantBotVerifierAPIRequest
if !decodeAction(w, r, &body) {
return
}
if body.BotID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid bot_id")
return
}
if body.IconDocumentID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid icon_document_id")
return
}
if strings.TrimSpace(body.CompanyName) == "" {
writeAPIError(w, http.StatusBadRequest, "company_name is required")
return
}
if body.Version.Int64() < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid version")
return
}
req := admin.GrantBotVerifierRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-bot-verifier"),
BotID: body.BotID.Int64(),
IconDocumentID: body.IconDocumentID.Int64(),
CompanyName: body.CompanyName,
DefaultDescription: body.DefaultDescription,
CanModifyCustomDescription: body.CanModifyCustomDescription,
Version: body.Version.Int64(),
}
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/verifiers/grant", req)
writeBotVerificationResultAPI(w, result, status, err)
}
type setBotVerifierEnabledAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
BotID flexInt64 `json:"bot_id"`
Enabled bool `json:"enabled"`
}
func (s *server) handleSetBotVerifierEnabledAPI(w http.ResponseWriter, r *http.Request) {
var body setBotVerifierEnabledAPIRequest
if !decodeAction(w, r, &body) {
return
}
if body.BotID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid bot_id")
return
}
req := admin.SetBotVerifierEnabledRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-bot-verifier-enabled"),
BotID: body.BotID.Int64(),
Enabled: body.Enabled,
}
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/verifiers/set-enabled", req)
writeBotVerificationResultAPI(w, result, status, err)
}
type revokeBotVerifierAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
BotID flexInt64 `json:"bot_id"`
}
func (s *server) handleRevokeBotVerifierAPI(w http.ResponseWriter, r *http.Request) {
var body revokeBotVerifierAPIRequest
if !decodeAction(w, r, &body) {
return
}
if body.BotID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid bot_id")
return
}
req := admin.RevokeBotVerifierRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-bot-verifier"),
BotID: body.BotID.Int64(),
}
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/verifiers/revoke", req)
writeBotVerificationResultAPI(w, result, status, err)
}
// upsertVerificationIconAPIRequest adds or updates a catalogue entry. owner_bot_id
// is optional: absent (or 0) means a shared entry any verifier may use.
type upsertVerificationIconAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
DocumentID flexInt64 `json:"document_id"`
Name string `json:"name"`
OwnerBotID flexInt64 `json:"owner_bot_id"`
}
func (s *server) handleUpsertVerificationIconAPI(w http.ResponseWriter, r *http.Request) {
var body upsertVerificationIconAPIRequest
if !decodeAction(w, r, &body) {
return
}
if body.DocumentID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid document_id")
return
}
if strings.TrimSpace(body.Name) == "" {
writeAPIError(w, http.StatusBadRequest, "name is required")
return
}
if body.OwnerBotID.Int64() < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid owner_bot_id")
return
}
req := admin.UpsertVerificationIconRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "upsert-verification-icon"),
DocumentID: body.DocumentID.Int64(),
Name: body.Name,
OwnerBotID: body.OwnerBotID.Int64(),
}
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/icons/upsert", req)
writeBotVerificationResultAPI(w, result, status, err)
}
type setVerificationIconActiveAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
IconID flexInt64 `json:"icon_id"`
Active bool `json:"active"`
}
func (s *server) handleSetVerificationIconActiveAPI(w http.ResponseWriter, r *http.Request) {
var body setVerificationIconActiveAPIRequest
if !decodeAction(w, r, &body) {
return
}
if body.IconID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid icon_id")
return
}
req := admin.SetVerificationIconActiveRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-verification-icon-active"),
IconID: body.IconID.Int64(),
Active: body.Active,
}
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/icons/set-active", req)
writeBotVerificationResultAPI(w, result, status, err)
}
// revokeCustomVerificationAPIRequest strips one verifier's mark from a peer. It
// addresses the (verifier, peer) pair rather than an application, because the
// operator may have to strip a mark no application ever produced.
type revokeCustomVerificationAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
VerifierBotID flexInt64 `json:"verifier_bot_id"`
PeerType string `json:"peer_type"`
PeerID flexInt64 `json:"peer_id"`
}
func (s *server) handleRevokeCustomVerificationAPI(w http.ResponseWriter, r *http.Request) {
var body revokeCustomVerificationAPIRequest
if !decodeAction(w, r, &body) {
return
}
if body.VerifierBotID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid verifier_bot_id")
return
}
peerType := strings.TrimSpace(body.PeerType)
if peerType == "" || !validMarkablePeerType(peerType) {
writeAPIError(w, http.StatusBadRequest, "invalid peer_type")
return
}
if body.PeerID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid peer_id")
return
}
req := admin.RevokeCustomVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-custom-verification"),
VerifierBotID: body.VerifierBotID.Int64(),
PeerType: domain.PeerType(peerType),
PeerID: body.PeerID.Int64(),
}
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/marks/revoke", req)
writeBotVerificationResultAPI(w, result, status, err)
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
func botVerificationPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := parseInt64(r.PathValue("id"))
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return 0, false
}
return id, true
}
func botVerificationDecisionPath(requestID int64, action string) string {
return "/v1/botverification/requests/" + strconv.FormatInt(requestID, 10) + "/" + action
}
// validMarkablePeerType accepts the peer kinds a third-party mark can sit on, plus
// the empty string for "no filter". An unmodelled value is refused rather than
// silently returning nothing, so a typo is reported.
func validMarkablePeerType(peerType string) bool {
switch domain.PeerType(peerType) {
case "", domain.PeerTypeUser, domain.PeerTypeChannel:
return true
default:
return false
}
}
// queryFlag reads a boolean query flag the way the panel writes it.
func queryFlag(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// writeBotVerificationResultAPI relays the admin API's own status to the browser.
//
// The generic action handlers flatten every upstream failure into 502, which is
// fine when the only failure mode is "bad request". These have more: 409 when
// another operator changed the row first or a verifier hit its mark bound, and 404
// for a row that is gone. Those have to reach the panel intact, because 409 is the
// one failure it resolves by reloading rather than by asking the operator to change
// something.
func writeBotVerificationResultAPI(w http.ResponseWriter, result admin.CommandResult, status int, err error) {
if err == nil {
writeJSON(w, http.StatusOK, result)
return
}
if result.Status == "" {
result.Status = "failed"
}
if result.Message == "" {
result.Message = "command failed"
}
if result.Error == "" {
result.Error = err.Error()
}
if status < 400 {
// No HTTP answer at all: the admin API was unreachable or unparsable.
status = http.StatusBadGateway
}
writeJSON(w, status, result)
}

View file

@ -0,0 +1,559 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"telesrv/internal/admin"
)
// Third-party bot verification in the panel BFF. The section is separate from the
// official verification one in every dimension that matters here: its own routes,
// its own two permissions, and no overlap with verification.* in either direction.
// botVerificationRoute is one panel route with a body its handler accepts.
type panelBotVerificationRoute struct {
method string
path string
body string
}
var panelBotVerificationReadRoutes = []panelBotVerificationRoute{
{http.MethodGet, "/api/botverification/verifiers", ""},
{http.MethodGet, "/api/botverification/icons", ""},
{http.MethodGet, "/api/botverification/marks", ""},
{http.MethodGet, "/api/botverification/requests", ""},
{http.MethodGet, "/api/botverification/requests/7", ""},
{http.MethodGet, "/api/botverification/counts", ""},
{http.MethodPost, "/api/botverification/requests/7/approve", `{}`},
{http.MethodPost, "/api/botverification/requests/7/reject", `{}`},
{http.MethodPost, "/api/botverification/requests/7/revoke", `{}`},
}
var panelBotVerificationManageRoutes = []panelBotVerificationRoute{
{http.MethodPost, "/api/actions/grant-bot-verifier", `{}`},
{http.MethodPost, "/api/actions/set-bot-verifier-enabled", `{}`},
{http.MethodPost, "/api/actions/revoke-bot-verifier", `{}`},
{http.MethodPost, "/api/actions/upsert-verification-icon", `{}`},
{http.MethodPost, "/api/actions/set-verification-icon-active", `{}`},
{http.MethodPost, "/api/actions/revoke-custom-verification", `{}`},
}
func panelBotVerificationRoutes() []panelBotVerificationRoute {
out := make([]panelBotVerificationRoute, 0,
len(panelBotVerificationReadRoutes)+len(panelBotVerificationManageRoutes))
out = append(out, panelBotVerificationReadRoutes...)
return append(out, panelBotVerificationManageRoutes...)
}
func TestBotVerificationPanelRoutesRequireASession(t *testing.T) {
srv := panelServer(t, permissionAll)
for _, item := range panelBotVerificationRoutes() {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`)))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s status=%d, want 401", item.method, item.path, rec.Code)
}
}
}
// Every mutating route in the section is behind the double-submit CSRF token, like
// every other one in the panel: a cookie-authenticated request forged by another
// origin must not be able to appoint a verifier.
func TestBotVerificationMutationsRequireTheCSRFHeader(t *testing.T) {
srv := panelServer(t, permissionAll)
cookies, token := signIn(t, srv)
for _, item := range panelBotVerificationRoutes() {
if item.method != http.MethodPost {
continue
}
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(
httptest.NewRequest(item.method, item.path, strings.NewReader(item.body)), cookies))
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), csrfHeaderName) {
t.Fatalf("%s status=%d body=%s, want 403 without a csrf header", item.path, rec.Code, rec.Body.String())
}
}
// A foreign origin is refused even when the token is right.
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier",
strings.NewReader(`{}`)), cookies)
req.Header.Set(csrfHeaderName, token)
req.Header.Set("Origin", "https://evil.example")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "origin") {
t.Fatalf("foreign origin status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
}
// Reads do not need the token: they change nothing, and requiring it would break
// the panel without adding protection.
func TestBotVerificationReadsDoNotNeedTheCSRFHeader(t *testing.T) {
srv := panelServer(t, permissionBotVerificationReview)
cookies, _ := signIn(t, srv)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(
httptest.NewRequest(http.MethodGet, "/api/botverification/verifiers", nil), cookies))
// No read store is wired in this fixture, so the gate passing is what is under
// test: 503 means the request got past authorisation and CSRF.
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("GET status=%d body=%s, want the gate passed without a token", rec.Code, rec.Body.String())
}
}
func TestBotVerificationRoutesRefuseASessionWithoutTheRight(t *testing.T) {
// A session holding only the OFFICIAL verification rights: the two mechanisms
// are separate, so it must not reach this section at all.
srv := panelServer(t, permissionVerificationReview, permissionVerificationRevoke)
cookies, token := signIn(t, srv)
check := func(item panelBotVerificationRoute, wantPermission string) {
var req *http.Request
if item.body == "" {
req = httptest.NewRequest(item.method, item.path, nil)
} else {
req = httptest.NewRequest(item.method, item.path, strings.NewReader(item.body))
req.Header.Set(csrfHeaderName, token)
}
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(req, cookies))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["code"] != "FORBIDDEN" || body["permission"] != wantPermission {
t.Fatalf("%s 403 body=%+v, want %s named", item.path, body, wantPermission)
}
}
for _, item := range panelBotVerificationReadRoutes {
check(item, permissionBotVerificationReview)
}
for _, item := range panelBotVerificationManageRoutes {
check(item, permissionBotVerificationManage)
}
}
// The two halves are independent: the review right does not appoint verifiers, and
// the manage right does not decide applications.
func TestBotVerificationReviewAndManageAreIndependent(t *testing.T) {
reviewOnly := panelServer(t, permissionBotVerificationReview)
cookies, token := signIn(t, reviewOnly)
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier",
strings.NewReader(`{"reason":"partner","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"x"}`)), cookies)
req.Header.Set(csrfHeaderName, token)
rec := httptest.NewRecorder()
reviewOnly.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), permissionBotVerificationManage) {
t.Fatalf("review-only on grant status=%d body=%s, want 403 naming manage", rec.Code, rec.Body.String())
}
manageOnly := panelServer(t, permissionBotVerificationManage)
cookies, token = signIn(t, manageOnly)
req = withCookies(httptest.NewRequest(http.MethodPost, "/api/botverification/requests/7/approve",
strings.NewReader(`{"reason":"verified","confirm":true,"version":3}`)), cookies)
req.Header.Set(csrfHeaderName, token)
rec = httptest.NewRecorder()
manageOnly.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), permissionBotVerificationReview) {
t.Fatalf("manage-only on approve status=%d body=%s, want 403 naming review", rec.Code, rec.Body.String())
}
}
// A session holding the third-party rights must not reach the official section
// either: the separation is symmetric.
func TestBotVerificationSessionCannotReachTheOfficialSection(t *testing.T) {
srv := panelServer(t, permissionBotVerificationReview, permissionBotVerificationManage)
cookies, _ := signIn(t, srv)
for _, path := range []string{"/api/verification/applications", "/api/verification/counts"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, path, nil), cookies))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
}
}
}
func TestApproveBotVerificationBFFForwardsActorVersionAndNote(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/approve", strings.NewReader(`{
"reason":"the outlet checks out","confirm":true,"version":"9223372036854775807",
"internal_note":"contact came through the press office"
}`))
req.SetPathValue("id", "88")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleApproveBotVerificationAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if upstream.path != "/v1/botverification/requests/88/approve" {
t.Fatalf("upstream path=%q", upstream.path)
}
var got admin.ApproveBotVerificationRequest
if err := json.Unmarshal(upstream.raw, &got); err != nil {
t.Fatalf("decode forwarded approval: %v (%s)", err, upstream.raw)
}
if got.Actor != "operator" {
t.Fatalf("actor=%q, want the signed-in operator", got.Actor)
}
// The version arrives as a decimal string from the browser and must survive
// exactly: a rounded version would decide the wrong revision of the row.
if got.RequestID != 88 || got.Version != 9223372036854775807 {
t.Fatalf("forwarded approval=%+v, want the exact int64 version", got)
}
if got.InternalNote != "contact came through the press office" || got.DryRun {
t.Fatalf("forwarded approval=%+v", got)
}
if got.CommandID == "" {
t.Fatal("no command id was minted for the idempotency key")
}
}
// confirm=false is a rehearsal: nothing may be written until the operator confirms.
func TestBotVerificationBFFDefaultsToADryRun(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/reject", strings.NewReader(
`{"reason":"not an outlet","confirm":false,"version":3}`))
req.SetPathValue("id", "88")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleRejectBotVerificationAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var got admin.RejectBotVerificationRequest
if err := json.Unmarshal(upstream.raw, &got); err != nil {
t.Fatalf("decode forwarded rejection: %v", err)
}
if !got.DryRun || got.Version != 3 || got.RequestID != 88 {
t.Fatalf("forwarded rejection=%+v", got)
}
// The same on an operator action.
req = requestWithActor(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-bot-verifier", strings.NewReader(
`{"reason":"programme ended","confirm":false,"bot_id":3003}`)), "operator")
rec = httptest.NewRecorder()
srv.handleRevokeBotVerifierAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("revoke status=%d body=%s", rec.Code, rec.Body.String())
}
var revoke admin.RevokeBotVerifierRequest
if err := json.Unmarshal(upstream.raw, &revoke); err != nil {
t.Fatalf("decode forwarded revocation: %v", err)
}
if !revoke.DryRun || revoke.BotID != 3003 {
t.Fatalf("forwarded revocation=%+v", revoke)
}
}
func TestGrantBotVerifierBFFForwardsThePayloadAndRejectsBadShapes(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := requestWithActor(httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(`{
"reason":"partner programme","confirm":true,
"bot_id":"9223372036854775807","icon_document_id":"9223372036854775806",
"company_name":"Example Trust","default_description":"verified by Example Trust",
"can_modify_custom_description":true,"version":"4"
}`)), "operator")
rec := httptest.NewRecorder()
srv.handleGrantBotVerifierAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if upstream.path != "/v1/botverification/verifiers/grant" {
t.Fatalf("upstream path=%q", upstream.path)
}
var got admin.GrantBotVerifierRequest
if err := json.Unmarshal(upstream.raw, &got); err != nil {
t.Fatalf("decode forwarded grant: %v", err)
}
if got.BotID != 9223372036854775807 || got.IconDocumentID != 9223372036854775806 ||
got.Version != 4 || got.Actor != "operator" || got.DryRun {
t.Fatalf("forwarded grant=%+v, want the exact int64s", got)
}
if got.CompanyName != "Example Trust" || !got.CanModifyCustomDescription {
t.Fatalf("forwarded grant=%+v", got)
}
for _, payload := range []string{
`{"reason":"x","confirm":true,"bot_id":0,"icon_document_id":900,"company_name":"y"}`,
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":0,"company_name":"y"}`,
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":" "}`,
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"y","version":-1}`,
} {
rec := httptest.NewRecorder()
srv.handleGrantBotVerifierAPI(rec, requestWithActor(
httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(payload)), "operator"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("payload %s status=%d body=%s, want 400", payload, rec.Code, rec.Body.String())
}
}
}
func TestBotVerificationOperatorActionsForwardTheirPayloads(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
rec := httptest.NewRecorder()
srv.handleSetBotVerifierEnabledAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/set-bot-verifier-enabled", strings.NewReader(
`{"reason":"abuse report","confirm":true,"bot_id":"3003","enabled":false}`)), "operator"))
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/verifiers/set-enabled" {
t.Fatalf("set-enabled status=%d path=%q body=%s", rec.Code, upstream.path, rec.Body.String())
}
var setEnabled admin.SetBotVerifierEnabledRequest
if err := json.Unmarshal(upstream.raw, &setEnabled); err != nil {
t.Fatalf("decode: %v", err)
}
if setEnabled.BotID != 3003 || setEnabled.Enabled || setEnabled.Actor != "operator" {
t.Fatalf("forwarded=%+v", setEnabled)
}
rec = httptest.NewRecorder()
srv.handleUpsertVerificationIconAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/upsert-verification-icon", strings.NewReader(
`{"reason":"new icon","confirm":true,"document_id":"9223372036854775807","name":"blue check","owner_bot_id":"3003"}`)), "operator"))
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/icons/upsert" {
t.Fatalf("upsert-icon status=%d path=%q body=%s", rec.Code, upstream.path, rec.Body.String())
}
var icon admin.UpsertVerificationIconRequest
if err := json.Unmarshal(upstream.raw, &icon); err != nil {
t.Fatalf("decode: %v", err)
}
if icon.DocumentID != 9223372036854775807 || icon.Name != "blue check" || icon.OwnerBotID != 3003 {
t.Fatalf("forwarded=%+v", icon)
}
// owner_bot_id is optional: absent means a shared catalogue entry.
rec = httptest.NewRecorder()
srv.handleUpsertVerificationIconAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/upsert-verification-icon", strings.NewReader(
`{"reason":"new icon","confirm":true,"document_id":900,"name":"shared"}`)), "operator"))
if rec.Code != http.StatusOK {
t.Fatalf("shared icon status=%d body=%s", rec.Code, rec.Body.String())
}
// A fresh target: owner_bot_id is omitted when zero, so decoding into the
// previous value would silently keep the reserved owner.
var shared admin.UpsertVerificationIconRequest
if err := json.Unmarshal(upstream.raw, &shared); err != nil {
t.Fatalf("decode: %v", err)
}
if shared.OwnerBotID != 0 || shared.Name != "shared" {
t.Fatalf("forwarded=%+v, want a shared entry", shared)
}
rec = httptest.NewRecorder()
srv.handleSetVerificationIconActiveAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/set-verification-icon-active", strings.NewReader(
`{"reason":"retired","confirm":true,"icon_id":"501","active":false}`)), "operator"))
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/icons/set-active" {
t.Fatalf("set-icon-active status=%d path=%q", rec.Code, upstream.path)
}
var iconActive admin.SetVerificationIconActiveRequest
if err := json.Unmarshal(upstream.raw, &iconActive); err != nil {
t.Fatalf("decode: %v", err)
}
if iconActive.IconID != 501 || iconActive.Active {
t.Fatalf("forwarded=%+v", iconActive)
}
rec = httptest.NewRecorder()
srv.handleRevokeCustomVerificationAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/revoke-custom-verification", strings.NewReader(
`{"reason":"impersonation","confirm":true,"verifier_bot_id":"3003","peer_type":"channel","peer_id":"9223372036854775807"}`)), "operator"))
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/marks/revoke" {
t.Fatalf("revoke-mark status=%d path=%q body=%s", rec.Code, upstream.path, rec.Body.String())
}
var mark admin.RevokeCustomVerificationRequest
if err := json.Unmarshal(upstream.raw, &mark); err != nil {
t.Fatalf("decode: %v", err)
}
if mark.VerifierBotID != 3003 || mark.PeerType != "channel" || mark.PeerID != 9223372036854775807 {
t.Fatalf("forwarded=%+v", mark)
}
for _, payload := range []string{
`{"reason":"x","confirm":true,"verifier_bot_id":0,"peer_type":"channel","peer_id":5}`,
`{"reason":"x","confirm":true,"verifier_bot_id":3003,"peer_type":"chat","peer_id":5}`,
`{"reason":"x","confirm":true,"verifier_bot_id":3003,"peer_type":"","peer_id":5}`,
`{"reason":"x","confirm":true,"verifier_bot_id":3003,"peer_type":"channel","peer_id":0}`,
} {
rec := httptest.NewRecorder()
srv.handleRevokeCustomVerificationAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/revoke-custom-verification", strings.NewReader(payload)), "operator"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("payload %s status=%d body=%s, want 400", payload, rec.Code, rec.Body.String())
}
}
}
// A body may not smuggle in an actor: the signed-in operator is the audit identity,
// and the strict decoder is what enforces it.
func TestBotVerificationRequestsRejectUnknownFields(t *testing.T) {
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/approve", strings.NewReader(
`{"reason":"ok","confirm":true,"version":3,"actor":"attacker"}`))
req.SetPathValue("id", "88")
rec := httptest.NewRecorder()
srv.handleApproveBotVerificationAPI(rec, requestWithActor(req, "operator"))
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "actor") {
t.Fatalf("status=%d body=%s, want 400 rejecting the injected actor", rec.Code, rec.Body.String())
}
// enabled is not part of the grant form: the kill switch is its own action, and
// a silently ignored field would hide that from the operator.
rec = httptest.NewRecorder()
srv.handleGrantBotVerifierAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"y","enabled":true}`)), "operator"))
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enabled") {
t.Fatalf("status=%d body=%s, want 400 naming the unknown field", rec.Code, rec.Body.String())
}
}
func TestBotVerificationPathIDIsValidated(t *testing.T) {
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
for _, id := range []string{"", "0", "-1", "abc"} {
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/x/approve", strings.NewReader(
`{"reason":"ok","confirm":true,"version":3}`))
req.SetPathValue("id", id)
rec := httptest.NewRecorder()
srv.handleApproveBotVerificationAPI(rec, requestWithActor(req, "operator"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("id=%q status=%d, want 400", id, rec.Code)
}
}
}
// A flattened 502 would hide the one failure the panel resolves by reloading.
func TestBotVerificationConflictReachesThePanelAs409(t *testing.T) {
upstream := &verificationUpstream{
status: http.StatusConflict,
body: admin.CommandResult{
CommandID: "c1", Status: "failed",
Error: admin.CodeCustomVerificationConflict + ": custom verification changed concurrently",
Message: "another operator changed this row first; reload it and try again",
},
}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/approve", strings.NewReader(
`{"reason":"ok","confirm":true,"version":3}`))
req.SetPathValue("id", "88")
rec := httptest.NewRecorder()
srv.handleApproveBotVerificationAPI(rec, requestWithActor(req, "operator"))
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String())
}
var result admin.CommandResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if !strings.Contains(result.Error, admin.CodeCustomVerificationConflict) ||
!strings.Contains(result.Message, "reload") {
t.Fatalf("result=%+v", result)
}
// The manage half too: two operators can race one verifier row.
rec = httptest.NewRecorder()
srv.handleGrantBotVerifierAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"y","version":3}`)), "operator"))
if rec.Code != http.StatusConflict {
t.Fatalf("grant status=%d body=%s, want 409", rec.Code, rec.Body.String())
}
// A 404 from upstream is preserved as well, so a decision on a row that is gone
// is not reported as an upstream outage.
upstream.status = http.StatusNotFound
upstream.body = admin.CommandResult{CommandID: "c2", Status: "failed",
Error: admin.CodeCustomVerificationRequestNotFound + ": custom verification request not found"}
req = httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/reject", strings.NewReader(
`{"reason":"ok","confirm":true,"version":3}`))
req.SetPathValue("id", "88")
rec = httptest.NewRecorder()
srv.handleRejectBotVerificationAPI(rec, requestWithActor(req, "operator"))
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
}
// An unreachable admin API is the one case with no upstream status at all.
func TestBotVerificationUnreachableUpstreamIs502(t *testing.T) {
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
rec := httptest.NewRecorder()
srv.handleRevokeBotVerifierAPI(rec, requestWithActor(httptest.NewRequest(
http.MethodPost, "/api/actions/revoke-bot-verifier", strings.NewReader(
`{"reason":"x","confirm":true,"bot_id":3003}`)), "operator"))
if rec.Code != http.StatusBadGateway {
t.Fatalf("status=%d body=%s, want 502", rec.Code, rec.Body.String())
}
}
func TestBotVerificationReadFiltersAreValidatedBeforeTheStore(t *testing.T) {
// No read store: a malformed query still has to be a 400, so the panel is told
// what it got wrong whether or not the database is reachable.
srv := &server{}
cases := []struct {
handler http.HandlerFunc
path string
}{
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?peer_type=chat"},
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?verifier_bot_id=abc"},
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?before_id=-1"},
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?limit=abc"},
{srv.handleCustomVerificationRequestsAPI, "/api/botverification/requests?status=in_review"},
{srv.handleCustomVerificationRequestsAPI, "/api/botverification/requests?peer_type=chat"},
{srv.handleCustomVerificationRequestsAPI, "/api/botverification/requests?limit=-1"},
{srv.handleBotVerifiersAPI, "/api/botverification/verifiers?limit=abc"},
{srv.handleVerificationIconsAPI, "/api/botverification/icons?limit=-2"},
}
for _, item := range cases {
rec := httptest.NewRecorder()
item.handler(rec, httptest.NewRequest(http.MethodGet, item.path, nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status=%d body=%s, want 400", item.path, rec.Code, rec.Body.String())
}
}
// A well-formed query with no store wired reports the store, not the query.
rec := httptest.NewRecorder()
srv.handleCustomVerificationRequestsAPI(rec, httptest.NewRequest(
http.MethodGet, "/api/botverification/requests?status=pending&peer_type=channel&limit=10", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s, want 503", rec.Code, rec.Body.String())
}
}
func TestQueryFlagReadsThePanelsBooleans(t *testing.T) {
for _, raw := range []string{"1", "true", "TRUE", " yes ", "on"} {
if !queryFlag(raw) {
t.Fatalf("queryFlag(%q) = false", raw)
}
}
for _, raw := range []string{"", "0", "false", "no", "maybe"} {
if queryFlag(raw) {
t.Fatalf("queryFlag(%q) = true", raw)
}
}
}

View file

@ -71,6 +71,11 @@ type uiConfig struct {
Password string
Token string
SessionKey []byte
// Permissions is the right set a panel session is issued with, from
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
// entry, so introducing the permission model never locks an operator out of a
// panel that worked before.
Permissions []string
}
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
@ -105,6 +110,7 @@ func loadConfig() (uiConfig, error) {
Password: appCfg.AdminUIPassword,
Token: appCfg.AdminUIToken,
SessionKey: sum[:],
Permissions: appCfg.AdminUIPermissions,
}, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,146 @@
package main
import (
"context"
"fmt"
"testing"
"time"
)
// The Accounts tab is hand-written SQL, so the collectible-username aggregation
// can only be proven against the real schema: the jsonb keys have to match the
// AccountUsername field names for pgx to unmarshal them, the ordering has to match
// the projection order clients see, and the editable slot must not leak into the
// collectible list. Gated on TELESRV_TEST_POSTGRES_DSN like the rest.
func TestReadStoreAccountsCarryCollectibleUsernames(t *testing.T) {
store, pool := verificationReadStore(t)
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
userID := 3_600_000_000 + time.Now().UnixNano()%1_000_000
editable := "slot" + suffix
// Deliberately out of alphabetical order and with a gap in sort_order, so a
// query that sorted by name or by insertion order would produce a different
// answer than the stored one.
collectibles := []struct {
name string
sortOrder int
active bool
collecting bool
}{
{name: "zeta" + suffix, sortOrder: 0, active: true, collecting: true},
{name: "alpha" + suffix, sortOrder: 5, active: false, collecting: true},
{name: "mid" + suffix, sortOrder: 2, active: true, collecting: true},
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type='user' AND peer_id=$1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM collectible_usernames WHERE username_lower LIKE $1`, "%"+suffix)
_, _ = pool.Exec(ctx, `DELETE FROM authorizations WHERE user_id=$1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM auth_keys WHERE auth_key_id=$1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID)
})
if _, err := pool.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`,
userID, userID, "+1889"+suffix, editable); err != nil {
t.Fatalf("seed user: %v", err)
}
// The list query joins authorizations, so an account with no device never
// appears there at all; an authorization in turn needs its auth key to exist.
if _, err := pool.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
t.Fatalf("seed auth key: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (user_id, auth_key_id, created_at, active_at)
VALUES ($1, $2, now(), now())`, userID, userID); err != nil {
t.Fatalf("seed authorization: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
VALUES (lower($1), lower($1), 'user', $2, true, true, 0)`, editable, userID); err != nil {
t.Fatalf("seed editable slot: %v", err)
}
for _, item := range collectibles {
var collectibleID int64
if err := pool.QueryRow(ctx, `
INSERT INTO collectible_usernames (username, username_lower, status, owner_peer_type, owner_peer_id,
original_owner_peer_type, original_owner_peer_id, purchase_date, currency, amount, created_at, updated_at)
VALUES ($1, lower($1), 'owned', 'user', $2, 'user', $2, now(), 'XTR', 0, now(), now())
RETURNING id`, item.name, userID).Scan(&collectibleID); err != nil {
t.Fatalf("seed collectible %s: %v", item.name, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order, collectible_id)
VALUES (lower($1), lower($1), 'user', $2, $3, false, $4, $5)`,
item.name, userID, item.active, item.sortOrder, collectibleID); err != nil {
t.Fatalf("attach collectible %s: %v", item.name, err)
}
}
want := []AccountUsername{
{Username: "zeta" + suffix, Active: true},
{Username: "mid" + suffix, Active: true},
{Username: "alpha" + suffix, Active: false},
}
detail, err := store.AccountDetail(ctx, userID)
if err != nil {
t.Fatalf("AccountDetail: %v", err)
}
assertCollectibles(t, "AccountDetail", detail.Account, editable, want)
rows, _, err := store.ListAccounts(ctx, 0, 0, 200)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
var listed *AccountRow
for i := range rows {
if rows[i].ID == userID {
listed = &rows[i]
break
}
}
if listed == nil {
t.Fatalf("seeded account %d is absent from the first page of %d accounts", userID, len(rows))
}
assertCollectibles(t, "ListAccounts", *listed, editable, want)
// An account holding nothing collectible reports an empty list, not null: the
// panel iterates it unconditionally.
if _, err := pool.Exec(ctx, `DELETE FROM peer_usernames
WHERE peer_type='user' AND peer_id=$1 AND collectible_id IS NOT NULL`, userID); err != nil {
t.Fatalf("drop collectibles: %v", err)
}
bare, err := store.AccountDetail(ctx, userID)
if err != nil {
t.Fatalf("AccountDetail without collectibles: %v", err)
}
if bare.Account.Collectibles == nil || len(bare.Account.Collectibles) != 0 {
t.Fatalf("collectibles without any rows = %#v, want an empty slice", bare.Account.Collectibles)
}
}
func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
t.Helper()
if row.Username != editable {
t.Fatalf("%s: editable username = %q, want %q", surface, row.Username, editable)
}
if len(row.Collectibles) != len(want) {
t.Fatalf("%s: collectibles = %#v, want %#v", surface, row.Collectibles, want)
}
for i := range want {
if row.Collectibles[i] != want[i] {
t.Fatalf("%s: collectibles = %#v, want %#v", surface, row.Collectibles, want)
}
}
// The editable slot is a different kind of row and must never be repeated in
// the collectible list.
for _, item := range row.Collectibles {
if item.Username == editable {
t.Fatalf("%s: editable slot leaked into the collectible list: %#v", surface, row.Collectibles)
}
}
}

View file

@ -0,0 +1,571 @@
package main
import (
"context"
"strconv"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// The third-party verification tables are read with hand-written SQL, so the only
// thing that can prove the column names, the CASE-per-peer-namespace projections
// and the `AND editable` username joins are right is running them against the real
// schema. Gated on TELESRV_TEST_POSTGRES_DSN, like every other integration test in
// the repo, and reusing verificationReadStore for the pool and the migration.
// botVerificationFixture seeds two verifier bots (one enabled, one switched off),
// a shared and a reserved icon, marks on a user peer and a channel peer, and
// applications in three states.
type botVerificationFixture struct {
verifierBot int64
disabledBot int64
applicant int64
userPeer int64
channel int64
sharedIcon int64
reservedIcon int64
sharedDoc int64
reservedDoc int64
userMark int64
channelMark int64
pendingReq int64
approvedReq int64
rejectedReq int64
suffix string
}
func seedBotVerificationFixture(t *testing.T, pool *pgxpool.Pool) botVerificationFixture {
t.Helper()
ctx := context.Background()
var fx botVerificationFixture
now := time.Now().UTC().Truncate(time.Microsecond)
// Usernames, channel ids and icon document ids are globally unique, so every run
// needs its own suffix: this database may still hold rows another run left.
unique := now.UnixNano() & 0x7fffffff
suffix := strconv.FormatInt(unique, 10)
fx.suffix = suffix
nextChannelID := 1_200_000_000 + unique%100_000_000
fx.sharedDoc = 7_000_000_000 + unique%1_000_000
fx.reservedDoc = fx.sharedDoc + 1
insertUser := func(first, username string, isBot bool) int64 {
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, last_name, username, is_bot)
VALUES ($1, $2, $3, 'Fixture', $4, $5)
RETURNING id`, unique, "71"+strconv.FormatInt(unique, 10), first, username, isBot).Scan(&id); err != nil {
t.Fatalf("insert user %s: %v", first, err)
}
unique++
return id
}
fx.verifierBot = insertUser("Verifierbot", "verifierbot"+suffix, true)
fx.disabledBot = insertUser("Disabledbot", "disabledbot"+suffix, true)
fx.applicant = insertUser("Applicant", "bvapplicant"+suffix, false)
fx.userPeer = insertUser("Marked", "markeduser"+suffix, false)
fx.channel = nextChannelID
if _, err := pool.Exec(ctx, `
INSERT INTO channels (
id, access_hash, creator_user_id, title, username, broadcast, megagroup,
participants_count, admins_count, top_message_id, pts, date
)
VALUES ($1, $2, $3, $4, $5, true, false, 1, 1, 1, 1, $6)`,
fx.channel, unique, fx.applicant, "Fixture Marked News", "markednews"+suffix, int32(now.Unix())); err != nil {
t.Fatalf("insert channel: %v", err)
}
unique++
insertIcon := func(documentID, ownerBotID int64, name string, active bool) int64 {
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO verification_icons (document_id, owner_bot_id, name, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5) RETURNING id`,
documentID, ownerBotID, name, active, now).Scan(&id); err != nil {
t.Fatalf("insert icon %s: %v", name, err)
}
return id
}
fx.sharedIcon = insertIcon(fx.sharedDoc, 0, "shared check "+suffix, true)
// Reserved to the verifier bot and retired, so both the owner join and the
// active filter have a case to answer.
fx.reservedIcon = insertIcon(fx.reservedDoc, fx.verifierBot, "reserved check "+suffix, false)
insertVerifier := func(botID, documentID int64, company string, enabled, canModify bool) {
if _, err := pool.Exec(ctx, `
INSERT INTO bot_verifier_settings (
bot_id, icon_document_id, company_name, default_description,
can_modify_custom_description, enabled, granted_by, grant_reason,
created_at, updated_at, version
) VALUES ($1, $2, $3, 'verified by the fixture', $4, $5, 'alice', 'partner programme', $6, $6, 4)`,
botID, documentID, company, canModify, enabled, now); err != nil {
t.Fatalf("insert verifier %d: %v", botID, err)
}
}
insertVerifier(fx.verifierBot, fx.sharedDoc, "Fixture Trust "+suffix, true, true)
insertVerifier(fx.disabledBot, fx.sharedDoc, "Switched Off "+suffix, false, false)
insertMark := func(peerType string, peerID int64, description string) int64 {
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO custom_verifications (
verifier_bot_id, peer_type, peer_id, icon_document_id, description,
granted_by_user_id, created_at, updated_at, version
) VALUES ($1, $2, $3, $4, $5, $1, $6, $6, 2) RETURNING id`,
fx.verifierBot, peerType, peerID, fx.sharedDoc, description, now).Scan(&id); err != nil {
t.Fatalf("insert %s mark: %v", peerType, err)
}
return id
}
fx.userMark = insertMark("user", fx.userPeer, "verified individual")
fx.channelMark = insertMark("channel", fx.channel, "verified outlet")
insertRequest := func(peerType string, peerID int64, status, reason string) int64 {
var approvedAt, rejectedAt *time.Time
decisionReason := ""
decidedBy := ""
switch status {
case "approved":
approvedAt = &now
decidedBy = "alice"
case "rejected":
rejectedAt = &now
decidedBy = "bob"
decisionReason = reason
}
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO custom_verification_requests (
verifier_bot_id, applicant_user_id, peer_type, peer_id, peer_title, peer_username,
reason, requested_description, status, decided_by, decision_reason, internal_note,
correlation_id, created_at, updated_at, approved_at, rejected_at, version
) VALUES (
$1, $2, $3, $4, $5, $6, 'we are the outlet', 'verified partner', $7, $8, $9,
'operator only', $10, $11, $11, $12, $13, 3
) RETURNING id`,
fx.verifierBot, fx.applicant, peerType, peerID,
"Snapshot "+peerType, "snapshot"+peerType+suffix,
status, decidedBy, decisionReason, "bvcorr-"+status,
now, approvedAt, rejectedAt,
).Scan(&id); err != nil {
t.Fatalf("insert %s request: %v", status, err)
}
return id
}
// One live application per (verifier, peer) pair, so the three seeded rows have
// to name three different peers: the partial unique index enforces it.
fx.pendingReq = insertRequest("channel", fx.channel, "pending", "")
fx.approvedReq = insertRequest("user", fx.userPeer, "approved", "")
// Filed against a peer that does not exist, so the live-peer join has a negative
// case and the snapshot fallback is exercised.
fx.rejectedReq = insertRequest("user", fx.userPeer+9_000_000, "rejected", "not an outlet")
t.Cleanup(func() {
reqIDs := []int64{fx.pendingReq, fx.approvedReq, fx.rejectedReq}
_, _ = pool.Exec(ctx, "DELETE FROM custom_verification_requests WHERE id = ANY($1::bigint[])", reqIDs)
_, _ = pool.Exec(ctx, "DELETE FROM custom_verifications WHERE id = ANY($1::bigint[])",
[]int64{fx.userMark, fx.channelMark})
_, _ = pool.Exec(ctx, "DELETE FROM bot_verifier_settings WHERE bot_id = ANY($1::bigint[])",
[]int64{fx.verifierBot, fx.disabledBot})
_, _ = pool.Exec(ctx, "DELETE FROM verification_icons WHERE id = ANY($1::bigint[])",
[]int64{fx.sharedIcon, fx.reservedIcon})
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", fx.channel)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])",
[]int64{fx.verifierBot, fx.disabledBot, fx.applicant, fx.userPeer})
})
return fx
}
func TestBotVerificationReadStoreVerifiers(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, err := store.ListBotVerifiers(ctx, false, 200)
if err != nil {
t.Fatalf("list verifiers: %v", err)
}
byID := map[int64]BotVerifierRow{}
for _, row := range rows {
byID[row.BotID] = row
}
verifier, ok := byID[fx.verifierBot]
if !ok {
t.Fatal("enabled verifier missing from the listing")
}
// The bot account is resolved through the join, and the icon's catalogue label
// comes from the entry the document id points at.
if verifier.BotUsername != "verifierbot"+fx.suffix || verifier.IconName != "shared check "+fx.suffix {
t.Fatalf("verifier projection = %+v", verifier)
}
if verifier.BotName == "" || verifier.CompanyName != "Fixture Trust "+fx.suffix {
t.Fatalf("verifier names = %+v", verifier)
}
if !verifier.Enabled || !verifier.CanModifyCustomDescription || verifier.Version != 4 ||
verifier.GrantedBy != "alice" || verifier.GrantReason != "partner programme" {
t.Fatalf("verifier settings = %+v", verifier)
}
// Both seeded marks belong to this verifier, and mark_count is what would
// cascade away with a revocation.
if verifier.MarkCount != 2 {
t.Fatalf("mark count = %d, want 2", verifier.MarkCount)
}
if disabled := byID[fx.disabledBot]; disabled.Enabled || disabled.MarkCount != 0 {
t.Fatalf("disabled verifier = %+v", disabled)
}
// enabled_only hides the switched-off verifier without dropping its row.
enabled, err := store.ListBotVerifiers(ctx, true, 200)
if err != nil {
t.Fatalf("list enabled verifiers: %v", err)
}
for _, row := range enabled {
if !row.Enabled {
t.Fatalf("enabled_only leaked %+v", row)
}
if row.BotID == fx.disabledBot {
t.Fatal("enabled_only returned the switched-off verifier")
}
}
// The detail read reuses the list scanner, so one column order serves both.
one, err := store.BotVerifier(ctx, fx.verifierBot)
if err != nil {
t.Fatalf("get verifier: %v", err)
}
if one.BotID != fx.verifierBot || one.MarkCount != 2 || one.IconName != verifier.IconName {
t.Fatalf("verifier detail = %+v", one)
}
if _, err := store.BotVerifier(ctx, fx.applicant); err == nil {
t.Fatal("a non-verifier resolved as one")
}
// The page bound is honoured.
page, err := store.ListBotVerifiers(ctx, false, 1)
if err != nil {
t.Fatalf("bounded list: %v", err)
}
if len(page) != 1 {
t.Fatalf("bounded page len=%d", len(page))
}
}
func TestBotVerificationReadStoreIcons(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, err := store.ListVerificationIcons(ctx, false, 200)
if err != nil {
t.Fatalf("list icons: %v", err)
}
byID := map[int64]VerificationIconRow{}
for _, row := range rows {
byID[row.ID] = row
}
shared, ok := byID[fx.sharedIcon]
if !ok {
t.Fatal("shared icon missing from the catalogue listing")
}
if shared.OwnerBotID != 0 || shared.OwnerBotUsername != "" || !shared.Active {
t.Fatalf("shared icon = %+v, want no owner", shared)
}
// Both seeded verifiers point at the shared document, so retiring it is a
// decision the operator has to make knowingly.
if shared.UsedByVerifiers != 2 {
t.Fatalf("shared icon used_by_verifiers = %d, want 2", shared.UsedByVerifiers)
}
reserved, ok := byID[fx.reservedIcon]
if !ok {
t.Fatal("reserved icon missing from the catalogue listing")
}
if reserved.OwnerBotID != fx.verifierBot || reserved.OwnerBotUsername != "verifierbot"+fx.suffix {
t.Fatalf("reserved icon = %+v, want the owner resolved", reserved)
}
if reserved.Active || reserved.UsedByVerifiers != 0 {
t.Fatalf("reserved icon = %+v", reserved)
}
// active_only hides the retired entry.
active, err := store.ListVerificationIcons(ctx, true, 200)
if err != nil {
t.Fatalf("list active icons: %v", err)
}
for _, row := range active {
if !row.Active {
t.Fatalf("active_only leaked %+v", row)
}
if row.ID == fx.reservedIcon {
t.Fatal("active_only returned the retired entry")
}
}
// Newest first.
if len(rows) >= 2 && rows[0].ID < rows[1].ID {
t.Fatalf("catalogue is not ordered newest first: %d before %d", rows[0].ID, rows[1].ID)
}
}
func TestBotVerificationReadStoreMarks(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", 0, 200)
if err != nil {
t.Fatalf("list marks: %v", err)
}
byID := map[int64]CustomVerificationRow{}
for _, row := range rows {
byID[row.ID] = row
}
// A user peer resolves through users; the verifier's company comes from its
// settings row.
userMark, ok := byID[fx.userMark]
if !ok {
t.Fatal("user mark missing from the listing")
}
if userMark.PeerType != "user" || userMark.PeerID != fx.userPeer ||
userMark.PeerUsername != "markeduser"+fx.suffix {
t.Fatalf("user mark peer = %+v", userMark)
}
if userMark.PeerTitle == "" || userMark.VerifierBotUsername != "verifierbot"+fx.suffix ||
userMark.CompanyName != "Fixture Trust "+fx.suffix {
t.Fatalf("user mark projection = %+v", userMark)
}
if userMark.IconDocumentID != fx.sharedDoc || userMark.Description != "verified individual" ||
userMark.Version != 2 {
t.Fatalf("user mark = %+v", userMark)
}
// A channel peer resolves through channels: the CASE picks the right namespace.
channelMark, ok := byID[fx.channelMark]
if !ok {
t.Fatal("channel mark missing from the listing")
}
if channelMark.PeerType != "channel" || channelMark.PeerTitle != "Fixture Marked News" ||
channelMark.PeerUsername != "markednews"+fx.suffix {
t.Fatalf("channel mark peer = %+v", channelMark)
}
// Filters.
typed, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "channel", "", 0, 50)
if err != nil {
t.Fatalf("peer_type list: %v", err)
}
for _, row := range typed {
if row.PeerType != "channel" {
t.Fatalf("peer_type filter leaked %+v", row)
}
}
other, _, err := store.ListCustomVerifications(ctx, fx.disabledBot, "", "", 0, 50)
if err != nil {
t.Fatalf("verifier filter list: %v", err)
}
for _, row := range other {
if row.VerifierBotID != fx.disabledBot {
t.Fatalf("verifier filter leaked %+v", row)
}
}
// q matches a mark id, a peer id, a verifier id and a username or title prefix.
for _, query := range []string{
strconv.FormatInt(fx.channelMark, 10),
strconv.FormatInt(fx.channel, 10),
strconv.FormatInt(fx.verifierBot, 10),
"markednews" + fx.suffix,
"@markeduser" + fx.suffix,
"fixture marked",
} {
found, _, err := store.ListCustomVerifications(ctx, 0, "", query, 0, 50)
if err != nil {
t.Fatalf("search %q: %v", query, err)
}
if len(found) == 0 {
t.Fatalf("search %q returned nothing", query)
}
}
// The keyset cursor excludes the row it points at.
after, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", fx.channelMark, 50)
if err != nil {
t.Fatalf("keyset list: %v", err)
}
for _, row := range after {
if row.ID >= fx.channelMark {
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.channelMark)
}
}
// The page bound is honoured and reports more.
page, more, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", 0, 1)
if err != nil {
t.Fatalf("bounded list: %v", err)
}
if len(page) != 1 || !more {
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
}
}
func TestBotVerificationReadStoreRequestsAndDetail(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
rows, _, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", 0, 200)
if err != nil {
t.Fatalf("list requests: %v", err)
}
byID := map[int64]CustomVerificationRequestRow{}
for _, row := range rows {
byID[row.ID] = row
}
pending, ok := byID[fx.pendingReq]
if !ok {
t.Fatal("pending application missing from the queue")
}
if pending.ApplicantUserID != fx.applicant || pending.ApplicantUsername != "bvapplicant"+fx.suffix ||
pending.VerifierBotUsername != "verifierbot"+fx.suffix {
t.Fatalf("applicant/verifier projection = %+v", pending)
}
// The peer is read live, not from the snapshot columns: an operator has to see
// the peer as it is now.
if pending.PeerTitle != "Fixture Marked News" || pending.PeerUsername != "markednews"+fx.suffix {
t.Fatalf("live peer projection = %+v, want the channel as it is now", pending)
}
if pending.Status != "pending" || pending.InternalNote != "operator only" ||
pending.CorrelationID != "bvcorr-pending" || pending.Version != 3 {
t.Fatalf("operator fields = %+v", pending)
}
if !pending.ApprovedAt.IsZero() || !pending.RejectedAt.IsZero() {
t.Fatalf("timestamps approved=%v rejected=%v, want an undecided application",
pending.ApprovedAt, pending.RejectedAt)
}
if approved := byID[fx.approvedReq]; approved.ApprovedAt.IsZero() || approved.DecidedBy != "alice" {
t.Fatalf("approved application = %+v", approved)
}
if rejected := byID[fx.rejectedReq]; rejected.RejectedAt.IsZero() || rejected.DecisionReason == "" {
t.Fatalf("rejected application = %+v", rejected)
}
// A peer that does not exist falls back to the snapshot the applicant filed
// with, so the row still renders as something the reviewer recognises.
if gone := byID[fx.rejectedReq]; gone.PeerTitle != "Snapshot user" ||
gone.PeerUsername != "snapshotuser"+fx.suffix {
t.Fatalf("missing peer = %+v, want the snapshot fallback", gone)
}
// Filters.
filtered, _, err := store.ListCustomVerificationRequests(ctx, "pending", 0, "", "", 0, 50)
if err != nil {
t.Fatalf("status list: %v", err)
}
for _, row := range filtered {
if row.Status != "pending" {
t.Fatalf("status filter leaked %+v", row)
}
}
typed, _, err := store.ListCustomVerificationRequests(ctx, "", 0, "channel", "", 0, 50)
if err != nil {
t.Fatalf("peer_type list: %v", err)
}
for _, row := range typed {
if row.PeerType != "channel" {
t.Fatalf("peer_type filter leaked %+v", row)
}
}
// q matches an application id, a peer id, the applicant id, and username or
// title prefixes on both the live peer and the snapshot.
for _, query := range []string{
strconv.FormatInt(fx.pendingReq, 10),
strconv.FormatInt(fx.channel, 10),
strconv.FormatInt(fx.applicant, 10),
"markednews" + fx.suffix,
"snapshotchannel" + fx.suffix,
"@bvapplicant" + fx.suffix,
"snapshot ",
} {
found, _, err := store.ListCustomVerificationRequests(ctx, "", 0, "", query, 0, 50)
if err != nil {
t.Fatalf("search %q: %v", query, err)
}
if len(found) == 0 {
t.Fatalf("search %q returned nothing", query)
}
}
// The keyset cursor excludes the row it points at, and the bound reports more.
after, _, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", fx.pendingReq, 50)
if err != nil {
t.Fatalf("keyset list: %v", err)
}
for _, row := range after {
if row.ID >= fx.pendingReq {
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.pendingReq)
}
}
page, more, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", 0, 1)
if err != nil {
t.Fatalf("bounded list: %v", err)
}
if len(page) != 1 || !more {
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
}
// The detail read carries the verifier and the live mark state.
detail, err := store.CustomVerificationRequestDetail(ctx, fx.approvedReq)
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.Request.ID != fx.approvedReq || detail.Verifier.BotID != fx.verifierBot ||
detail.Verifier.CompanyName != "Fixture Trust "+fx.suffix {
t.Fatalf("detail = %+v verifier=%+v", detail.Request, detail.Verifier)
}
// The approved application's peer really carries the mark.
if !detail.MarkActive {
t.Fatal("mark_active did not follow the granted mark")
}
// The pending application names the channel, which the fixture also marks, so
// the channel side of the EXISTS probe is covered too.
pendingDetail, err := store.CustomVerificationRequestDetail(ctx, fx.pendingReq)
if err != nil {
t.Fatalf("pending detail: %v", err)
}
if !pendingDetail.MarkActive {
t.Fatal("the channel mark was not seen by the detail read")
}
// The rejected one names a peer nobody marked: mark_active must say so, which is
// what tells "approved" apart from "approved and since stripped".
goneDetail, err := store.CustomVerificationRequestDetail(ctx, fx.rejectedReq)
if err != nil {
t.Fatalf("rejected detail: %v", err)
}
if goneDetail.MarkActive {
t.Fatal("an unmarked peer was reported as carrying a mark")
}
if _, err := store.CustomVerificationRequestDetail(ctx, 0); err == nil {
t.Fatal("detail of a missing application succeeded")
}
}
func TestBotVerificationReadStoreCounts(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedBotVerificationFixture(t, pool)
ctx := context.Background()
counts, err := store.CustomVerificationRequestCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
// Every modelled status is present, so the panel never tells "none" from
// "missing".
for _, status := range []string{"pending", "approved", "rejected", "revoked"} {
if _, ok := counts[status]; !ok {
t.Fatalf("counts %+v missing %q", counts, status)
}
}
if counts["pending"] == "0" || counts["approved"] == "0" || counts["rejected"] == "0" {
t.Fatalf("counts %+v did not see the seeded applications (%d/%d/%d)",
counts, fx.pendingReq, fx.approvedReq, fx.rejectedReq)
}
}

View file

@ -0,0 +1,354 @@
package main
import (
"context"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store/postgres"
)
// The verification review queue is read with hand-written SQL, so the only thing
// that can prove the column names, the array and nullable-timestamp scans, and the
// ownership predicates are right is running them against the real schema. Gated on
// TELESRV_TEST_POSTGRES_DSN, like every other integration test in the repo.
func verificationReadStore(t *testing.T) (*readStore, *pgxpool.Pool) {
t.Helper()
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
parsed, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse TELESRV_TEST_POSTGRES_DSN: %v", err)
}
if !strings.Contains(strings.ToLower(parsed.ConnConfig.Database), "test") {
t.Fatalf("TELESRV_TEST_POSTGRES_DSN must name a dedicated test database, got %q", parsed.ConnConfig.Database)
}
if err := postgres.Migrate(dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("open pool: %v", err)
}
t.Cleanup(pool.Close)
return newReadStore(pool), pool
}
// verificationFixture seeds one applicant who owns a bot and administers a public
// channel, plus one unrelated channel nobody controls, and files an application
// against each. It returns the applicant id and the three application ids.
type verificationFixture struct {
applicant int64
bot int64
channel int64
foreign int64
botApp int64
channelApp int64
rejectedApp int64
searchSuffix string
}
func seedVerificationFixture(t *testing.T, pool *pgxpool.Pool) verificationFixture {
t.Helper()
ctx := context.Background()
var fx verificationFixture
now := time.Now().UTC().Truncate(time.Microsecond)
// Usernames and channel ids are globally unique, so every run needs its own
// suffix; tests in this package may run against a database another run left
// rows in.
unique := now.UnixNano() & 0x7fffffff
suffix := strconv.FormatInt(unique, 10)
// channels.id carries no sequence: the caller assigns it.
nextChannelID := 1_000_000_000 + unique%100_000_000
insertUser := func(name, username string, isBot, verified bool) int64 {
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, last_name, username, is_bot, verified)
VALUES ($1, $2, $3, 'Reviewer', $4, $5, $6)
RETURNING id`, unique, "70"+strconv.FormatInt(unique, 10), name, username, isBot, verified).Scan(&id); err != nil {
t.Fatalf("insert user %s: %v", name, err)
}
unique++
return id
}
insertChannel := func(title, username string, verified bool) int64 {
id := nextChannelID
nextChannelID++
if _, err := pool.Exec(ctx, `
INSERT INTO channels (
id, access_hash, creator_user_id, title, username, broadcast, megagroup,
participants_count, admins_count, top_message_id, pts, date, verified
)
VALUES ($1, $2, $3, $4, $5, true, false, 1, 1, 1, 1, $6, $7)`,
id, unique, fx.applicant, title, username, int32(now.Unix()), verified); err != nil {
t.Fatalf("insert channel %s: %v", title, err)
}
unique++
return id
}
fx.applicant = insertUser("Applicant", "applicant"+suffix, false, false)
fx.bot = insertUser("Fixturebot", "fixturebot"+suffix, true, false)
if _, err := pool.Exec(ctx, `
INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $2, 'secret')`,
fx.bot, fx.applicant); err != nil {
t.Fatalf("insert bot: %v", err)
}
fx.channel = insertChannel("Fixture News", "fixturenews"+suffix, true)
fx.foreign = insertChannel("Foreign Channel", "foreignchannel"+suffix, false)
if _, err := pool.Exec(ctx, `
INSERT INTO user_channel_member_index (user_id, channel_id, status, role, broadcast, public_username)
VALUES ($1, $2, 'active', 'creator', true, true)`, fx.applicant, fx.channel); err != nil {
t.Fatalf("insert member index: %v", err)
}
insertApplication := func(
targetType string, targetID int64, status, reviewer, reason string,
reviewed bool,
) int64 {
var reviewedAt *time.Time
if reviewed {
reviewedAt = &now
}
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO verification_applications (
applicant_user_id, target_type, target_id, target_title, target_username,
category, description, official_website, social_links, press_links, additional_note,
status, reviewer_admin_id, decision_reason, internal_note, correlation_id,
created_at, updated_at, submitted_at, reviewed_at, version
) VALUES (
$1, $2, $3, $4, $5,
'media', 'a description long enough to satisfy the domain bar for submission',
'https://example.test', $6, $7, 'note',
$8, $9, $10, 'operator only', $11,
$12, $12, $12, $13, 3
) RETURNING id`,
fx.applicant, targetType, targetID, "Snapshot "+targetType, "snapshot"+targetType+suffix,
[]string{"https://social.example.test/a"},
[]string{"https://press.example.test/a", "https://press.example.test/b"},
status, reviewer, reason, "corr-"+status,
now, reviewedAt,
).Scan(&id); err != nil {
t.Fatalf("insert %s application: %v", status, err)
}
return id
}
fx.channelApp = insertApplication("channel", fx.channel, "submitted", "", "", false)
fx.botApp = insertApplication("bot", fx.bot, "in_review", "alice", "", false)
// Filed as a user target against an id the applicant is not, so the ownership
// predicate has a negative case to answer.
fx.rejectedApp = insertApplication("user", fx.foreign, "rejected", "bob", "press links are self-published", true)
fx.searchSuffix = suffix
if _, err := pool.Exec(ctx, `
INSERT INTO verification_application_events
(application_id, kind, from_status, to_status, actor, reason, note, correlation_id, created_at)
VALUES
($1, 'submitted', 'draft', 'submitted', '', '', '', 'corr-submitted', $2),
($1, 'claimed', 'submitted', 'in_review', 'alice', '', 'handover note', 'corr-claimed', $2)`,
fx.channelApp, now); err != nil {
t.Fatalf("insert events: %v", err)
}
t.Cleanup(func() {
ids := []int64{fx.channelApp, fx.botApp, fx.rejectedApp}
_, _ = pool.Exec(ctx, "DELETE FROM verification_notification_outbox WHERE application_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM verification_application_events WHERE application_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM verification_applications WHERE id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_channel_member_index WHERE user_id = $1", fx.applicant)
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{fx.channel, fx.foreign})
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", fx.bot)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{fx.applicant, fx.bot})
})
return fx
}
func TestVerificationReadStoreQueue(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedVerificationFixture(t, pool)
ctx := context.Background()
rows, hasMore, err := store.ListVerificationApplications(ctx, "", "", "", "", 0, 200)
if err != nil {
t.Fatalf("list: %v", err)
}
byID := map[int64]VerificationApplicationRow{}
for _, row := range rows {
byID[row.ID] = row
}
channelRow, ok := byID[fx.channelApp]
if !ok {
t.Fatalf("submitted application missing from the queue (hasMore=%v)", hasMore)
}
// The applicant is resolved through the join, the arrays survive the scan, and
// the live target badge is read from the peer rather than the snapshot.
if channelRow.ApplicantUserID != fx.applicant || channelRow.ApplicantUsername != "applicant"+fx.searchSuffix {
t.Fatalf("applicant projection = %+v", channelRow)
}
if !strings.Contains(channelRow.ApplicantName, "Applicant") {
t.Fatalf("applicant name = %q", channelRow.ApplicantName)
}
if len(channelRow.SocialLinks) != 1 || len(channelRow.PressLinks) != 2 {
t.Fatalf("link arrays = %+v / %+v", channelRow.SocialLinks, channelRow.PressLinks)
}
if !channelRow.TargetVerified {
t.Fatal("target_verified did not follow the live channel record")
}
if channelRow.InternalNote != "operator only" || channelRow.CorrelationID != "corr-submitted" {
t.Fatalf("operator fields = %+v", channelRow)
}
if channelRow.SubmittedAt.IsZero() || !channelRow.ReviewedAt.IsZero() {
t.Fatalf("timestamps submitted=%v reviewed=%v, want an undecided application", channelRow.SubmittedAt, channelRow.ReviewedAt)
}
if decided := byID[fx.rejectedApp]; decided.ReviewedAt.IsZero() || decided.DecisionReason == "" {
t.Fatalf("decided application = %+v", decided)
}
// Filters.
filtered, _, err := store.ListVerificationApplications(ctx, "in_review", "", "alice", "", 0, 50)
if err != nil {
t.Fatalf("filtered list: %v", err)
}
for _, row := range filtered {
if row.Status != "in_review" || row.ReviewerAdminID != "alice" {
t.Fatalf("status/reviewer filter leaked %+v", row)
}
}
typed, _, err := store.ListVerificationApplications(ctx, "", "bot", "", "", 0, 50)
if err != nil {
t.Fatalf("target_type list: %v", err)
}
for _, row := range typed {
if row.TargetType != "bot" {
t.Fatalf("target_type filter leaked %+v", row)
}
}
// q matches the application id, the target id and a username prefix.
for _, query := range []string{
strconv.FormatInt(fx.channelApp, 10),
strconv.FormatInt(fx.channel, 10),
"snapshotchannel" + fx.searchSuffix,
"@applicant" + fx.searchSuffix,
} {
found, _, err := store.ListVerificationApplications(ctx, "", "", "", query, 0, 50)
if err != nil {
t.Fatalf("search %q: %v", query, err)
}
if len(found) == 0 {
t.Fatalf("search %q returned nothing", query)
}
}
// The keyset cursor excludes the row it points at.
after, _, err := store.ListVerificationApplications(ctx, "", "", "", "", fx.channelApp, 50)
if err != nil {
t.Fatalf("keyset list: %v", err)
}
for _, row := range after {
if row.ID >= fx.channelApp {
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.channelApp)
}
}
// The page bound is honoured and reports more.
page, more, err := store.ListVerificationApplications(ctx, "", "", "", "", 0, 1)
if err != nil {
t.Fatalf("bounded list: %v", err)
}
if len(page) != 1 || !more {
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
}
}
func TestVerificationReadStoreDetailAndOwnership(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedVerificationFixture(t, pool)
ctx := context.Background()
detail, err := store.VerificationApplicationDetail(ctx, fx.channelApp)
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.Application.ID != fx.channelApp || len(detail.Events) != 2 {
t.Fatalf("detail = %+v events=%d", detail.Application, len(detail.Events))
}
// Newest first, and the operator-only note travels with the event.
if detail.Events[0].Kind != string(domain.VerificationEventClaimed) ||
detail.Events[0].Note != "handover note" || detail.Events[0].Actor != "alice" {
t.Fatalf("events = %+v", detail.Events)
}
if !detail.ApplicantControlsTarget {
t.Fatal("channel creator was not recognised as controlling the target")
}
// A bot the applicant owns.
botDetail, err := store.VerificationApplicationDetail(ctx, fx.botApp)
if err != nil {
t.Fatalf("bot detail: %v", err)
}
if !botDetail.ApplicantControlsTarget {
t.Fatal("bot owner was not recognised as controlling the target")
}
// A target the applicant has nothing to do with: the application was filed as
// a user target against a foreign channel id, so identity does not match.
foreignDetail, err := store.VerificationApplicationDetail(ctx, fx.rejectedApp)
if err != nil {
t.Fatalf("foreign detail: %v", err)
}
if foreignDetail.ApplicantControlsTarget {
t.Fatal("an unrelated target was reported as controlled")
}
if _, err := store.VerificationApplicationDetail(ctx, 0); err == nil {
t.Fatal("detail of a missing application succeeded")
}
// A user target that is the applicant themself is controlled by definition.
controls, err := store.applicantControlsVerificationTarget(ctx, fx.applicant, "user", fx.applicant)
if err != nil || !controls {
t.Fatalf("self target controls=%v err=%v", controls, err)
}
// BotFather is owned by nobody, whatever the bots table says.
controls, err = store.applicantControlsVerificationTarget(ctx, fx.applicant, "bot", domain.BotFatherUserID)
if err != nil || controls {
t.Fatalf("botfather controls=%v err=%v", controls, err)
}
// An unmodelled target type is never controlled.
controls, err = store.applicantControlsVerificationTarget(ctx, fx.applicant, "group", fx.channel)
if err != nil || controls {
t.Fatalf("unmodelled target controls=%v err=%v", controls, err)
}
}
func TestVerificationReadStoreCounts(t *testing.T) {
store, pool := verificationReadStore(t)
fx := seedVerificationFixture(t, pool)
ctx := context.Background()
counts, err := store.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
// Every modelled status is present, so the panel never tells "none" from
// "missing".
for _, status := range []string{"draft", "submitted", "in_review", "approved", "rejected", "cancelled"} {
if _, ok := counts[status]; !ok {
t.Fatalf("counts %+v missing %q", counts, status)
}
}
if counts["submitted"] == "0" || counts["in_review"] == "0" || counts["rejected"] == "0" {
t.Fatalf("counts %+v did not see the seeded applications (%d/%d/%d)",
counts, fx.channelApp, fx.botApp, fx.rejectedApp)
}
}

View file

@ -0,0 +1,210 @@
package main
import (
"context"
"crypto/subtle"
"net/http"
"net/url"
"strings"
"time"
)
// Panel session authorisation and CSRF.
//
// The panel authenticates with a cookie, which is what makes it a CSRF target:
// a request forged by any other origin arrives with the operator's session
// attached. Two independent checks close that.
//
// 1. Double-submit token. At login the server mints a random token, publishes it
// in a readable cookie (telesrv_admin_csrf) and requires the same value in the
// X-CSRF-Token header of every mutating request. A cross-origin page can make
// the browser *send* the cookie but cannot read it, so it cannot produce the
// header. Double-submit is the right shape here specifically because this
// process keeps no server-side session store: the session lives entirely in a
// signed cookie, so there is nowhere to park a per-session token, and the
// stateless variant is the one that survives a restart and a second replica.
// The token is additionally bound into the signed session claims, so a
// cookie-writing neighbour (a sibling subdomain) cannot supply a matching
// cookie/header pair of its own choosing either.
//
// 2. Origin agreement. When the browser states an Origin, it must be this host.
// That catches a forged request from a page that somehow does hold a token.
//
// Both comparisons are constant time, for the same reason the session MAC is.
// Panel permission names. They match the strings an operator configures in
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
const (
permissionAll = "*"
permissionVerificationReview = "verification.review"
permissionVerificationRevoke = "verification.revoke"
// Third-party bot verification. Deliberately not implied by the official
// verification rights above: the two are separate mechanisms over separate
// tables, so a session trusted with one queue is not thereby trusted with the
// other. review reads and decides applications; manage appoints verifiers,
// curates the icon catalogue and strips granted marks.
permissionBotVerificationReview = "botverification.review"
permissionBotVerificationManage = "botverification.manage"
)
type permissionsKey struct{}
// requireAuthAPI is the gate on every authenticated API route: a valid session,
// and -- for a mutating request -- a valid CSRF token.
func (s *server) requireAuthAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now())
if !ok {
clearSessionCookie(w)
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
if !checkMutationSafety(w, r, claims) {
return
}
ctx := context.WithValue(r.Context(), actorKey{}, claims.Actor)
ctx = context.WithValue(ctx, permissionsKey{}, newPanelPermissions(claims.Permissions))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requirePermission refuses a session that was not granted the right, before the
// request ever reaches the admin API. The panel is the only caller that can be
// driven by a browser, so the check belongs here as well as upstream: a 403 from
// this process costs no round trip and cannot be confused with a domain failure.
func (s *server) requirePermission(permission string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !permissionsFromContext(r.Context()).Has(permission) {
writeJSON(w, http.StatusForbidden, map[string]any{
"error": "permission " + permission + " is required",
"code": "FORBIDDEN",
"permission": permission,
})
return
}
next.ServeHTTP(w, r)
})
}
// checkMutationSafety enforces the CSRF contract on a mutating request.
func checkMutationSafety(w http.ResponseWriter, r *http.Request, claims sessionClaims) bool {
if !mutatingMethod(r.Method) {
return true
}
if !sameOriginRequest(r) {
writeAPIError(w, http.StatusForbidden, "origin is not allowed")
return false
}
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
writeAPIError(w, http.StatusForbidden, "missing "+csrfCookieName+" cookie; sign in again")
return false
}
header := strings.TrimSpace(r.Header.Get(csrfHeaderName))
if header == "" {
writeAPIError(w, http.StatusForbidden, "missing "+csrfHeaderName+" header")
return false
}
if subtle.ConstantTimeCompare([]byte(header), []byte(cookie.Value)) != 1 {
writeAPIError(w, http.StatusForbidden, csrfHeaderName+" does not match the "+csrfCookieName+" cookie")
return false
}
// The signed session is the third leg: it pins the pair to the session this
// server issued. A session minted before the token existed carries no CSRF
// claim and is refused, which forces one re-login rather than leaving a
// half-protected session running.
if claims.CSRF == "" || subtle.ConstantTimeCompare([]byte(header), []byte(claims.CSRF)) != 1 {
writeAPIError(w, http.StatusForbidden, "csrf token is not bound to this session; sign in again")
return false
}
return true
}
// mutatingMethod reports whether the method changes state. GET/HEAD/OPTIONS are
// the safe ones; everything else has to carry a token.
func mutatingMethod(method string) bool {
switch strings.ToUpper(method) {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return false
default:
return true
}
}
// sameOriginRequest checks the Origin header against the request host.
//
// An absent Origin is accepted: browsers omit it on same-origin requests and
// non-browser callers (curl, tests) never send it, so requiring it would break
// the panel without adding protection the token does not already give. A present
// Origin must be this host -- including the literal "null" a sandboxed or
// privacy-stripped context sends, which is by definition not this host.
//
// This compares against r.Host, so a reverse proxy in front of the panel has to
// preserve it (nginx: proxy_set_header Host $host).
func sameOriginRequest(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
return true
}
parsed, err := url.Parse(origin)
if err != nil || parsed.Host == "" {
return false
}
return strings.EqualFold(parsed.Host, r.Host)
}
// panelPermissions is a resolved session permission set.
type panelPermissions struct {
all bool
names map[string]struct{}
list []string
}
func newPanelPermissions(permissions []string) panelPermissions {
set := panelPermissions{names: make(map[string]struct{}, len(permissions))}
for _, permission := range permissions {
permission = strings.TrimSpace(permission)
if permission == "" {
continue
}
if _, dup := set.names[permission]; dup {
continue
}
if permission == permissionAll {
set.all = true
}
set.names[permission] = struct{}{}
set.list = append(set.list, permission)
}
return set
}
// Has reports whether the session was granted the permission.
func (p panelPermissions) Has(permission string) bool {
if p.all {
return true
}
_, ok := p.names[permission]
return ok
}
// List is what the panel is told about itself, so the UI can hide a section the
// session may not use instead of rendering it into a 403.
func (p panelPermissions) List() []string {
if p.list == nil {
return []string{}
}
return p.list
}
func permissionsFromContext(ctx context.Context) panelPermissions {
if permissions, ok := ctx.Value(permissionsKey{}).(panelPermissions); ok {
return permissions
}
return panelPermissions{}
}

View file

@ -47,7 +47,10 @@ func newServer(cfg uiConfig, read *readStore) (*server, error) {
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleAPILogin)
mux.HandleFunc("POST /api/logout", s.handleAPILogout)
// Logout goes through the same gate as every other mutating route: a forced
// logout is a state change, and an invalid session is cleared by the gate
// itself, so nothing is stranded by protecting it.
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout)))
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
@ -67,6 +70,10 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("GET /api/account-ratings", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingsAPI)))
mux.Handle("GET /api/account-ratings/{user_id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingDetailAPI)))
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
mux.Handle("GET /api/moderation/cases/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCaseAPI)))
mux.Handle("GET /api/moderation/reports/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationReportAPI)))
@ -99,6 +106,43 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI)))
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)))
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/recompute-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleRecomputeAccountRatingAPI)))
mux.Handle("POST /api/actions/adjust-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleAdjustAccountRatingAPI)))
// Official platform verification. Every route needs verification.review;
// clearing an existing badge needs verification.revoke on top of it.
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
mux.Handle("GET /api/verification/applications/{id}", s.verificationRead(s.handleVerificationApplicationDetailAPI))
mux.Handle("GET /api/verification/counts", s.verificationRead(s.handleVerificationCountsAPI))
mux.Handle("POST /api/verification/applications/{id}/claim", s.verificationRead(s.handleClaimVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/approve", s.verificationRead(s.handleApproveVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/reject", s.verificationRead(s.handleRejectVerificationAPI))
mux.Handle("POST /api/actions/revoke-verification", s.requireAuthAPI(
s.requirePermission(permissionVerificationReview,
s.requirePermission(permissionVerificationRevoke, http.HandlerFunc(s.handleRevokeVerificationAPI)))))
// Third-party bot verification. A separate section from the official
// verification block above -- separate tables, separate rights, separate routes.
// Reads and queue decisions need botverification.review; appointing verifiers,
// curating the icon catalogue and stripping a granted mark need
// botverification.manage.
mux.Handle("GET /api/botverification/verifiers", s.botVerificationRead(s.handleBotVerifiersAPI))
mux.Handle("GET /api/botverification/icons", s.botVerificationRead(s.handleVerificationIconsAPI))
mux.Handle("GET /api/botverification/marks", s.botVerificationRead(s.handleCustomVerificationsAPI))
mux.Handle("GET /api/botverification/requests", s.botVerificationRead(s.handleCustomVerificationRequestsAPI))
mux.Handle("GET /api/botverification/requests/{id}", s.botVerificationRead(s.handleCustomVerificationRequestDetailAPI))
mux.Handle("GET /api/botverification/counts", s.botVerificationRead(s.handleCustomVerificationCountsAPI))
mux.Handle("POST /api/botverification/requests/{id}/approve", s.botVerificationRead(s.handleApproveBotVerificationAPI))
mux.Handle("POST /api/botverification/requests/{id}/reject", s.botVerificationRead(s.handleRejectBotVerificationAPI))
mux.Handle("POST /api/botverification/requests/{id}/revoke", s.botVerificationRead(s.handleRevokeBotVerificationAPI))
mux.Handle("POST /api/actions/grant-bot-verifier", s.botVerificationManage(s.handleGrantBotVerifierAPI))
mux.Handle("POST /api/actions/set-bot-verifier-enabled", s.botVerificationManage(s.handleSetBotVerifierEnabledAPI))
mux.Handle("POST /api/actions/revoke-bot-verifier", s.botVerificationManage(s.handleRevokeBotVerifierAPI))
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI))
mux.Handle("POST /api/actions/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
@ -108,23 +152,6 @@ func (s *server) routes() http.Handler {
type actorKey struct{}
func (s *server) requireAuthAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now())
if !ok {
clearSessionCookie(w)
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, claims.Actor)))
})
}
func actorFromContext(ctx context.Context) string {
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
return actor
@ -149,7 +176,18 @@ type loginRequest struct {
Secret string `json:"secret"`
}
// sessionTTL bounds a signed panel session and the CSRF cookie that goes with it,
// so the two never outlive each other.
const sessionTTL = 12 * time.Hour
func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
// Login is the one mutating route without a CSRF token, because no session
// exists yet to bind one to. The Origin check still applies, and the request
// carries the operator credential, which a forging page does not have.
if !sameOriginRequest(r) {
writeAPIError(w, http.StatusForbidden, "origin is not allowed")
return
}
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
@ -159,10 +197,18 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
return
}
csrfToken, err := newCSRFToken()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
permissions := newPanelPermissions(s.cfg.Permissions)
value, err := signSession(s.cfg.SessionKey, sessionClaims{
Actor: "admin",
Exp: time.Now().Add(12 * time.Hour).Unix(),
Nonce: newCommandID("sess"),
Actor: "admin",
Exp: time.Now().Add(sessionTTL).Unix(),
Nonce: newCommandID("sess"),
Permissions: permissions.List(),
CSRF: csrfToken,
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
@ -172,11 +218,16 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: int((12 * time.Hour).Seconds()),
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]any{"actor": "admin"})
setCSRFCookie(w, csrfToken, sessionTTL)
writeJSON(w, http.StatusOK, map[string]any{
"actor": "admin",
"permissions": permissions.List(),
"csrf_token": csrfToken,
})
}
func (s *server) validSecret(secret string) bool {
@ -194,8 +245,14 @@ func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleSession is what the panel asks on load. It reports the permissions the
// session carries, so the UI can hide a section the operator may not use rather
// than letting them walk into a 403.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"actor": actorFromContext(r.Context())})
writeJSON(w, http.StatusOK, map[string]any{
"actor": actorFromContext(r.Context()),
"permissions": permissionsFromContext(r.Context()).List(),
})
}
func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
@ -1482,12 +1539,12 @@ func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Re
}
type giveGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
GiftID int64 `json:"gift_id,string"`
HideName bool `json:"hide_name"`
Message string `json:"message"`
@ -1519,6 +1576,367 @@ func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
writeCommandResultAPI(w, result, err)
}
// flexInt64 decodes an int64 the panel may send either as a JSON number or as a
// decimal string. Ids and nanoton amounts are sent as strings to stay exact past
// 2^53, while a picker-supplied peer id arrives as a plain number; an empty
// string and null both mean "unset", which is how an untouched form field looks.
type flexInt64 int64
// Int64 returns the decoded value.
func (v flexInt64) Int64() int64 { return int64(v) }
func (v *flexInt64) UnmarshalJSON(raw []byte) error {
text, empty := flexScalarText(raw)
if empty {
*v = 0
return nil
}
parsed, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return fmt.Errorf("invalid integer %s", string(raw))
}
*v = flexInt64(parsed)
return nil
}
// flexUnix decodes an optional timestamp as a Unix second count. A date input
// produces an RFC3339 string and a scripted call a plain number, so both are
// accepted; empty means "unset", which the mint command stamps with its clock.
type flexUnix int64
// Unix returns the decoded timestamp in seconds, or zero when unset.
func (v flexUnix) Unix() int64 { return int64(v) }
func (v *flexUnix) UnmarshalJSON(raw []byte) error {
text, empty := flexScalarText(raw)
if empty {
*v = 0
return nil
}
if parsed, err := strconv.ParseInt(text, 10, 64); err == nil {
*v = flexUnix(parsed)
return nil
}
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if parsed, err := time.Parse(layout, text); err == nil {
*v = flexUnix(parsed.UTC().Unix())
return nil
}
}
return fmt.Errorf("invalid timestamp %s", string(raw))
}
// flexScalarText unwraps a JSON scalar to its textual form and reports whether
// it carries no value at all (null, empty string, blank).
func flexScalarText(raw []byte) (string, bool) {
text := strings.TrimSpace(string(raw))
if text == "" || text == "null" {
return "", true
}
if unquoted, err := strconv.Unquote(text); err == nil {
text = strings.TrimSpace(unquoted)
}
if text == "" {
return "", true
}
return text, false
}
type mintCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
OwnerUserID flexInt64 `json:"owner_user_id"`
OwnerChannelID flexInt64 `json:"owner_channel_id"`
Currency string `json:"currency"`
Amount flexInt64 `json:"amount"`
CryptoCurrency string `json:"crypto_currency"`
CryptoAmount flexInt64 `json:"crypto_amount"`
URL string `json:"url"`
PurchaseDate flexUnix `json:"purchase_date"`
}
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body mintCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.MintCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "mint-collectible-username"),
Username: body.Username,
OwnerUserID: body.OwnerUserID.Int64(),
OwnerChannelID: body.OwnerChannelID.Int64(),
Currency: body.Currency,
Amount: body.Amount.Int64(),
CryptoCurrency: body.CryptoCurrency,
CryptoAmount: body.CryptoAmount.Int64(),
URL: body.URL,
PurchaseDate: body.PurchaseDate.Unix(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/mint", req)
writeCommandResultAPI(w, result, err)
}
type transferCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
ToUserID flexInt64 `json:"to_user_id"`
ToChannelID flexInt64 `json:"to_channel_id"`
}
func (s *server) handleTransferCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body transferCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.TransferCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "transfer-collectible-username"),
Username: body.Username,
ToUserID: body.ToUserID.Int64(),
ToChannelID: body.ToChannelID.Int64(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/transfer", req)
writeCommandResultAPI(w, result, err)
}
type revokeCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
Burn bool `json:"burn"`
}
func (s *server) handleRevokeCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body revokeCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
prefix := "revoke-collectible-username"
if body.Burn {
prefix = "burn-collectible-username"
}
req := admin.RevokeCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, prefix),
Username: body.Username,
Burn: body.Burn,
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/revoke", req)
writeCommandResultAPI(w, result, err)
}
type deleteCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
}
// handleDeleteCollectibleUsernameAPI erases an asset and its provenance. The
// panel gates it behind the same reason + dry-run + confirm flow as a burn, but
// the outcome differs: the name becomes issuable again from scratch.
func (s *server) handleDeleteCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body deleteCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeleteCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-collectible-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/delete", req)
writeCommandResultAPI(w, result, err)
}
type recomputeAccountRatingAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID flexInt64 `json:"user_id"`
}
func (s *server) handleRecomputeAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
var body recomputeAccountRatingAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RecomputeAccountRatingRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "recompute-account-rating"),
UserID: body.UserID.Int64(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/recompute", req)
writeCommandResultAPI(w, result, err)
}
type adjustAccountRatingAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID flexInt64 `json:"user_id"`
Amount flexInt64 `json:"amount"`
}
func (s *server) handleAdjustAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
var body adjustAccountRatingAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.AdjustAccountRatingRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "adjust-account-rating"),
UserID: body.UserID.Int64(),
Amount: body.Amount.Int64(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/adjust", req)
writeCommandResultAPI(w, result, err)
}
// handleCollectibleUsernamesAPI pages the collectible asset table straight from
// PostgreSQL, like every other table view, and echoes the keyset cursor as a
// decimal string so an int64 id survives the round trip through the browser.
func (s *server) handleCollectibleUsernamesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
query := r.URL.Query()
status := strings.TrimSpace(query.Get("status"))
switch status {
case "", string(domain.CollectibleUsernameStatusVault),
string(domain.CollectibleUsernameStatusOwned),
string(domain.CollectibleUsernameStatusBurned):
default:
writeAPIError(w, http.StatusBadRequest, "invalid status")
return
}
ownerUserID, err := parseInt64(query.Get("owner_user_id"))
if err != nil || ownerUserID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid owner_user_id")
return
}
beforeID, err := parseInt64(query.Get("before_id"))
if err != nil || beforeID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
rows, hasMore, err := s.read.ListCollectibleUsernames(r.Context(), status, ownerUserID, beforeID, query.Get("q"), limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := ""
if hasMore && len(rows) > 0 {
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleCollectibleUsernameDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
id, err := parseInt64(r.PathValue("id"))
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.CollectibleUsernameDetail(r.Context(), id)
if err != nil {
if errors.Is(err, errReadNotFound) {
writeAPIError(w, http.StatusNotFound, "collectible username not found")
return
}
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"asset": detail.Asset,
"transfers": detail.Transfers,
})
}
// handleAccountRatingsAPI pages the leaderboard. next_before_id is the last
// user id: the keyset predicate resolves the full (level, stars, user_id) cursor
// from it, so one opaque-looking value is enough to continue the page.
func (s *server) handleAccountRatingsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
query := r.URL.Query()
minLevel, err := parseInt(query.Get("min_level"))
if err != nil || minLevel < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid min_level")
return
}
userID, err := parseInt64(query.Get("user_id"))
if err != nil || userID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
return
}
beforeID, err := parseInt64(query.Get("before_id"))
if err != nil || beforeID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
rows, hasMore, err := s.read.ListAccountRatings(r.Context(), minLevel, userID, beforeID, limit, query.Get("q"))
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := ""
if hasMore && len(rows) > 0 {
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].UserID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleAccountRatingDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
userID, err := parseInt64(r.PathValue("user_id"))
if err != nil || userID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
return
}
detail, err := s.read.AccountRatingDetail(r.Context(), userID)
if err != nil {
if errors.Is(err, errReadNotFound) {
writeAPIError(w, http.StatusNotFound, "account rating not found")
return
}
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"rating": detail.Rating,
"events": detail.Events,
})
}
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") {
@ -1570,6 +1988,42 @@ func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any)
return result, nil
}
// callAdminCommand is callAdminAPI with the upstream status preserved.
//
// callAdminAPI deliberately loses it: every caller it has answers 502 for any
// failure. A verification decision needs the distinction, so this variant returns
// the HTTP status alongside the result and lets the handler map it. A status of 0
// means no HTTP answer was obtained at all.
func (s *server) callAdminCommand(ctx context.Context, apiPath string, payload any) (admin.CommandResult, int, error) {
body, err := json.Marshal(payload)
if err != nil {
return admin.CommandResult{}, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body))
if err != nil {
return admin.CommandResult{}, 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, 0, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, resp.StatusCode, errors.New(result.Error)
}
return result, resp.StatusCode, nil
}
func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadata any, fileName string, data []byte) (admin.CommandResult, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)

View file

@ -2,6 +2,7 @@ package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
@ -13,10 +14,29 @@ import (
const sessionCookieName = "telesrv_admin_session"
// csrfCookieName is the double-submit cookie. It is deliberately NOT HttpOnly:
// the panel's own JavaScript has to read it back to echo it in the X-CSRF-Token
// header, which is the whole mechanism.
const csrfCookieName = "telesrv_admin_csrf"
// csrfHeaderName is the header the panel echoes the cookie in.
const csrfHeaderName = "X-CSRF-Token"
type sessionClaims struct {
Actor string `json:"actor"`
Exp int64 `json:"exp"`
Nonce string `json:"nonce"`
// Permissions is the right set granted to this session, taken from
// TELESRV_ADMIN_UI_PERMISSIONS at login. It travels inside the signed cookie
// rather than being re-read per request, so a session keeps the rights it was
// issued with, and it cannot be edited by the browser: the HMAC covers it.
Permissions []string `json:"permissions,omitempty"`
// CSRF is the double-submit token bound to this session. Binding it into the
// signed claims is what makes the cookie/header pair unforgeable by a sibling
// origin that can only *write* cookies (a subdomain, say): such an attacker
// can set both the cookie and the header to a value they know, but they cannot
// produce a session cookie that agrees with it.
CSRF string `json:"csrf,omitempty"`
}
func signSession(key []byte, claims sessionClaims) (string, error) {
@ -56,6 +76,28 @@ func verifySession(key []byte, value string, now time.Time) (sessionClaims, bool
return claims, true
}
// newCSRFToken mints a fresh double-submit token.
func newCSRFToken() (string, error) {
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
}
// setCSRFCookie publishes the token to the browser.
func setCSRFCookie(w http.ResponseWriter, token string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
MaxAge: int(ttl.Seconds()),
// Readable by the panel's script on purpose; see csrfCookieName.
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
@ -65,4 +107,12 @@ func clearSessionCookie(w http.ResponseWriter) {
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}

View file

@ -216,3 +216,214 @@ func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
t.Fatalf("forwarded gift request = %+v", got)
}
}
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
var got admin.MintCollectibleUsernameRequest
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/collectible-usernames/mint" || r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
// The panel sends a picker id as a number, a nanoton amount as a string and an
// RFC3339 purchase date; all three have to survive the hop unchanged.
req := httptest.NewRequest(http.MethodPost, "/api/actions/mint-collectible-username", strings.NewReader(`{
"reason":"fragment import","confirm":false,
"username":"@Durov","owner_user_id":1001,"currency":"TON",
"amount":"9223372036854775807","crypto_currency":"TON","crypto_amount":"250000000000",
"url":"https://fragment.example/durov","purchase_date":"2026-07-26T00:00:00Z"
}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleMintCollectibleUsernameAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got.Actor != "operator" || !got.DryRun || got.CommandID == "" {
t.Fatalf("forwarded command meta = %+v", got.CommandMeta)
}
if got.Username != "@Durov" || got.OwnerUserID != 1001 || got.Amount != maxInt64 ||
got.CryptoAmount != 250000000000 || got.PurchaseDate != time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC).Unix() {
t.Fatalf("forwarded mint request = %+v", got)
}
}
func TestAdjustAccountRatingBFFForwardsNumericPayload(t *testing.T) {
var got admin.AdjustAccountRatingRequest
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/account-ratings/adjust" {
t.Fatalf("upstream path=%q", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed"})
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
`{"reason":"manual penalty","confirm":true,"user_id":1001,"amount":-2500}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleAdjustAccountRatingAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got.Actor != "operator" || got.UserID != 1001 || got.Amount != -2500 || got.DryRun {
t.Fatalf("forwarded adjust request = %+v", got)
}
}
func TestRevokeCollectibleUsernameBFFRejectsUnknownFields(t *testing.T) {
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-collectible-username", strings.NewReader(
`{"reason":"fraud","confirm":true,"username":"durov","burn":true,"actor":"attacker"}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleRevokeCollectibleUsernameAPI(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "actor") {
t.Fatalf("status=%d body=%s, want 400 rejecting the unknown actor field", rec.Code, rec.Body.String())
}
}
func TestCollectibleUsernameAndRatingRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
raw, err := json.Marshal(CollectibleUsernameRow{
ID: maxInt64, OwnerPeerID: maxInt64, Amount: maxInt64, CryptoAmount: maxInt64,
OriginalOwnerPeerID: maxInt64, Version: maxInt64,
})
if err != nil {
t.Fatalf("marshal collectible username row: %v", err)
}
var asset map[string]any
if err := json.Unmarshal(raw, &asset); err != nil {
t.Fatalf("unmarshal collectible username row: %v", err)
}
for _, field := range []string{"ID", "OwnerPeerID", "Amount", "CryptoAmount", "OriginalOwnerPeerID", "Version"} {
if asset[field] != "9223372036854775807" {
t.Fatalf("asset %s = %#v, want exact decimal string", field, asset[field])
}
}
raw, err = json.Marshal(AccountRatingRow{
UserID: maxInt64, Stars: maxInt64, CurrentLevelStars: maxInt64, NextLevelStars: maxInt64,
StarsComponent: maxInt64, ActivityComponent: maxInt64, PenaltyComponent: maxInt64,
ManualComponent: -maxInt64, PendingStars: maxInt64, Version: maxInt64,
})
if err != nil {
t.Fatalf("marshal account rating row: %v", err)
}
var rating map[string]any
if err := json.Unmarshal(raw, &rating); err != nil {
t.Fatalf("unmarshal account rating row: %v", err)
}
for _, field := range []string{
"UserID", "Stars", "CurrentLevelStars", "NextLevelStars",
"StarsComponent", "ActivityComponent", "PenaltyComponent", "PendingStars", "Version",
} {
if rating[field] != "9223372036854775807" {
t.Fatalf("rating %s = %#v, want exact decimal string", field, rating[field])
}
}
if rating["ManualComponent"] != "-9223372036854775807" {
t.Fatalf("rating ManualComponent = %#v, want signed decimal string", rating["ManualComponent"])
}
transfer, err := json.Marshal(CollectibleUsernameTransferRow{
ID: maxInt64, CollectibleID: maxInt64, FromPeerID: maxInt64, ToPeerID: maxInt64, Amount: maxInt64,
})
if err != nil {
t.Fatalf("marshal transfer row: %v", err)
}
var log map[string]any
if err := json.Unmarshal(transfer, &log); err != nil {
t.Fatalf("unmarshal transfer row: %v", err)
}
for _, field := range []string{"ID", "CollectibleID", "FromPeerID", "ToPeerID", "Amount"} {
if log[field] != "9223372036854775807" {
t.Fatalf("transfer %s = %#v, want exact decimal string", field, log[field])
}
}
}
func TestFlexScalarsAcceptNumbersStringsAndBlanks(t *testing.T) {
var body mintCollectibleUsernameAPIRequest
req := httptest.NewRequest(http.MethodPost, "/api/actions/mint-collectible-username", strings.NewReader(`{
"username":"durov","currency":"XTR","amount":"","owner_user_id":null,
"crypto_amount":"9223372036854775807","purchase_date":"2026-07-26"
}`))
if err := decodeJSON(req, &body); err != nil {
t.Fatalf("decode mint action: %v", err)
}
if body.Amount.Int64() != 0 || body.OwnerUserID.Int64() != 0 ||
body.CryptoAmount.Int64() != 9223372036854775807 ||
body.PurchaseDate.Unix() != time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC).Unix() {
t.Fatalf("decoded mint action = %+v", body)
}
var rating adjustAccountRatingAPIRequest
numeric := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
`{"user_id":1001,"amount":-2500}`))
if err := decodeJSON(numeric, &rating); err != nil {
t.Fatalf("decode adjust action: %v", err)
}
if rating.UserID.Int64() != 1001 || rating.Amount.Int64() != -2500 {
t.Fatalf("decoded adjust action = %+v", rating)
}
var broken adjustAccountRatingAPIRequest
invalid := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
`{"user_id":"not-a-number"}`))
if err := decodeJSON(invalid, &broken); err == nil {
t.Fatal("decoded a non-numeric user_id")
}
}
func TestNewCollectibleAndRatingRoutesRequireSession(t *testing.T) {
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
if err != nil {
t.Fatalf("newServer: %v", err)
}
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/collectible-usernames"},
{http.MethodGet, "/api/collectible-usernames/7"},
{http.MethodGet, "/api/account-ratings"},
{http.MethodGet, "/api/account-ratings/7"},
{http.MethodPost, "/api/actions/mint-collectible-username"},
{http.MethodPost, "/api/actions/transfer-collectible-username"},
{http.MethodPost, "/api/actions/revoke-collectible-username"},
{http.MethodPost, "/api/actions/recompute-account-rating"},
{http.MethodPost, "/api/actions/adjust-account-rating"},
}
for _, item := range cases {
req := httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s status=%d, want 401", item.method, item.path, rec.Code)
}
}
}
func TestEscapeLikePatternKeepsUsernameSearchLiteral(t *testing.T) {
if got := escapeLikePattern("crypto_king"); got != `crypto\_king` {
t.Fatalf("escapeLikePattern underscore = %q", got)
}
if got := escapeLikePattern(`100%_\x`); got != `100\%\_\\x` {
t.Fatalf("escapeLikePattern metacharacters = %q", got)
}
if got := escapeLikePattern(""); got != "" {
t.Fatalf("escapeLikePattern empty = %q", got)
}
}

View file

@ -0,0 +1,262 @@
package main
import (
"errors"
"net/http"
"strconv"
"strings"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// Official platform verification in the panel BFF.
//
// Reads come straight from PostgreSQL, like every other table view, so the queue
// pages without a hop through the admin API and the applicant can be resolved by
// a join. Decisions go the other way -- always through the admin API, so the
// command journal, the status machine and the optimistic lock are enforced in one
// place and a panel action is indistinguishable from an API one in the audit
// trail.
// verificationRead mounts a route behind a session and the verification.review
// right.
func (s *server) verificationRead(handler http.HandlerFunc) http.Handler {
return s.requireAuthAPI(s.requirePermission(permissionVerificationReview, handler))
}
// handleVerificationApplicationsAPI pages the review queue. The filter is
// validated before the read store is consulted: a malformed query is a 400
// whether or not the database happens to be reachable.
func (s *server) handleVerificationApplicationsAPI(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
status := strings.TrimSpace(query.Get("status"))
if status != "" && !domain.VerificationStatus(status).Valid() {
writeAPIError(w, http.StatusBadRequest, "invalid status")
return
}
targetType := strings.TrimSpace(query.Get("target_type"))
if targetType != "" && !domain.VerificationTargetType(targetType).Valid() {
writeAPIError(w, http.StatusBadRequest, "invalid target_type")
return
}
beforeID, err := parseInt64(query.Get("before_id"))
if err != nil || beforeID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, hasMore, err := s.read.ListVerificationApplications(
r.Context(), status, targetType, strings.TrimSpace(query.Get("reviewer")), query.Get("q"), beforeID, limit,
)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := ""
if hasMore && len(rows) > 0 {
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleVerificationApplicationDetailAPI(w http.ResponseWriter, r *http.Request) {
id, err := parseInt64(r.PathValue("id"))
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
detail, err := s.read.VerificationApplicationDetail(r.Context(), id)
if err != nil {
if errors.Is(err, errReadNotFound) {
writeAPIError(w, http.StatusNotFound, "verification application not found")
return
}
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"application": detail.Application,
"events": detail.Events,
// Both flags describe the target as it is now, not as it was at
// submission: a reviewer has to see that the applicant lost control of the
// peer, or that the badge is already on, before deciding.
"applicant_controls_target": detail.ApplicantControlsTarget,
"target_verified": detail.Application.TargetVerified,
})
}
func (s *server) handleVerificationCountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
counts, err := s.read.VerificationStatusCounts(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"counts": counts})
}
// verificationDecisionAPIRequest is the decision payload shared by all three
// per-application actions. version is the optimistic-locking token the reviewer
// read; internal_note is operator-only and is not part of what the applicant is
// told. It is optional everywhere, including on a claim, so one panel form can
// drive all three actions without tripping the strict decoder.
type verificationDecisionAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Version flexInt64 `json:"version"`
InternalNote string `json:"internal_note"`
}
func (s *server) handleClaimVerificationAPI(w http.ResponseWriter, r *http.Request) {
id, ok := verificationPathID(w, r)
if !ok {
return
}
var body verificationDecisionAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ClaimVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "claim-verification"),
ApplicationID: id,
Version: body.Version.Int64(),
InternalNote: body.InternalNote,
}
result, status, err := s.callAdminCommand(r.Context(), verificationDecisionPath(id, "claim"), req)
writeVerificationResultAPI(w, result, status, err)
}
func (s *server) handleApproveVerificationAPI(w http.ResponseWriter, r *http.Request) {
id, ok := verificationPathID(w, r)
if !ok {
return
}
var body verificationDecisionAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ApproveVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "approve-verification"),
ApplicationID: id,
Version: body.Version.Int64(),
InternalNote: body.InternalNote,
}
result, status, err := s.callAdminCommand(r.Context(), verificationDecisionPath(id, "approve"), req)
writeVerificationResultAPI(w, result, status, err)
}
func (s *server) handleRejectVerificationAPI(w http.ResponseWriter, r *http.Request) {
id, ok := verificationPathID(w, r)
if !ok {
return
}
var body verificationDecisionAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RejectVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reject-verification"),
ApplicationID: id,
Version: body.Version.Int64(),
InternalNote: body.InternalNote,
}
result, status, err := s.callAdminCommand(r.Context(), verificationDecisionPath(id, "reject"), req)
writeVerificationResultAPI(w, result, status, err)
}
// revokeVerificationAPIRequest clears a badge. It addresses the target, not an
// application: the approved application stays approved as history.
type revokeVerificationAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
TargetType string `json:"target_type"`
TargetID flexInt64 `json:"target_id"`
InternalNote string `json:"internal_note"`
}
func (s *server) handleRevokeVerificationAPI(w http.ResponseWriter, r *http.Request) {
var body revokeVerificationAPIRequest
if !decodeAction(w, r, &body) {
return
}
targetType := domain.VerificationTargetType(strings.TrimSpace(body.TargetType))
if !targetType.Valid() {
writeAPIError(w, http.StatusBadRequest, "invalid target_type")
return
}
if body.TargetID.Int64() <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid target_id")
return
}
req := admin.RevokeVerificationRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-verification"),
TargetType: targetType,
TargetID: body.TargetID.Int64(),
InternalNote: body.InternalNote,
}
result, status, err := s.callAdminCommand(r.Context(), "/v1/verification/revoke", req)
writeVerificationResultAPI(w, result, status, err)
}
func verificationPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := parseInt64(r.PathValue("id"))
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return 0, false
}
return id, true
}
func verificationDecisionPath(applicationID int64, action string) string {
return "/v1/verification/applications/" + strconv.FormatInt(applicationID, 10) + "/" + action
}
// writeVerificationResultAPI relays the admin API's own status to the browser.
//
// The other action handlers flatten every upstream failure into 502, which is
// fine when the only failure mode is "bad request". A verification decision has
// one more: 409 when another reviewer decided first. That has to reach the panel
// as 409, because it is the single case the panel resolves by reloading the
// application rather than by asking the operator to change something.
func writeVerificationResultAPI(w http.ResponseWriter, result admin.CommandResult, status int, err error) {
if err == nil {
writeJSON(w, http.StatusOK, result)
return
}
if result.Status == "" {
result.Status = "failed"
}
if result.Message == "" {
result.Message = "command failed"
}
if result.Error == "" {
result.Error = err.Error()
}
if status < 400 {
// No HTTP answer at all: the admin API was unreachable or unparsable.
status = http.StatusBadGateway
}
writeJSON(w, status, result)
}

View file

@ -0,0 +1,690 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/admin"
)
const testSessionKey = "01234567890123456789012345678901"
// panelServer builds a BFF whose sessions carry the given permissions.
func panelServer(t *testing.T, permissions ...string) *server {
t.Helper()
srv, err := newServer(uiConfig{
SessionKey: []byte(testSessionKey),
Password: "letmein",
Permissions: permissions,
}, nil)
if err != nil {
t.Fatalf("newServer: %v", err)
}
return srv
}
// signIn performs a real login against the routed server and returns the cookies
// plus the CSRF token the panel would echo, so the tests exercise the same pairing
// the browser gets.
func signIn(t *testing.T, srv *server) ([]*http.Cookie, string) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Actor string `json:"actor"`
Permissions []string `json:"permissions"`
CSRFToken string `json:"csrf_token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode login: %v", err)
}
if body.CSRFToken == "" {
t.Fatal("login did not mint a csrf token")
}
cookies := rec.Result().Cookies()
var sawCSRFCookie bool
for _, cookie := range cookies {
if cookie.Name != csrfCookieName {
continue
}
sawCSRFCookie = true
if cookie.HttpOnly {
t.Fatal("csrf cookie is HttpOnly; the panel could not read it back")
}
if cookie.Value != body.CSRFToken || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("csrf cookie=%+v", cookie)
}
}
if !sawCSRFCookie {
t.Fatal("login did not set the csrf cookie")
}
return cookies, body.CSRFToken
}
func withCookies(req *http.Request, cookies []*http.Cookie) *http.Request {
for _, cookie := range cookies {
req.AddCookie(cookie)
}
return req
}
func TestPanelSessionReportsPermissions(t *testing.T) {
srv := panelServer(t, permissionVerificationReview)
cookies, _ := signIn(t, srv)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
if rec.Code != http.StatusOK {
t.Fatalf("session status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Actor string `json:"actor"`
Permissions []string `json:"permissions"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode session: %v", err)
}
if body.Actor != "admin" || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
t.Fatalf("session=%+v, want the granted permissions reported to the panel", body)
}
}
func TestPanelSessionReportsTheWildcardDefault(t *testing.T) {
// The shipped default is the wildcard, so an operator upgrading into the
// permission model keeps every section.
srv := panelServer(t, permissionAll)
cookies, _ := signIn(t, srv)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
if !strings.Contains(rec.Body.String(), `"*"`) {
t.Fatalf("session body=%s, want the wildcard reported", rec.Body.String())
}
}
func TestMutatingRequestsRequireTheCSRFHeader(t *testing.T) {
srv := panelServer(t, permissionAll)
cookies, token := signIn(t, srv)
const path = "/api/actions/set-verified"
const payload = `{"reason":"official","confirm":false,"user_id":1001,"verified":true}`
// No header at all.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)), cookies))
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), csrfHeaderName) {
t.Fatalf("missing header status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
// A header that does not match the cookie.
rec = httptest.NewRecorder()
req := withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)), cookies)
req.Header.Set(csrfHeaderName, token+"-tampered")
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("mismatched header status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
// A matching header from a different session's token: it agrees with the
// cookie the attacker planted but not with the signed session.
otherSrv := panelServer(t, permissionAll)
_, otherToken := signIn(t, otherSrv)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
for _, cookie := range cookies {
if cookie.Name == sessionCookieName {
req.AddCookie(cookie)
}
}
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: otherToken})
req.Header.Set(csrfHeaderName, otherToken)
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not bound to this session") {
t.Fatalf("foreign token status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
// A session minted before the CSRF token existed is refused rather than left
// half protected.
legacy, err := signSession([]byte(testSessionKey), sessionClaims{
Actor: "admin", Exp: time.Now().Add(time.Hour).Unix(), Nonce: "n",
Permissions: []string{permissionAll},
})
if err != nil {
t.Fatalf("signSession: %v", err)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: legacy})
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "anything"})
req.Header.Set(csrfHeaderName, "anything")
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("pre-CSRF session status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
}
func TestCSRFProtectionCoversEveryExistingMutatingRoute(t *testing.T) {
srv := panelServer(t, permissionAll)
cookies, _ := signIn(t, srv)
// A representative slice of the routes that predate CSRF: they must all be
// closed, not just the new ones.
for _, path := range []string{
"/api/logout",
"/api/actions/set-frozen",
"/api/actions/grant-stars",
"/api/actions/delete-bot",
"/api/actions/revoke-collectible-username",
"/api/actions/adjust-account-rating",
"/api/moderation/cases/7/claim",
"/api/verification/applications/7/approve",
"/api/actions/revoke-verification",
} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)), cookies))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s status=%d body=%s, want 403 without a csrf header", path, rec.Code, rec.Body.String())
}
}
}
func TestReadRequestsDoNotNeedTheCSRFHeader(t *testing.T) {
srv := panelServer(t, permissionAll)
cookies, _ := signIn(t, srv)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
if rec.Code != http.StatusOK {
t.Fatalf("GET status=%d body=%s, want a token-free read", rec.Code, rec.Body.String())
}
}
func TestForeignOriginIsRefusedEvenWithAValidToken(t *testing.T) {
srv := panelServer(t, permissionAll)
cookies, token := signIn(t, srv)
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", strings.NewReader(
`{"reason":"official","confirm":false,"user_id":1001,"verified":true}`)), cookies)
req.Header.Set(csrfHeaderName, token)
req.Header.Set("Origin", "https://evil.example")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "origin") {
t.Fatalf("foreign origin status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
// The panel's own origin is accepted.
if !sameOriginRequest(originRequest("https://panel.example", "panel.example")) {
t.Fatal("same origin refused")
}
// A missing Origin is accepted: browsers omit it and non-browser callers never
// send it, and the token check still applies.
if !sameOriginRequest(originRequest("", "panel.example")) {
t.Fatal("absent origin refused")
}
// An opaque origin is not this host.
if sameOriginRequest(originRequest("null", "panel.example")) {
t.Fatal("opaque origin accepted")
}
if sameOriginRequest(originRequest("not a url", "panel.example")) {
t.Fatal("unparsable origin accepted")
}
}
func originRequest(origin, host string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", nil)
req.Host = host
if origin != "" {
req.Header.Set("Origin", origin)
}
return req
}
func TestLoginRefusesAForeignOrigin(t *testing.T) {
srv := panelServer(t, permissionAll)
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
req.Header.Set("Origin", "https://evil.example")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("cross-origin login status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
}
func TestVerificationRoutesRefuseASessionWithoutTheReviewRight(t *testing.T) {
srv := panelServer(t, "gifts.import")
cookies, token := signIn(t, srv)
cases := []struct {
method string
path string
body string
}{
{http.MethodGet, "/api/verification/applications", ""},
{http.MethodGet, "/api/verification/applications/7", ""},
{http.MethodGet, "/api/verification/counts", ""},
{http.MethodPost, "/api/verification/applications/7/claim", `{}`},
{http.MethodPost, "/api/verification/applications/7/approve", `{}`},
{http.MethodPost, "/api/verification/applications/7/reject", `{}`},
{http.MethodPost, "/api/actions/revoke-verification", `{}`},
}
for _, item := range cases {
var req *http.Request
if item.body == "" {
req = httptest.NewRequest(item.method, item.path, nil)
} else {
req = httptest.NewRequest(item.method, item.path, strings.NewReader(item.body))
req.Header.Set(csrfHeaderName, token)
}
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(req, cookies))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["code"] != "FORBIDDEN" || body["permission"] != permissionVerificationReview {
t.Fatalf("%s 403 body=%+v, want the missing permission named", item.path, body)
}
}
}
func TestRevokeVerificationNeedsTheRevokeRightOnTopOfReview(t *testing.T) {
srv := panelServer(t, permissionVerificationReview)
cookies, token := signIn(t, srv)
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(
`{"reason":"impersonation","confirm":true,"target_type":"channel","target_id":5005}`)), cookies)
req.Header.Set(csrfHeaderName, token)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["permission"] != permissionVerificationRevoke {
t.Fatalf("403 body=%+v, want verification.revoke named", body)
}
}
func TestVerificationRoutesRequireASession(t *testing.T) {
srv := panelServer(t, permissionAll)
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/verification/applications"},
{http.MethodGet, "/api/verification/applications/7"},
{http.MethodGet, "/api/verification/counts"},
{http.MethodPost, "/api/verification/applications/7/claim"},
{http.MethodPost, "/api/verification/applications/7/approve"},
{http.MethodPost, "/api/verification/applications/7/reject"},
{http.MethodPost, "/api/actions/revoke-verification"},
}
for _, item := range cases {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`)))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s status=%d, want 401", item.method, item.path, rec.Code)
}
}
}
// verificationUpstream stands in for the admin API and records what the BFF sent.
type verificationUpstream struct {
path string
raw []byte
status int
body any
}
func (u *verificationUpstream) handler(t *testing.T) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer api-secret" {
t.Fatalf("upstream authorization=%q", r.Header.Get("Authorization"))
}
u.path = r.URL.Path
defer r.Body.Close()
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read upstream body: %v", err)
}
u.raw = raw
status := u.status
if status == 0 {
status = http.StatusOK
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(u.body)
}
}
// requestWithActor stands in for the session middleware, which is what puts the
// signed-in operator into the request context.
func requestWithActor(r *http.Request, actor string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), actorKey{}, actor))
}
func TestApproveVerificationBFFForwardsActorVersionAndNote(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(`{
"reason":"press coverage verified","confirm":true,"version":"9223372036854775807",
"internal_note":"contact came through the press office"
}`))
req.SetPathValue("id", "77")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleApproveVerificationAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if upstream.path != "/v1/verification/applications/77/approve" {
t.Fatalf("upstream path=%q", upstream.path)
}
var got admin.ApproveVerificationRequest
if err := json.Unmarshal(upstream.raw, &got); err != nil {
t.Fatalf("decode forwarded approval: %v (%s)", err, upstream.raw)
}
if got.Actor != "operator" {
t.Fatalf("actor=%q, want the signed-in operator", got.Actor)
}
if got.ApplicationID != 77 || got.Version != 9223372036854775807 {
t.Fatalf("forwarded approval=%+v, want the exact int64 version", got)
}
if got.InternalNote != "contact came through the press office" || got.DryRun {
t.Fatalf("forwarded approval=%+v", got)
}
if got.CommandID == "" {
t.Fatal("no command id was minted for the idempotency key")
}
}
func TestClaimVerificationBFFDefaultsToADryRun(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/claim", strings.NewReader(
`{"reason":"queue sweep","confirm":false,"version":3}`))
req.SetPathValue("id", "77")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleClaimVerificationAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var got admin.ClaimVerificationRequest
if err := json.Unmarshal(upstream.raw, &got); err != nil {
t.Fatalf("decode forwarded claim: %v", err)
}
// confirm=false is a rehearsal: nothing may be written until the operator
// confirms.
if !got.DryRun || got.Version != 3 || got.ApplicationID != 77 {
t.Fatalf("forwarded claim=%+v", got)
}
}
func TestRevokeVerificationBFFForwardsTargetAndRejectsBadShapes(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := requestWithActor(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(`{
"reason":"impersonation confirmed","confirm":true,"target_type":"channel",
"target_id":"9223372036854775807","internal_note":"legal asked for it"
}`)), "operator")
rec := httptest.NewRecorder()
srv.handleRevokeVerificationAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if upstream.path != "/v1/verification/revoke" {
t.Fatalf("upstream path=%q", upstream.path)
}
var got admin.RevokeVerificationRequest
if err := json.Unmarshal(upstream.raw, &got); err != nil {
t.Fatalf("decode forwarded revocation: %v", err)
}
if got.TargetID != 9223372036854775807 || got.TargetType != "channel" ||
got.Actor != "operator" || got.InternalNote != "legal asked for it" || got.DryRun {
t.Fatalf("forwarded revocation=%+v", got)
}
for _, payload := range []string{
`{"reason":"x","confirm":true,"target_type":"group","target_id":5}`,
`{"reason":"x","confirm":true,"target_type":"channel","target_id":0}`,
} {
rec := httptest.NewRecorder()
srv.handleRevokeVerificationAPI(rec, requestWithActor(
httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(payload)), "operator"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("payload %s status=%d body=%s, want 400", payload, rec.Code, rec.Body.String())
}
}
}
func TestVerificationDecisionRejectsUnknownFields(t *testing.T) {
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(
`{"reason":"ok","confirm":true,"version":3,"actor":"attacker"}`))
req.SetPathValue("id", "77")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleApproveVerificationAPI(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "actor") {
t.Fatalf("status=%d body=%s, want 400 rejecting the injected actor", rec.Code, rec.Body.String())
}
}
func TestVerificationVersionConflictReachesThePanelAs409(t *testing.T) {
upstream := &verificationUpstream{
status: http.StatusConflict,
body: admin.CommandResult{
CommandID: "c1", Status: "failed",
Error: admin.CodeVerificationConflict + ": verification application changed concurrently",
Message: "another reviewer changed this application first; reload it and decide again",
},
}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(
`{"reason":"ok","confirm":true,"version":3}`))
req.SetPathValue("id", "77")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleApproveVerificationAPI(rec, req)
// A flattened 502 would hide the one failure the panel resolves by reloading.
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String())
}
var result admin.CommandResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if !strings.Contains(result.Error, admin.CodeVerificationConflict) || !strings.Contains(result.Message, "reload") {
t.Fatalf("relayed result=%+v", result)
}
}
func TestVerificationUnreachableAdminAPIIsABadGateway(t *testing.T) {
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/reject", strings.NewReader(
`{"reason":"press links are self-published","confirm":true,"version":3}`))
req.SetPathValue("id", "77")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleRejectVerificationAPI(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status=%d body=%s, want 502", rec.Code, rec.Body.String())
}
}
func TestVerificationRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
raw, err := json.Marshal(VerificationApplicationRow{
ID: maxInt64, ApplicantUserID: maxInt64, TargetID: maxInt64, Version: maxInt64,
})
if err != nil {
t.Fatalf("marshal verification row: %v", err)
}
var application map[string]any
if err := json.Unmarshal(raw, &application); err != nil {
t.Fatalf("unmarshal verification row: %v", err)
}
for _, field := range []string{"ID", "ApplicantUserID", "TargetID", "Version"} {
if application[field] != "9223372036854775807" {
t.Fatalf("application %s = %#v, want an exact decimal string", field, application[field])
}
}
raw, err = json.Marshal(VerificationEventRow{ID: maxInt64})
if err != nil {
t.Fatalf("marshal verification event row: %v", err)
}
var event map[string]any
if err := json.Unmarshal(raw, &event); err != nil {
t.Fatalf("unmarshal verification event row: %v", err)
}
if event["ID"] != "9223372036854775807" {
t.Fatalf("event ID = %#v, want an exact decimal string", event["ID"])
}
}
func TestVerificationQueryValidationRejectsUnmodelledFilters(t *testing.T) {
srv := panelServer(t, permissionVerificationReview)
cookies, _ := signIn(t, srv)
for _, query := range []string{"?status=pending", "?target_type=group", "?before_id=-1", "?limit=abc"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(
httptest.NewRequest(http.MethodGet, "/api/verification/applications"+query, nil), cookies))
// The read store is absent in this fixture, so a rejected filter is a 400
// and an accepted one would be a 503: either way the validation is proven.
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
}
}
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, withCookies(
httptest.NewRequest(http.MethodGet, "/api/verification/applications?status=submitted&target_type=channel", nil), cookies))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("valid filter status=%d body=%s, want the read store to be reached", rec.Code, rec.Body.String())
}
}
func TestPanelPermissionsWildcardAndMembership(t *testing.T) {
all := newPanelPermissions([]string{permissionAll})
if !all.Has(permissionVerificationReview) || !all.Has(permissionVerificationRevoke) {
t.Fatal("wildcard session refused a permission")
}
bounded := newPanelPermissions([]string{" verification.review ", "", "verification.review"})
if !bounded.Has(permissionVerificationReview) || bounded.Has(permissionVerificationRevoke) {
t.Fatalf("bounded session = %+v", bounded.List())
}
if len(bounded.List()) != 1 {
t.Fatalf("bounded list=%+v, want the duplicate collapsed", bounded.List())
}
if got := newPanelPermissions(nil).List(); got == nil || len(got) != 0 {
t.Fatalf("empty list=%#v, want an empty array rather than null", got)
}
}
// The CSRF gate must let a correctly-tokened request through -- including on the
// routes that predate it -- or the panel is simply broken rather than protected.
func TestExistingMutatingRoutesStillWorkWithAValidToken(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := panelServer(t, permissionAll)
srv.cfg.AdminAPIURL = api.URL
srv.cfg.AdminAPIToken = "api-secret"
cookies, token := signIn(t, srv)
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", strings.NewReader(
`{"reason":"official","confirm":false,"user_id":1001,"verified":true}`)), cookies)
req.Header.Set(csrfHeaderName, token)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("tokened legacy action status=%d body=%s", rec.Code, rec.Body.String())
}
if upstream.path != "/v1/accounts/set-verified" {
t.Fatalf("upstream path=%q", upstream.path)
}
// And logout, which is now behind the same gate.
req = withCookies(httptest.NewRequest(http.MethodPost, "/api/logout", nil), cookies)
req.Header.Set(csrfHeaderName, token)
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("tokened logout status=%d body=%s", rec.Code, rec.Body.String())
}
// Both cookies are cleared, so the browser cannot keep replaying either half.
cleared := map[string]bool{}
for _, cookie := range rec.Result().Cookies() {
if cookie.MaxAge < 0 {
cleared[cookie.Name] = true
}
}
if !cleared[sessionCookieName] || !cleared[csrfCookieName] {
t.Fatalf("logout cleared=%+v, want both cookies expired", cleared)
}
}
// The panel drives claim, approve and reject from one form, so a claim carrying an
// internal note must not be rejected by the strict decoder.
func TestClaimVerificationAcceptsAnOptionalInternalNote(t *testing.T) {
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
api := httptest.NewServer(upstream.handler(t))
defer api.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/claim", strings.NewReader(
`{"reason":"queue sweep","confirm":true,"version":3,"internal_note":"waiting on legal"}`))
req.SetPathValue("id", "77")
req = requestWithActor(req, "operator")
rec := httptest.NewRecorder()
srv.handleClaimVerificationAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var got admin.ClaimVerificationRequest
if err := json.Unmarshal(upstream.raw, &got); err != nil {
t.Fatalf("decode forwarded claim: %v", err)
}
if got.InternalNote != "waiting on legal" {
t.Fatalf("forwarded claim=%+v", got)
}
}
func TestMutatingMethodClassification(t *testing.T) {
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions, "get"} {
if mutatingMethod(method) {
t.Fatalf("%s classified as mutating", method)
}
}
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
if !mutatingMethod(method) {
t.Fatalf("%s classified as safe", method)
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -21,8 +21,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DJw3UpEg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D5Lc7N2D.css">
<script type="module" crossorigin src="/assets/index-D_BLAfeq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KZOn7Xwd.css">
</head>
<body>
<div id="root"></div>

View file

@ -758,9 +758,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@ -797,9 +797,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
@ -817,7 +817,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View file

@ -1,12 +1,16 @@
import { useEffect, useState } from "react";
import { api, APIError } from "./api";
import { api } from "./api";
import { BootScreen, Shell } from "./components/Layout";
import { LoginPage } from "./pages/LoginPage";
import { PermissionsProvider } from "./permissions";
import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing";
import type { AdminSession } from "./types";
export function App() {
const [actor, setActor] = useState<string | null | undefined>(undefined);
// One GET /api/session at boot carries both the actor and the permission set the
// signed session was issued with.
const [session, setSession] = useState<AdminSession | null | undefined>(undefined);
const [route, setRoute] = useState<RouteState>(() => currentRoute());
useEffect(() => {
@ -17,14 +21,10 @@ export function App() {
useEffect(() => {
api.session()
.then((session) => setActor(session.actor))
.catch((error) => {
if (error instanceof APIError && error.status === 401) {
setActor(null);
return;
}
setActor(null);
});
.then((next) => setSession(next))
// A 401 and an unreachable backend both end at the login screen; there is
// nothing the panel can render without a session.
.catch(() => setSession(null));
}, []);
const navigate = (href: string) => {
@ -32,17 +32,19 @@ export function App() {
setRoute(currentRoute());
};
if (actor === undefined) {
if (session === undefined) {
return <BootScreen />;
}
if (actor === null) {
return <LoginPage onLogin={setActor} />;
if (session === null) {
return <LoginPage onLogin={setSession} />;
}
return (
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
<PermissionsProvider permissions={session.permissions ?? []}>
<Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
</PermissionsProvider>
);
}

View file

@ -1,11 +1,23 @@
import type {
AccountDetail,
AccountListResponse,
AccountRatingDetail,
AccountRatingListResponse,
AdminLoginResult,
AdminSession,
BotDetail,
BotListResponse,
BotVerificationCountsResponse,
BotVerifierListResponse,
ChannelDetail,
CustomVerificationListResponse,
CustomVerificationRequestDetail,
CustomVerificationRequestListResponse,
VerificationIconListResponse,
EmojiListResponse,
ChannelListResponse,
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
CommandResult,
GroupMessageDetail,
GroupMessageListResponse,
@ -16,7 +28,10 @@ import type {
ModerationReport,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
StarGiftListResponse,
VerificationApplicationDetail,
VerificationApplicationListResponse,
VerificationCountsResponse
} from "./types";
export class APIError extends Error {
@ -28,12 +43,81 @@ export class APIError extends Error {
}
}
// The backend publishes the CSRF token in a deliberately readable cookie and
// refuses every mutating request whose X-CSRF-Token header does not repeat it
// (cmd/telesrv-admin/security.go). Echoing it here — inside request<T> — is what
// keeps a new endpoint from silently shipping without the header.
const csrfCookieName = "telesrv_admin_csrf";
const csrfHeaderName = "X-CSRF-Token";
// Login answers with the token in the body as well as in Set-Cookie. Keeping the
// body value is the fallback for the window where the browser has not applied
// the cookie yet, or where the cookie is not readable back to the script.
let issuedCSRFToken = "";
export function rememberCSRFToken(token: string | undefined): void {
issuedCSRFToken = (token ?? "").trim();
}
function readCSRFCookie(): string {
if (typeof document === "undefined") return "";
for (const chunk of document.cookie.split(";")) {
const entry = chunk.trim();
const separator = entry.indexOf("=");
if (separator <= 0 || entry.slice(0, separator) !== csrfCookieName) continue;
try {
return decodeURIComponent(entry.slice(separator + 1));
} catch {
return entry.slice(separator + 1);
}
}
return "";
}
// The cookie wins: it is the value the server compares against, and it survives
// a page reload that the in-memory copy does not.
export function csrfToken(): string {
return readCSRFCookie() || issuedCSRFToken;
}
// Same classification the backend uses: GET/HEAD/OPTIONS are safe, everything
// else carries a token.
function mutatingMethod(method: string | undefined): boolean {
const verb = (method ?? "GET").toUpperCase();
return verb !== "GET" && verb !== "HEAD" && verb !== "OPTIONS";
}
function plainHeaders(source: HeadersInit | undefined): Record<string, string> {
if (!source) return {};
if (source instanceof Headers) {
const out: Record<string, string> = {};
source.forEach((value, key) => {
out[key] = value;
});
return out;
}
if (Array.isArray(source)) {
return Object.fromEntries(source);
}
return { ...source };
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
// A multipart body must keep the boundary the browser generates, so its
// Content-Type is left alone; the CSRF header is added either way.
const headers: Record<string, string> = isForm ? {} : { "Content-Type": "application/json" };
Object.assign(headers, plainHeaders(init.headers));
if (mutatingMethod(init.method)) {
const token = csrfToken();
if (token) {
headers[csrfHeaderName] = token;
}
}
const response = await fetch(url, {
credentials: "same-origin",
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
...init
...init,
headers
});
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@ -52,11 +136,16 @@ export function errorMessage(error: unknown): string {
}
export const api = {
session: () => request<{ actor: string }>("/api/session"),
login: (secret: string) => request<{ actor: string }>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
}),
session: () => request<AdminSession>("/api/session"),
login: async (secret: string) => {
const result = await request<AdminLoginResult>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
});
// Stashed here rather than in the caller so no login path can forget it.
rememberCSRFToken(result.csrf_token);
return result;
},
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
@ -64,6 +153,36 @@ export const api = {
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
collectibleUsernames: (params: URLSearchParams) =>
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
collectibleUsername: (id: string) =>
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
accountRatings: (params: URLSearchParams) =>
request<AccountRatingListResponse>(`/api/account-ratings?${params.toString()}`),
accountRating: (userID: string) =>
request<AccountRatingDetail>(`/api/account-ratings/${encodeURIComponent(userID)}`),
verificationApplications: (params: URLSearchParams) =>
request<VerificationApplicationListResponse>(`/api/verification/applications?${params.toString()}`),
// The application id is an int64 decimal string end to end, so it is never
// parsed into a number on the way to the URL.
verificationApplication: (id: string) =>
request<VerificationApplicationDetail>(`/api/verification/applications/${encodeURIComponent(id)}`),
verificationCounts: () => request<VerificationCountsResponse>("/api/verification/counts"),
// Third-party verification lives under its own prefix: the two mechanisms share
// no state, so they share no route either.
botVerifiers: (params: URLSearchParams) =>
request<BotVerifierListResponse>(`/api/botverification/verifiers?${params.toString()}`),
verificationIcons: (params: URLSearchParams) =>
request<VerificationIconListResponse>(`/api/botverification/icons?${params.toString()}`),
customVerifications: (params: URLSearchParams) =>
request<CustomVerificationListResponse>(`/api/botverification/marks?${params.toString()}`),
customVerificationRequests: (params: URLSearchParams) =>
request<CustomVerificationRequestListResponse>(`/api/botverification/requests?${params.toString()}`),
// The application id is an int64 decimal string end to end, so it is never parsed
// into a number on the way to the URL.
customVerificationRequest: (id: string) =>
request<CustomVerificationRequestDetail>(`/api/botverification/requests/${encodeURIComponent(id)}`),
botVerificationCounts: () => request<BotVerificationCountsResponse>("/api/botverification/counts"),
emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),

View file

@ -16,7 +16,9 @@ export function ActionButton({
icon,
compact = false,
tone = "danger",
onDone
disabled = false,
onDone,
onError
}: {
label: string;
path: string;
@ -24,7 +26,15 @@ export function ActionButton({
icon?: ReactNode;
compact?: boolean;
tone?: ActionTone;
// disabled keeps a form from opening the confirm flow at all while its own
// validation is unhappy, so the operator fixes the field instead of reading a
// backend rejection.
disabled?: boolean;
onDone?: () => void;
// onError lets a page react to a failure the operator cannot fix by editing the
// form — an optimistic-locking 409, say — and replace the raw backend text with
// an explanation by returning it.
onError?: (error: unknown) => string | undefined;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
@ -54,7 +64,7 @@ export function ActionButton({
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
setError(onError?.(err) || errorMessage(err));
} finally {
setBusy(false);
}
@ -75,6 +85,7 @@ export function ActionButton({
<button
className={triggerClass}
type="button"
disabled={disabled}
onClick={() => {
reset();
setOpen(true);

View file

@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
import type { AccountRow, ChannelRow } from "../types";
import type { AccountRow, BotRow, ChannelRow } from "../types";
import { Badge } from "./ui";
export function UserPicker({
@ -100,6 +100,103 @@ export function UserPicker({
);
}
// BotPicker is the same widget over /api/bots. Verifier status is granted to a bot
// account, and an operator knows the handle rather than the id, so the grant form
// resolves it here instead of asking for a raw number.
export function BotPicker({
label,
value,
onChange
}: {
label: string;
value: BotRow | null;
onChange: (row: BotRow | null) => void;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [rows, setRows] = useState<BotRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim().replace(/^@/, ""));
}
try {
const result = await api.bots(params);
setRows(result.rows ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> {t("common.clear")}
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{value.FirstName || "-"}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || "-"}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder={t("picker.botPlaceholder")}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{row.FirstName || "-"}</strong>
<span>{displayUsername(row.Username) || "-"}</span>
{row.System ? <Badge tone="warn">{t("picker.system")}</Badge> : <Badge>{t("picker.regular")}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
</div>
</div>
);
}
export function ChannelPicker({
label,
value,

View file

@ -1,4 +1,6 @@
import {
AtSign,
BadgeCheck,
Bot,
ChevronDown,
Database,
@ -10,6 +12,8 @@ import {
ShieldAlert,
ShieldCheck,
Smile,
Stamp,
Trophy,
Users,
Gift,
Send
@ -17,6 +21,7 @@ import {
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { LanguageSwitch, useI18n } from "../i18n";
import { permissionBotVerificationReview, permissionVerificationReview, useCan } from "../permissions";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AppLink } from "./AppLink";
@ -51,6 +56,12 @@ export function Shell({
children: ReactNode;
}) {
const { t } = useI18n();
// The verification queue is hidden for a session without verification.review:
// the entry would only lead to a 403 (and the route itself is gated as well).
const canReviewVerification = useCan(permissionVerificationReview);
// Same reasoning for the third-party queue, which has its own right: the two
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
@ -82,6 +93,14 @@ export function Shell({
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{t("layout.moderation")}</NavLink>
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{t("layout.verification")}</NavLink>
)}
{canReviewBotVerification && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{t("layout.botVerification")}</NavLink>
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{t("layout.collectibleUsernames")}</NavLink>
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{t("layout.accountRatings")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>

View file

@ -1,8 +1,8 @@
import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
import type { AuditLogRow } from "../types";
import { displayUsername, formatDate } from "../lib/format";
import type { AccountUsername, AuditLogRow } from "../types";
type Tone = "neutral" | "good" | "danger" | "warn";
@ -129,3 +129,33 @@ export function LoadingSurface({ label }: { label: string }) {
export function JsonBlock({ value }: { value: string }) {
return <pre className="json-block">{value || "{}"}</pre>;
}
// UsernameCell renders a peer's editable username with its collectible usernames
// branching off underneath, in the order clients project them.
//
// An inactive collectible is shown rather than hidden: the peer still owns it, it
// just does not resolve publicly, and an operator looking for "where did that name
// go" needs to see it. It is marked instead of dropped.
// Pass an empty username to render the branch on its own, which is what the
// detail header does: it already shows the editable slot on the line above.
export function UsernameCell({ username, collectibles }: { username?: string; collectibles?: AccountUsername[] | null }) {
const { t } = useI18n();
const main = displayUsername(username ?? "");
const branch = collectibles ?? [];
if (branch.length === 0) {
return <>{main || "-"}</>;
}
return (
<>
{main}
<ul className="username-branch">
{branch.map((item) => (
<li key={item.Username} className={item.Active ? "" : "inactive"}>
<span>{displayUsername(item.Username)}</span>
{!item.Active && <em>{t("usernames.inactive")}</em>}
</li>
))}
</ul>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -44,12 +44,131 @@ export function formatUnix(value: number): string {
return date.toLocaleString();
}
// safeHttpURL vets a link an applicant typed. Only http(s) is turned into an
// anchor: a submitted string may just as well be javascript:, data: or a bare
// word, and must stay inert text in that case. The parsed href is returned so a
// malformed authority cannot slip through the prefix test.
export function safeHttpURL(value: string): string {
const raw = (value ?? "").trim();
if (!/^https?:\/\//i.test(raw)) return "";
try {
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
return parsed.href;
} catch {
return "";
}
}
export function toInt(value: string): number {
if (!value.trim()) return 0;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
// int64 values arrive as JSON strings; keep parsing tolerant so an unexpected
// empty string or "null" never renders as NaN.
export function toNumeric(value: string): number {
const raw = (value ?? "").trim();
if (!raw) return 0;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
export function formatQuantity(value: string): string {
const raw = (value ?? "").trim();
if (!raw) return "0";
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed.toLocaleString() : raw;
}
// Currency scaling for fragment.collectibleInfo.
//
// The wire format is integer smallest units: core.telegram.org says amount is
// "Total price in the smallest units of the currency (integer, not
// float/double)" -- $1.45 is 145 -- and crypto_amount likewise, so TON is
// nanotons (1 TON = 1e9). Clients divide by that exponent before drawing the
// price, which is why a panel that both stores and shows the raw integer makes an
// operator type 900 for "900 TON" and Telegram Desktop then renders 0.0000009.
//
// Everything the operator reads or types in the panel is therefore in whole
// currency units, and these helpers are the only conversion boundary.
const currencyExponents: Record<string, number> = {
// Stars have no subunit: an XTR amount is a count of stars.
XTR: 0,
// Nanotons.
TON: 9,
// Fiat minor units.
USD: 2,
EUR: 2,
RUB: 2
};
export function currencyExponent(currency: string): number {
const key = (currency ?? "").trim().toUpperCase();
// Two decimals is the ISO 4217 default, and it is what an unknown fiat code
// most likely is; guessing 0 would silently multiply a price by 100.
return key in currencyExponents ? currencyExponents[key] : 2;
}
// formatCurrencyAmount renders smallest units as whole currency units. It works
// on the decimal string rather than a JS number so a nanoton amount beyond
// Number.MAX_SAFE_INTEGER is not rounded on the way to the screen.
export function formatCurrencyAmount(value: string, currency: string): string {
const raw = (value ?? "").trim();
if (!raw) return "0";
if (!/^-?\d+$/.test(raw)) return raw;
const exponent = currencyExponent(currency);
const negative = raw.startsWith("-");
const digits = (negative ? raw.slice(1) : raw).replace(/^0+(?=\d)/, "");
const padded = digits.padStart(exponent + 1, "0");
const whole = padded.slice(0, padded.length - exponent) || "0";
let fraction = exponent > 0 ? padded.slice(padded.length - exponent) : "";
// Fiat keeps its two decimals the way a client draws them ($10.00); a
// nine-decimal crypto amount would just be a wall of zeros, so trim those.
if (exponent > 2) fraction = fraction.replace(/0+$/, "");
const sign = negative ? "-" : "";
return fraction ? `${sign}${groupDigits(whole)}.${fraction}` : `${sign}${groupDigits(whole)}`;
}
// groupDigits inserts thousands separators without going through a JS number, so
// a value past Number.MAX_SAFE_INTEGER keeps every digit.
function groupDigits(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, "");
}
// formatCurrency is formatCurrencyAmount with the code appended, which is the
// shape every price cell in the panel wants.
export function formatCurrency(value: string, currency: string): string {
const code = (currency ?? "").trim().toUpperCase();
const amount = formatCurrencyAmount(value, code);
return code ? `${amount} ${code}` : amount;
}
// toSmallestUnits turns what the operator typed -- whole currency units, with an
// optional fraction -- into the integer decimal string the API expects. It
// returns null for anything that is not a plain non-negative amount, or that
// carries more decimals than the currency has, so the form can refuse instead of
// silently truncating a price.
export function toSmallestUnits(value: string, currency: string): string | null {
const raw = (value ?? "").trim().replace(/\s+/g, "").replace(",", ".");
if (!raw) return "0";
if (!/^\d*(\.\d*)?$/.test(raw) || raw === "." ) return null;
const exponent = currencyExponent(currency);
const [wholePart, fractionPart = ""] = raw.split(".");
if (fractionPart.length > exponent) return null;
const digits = `${wholePart || "0"}${fractionPart.padEnd(exponent, "0")}`.replace(/^0+(?=\d)/, "");
return digits === "" ? "0" : digits;
}
export function formatSigned(value: string): string {
const raw = (value ?? "").trim();
if (!raw) return "0";
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return raw;
return parsed > 0 ? `+${parsed.toLocaleString()}` : parsed.toLocaleString();
}
export function parseIDs(value: string, invalidMessage = "msg ids invalid"): number[] {
const ids = value
.split(/[\s,]+/)

View file

@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary, UsernameCell } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
@ -65,6 +65,11 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || t("account.noUsername")} · {displayPhone(account.Phone) || t("account.noPhone")}</div>
{account.Collectibles?.length > 0 && (
<div className="entity-subtitle">
<UsernameCell username="" collectibles={account.Collectibles} />
</div>
)}
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}

View file

@ -0,0 +1,261 @@
import { ArrowLeft, Calculator, RefreshCw, SlidersHorizontal, User } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, LoadingSurface, Metric, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, formatQuantity, formatSigned, toNumeric } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRatingDetail, AccountRatingEventKind, AccountRatingRow } from "../types";
import { LevelBadge, RatingProgress, levelProgress } from "./AccountRatingsPage";
export function AccountRatingDetailPage({ userID, navigate }: { userID: string; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<AccountRatingDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [adjustment, setAdjustment] = useState("");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.accountRating(userID));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [userID]);
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? t("rating.loadingDetail") : t("account.waitingData")} />;
}
const rating = detail.rating;
const events = detail.events ?? [];
const pending = toNumeric(rating.PendingStars);
const progress = levelProgress(rating);
// user_id / amount are `,string` int64 fields on the backend, so they stay
// decimal strings and never pass through a float.
const payloadUserID = rating.UserID || userID;
return (
<PageFrame
title={t("rating.detailTitle", { user: displayUsername(rating.Username) || rating.FirstName || rating.UserID })}
eyebrow={t("rating.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayUsername(rating.Username) || rating.FirstName || t("bots.unnamed")}</div>
<div className="entity-subtitle">{t("rating.userID")}: {rating.UserID}</div>
</div>
<div className="entity-badges">
<LevelBadge level={rating.Level} />
{pending !== 0 && <Badge tone="warn">{t("rating.pendingBadge", { amount: formatSigned(rating.PendingStars) })}</Badge>}
</div>
</section>
<div className="metric-row">
<Metric label={t("rating.stars")} value={formatQuantity(rating.Stars)} mono />
<Metric label={t("rating.level")} value={String(rating.Level)} tone="good" />
<Metric
label={t("rating.nextLevel")}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : t("rating.maxLevel")}
mono={rating.HasNextLevel}
/>
<Metric
label={t("rating.toNextLevel")}
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
mono
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
/>
</div>
<section className="section-block">
<SectionHead title={t("rating.breakdownTitle")} text={t("rating.breakdownHint")} />
<Breakdown rating={rating} />
<div className="summary-grid">
<Summary label={t("rating.currentLevelStars")} value={formatQuantity(rating.CurrentLevelStars)} mono />
<Summary
label={t("rating.nextLevelStars")}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : t("rating.maxLevel")}
mono={rating.HasNextLevel}
/>
<Summary label={t("rating.computedAt")} value={formatDate(rating.ComputedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(rating.UpdatedAt) || "-"} />
</div>
<div className="progress-wide">
<RatingProgress row={rating} />
</div>
</section>
{pending !== 0 && (
<section className="section-block">
<SectionHead title={t("rating.pendingTitle")} text={t("rating.pendingHint")} />
<div className="summary-grid">
<Summary label={t("rating.pending")} value={formatSigned(rating.PendingStars)} mono />
<Summary label={t("rating.pendingDate")} value={formatDate(rating.PendingDate) || "-"} />
</div>
</section>
)}
<section className="section-block">
<SectionHead title={t("rating.eventsTitle")} text={t("rating.eventsHint")} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("rating.eventKind")}</th>
<th>{t("rating.amount")}</th>
<th>{t("audit.reason")}</th>
<th>{t("audit.actor")}</th>
<th>{t("common.time")}</th>
</tr>
</thead>
<tbody>
{events.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td><EventKind kind={row.Kind} /></td>
<td className="mono">{formatSigned(row.Amount)}</td>
<td className="truncate">{row.Reason || "-"}</td>
<td>{row.Actor || "-"}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
</tr>
))}
{events.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("rating.actionDock")}</div>
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
<User size={15} /> {t("rating.openAccount")}
</button>
<div className="action-stack">
<ActionButton
label={t("rating.recompute")}
icon={<Calculator size={15} />}
tone="neutral"
path="/api/actions/recompute-account-rating"
payload={() => ({ user_id: payloadUserID })}
onDone={load}
/>
</div>
<p className="bot-create-note">{t("rating.recomputeHint")}</p>
<div className="dock-title">{t("rating.adjustTitle")}</div>
<label className="duration-field">
<span>{t("rating.adjustAmount")}</span>
<input
value={adjustment}
onChange={(event) => setAdjustment(event.target.value)}
type="number"
step="1"
placeholder="-500"
/>
</label>
<div className="action-stack">
<ActionButton
label={t("rating.adjust")}
icon={<SlidersHorizontal size={15} />}
tone="warn"
path="/api/actions/adjust-account-rating"
payload={() => ({
user_id: payloadUserID,
amount: String(Number.parseInt(adjustment.trim() || "0", 10) || 0)
})}
onDone={() => {
setAdjustment("");
void load();
}}
/>
</div>
<p className="bot-create-note">{t("rating.adjustHint")}</p>
</section>
}
/>
</PageFrame>
);
}
function Breakdown({ rating }: { rating: AccountRatingRow }) {
const { t } = useI18n();
// PenaltyComponent is stored as a positive magnitude and subtracted by the
// scorer, so it is shown (and summed) as a negative contribution.
const components = [
{ key: "stars", label: t("rating.componentStars"), hint: t("rating.componentStarsHint"), value: toNumeric(rating.StarsComponent) },
{ key: "activity", label: t("rating.componentActivity"), hint: t("rating.componentActivityHint"), value: toNumeric(rating.ActivityComponent) },
{ key: "penalty", label: t("rating.componentPenalty"), hint: t("rating.componentPenaltyHint"), value: -toNumeric(rating.PenaltyComponent) },
{ key: "manual", label: t("rating.componentManual"), hint: t("rating.componentManualHint"), value: toNumeric(rating.ManualComponent) }
];
const scale = Math.max(1, ...components.map((item) => Math.abs(item.value)));
// The score is clamped at zero, and a delayed increase sits in PendingStars
// instead of the score, so both cases are expected rather than drift.
const sum = Math.max(0, components.reduce((total, item) => total + item.value, 0));
const total = toNumeric(rating.Stars);
const pending = toNumeric(rating.PendingStars);
return (
<>
<div className="breakdown-list">
{components.map((item) => {
const percent = Math.min(100, (Math.abs(item.value) / scale) * 100);
const tone = item.value < 0 ? "danger" : item.value > 0 ? "good" : "";
return (
<div className="breakdown-row" key={item.key}>
<div className="breakdown-label">
<strong>{item.label}</strong>
<small>{item.hint}</small>
</div>
<div className={`progress-bar ${tone}`} role="img" aria-label={String(item.value)}>
<span style={{ width: `${percent}%` }} />
</div>
<div className={`breakdown-value mono ${tone}`}>{formatSigned(String(item.value))}</div>
</div>
);
})}
<div className="breakdown-row total">
<div className="breakdown-label"><strong>{t("rating.componentTotal")}</strong></div>
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
</div>
</div>
{pending === 0 && sum !== total && (
<Alert>{t("rating.breakdownMismatch", { sum: formatQuantity(String(sum)), total: formatQuantity(rating.Stars) })}</Alert>
)}
{pending !== 0 && <p className="bot-create-note">{t("rating.breakdownPending", { amount: formatSigned(rating.PendingStars) })}</p>}
</>
);
}
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
const { t } = useI18n();
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
return <Badge tone={tone}>{t(`rating.kind.${kind}`)}</Badge>;
}

View file

@ -0,0 +1,167 @@
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, formatQuantity, toNumeric } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRatingRow } from "../types";
export function AccountRatingsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [minLevel, setMinLevel] = useState("");
const [search, setSearch] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<AccountRatingRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
// One free-text field: the backend matches a username prefix (editable or
// collectible), a first/last name prefix, and a bare number as the user id.
const wanted = search.trim();
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (minLevel.trim()) params.set("min_level", minLevel.trim());
if (wanted) params.set("q", wanted);
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.accountRatings(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const topLevel = rows.reduce((max, row) => Math.max(max, row.Level), 0);
const pendingCount = rows.filter((row) => toNumeric(row.PendingStars) !== 0).length;
const avgLevel = rows.length > 0
? (rows.reduce((sum, row) => sum + row.Level, 0) / rows.length).toFixed(1)
: "0";
return (
<PageFrame
title={t("rating.pageTitle")}
eyebrow={t("rating.eyebrow")}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("rating.metricLoaded")} value={String(rows.length)} />
<Metric label={t("rating.metricTopLevel")} value={String(topLevel)} tone="good" />
<Metric label={t("rating.metricAvgLevel")} value={avgLevel} />
<Metric label={t("rating.metricPending")} value={String(pendingCount)} tone={pendingCount ? "warn" : "neutral"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t("rating.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("rating.minLevel")}</span>
<input className="small-input" value={minLevel} onChange={(event) => setMinLevel(event.target.value)} type="number" min="0" placeholder="0" />
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("rating.userID")}</th>
<th>{t("common.username")}</th>
<th>{t("rating.level")}</th>
<th>{t("rating.stars")}</th>
<th>{t("rating.progress")}</th>
<th>{t("rating.pending")}</th>
<th>{t("rating.computedAt")}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.UserID}>
<td className="mono">{row.UserID}</td>
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
<td><LevelBadge level={row.Level} /></td>
<td className="mono">{formatQuantity(row.Stars)}</td>
<td><RatingProgress row={row} /></td>
<td className="mono">{toNumeric(row.PendingStars) !== 0 ? formatQuantity(row.PendingStars) : "-"}</td>
<td>{formatDate(row.ComputedAt) || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/account-ratings/${row.UserID}`)}>
<Trophy size={14} /> {t("common.detail")} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
</button>
</div>
)}
</PageFrame>
);
}
export function LevelBadge({ level }: { level: number }) {
const { t } = useI18n();
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
return <Badge tone={tone}>{t("rating.levelValue", { level })}</Badge>;
}
export function levelProgress(row: AccountRatingRow): { percent: number; remaining: number; target: number; stars: number } {
const stars = toNumeric(row.Stars);
const current = toNumeric(row.CurrentLevelStars);
const target = toNumeric(row.NextLevelStars);
const span = target - current;
const percent = span > 0 ? Math.min(100, Math.max(0, ((stars - current) / span) * 100)) : 0;
return { percent, remaining: Math.max(0, target - stars), target, stars };
}
export function RatingProgress({ row }: { row: AccountRatingRow }) {
const { t } = useI18n();
if (!row.HasNextLevel) {
return <span className="progress-note">{t("rating.maxLevel")}</span>;
}
const { percent, remaining, target } = levelProgress(row);
return (
<div className="progress-cell">
<div className="progress-bar" role="img" aria-label={`${Math.round(percent)}%`}>
<span style={{ width: `${percent}%` }} />
</div>
<small>{t("rating.progressHint", { remaining: formatQuantity(String(remaining)), target: formatQuantity(String(target)) })}</small>
</div>
);
}

View file

@ -1,10 +1,10 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, UsernameCell } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { displayName, displayPhone, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types";
@ -107,7 +107,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td>{displayUsername(row.Username)}</td>
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>
<td>{displayName(row)}</td>
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>

View file

@ -0,0 +1,965 @@
import {
Ban,
BadgeCheck,
Building2,
ChevronDown,
ChevronRight,
ExternalLink,
Loader2,
Plus,
Power,
PowerOff,
RefreshCw,
Search,
Stamp,
Sticker,
Trash2
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { BotPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import {
permissionBotVerificationManage,
permissionVerificationReview,
usePermissions
} from "../permissions";
import type { Navigate } from "../routing";
import type {
BotRow,
BotVerificationPeerType,
BotVerifierRow,
CustomVerificationRequestRow,
CustomVerificationRequestStatus,
CustomVerificationRow,
VerificationIconRow
} from "../types";
type Tab = "requests" | "verifiers" | "icons" | "marks";
type StatusFilter = "all" | CustomVerificationRequestStatus;
type PeerTypeFilter = "all" | BotVerificationPeerType;
const statuses: CustomVerificationRequestStatus[] = ["pending", "approved", "rejected", "revoked"];
const peerTypes: BotVerificationPeerType[] = ["user", "channel"];
// The section owns four different objects — applications, verifiers, the icon
// catalogue and the granted marks — and mixing them into one table would hide which
// row an action addresses. They are separate tabs over one shared verifier/icon
// load: the roster feeds three of the four filters, so it is fetched once here
// rather than per tab.
export function BotVerificationPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const { can } = usePermissions();
const canManage = can(permissionBotVerificationManage);
const canSeeOfficial = can(permissionVerificationReview);
const [tab, setTab] = useState<Tab>("requests");
const [verifiers, setVerifiers] = useState<BotVerifierRow[]>([]);
const [icons, setIcons] = useState<VerificationIconRow[]>([]);
const [error, setError] = useState("");
const [rosterDenied, setRosterDenied] = useState(false);
async function loadRoster() {
setError("");
setRosterDenied(false);
try {
const [verifierResult, iconResult] = await Promise.all([
api.botVerifiers(new URLSearchParams({ limit: "200" })),
api.verificationIcons(new URLSearchParams({ limit: "200" }))
]);
setVerifiers(verifierResult.rows ?? []);
setIcons(iconResult.rows ?? []);
} catch (err) {
// A 403 here is not a fault to alarm about: it means the session may review
// applications but not see the roster. Saying so beats an empty table that
// reads as "no verifiers configured".
if (err instanceof APIError && err.status === 403) {
setVerifiers([]);
setIcons([]);
setRosterDenied(true);
return;
}
setError(errorMessage(err));
}
}
useEffect(() => {
void loadRoster();
}, []);
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
{ key: "requests", label: t("botverification.tabRequests"), icon: <Stamp size={15} /> },
{ key: "verifiers", label: t("botverification.tabVerifiers"), icon: <Building2 size={15} /> },
{ key: "icons", label: t("botverification.tabIcons"), icon: <Sticker size={15} /> },
{ key: "marks", label: t("botverification.tabMarks"), icon: <BadgeCheck size={15} /> }
];
return (
<PageFrame
title={t("botverification.pageTitle")}
eyebrow={t("botverification.eyebrow")}
actions={
canSeeOfficial ? (
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ExternalLink size={15} /> {t("botverification.openOfficial")}
</button>
) : undefined
}
>
{error && <Alert>{error}</Alert>}
{rosterDenied && <Alert>{t("botverification.rosterDenied")}</Alert>}
{/* The one thing an operator has to understand before touching anything here:
this is a verifier company's own icon, not the platform checkmark. */}
<section className="section-block">
<SectionHead title={t("botverification.explainTitle")} text={t("botverification.explainText")} />
<p className="bot-create-note">{t("botverification.explainIcon")}</p>
<p className="bot-create-note">{t("botverification.explainOfficial")}</p>
{!canManage && <p className="bot-create-note">{t("botverification.manageMissing")}</p>}
</section>
<div className="toolbar" role="group" aria-label={t("botverification.pageTitle")}>
{tabs.map((item) => (
<button
key={item.key}
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
>
{item.icon} {item.label}
</button>
))}
</div>
{tab === "requests" && <RequestsBlock navigate={navigate} verifiers={verifiers} />}
{tab === "verifiers" && (
<VerifiersBlock
verifiers={verifiers}
icons={icons}
canManage={canManage}
onChanged={loadRoster}
navigate={navigate}
/>
)}
{tab === "icons" && (
<IconsBlock icons={icons} verifiers={verifiers} canManage={canManage} onChanged={loadRoster} />
)}
{tab === "marks" && <MarksBlock verifiers={verifiers} canManage={canManage} navigate={navigate} />}
</PageFrame>
);
}
// ---------------------------------------------------------------------------
// Applications
// ---------------------------------------------------------------------------
function RequestsBlock({ navigate, verifiers }: { navigate: Navigate; verifiers: BotVerifierRow[] }) {
const { t } = useI18n();
const [status, setStatus] = useState<StatusFilter>("pending");
const [verifierBotID, setVerifierBotID] = useState("");
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<CustomVerificationRequestRow[]>([]);
const [counts, setCounts] = useState<Record<string, string>>({});
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// One free-text field: the backend matches the application id, the peer id and a
// username (applicant or peer), so "@durov", "42" and a peer id all work without a
// mode switch.
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (status !== "all") params.set("status", status);
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
if (peerType !== "all") params.set("peer_type", peerType);
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.customVerificationRequests(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
// The counts describe the whole queue, not the current page, so they are fetched
// separately from the keyset listing.
async function loadCounts() {
try {
const result = await api.botVerificationCounts();
setCounts(result.counts ?? {});
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load(false);
void loadCounts();
}, []);
function refresh() {
void load(false);
void loadCounts();
}
return (
<>
<section className="section-block">
<SectionHead
title={t("botverification.queueTitle")}
text={t("botverification.queueHint")}
action={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
/>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={t(`botverification.status.${item}`)}
value={counts[item] ?? "0"}
mono
tone={countTone(item, counts[item] ?? "0")}
/>
))}
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("botverification.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.status")}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{t("botverification.statusAll")}</option>
{statuses.map((item) => (
<option key={item} value={item}>{t(`botverification.status.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("botverification.verifier")}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{t("botverification.peerType")}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{t("botverification.peerTypeAll")}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{t(`botverification.peer.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("botverification.verifier")}</th>
<th>{t("botverification.target")}</th>
<th>{t("botverification.applicant")}</th>
<th>{t("botverification.reason")}</th>
<th>{t("common.status")}</th>
<th>{t("botverification.createdAt")}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
#{row.ID}
</button>
</td>
<td>
<strong>{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
<div className="entity-subtitle mono">{row.VerifierBotID}</div>
</td>
<td>
<strong>{peerLabel(row)}</strong>
<div className="entity-subtitle mono">
{t(`botverification.peer.${row.PeerType}`)} · {row.PeerID}
</div>
</td>
<td>
{displayUsername(row.ApplicantUsername) || "-"}
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
</td>
<td className="truncate">{row.Reason || "-"}</td>
<td><RequestStatusBadge status={row.Status} /></td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
<Stamp size={14} /> {t("common.detail")} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
</button>
</div>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Verifiers
// ---------------------------------------------------------------------------
function VerifiersBlock({
verifiers,
icons,
canManage,
onChanged,
navigate
}: {
verifiers: BotVerifierRow[];
icons: VerificationIconRow[];
canManage: boolean;
onChanged: () => void;
navigate: Navigate;
}) {
const { t } = useI18n();
const [bot, setBot] = useState<BotRow | null>(null);
// editing carries the bot id of the row being updated: the grant endpoint is an
// upsert, and version is the optimistic lock of the row it overwrites. A fresh
// grant sends "0", which is what "there is no row yet" means.
const [editing, setEditing] = useState<BotVerifierRow | null>(null);
const [iconDocumentID, setIconDocumentID] = useState("");
const [company, setCompany] = useState("");
const [defaultDescription, setDefaultDescription] = useState("");
const [canModify, setCanModify] = useState(false);
const activeIcons = icons.filter((icon) => icon.Active);
// A verifier can hold an icon the operator has since retired. Editing that row must
// not silently swap the icon just because the select has no matching option, so the
// current document is kept in the list and labelled instead.
const iconOptions: Array<{ value: string; label: string }> = activeIcons.map((icon) => ({
value: icon.DocumentID,
label: `${icon.Name} · ${icon.DocumentID}`
}));
if (iconDocumentID && !iconOptions.some((option) => option.value === iconDocumentID)) {
const retired = icons.find((icon) => icon.DocumentID === iconDocumentID);
iconOptions.unshift({
value: iconDocumentID,
label: `${retired?.Name ?? iconDocumentID} · ${iconDocumentID} (${t("botverification.iconInactive")})`
});
}
function startEdit(row: BotVerifierRow) {
setEditing(row);
setBot(null);
setIconDocumentID(row.IconDocumentID);
setCompany(row.CompanyName);
setDefaultDescription(row.DefaultDescription);
setCanModify(row.CanModifyCustomDescription);
}
function resetForm() {
setEditing(null);
setBot(null);
setIconDocumentID("");
setCompany("");
setDefaultDescription("");
setCanModify(false);
}
// int64 fields go out as decimal strings (the backend tags them `,string`), which
// is also the shape they arrived in, so nothing is re-parsed on the way back.
function grantPayload(): Record<string, unknown> {
const botID = editing ? editing.BotID : bot ? String(bot.ID) : "0";
return {
bot_id: botID,
icon_document_id: iconDocumentID || "0",
company_name: company.trim(),
default_description: defaultDescription.trim(),
can_modify_custom_description: canModify,
version: editing ? editing.Version : "0"
};
}
return (
<>
{canManage && (
<section className="section-block">
<SectionHead
title={editing ? t("botverification.updateTitle") : t("botverification.grantTitle")}
text={t("botverification.grantHint")}
action={
editing ? (
<button className="btn icon-text" type="button" onClick={resetForm}>
{t("botverification.cancelEdit")}
</button>
) : undefined
}
/>
{editing ? (
<p className="bot-create-note">
{t("botverification.editing", {
bot: displayUsername(editing.BotUsername) || editing.BotID,
version: editing.Version
})}
</p>
) : (
<BotPicker label={t("botverification.grantBot")} value={bot} onChange={setBot} />
)}
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("botverification.grantIcon")}</span>
<select value={iconDocumentID} onChange={(event) => setIconDocumentID(event.target.value)}>
<option value="">{t("botverification.grantIconPick")}</option>
{iconOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="duration-field">
<span>{t("botverification.company")}</span>
<input
value={company}
onChange={(event) => setCompany(event.target.value)}
placeholder={t("botverification.companyPlaceholder")}
/>
</label>
<label className="duration-field">
<span>{t("botverification.defaultDescription")}</span>
<input
value={defaultDescription}
onChange={(event) => setDefaultDescription(event.target.value)}
placeholder={t("botverification.defaultDescriptionPlaceholder")}
/>
</label>
</div>
<label className="checkline">
<input type="checkbox" checked={canModify} onChange={(event) => setCanModify(event.target.checked)} />
{t("botverification.canModify")}
</label>
<p className="bot-create-note">{t("botverification.canModifyHint")}</p>
{activeIcons.length === 0 && <Alert>{t("botverification.noActiveIcons")}</Alert>}
<div className="bot-create-actions">
<span className="bot-create-note">{t("botverification.grantNote")}</span>
<ActionButton
label={editing ? t("botverification.update") : t("botverification.grant")}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/grant-bot-verifier"
payload={grantPayload}
onDone={() => {
resetForm();
onChanged();
}}
/>
</div>
</section>
)}
<section className="section-block">
<SectionHead
title={t("botverification.verifiersTitle")}
text={t("botverification.verifiersHint")}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("botverification.bot")}</th>
<th>{t("botverification.company")}</th>
<th>{t("botverification.icon")}</th>
<th>{t("botverification.canModifyShort")}</th>
<th>{t("common.status")}</th>
<th>{t("botverification.markCount")}</th>
<th>{t("botverification.grantedBy")}</th>
<th>{t("common.updatedAt")}</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{verifiers.map((row) => (
<tr key={row.BotID}>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/bots/${row.BotID}`)}>
<strong>{displayUsername(row.BotUsername) || row.BotName || row.BotID}</strong>
</button>
<div className="entity-subtitle mono">{row.BotID}</div>
</td>
<td>
<strong>{row.CompanyName || "-"}</strong>
<div className="entity-subtitle truncate">{row.DefaultDescription || t("botverification.notProvided")}</div>
</td>
<td>
{row.IconName || "-"}
<div className="entity-subtitle mono">{row.IconDocumentID}</div>
</td>
<td>{row.CanModifyCustomDescription ? t("common.yes") : t("common.no")}</td>
<td>
{row.Enabled
? <Badge tone="good">{t("botverification.enabled")}</Badge>
: <Badge tone="warn">{t("botverification.disabled")}</Badge>}
</td>
<td className="mono">{String(row.MarkCount ?? "0")}</td>
<td>
{row.GrantedBy || "-"}
<div className="entity-subtitle truncate">{row.GrantReason || "-"}</div>
</td>
<td>{formatDate(row.UpdatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<button className="btn compact-btn" type="button" onClick={() => startEdit(row)}>
{t("botverification.edit")}
</button>
<ActionButton
label={row.Enabled ? t("botverification.disable") : t("botverification.enable")}
icon={row.Enabled ? <PowerOff size={14} /> : <Power size={14} />}
tone={row.Enabled ? "warn" : "neutral"}
compact
path="/api/actions/set-bot-verifier-enabled"
payload={() => ({ bot_id: row.BotID, enabled: !row.Enabled })}
onDone={onChanged}
/>
<ActionButton
label={t("botverification.revokeVerifier")}
icon={<Trash2 size={14} />}
tone="danger"
compact
path="/api/actions/revoke-bot-verifier"
payload={() => ({ bot_id: row.BotID })}
onDone={onChanged}
/>
</div>
</td>
)}
</tr>
))}
{verifiers.length === 0 && <EmptyRow colSpan={canManage ? 9 : 8} />}
</tbody>
</table>
</div>
<p className="bot-create-note">{t("botverification.disableHint")}</p>
<p className="bot-create-note">{t("botverification.revokeVerifierHint")}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Icon catalogue
// ---------------------------------------------------------------------------
function IconsBlock({
icons,
verifiers,
canManage,
onChanged
}: {
icons: VerificationIconRow[];
verifiers: BotVerifierRow[];
canManage: boolean;
onChanged: () => void;
}) {
const { t } = useI18n();
const [documentID, setDocumentID] = useState("");
const [name, setName] = useState("");
const [ownerBotID, setOwnerBotID] = useState("");
// owner_bot_id is omitted entirely for a shared entry rather than sent as "" —
// `,string,omitempty` cannot decode an empty string.
function iconPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
document_id: documentID.trim() || "0",
name: name.trim()
};
if (ownerBotID) payload.owner_bot_id = ownerBotID;
return payload;
}
return (
<>
{canManage && (
<section className="section-block">
<SectionHead title={t("botverification.addIconTitle")} text={t("botverification.addIconHint")} />
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("botverification.iconDocument")}</span>
<input
value={documentID}
onChange={(event) => setDocumentID(event.target.value)}
inputMode="numeric"
placeholder="5361371319611781774"
/>
</label>
<label className="duration-field">
<span>{t("botverification.iconName")}</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t("botverification.iconNamePlaceholder")}
/>
</label>
<label className="duration-field">
<span>{t("botverification.iconOwner")}</span>
<select value={ownerBotID} onChange={(event) => setOwnerBotID(event.target.value)}>
<option value="">{t("botverification.iconOwnerShared")}</option>
{verifiers.map((row) => (
<option key={row.BotID} value={row.BotID}>
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`}
</option>
))}
</select>
</label>
</div>
<p className="bot-create-note">{t("botverification.iconDocumentHint")}</p>
<p className="bot-create-note">{t("botverification.iconOwnerHint")}</p>
<div className="bot-create-actions">
<span className="bot-create-note">{t("botverification.addIconNote")}</span>
<ActionButton
label={t("botverification.addIcon")}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/upsert-verification-icon"
payload={iconPayload}
onDone={() => {
setDocumentID("");
setName("");
setOwnerBotID("");
onChanged();
}}
/>
</div>
</section>
)}
<section className="section-block">
<SectionHead
title={t("botverification.iconsTitle")}
text={t("botverification.iconsHint")}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("botverification.iconDocument")}</th>
<th>{t("botverification.iconName")}</th>
<th>{t("botverification.iconOwner")}</th>
<th>{t("common.status")}</th>
<th>{t("botverification.usedBy")}</th>
<th>{t("botverification.createdAt")}</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{icons.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.DocumentID}</td>
<td><strong>{row.Name || "-"}</strong></td>
<td>
{row.OwnerBotID && row.OwnerBotID !== "0"
? <>
{displayUsername(row.OwnerBotUsername) || row.OwnerBotID}
<div className="entity-subtitle mono">{row.OwnerBotID}</div>
</>
: <Badge>{t("botverification.iconOwnerShared")}</Badge>}
</td>
<td>
{row.Active
? <Badge tone="good">{t("botverification.iconActive")}</Badge>
: <Badge tone="warn">{t("botverification.iconInactive")}</Badge>}
</td>
<td className="mono">{String(row.UsedByVerifiers ?? "0")}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<ActionButton
label={row.Active ? t("botverification.deactivateIcon") : t("botverification.activateIcon")}
icon={row.Active ? <PowerOff size={14} /> : <Power size={14} />}
tone={row.Active ? "warn" : "neutral"}
compact
path="/api/actions/set-verification-icon-active"
payload={() => ({ icon_id: row.ID, active: !row.Active })}
onDone={onChanged}
/>
</div>
</td>
)}
</tr>
))}
{icons.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
</tbody>
</table>
</div>
<p className="bot-create-note">{t("botverification.deactivateIconHint")}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Granted marks
// ---------------------------------------------------------------------------
function MarksBlock({
verifiers,
canManage,
navigate
}: {
verifiers: BotVerifierRow[];
canManage: boolean;
navigate: Navigate;
}) {
const { t } = useI18n();
const [verifierBotID, setVerifierBotID] = useState("");
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<CustomVerificationRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
if (peerType !== "all") params.set("peer_type", peerType);
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.customVerifications(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
return (
<>
<section className="section-block">
<SectionHead
title={t("botverification.marksTitle")}
text={t("botverification.marksHint")}
action={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
/>
{error && <Alert>{error}</Alert>}
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("botverification.markSearchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("botverification.verifier")}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{t("botverification.peerType")}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{t("botverification.peerTypeAll")}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{t(`botverification.peer.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("botverification.verifier")}</th>
<th>{t("botverification.target")}</th>
<th>{t("botverification.description")}</th>
<th>{t("botverification.icon")}</th>
<th>{t("botverification.createdAt")}</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">#{row.ID}</td>
<td>
<strong>{row.CompanyName || displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
<div className="entity-subtitle mono">
{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}
</div>
</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(peerHref(row.PeerType, row.PeerID))}>
<strong>{peerLabel(row)}</strong>
</button>
<div className="entity-subtitle mono">
{t(`botverification.peer.${row.PeerType}`)} · {row.PeerID}
</div>
</td>
<td className="truncate">{row.Description || t("botverification.notProvided")}</td>
<td className="mono">{row.IconDocumentID}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<ActionButton
label={t("botverification.revokeMark")}
icon={<Ban size={14} />}
tone="danger"
compact
path="/api/actions/revoke-custom-verification"
payload={() => ({
verifier_bot_id: row.VerifierBotID,
peer_type: row.PeerType,
peer_id: row.PeerID
})}
onDone={() => load(false)}
/>
</div>
</td>
)}
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
</tbody>
</table>
</div>
<p className="bot-create-note">{t("botverification.revokeMarkHint")}</p>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
</button>
</div>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Shared bits
// ---------------------------------------------------------------------------
// The verifier filter lists the roster rather than asking for a bot id: a company
// name is what an operator reads in the queue, and a disabled verifier still owns
// rows worth filtering by, so it stays in the list and is labelled instead.
function VerifierOptions({
value,
verifiers,
onChange
}: {
value: string;
verifiers: BotVerifierRow[];
onChange: (value: string) => void;
}) {
const { t } = useI18n();
return (
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">{t("botverification.verifierAll")}</option>
{verifiers.map((row) => (
<option key={row.BotID} value={row.BotID}>
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`
+ (row.Enabled ? "" : ` (${t("botverification.disabled")})`)}
</option>
))}
</select>
);
}
export function RequestStatusBadge({ status }: { status: CustomVerificationRequestStatus }) {
const { t } = useI18n();
return <Badge tone={statusTone(status)}>{t(`botverification.status.${status}`)}</Badge>;
}
export function statusTone(status: CustomVerificationRequestStatus): "neutral" | "good" | "warn" | "danger" {
if (status === "approved") return "good";
if (status === "pending") return "warn";
if (status === "rejected") return "danger";
return "neutral";
}
// pending is the only status that waits for somebody, so it is the only one
// highlighted — and only while something actually sits in it.
function countTone(status: CustomVerificationRequestStatus, count: string): "neutral" | "good" | "warn" {
if (status === "pending") return count !== "0" && count !== "" ? "warn" : "neutral";
return status === "approved" ? "good" : "neutral";
}
export function peerLabel(row: { PeerUsername: string; PeerTitle: string; PeerID: string }): string {
return displayUsername(row.PeerUsername) || row.PeerTitle || `#${row.PeerID}`;
}
// The panel page that owns the peer type. A third-party mark can sit on an ordinary
// account or on a bot — both are user rows, so both open the account page.
export function peerHref(peerType: BotVerificationPeerType, peerID: string): string {
return peerType === "channel" ? `/channels/${peerID}` : `/accounts/${peerID}`;
}

View file

@ -0,0 +1,360 @@
import {
ArrowLeft,
BadgeCheck,
Ban,
Building2,
CheckCircle2,
ExternalLink,
RefreshCw,
ShieldOff,
Stamp,
User,
XCircle
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotVerifierRow, CustomVerificationRequestDetail } from "../types";
import { RequestStatusBadge, peerHref, peerLabel } from "./BotVerificationPage";
export function BotVerificationRequestPage({ id, navigate }: { id: string; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<CustomVerificationRequestDetail | null>(null);
const [note, setNote] = useState("");
const [conflict, setConflict] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.customVerificationRequest(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
function refresh() {
setConflict(false);
void load();
}
useEffect(() => {
void load();
}, [id]);
// 409 is the one failure the operator cannot fix by editing the form: another
// admin decided against the version this page read. The panel says so in plain
// words and reloads, so the next attempt carries the current version.
function handleActionError(err: unknown): string | undefined {
if (err instanceof APIError && err.status === 409) {
setConflict(true);
void load();
return t("botverification.conflict");
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={t("botverification.loadingDetail")} />;
}
const request = detail.request;
const verifier = liveVerifier(detail.verifier);
const markActive = detail.mark_active;
const canDecide = request.Status === "pending";
const canRevoke = request.Status === "approved";
const trimmedNote = note.trim();
// What the mark would actually say: the applicant's wording only when this
// verifier is allowed to override its own default, otherwise the default. Same
// rule the backend applies (BotVerifierSettings.DescriptionFor), shown here so a
// reviewer is not surprised by the text that ends up in the profile.
const requestedDescription = request.RequestedDescription.trim();
const descriptionAllowed = Boolean(verifier?.CanModifyCustomDescription) && requestedDescription !== "";
const effectiveDescription = descriptionAllowed
? requestedDescription
: (verifier?.DefaultDescription ?? "").trim();
// version is the optimistic-locking token: it goes with every decision, as the
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
function decisionPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = { version: request.Version };
if (trimmedNote) payload.internal_note = trimmedNote;
return payload;
}
function afterDecision() {
setNote("");
setConflict(false);
void load();
}
return (
<PageFrame
title={t("botverification.detailTitle", { id: request.ID })}
eyebrow={t("botverification.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/bot-verification")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{t("botverification.conflict")}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{peerLabel(request)}</div>
<div className="entity-subtitle mono">
#{request.ID} · {t(`botverification.peer.${request.PeerType}`)}:{request.PeerID} · v{request.Version}
</div>
</div>
<div className="entity-badges">
<RequestStatusBadge status={request.Status} />
{markActive
? <Badge tone="good"><BadgeCheck size={12} /> {t("botverification.markActive")}</Badge>
: <Badge tone="neutral">{t("botverification.markInactive")}</Badge>}
</div>
</section>
{/* Repeated on the detail page on purpose: the decision an operator is
about to take grants a company's icon, not the platform badge. */}
<section className="section-block">
<SectionHead title={t("botverification.explainTitle")} text={t("botverification.explainText")} />
<p className="bot-create-note">{t("botverification.explainIcon")}</p>
</section>
<section className="section-block">
<SectionHead
title={t("botverification.verifierSection")}
text={t("botverification.verifierHint")}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/bots/${request.VerifierBotID}`)}>
<Building2 size={15} /> {t("botverification.openVerifier")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("botverification.company")} value={verifier?.CompanyName || "-"} />
<Summary label={t("botverification.bot")} value={displayUsername(request.VerifierBotUsername) || "-"} />
<Summary label={t("botverification.verifierID")} value={request.VerifierBotID} mono />
<Summary label={t("botverification.iconDocument")} value={verifier?.IconDocumentID || "-"} mono />
<Summary label={t("botverification.iconName")} value={verifier?.IconName || "-"} />
<Summary
label={t("botverification.canModifyShort")}
value={verifier?.CanModifyCustomDescription ? t("common.yes") : t("common.no")}
/>
</div>
<FieldBlock label={t("botverification.defaultDescription")}>
{verifier?.DefaultDescription
? <p className="about-text">{verifier.DefaultDescription}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
{!verifier && <Alert>{t("botverification.verifierMissing")}</Alert>}
{verifier && !verifier.Enabled && <Alert>{t("botverification.verifierDisabledHint")}</Alert>}
</section>
<section className="section-block">
<SectionHead
title={t("botverification.targetSection")}
text={t("botverification.targetHint")}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(peerHref(request.PeerType, request.PeerID))}
>
<ExternalLink size={15} /> {t("botverification.openTarget")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.type")} value={t(`botverification.peer.${request.PeerType}`)} />
<Summary label={t("common.username")} value={displayUsername(request.PeerUsername) || "-"} />
<Summary label={t("botverification.targetTitle")} value={request.PeerTitle || "-"} />
<Summary label={t("botverification.targetID")} value={request.PeerID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={t("botverification.applicantSection")}
text={t("botverification.applicantHint")}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(`/accounts/${request.ApplicantUserID}`)}
>
<User size={15} /> {t("botverification.openApplicant")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.username")} value={displayUsername(request.ApplicantUsername) || "-"} />
<Summary label={t("botverification.applicantID")} value={request.ApplicantUserID} mono />
<Summary label={t("botverification.createdAt")} value={formatDate(request.CreatedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(request.UpdatedAt) || "-"} />
</div>
</section>
<section className="section-block">
<SectionHead title={t("botverification.requestSection")} text={t("botverification.requestHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("botverification.correlationID")} value={request.CorrelationID || "-"} mono />
<Summary label={t("common.status")} value={t(`botverification.status.${request.Status}`)} />
</div>
<FieldBlock label={t("botverification.reason")}>
{request.Reason
? <p className="about-text">{request.Reason}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("botverification.requestedDescription")}>
{requestedDescription
? <p className="about-text">{requestedDescription}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("botverification.markPreview")}>
{effectiveDescription
? <p className="about-text">{effectiveDescription}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
<p className="bot-create-note">{t("botverification.markPreviewHint")}</p>
{requestedDescription !== "" && !descriptionAllowed && (
<p className="bot-create-note">{t("botverification.descriptionIgnoredHint")}</p>
)}
</div>
</section>
<section className="section-block">
<SectionHead title={t("botverification.decisionSection")} text={t("botverification.decisionHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("botverification.decidedBy")} value={request.DecidedBy || "-"} />
<Summary label={t("botverification.approvedAt")} value={formatDate(request.ApprovedAt) || "-"} />
<Summary label={t("botverification.rejectedAt")} value={formatDate(request.RejectedAt) || "-"} />
<Summary label={t("botverification.version")} value={request.Version} mono />
</div>
<FieldBlock label={t("botverification.decisionReason")}>
{request.DecisionReason
? <p className="about-text">{request.DecisionReason}</p>
: <p className="bot-create-note">{t("botverification.noDecision")}</p>}
</FieldBlock>
{/* The internal note is the operator handover text and is labelled as
admin-only wherever it appears. */}
<FieldBlock label={`${t("botverification.internalNote")} · ${t("botverification.adminOnly")}`}>
{request.InternalNote
? <p className="about-text">{request.InternalNote}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"><Stamp size={14} /> {t("botverification.actionDock")}</div>
{!canDecide && !canRevoke && <p className="bot-create-note">{t("botverification.noActions")}</p>}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{t("botverification.internalNote")}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={t("botverification.internalNotePlaceholder")}
/>
</label>
<p className="bot-create-note">{t("botverification.internalNoteHint")}</p>
</>
)}
{canDecide && (
<>
{!verifier && <Alert>{t("botverification.verifierMissing")}</Alert>}
{verifier && !verifier.Enabled && <Alert>{t("botverification.verifierDisabledHint")}</Alert>}
{markActive && <p className="bot-create-note">{t("botverification.markActiveHint")}</p>}
<div className="action-stack">
<ActionButton
label={t("botverification.approve")}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/botverification/requests/${request.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={t("botverification.reject")}
icon={<XCircle size={15} />}
tone="warn"
path={`/api/botverification/requests/${request.ID}/reject`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
</div>
<p className="bot-create-note">{t("botverification.approveHint")}</p>
<p className="bot-create-note">{t("botverification.rejectHint")}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {t("botverification.dangerZone")}</div>
<div className="danger-zone">
<ActionButton
label={t("botverification.revokeRequest")}
icon={<Ban size={15} />}
tone="danger"
path={`/api/botverification/requests/${request.ID}/revoke`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<p className="bot-create-note">{t("botverification.revokeRequestHint")}</p>
{!markActive && <p className="bot-create-note">{t("botverification.revokeNoMark")}</p>}
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="duration-field">
<span>{label}</span>
{children}
</div>
);
}
// A verifier whose row was revoked after the application was filed can come back as
// null or as a zeroed record, depending on how the backend renders "gone". Both mean
// the same thing to a reviewer, so they collapse into one absent value here.
function liveVerifier(row: BotVerifierRow | null): BotVerifierRow | null {
if (!row) return null;
if (!row.BotID || row.BotID === "0") return null;
return row;
}

View file

@ -0,0 +1,253 @@
import { ArrowLeft, ArrowLeftRight, ExternalLink, Flame, Trash2, RefreshCw, Undo2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatCurrency, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type {
AccountRow,
ChannelRow,
CollectibleUsernameDetail,
CollectibleUsernameTransferKind
} from "../types";
import { UsernameStatus, ownerLabel, priceLabel } from "./CollectibleUsernamesPage";
type RecipientKind = "user" | "channel";
export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<CollectibleUsernameDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [recipientKind, setRecipientKind] = useState<RecipientKind>("user");
const [recipientUser, setRecipientUser] = useState<AccountRow | null>(null);
const [recipientChannel, setRecipientChannel] = useState<ChannelRow | null>(null);
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.collectibleUsername(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? t("usernames.loadingDetail") : t("account.waitingData")} />;
}
const asset = detail.asset;
const transfers = detail.transfers ?? [];
const vaultLabel = t("usernames.statusVault");
const hasOwner = Boolean(asset.OwnerPeerType) && asset.OwnerPeerID !== "" && asset.OwnerPeerID !== "0";
const burned = asset.Status === "burned";
function openOwner() {
if (!hasOwner) return;
navigate(asset.OwnerPeerType === "channel" ? `/channels/${asset.OwnerPeerID}` : `/accounts/${asset.OwnerPeerID}`);
}
// Peer ids travel as decimal strings to match the backend `,string` tags.
function transferPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = { username: asset.Username };
if (recipientKind === "user" && recipientUser) payload.to_user_id = String(recipientUser.ID);
if (recipientKind === "channel" && recipientChannel) payload.to_channel_id = String(recipientChannel.ID);
return payload;
}
return (
<PageFrame
title={t("usernames.detailTitle", { username: displayUsername(asset.Username) })}
eyebrow={t("usernames.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/collectible-usernames")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayUsername(asset.Username)}</div>
<div className="entity-subtitle">{t("usernames.assetID", { id: asset.ID })}</div>
</div>
<div className="entity-badges">
<UsernameStatus status={asset.Status} />
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>
{t("usernames.transferCount", { count: asset.TransferCount })}
</Badge>
{asset.Status === "owned" && (
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
{asset.RegistryActive ? t("usernames.registryActive") : t("usernames.registryHidden")}
</Badge>
)}
</div>
</section>
<div className="summary-grid">
<Summary label={t("common.owner")} value={ownerLabel(asset, vaultLabel)} />
<Summary label={t("usernames.price")} value={priceLabel(asset)} mono />
<Summary label={t("usernames.purchaseDate")} value={formatDate(asset.PurchaseDate) || "-"} />
<Summary
label={t("usernames.originalOwner")}
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
/>
<Summary label={t("usernames.transfers")} value={String(asset.TransferCount)} mono />
<Summary label={t("account.createdAt")} value={formatDate(asset.CreatedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(asset.UpdatedAt) || "-"} />
</div>
<div className="toolbar">
{hasOwner && (
<button className="row-link" type="button" onClick={openOwner}>
{asset.OwnerPeerType === "channel" ? t("usernames.openOwnerChannel") : t("usernames.openOwnerAccount")}
</button>
)}
{asset.URL && (
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
<ExternalLink size={14} /> {t("usernames.openMarketplace")}
</a>
)}
</div>
{!burned && (
<section className="section-block">
<SectionHead title={t("usernames.transferTitle")} text={t("usernames.transferHint")} />
<div className="toolbar" role="group" aria-label={t("usernames.recipientKind")}>
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
{t("usernames.recipientUser")}
</button>
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
{t("usernames.recipientChannel")}
</button>
</div>
{recipientKind === "user"
? <UserPicker label={t("usernames.recipientUser")} value={recipientUser} onChange={setRecipientUser} />
: <ChannelPicker label={t("usernames.recipientChannel")} value={recipientChannel} onChange={setRecipientChannel} />}
<div className="bot-create-actions">
<span className="bot-create-note">{t("usernames.transferNote")}</span>
<ActionButton
label={t("usernames.transfer")}
icon={<ArrowLeftRight size={15} />}
tone="warn"
path="/api/actions/transfer-collectible-username"
payload={transferPayload}
onDone={load}
/>
</div>
</section>
)}
<section className="section-block">
<SectionHead title={t("usernames.historyTitle")} text={t("usernames.historyHint")} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("usernames.eventKind")}</th>
<th>{t("usernames.fromPeer")}</th>
<th>{t("usernames.toPeer")}</th>
<th>{t("usernames.price")}</th>
<th>{t("audit.actor")}</th>
<th>{t("audit.reason")}</th>
<th>{t("common.time")}</th>
</tr>
</thead>
<tbody>
{transfers.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td><TransferKind kind={row.Kind} /></td>
<td className="mono">{peerLabel(row.FromPeerType, row.FromPeerID, vaultLabel, row.FromUsername)}</td>
<td className="mono">{peerLabel(row.ToPeerType, row.ToPeerID, vaultLabel, row.ToUsername)}</td>
<td className="mono">{row.Amount && row.Amount !== "0" ? formatCurrency(row.Amount, row.Currency) : "-"}</td>
<td>{row.Actor || "-"}</td>
<td className="truncate">{row.Reason || "-"}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
</tr>
))}
{transfers.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("usernames.actionDock")}</div>
{burned ? (
<p className="bot-create-note">{t("usernames.burnedHint")}</p>
) : (
<>
<div className="action-stack">
<ActionButton
label={t("usernames.revoke")}
icon={<Undo2 size={15} />}
tone="warn"
path="/api/actions/revoke-collectible-username"
payload={() => ({ username: asset.Username, burn: false })}
onDone={load}
/>
</div>
<p className="bot-create-note">{t("usernames.revokeHint")}</p>
<div className="danger-zone">
<ActionButton
label={t("usernames.burn")}
icon={<Flame size={15} />}
tone="danger"
path="/api/actions/revoke-collectible-username"
payload={() => ({ username: asset.Username, burn: true })}
onDone={load}
/>
<p className="bot-create-note">{t("usernames.burnHint")}</p>
<ActionButton
label={t("usernames.delete")}
icon={<Trash2 size={15} />}
tone="danger"
path="/api/actions/delete-collectible-username"
payload={() => ({ username: asset.Username })}
onDone={() => navigate("/collectible-usernames")}
/>
<p className="bot-create-note">{t("usernames.deleteHint")}</p>
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function TransferKind({ kind }: { kind: CollectibleUsernameTransferKind }) {
const { t } = useI18n();
const tone = kind === "burn" ? "danger" : kind === "revoke" ? "warn" : kind === "mint" ? "good" : "neutral";
return <Badge tone={tone}>{t(`usernames.kind.${kind}`)}</Badge>;
}
function peerLabel(type: string, peerID: string, vaultLabel: string, username = ""): string {
if (!type || peerID === "" || peerID === "0") return vaultLabel;
const handle = displayUsername(username);
return handle ? `${handle} · ${type}:${peerID}` : `${type}:${peerID}`;
}

View file

@ -0,0 +1,309 @@
import { AtSign, ChevronDown, ChevronRight, Flame, Loader2, Plus, RefreshCw, Search, Vault } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { useI18n } from "../i18n";
import { currencyExponent, displayUsername, formatCurrency, formatDate, toSmallestUnits } from "../lib/format";
import type { Navigate } from "../routing";
import type {
AccountRow,
ChannelRow,
CollectibleCurrency,
CollectibleUsernameRow,
CollectibleUsernameStatus
} from "../types";
type StatusFilter = "all" | CollectibleUsernameStatus;
type OwnerKind = "vault" | "user" | "channel";
export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [status, setStatus] = useState<StatusFilter>("all");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<CollectibleUsernameRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// Mint form state.
const [ownerKind, setOwnerKind] = useState<OwnerKind>("vault");
const [owner, setOwner] = useState<AccountRow | null>(null);
const [ownerChannel, setOwnerChannel] = useState<ChannelRow | null>(null);
const [mintUsername, setMintUsername] = useState("");
const [currency, setCurrency] = useState<CollectibleCurrency>("XTR");
const [amount, setAmount] = useState("");
const [cryptoCurrency, setCryptoCurrency] = useState("");
const [cryptoAmount, setCryptoAmount] = useState("");
const [url, setUrl] = useState("");
const [purchaseDate, setPurchaseDate] = useState("");
const [purchaseTime, setPurchaseTime] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (status !== "all") params.set("status", status);
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.collectibleUsernames(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const vaultCount = rows.filter((row) => row.Status === "vault").length;
const ownedCount = rows.filter((row) => row.Status === "owned").length;
const burnedCount = rows.filter((row) => row.Status === "burned").length;
// int64 request fields are sent as decimal strings (the backend tags them
// `,string`); purchase_date is Unix seconds. Optional owner keys are omitted
// entirely rather than sent empty, because `,string,omitempty` cannot decode "".
// Both amounts are typed in whole currency units and converted here: the API
// and fragment.collectibleInfo carry smallest units, so 900 TON has to leave
// the panel as 900000000000 nanotons or clients render 0.0000009.
const minorAmount = toSmallestUnits(amount, currency);
const minorCryptoAmount = cryptoCurrency ? toSmallestUnits(cryptoAmount, cryptoCurrency) : "0";
const amountInvalid = minorAmount === null;
const cryptoAmountInvalid = minorCryptoAmount === null;
function mintPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
username: mintUsername.trim().replace(/^@/, ""),
currency,
amount: minorAmount ?? "0"
};
if (ownerKind === "user" && owner) payload.owner_user_id = String(owner.ID);
if (ownerKind === "channel" && ownerChannel) payload.owner_channel_id = String(ownerChannel.ID);
// The backend accepts either no crypto leg at all, or TON with a positive
// nanoton amount — never a currency without an amount.
if (cryptoCurrency) {
payload.crypto_currency = cryptoCurrency;
payload.crypto_amount = minorCryptoAmount ?? "0";
}
if (url.trim()) payload.url = url.trim();
if (purchaseDate) {
// fragment.collectibleInfo.purchase_date is a unix timestamp, and the date has
// always been read as UTC here. The time follows the same clock rather than the
// operator's local one, so adding it cannot silently shift what a date-only
// entry used to mean; the field label says UTC.
const parsed = Date.parse(`${purchaseDate}T${purchaseTime || "00:00"}:00Z`);
if (Number.isFinite(parsed)) payload.purchase_date = Math.floor(parsed / 1000);
}
return payload;
}
return (
<PageFrame
title={t("usernames.pageTitle")}
eyebrow={t("usernames.eyebrow")}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("usernames.metricLoaded")} value={String(rows.length)} />
<Metric label={t("usernames.metricVault")} value={String(vaultCount)} />
<Metric label={t("usernames.metricOwned")} value={String(ownedCount)} tone="good" />
<Metric label={t("usernames.metricBurned")} value={String(burnedCount)} tone={burnedCount ? "danger" : "neutral"} />
</div>
<section className="section-block">
<SectionHead title={t("usernames.mintTitle")} text={t("usernames.mintHint")} />
<div className="toolbar" role="group" aria-label={t("usernames.ownerKind")}>
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
<Vault size={15} /> {t("usernames.ownerVault")}
</button>
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
{t("usernames.ownerUser")}
</button>
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
{t("usernames.ownerChannel")}
</button>
</div>
{ownerKind === "user" && <UserPicker label={t("usernames.ownerUser")} value={owner} onChange={setOwner} />}
{ownerKind === "channel" && <ChannelPicker label={t("usernames.ownerChannel")} value={ownerChannel} onChange={setOwnerChannel} />}
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("common.username")}</span>
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
</label>
<label className="duration-field">
<span>{t("usernames.currency")}</span>
<select value={currency} onChange={(event) => setCurrency(event.target.value as CollectibleCurrency)}>
<option value="XTR">XTR</option>
<option value="TON">TON</option>
<option value="USD">USD</option>
</select>
</label>
<label className="duration-field">
<span>{t("usernames.amount", { currency })}</span>
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
</label>
<label className="duration-field">
<span>{t("usernames.cryptoCurrency")}</span>
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
<option value="">{t("usernames.cryptoNone")}</option>
<option value="TON">TON</option>
</select>
</label>
{cryptoCurrency !== "" && (
<label className="duration-field">
<span>{t("usernames.cryptoAmount", { currency: cryptoCurrency })}</span>
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
</label>
)}
<label className="duration-field">
<span>{t("usernames.url")}</span>
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
</label>
<label className="duration-field">
<span>{t("usernames.purchaseDate")}</span>
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
</label>
<label className="duration-field">
<span>{t("usernames.purchaseTime")}</span>
<input
value={purchaseTime}
onChange={(event) => setPurchaseTime(event.target.value)}
type="time"
step={60}
disabled={!purchaseDate}
/>
</label>
</div>
<p className="bot-create-note">
{t("usernames.amountHint", {
currency,
decimals: String(currencyExponent(currency)),
preview: formatCurrency(minorAmount ?? "0", currency)
})}
</p>
{amountInvalid && <Alert>{t("usernames.amountInvalid", { currency, decimals: String(currencyExponent(currency)) })}</Alert>}
{cryptoCurrency !== "" && cryptoAmountInvalid && (
<Alert>{t("usernames.amountInvalid", { currency: cryptoCurrency, decimals: String(currencyExponent(cryptoCurrency)) })}</Alert>
)}
<div className="bot-create-actions">
<span className="bot-create-note">{t("usernames.mintNote")}</span>
<ActionButton
disabled={amountInvalid || cryptoAmountInvalid}
label={t("usernames.mint")}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/mint-collectible-username"
payload={mintPayload}
onDone={() => load(false)}
/>
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("usernames.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.status")}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{t("usernames.statusAll")}</option>
<option value="vault">{t("usernames.statusVault")}</option>
<option value="owned">{t("usernames.statusOwned")}</option>
<option value="burned">{t("usernames.statusBurned")}</option>
</select>
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.username")}</th>
<th>{t("common.status")}</th>
<th>{t("common.owner")}</th>
<th>{t("usernames.price")}</th>
<th>{t("usernames.purchaseDate")}</th>
<th>{t("usernames.transfers")}</th>
<th>{t("common.updatedAt")}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td><strong>{displayUsername(row.Username)}</strong></td>
<td><UsernameStatus status={row.Status} /></td>
<td>{ownerLabel(row, t("usernames.statusVault"))}</td>
<td className="mono">{priceLabel(row)}</td>
<td>{formatDate(row.PurchaseDate) || "-"}</td>
<td className="mono">{row.TransferCount}</td>
<td>{formatDate(row.UpdatedAt) || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/collectible-usernames/${row.ID}`)}>
<AtSign size={14} /> {t("common.detail")} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
</button>
</div>
)}
</PageFrame>
);
}
export function UsernameStatus({ status }: { status: CollectibleUsernameStatus }) {
const { t } = useI18n();
if (status === "owned") return <Badge tone="good">{t("usernames.statusOwned")}</Badge>;
if (status === "burned") return <Badge tone="danger"><Flame size={12} /> {t("usernames.statusBurned")}</Badge>;
return <Badge><Vault size={12} /> {t("usernames.statusVault")}</Badge>;
}
export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): string {
if (!row.OwnerPeerType || row.OwnerPeerID === "" || row.OwnerPeerID === "0") return vaultLabel;
const name = displayUsername(row.OwnerUsername) || row.OwnerName || row.OwnerPeerID;
return `${name} · ${row.OwnerPeerType}:${row.OwnerPeerID}`;
}
// priceLabel renders what a Telegram client will draw, not the stored integer:
// both legs are smallest units on the wire (see formatCurrency).
export function priceLabel(row: CollectibleUsernameRow): string {
const base = formatCurrency(row.Amount, row.Currency);
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
}
return base;
}

View file

@ -4,8 +4,9 @@ import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { LanguageSwitch, useI18n } from "../i18n";
import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
const { t } = useI18n();
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
@ -16,8 +17,10 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
setBusy(true);
setError("");
try {
// The login answer carries the permission set and the CSRF token; api.login
// remembers the token, the session state keeps the rights.
const result = await api.login(secret);
onLogin(result.actor);
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
} catch (err) {
setError(errorMessage(err));
} finally {

View file

@ -1,6 +1,10 @@
import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage";
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
import { AccountRatingsPage } from "./AccountRatingsPage";
import { AccountsPage } from "./AccountsPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
@ -15,12 +19,71 @@ import { GiftsPage } from "./GiftsPage";
import { GiveGiftsPage } from "./GiveGiftsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
import { BotVerificationPage } from "./BotVerificationPage";
import { BotVerificationRequestPage } from "./BotVerificationRequestPage";
import { VerificationDetailPage } from "./VerificationDetailPage";
import { VerificationPage } from "./VerificationPage";
import {
PermissionGate,
permissionBotVerificationReview,
permissionVerificationReview
} from "../permissions";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
// int64 ids stay strings so large values never lose precision.
const collectibleUsernameID = route.path.match(/^\/collectible-usernames\/(\d+)$/)?.[1];
const ratingUserID = route.path.match(/^\/account-ratings\/(\d+)$/)?.[1];
const verificationID = route.path.match(/^\/verification\/(\d+)$/)?.[1];
// Third-party verification: a separate section with its own rights, matched before
// the official one so neither prefix can shadow the other.
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
if (botVerificationRequestID) {
return (
<PermissionGate permission={permissionBotVerificationReview}>
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
</PermissionGate>
);
}
if (route.path === "/bot-verification") {
return (
<PermissionGate permission={permissionBotVerificationReview}>
<BotVerificationPage navigate={navigate} />
</PermissionGate>
);
}
// The detail match has to be tested before the exact "/verification" branch, and
// the whole section is wrapped in the permission gate so a direct URL explains
// itself instead of rendering an empty queue.
if (verificationID) {
return (
<PermissionGate permission={permissionVerificationReview}>
<VerificationDetailPage id={verificationID} navigate={navigate} />
</PermissionGate>
);
}
if (route.path === "/verification") {
return (
<PermissionGate permission={permissionVerificationReview}>
<VerificationPage navigate={navigate} />
</PermissionGate>
);
}
if (collectibleUsernameID) {
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
}
if (ratingUserID) {
return <AccountRatingDetailPage userID={ratingUserID} navigate={navigate} />;
}
if (route.path === "/collectible-usernames") {
return <CollectibleUsernamesPage navigate={navigate} />;
}
if (route.path === "/account-ratings") {
return <AccountRatingsPage navigate={navigate} />;
}
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}

View file

@ -0,0 +1,412 @@
import {
ArrowLeft,
BadgeCheck,
Ban,
CheckCircle2,
ExternalLink,
Handshake,
RefreshCw,
ShieldOff,
User,
XCircle
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, safeHttpURL } from "../lib/format";
import { permissionVerificationRevoke, usePermissions } from "../permissions";
import type { Navigate } from "../routing";
import type { VerificationApplicationDetail, VerificationEventKind } from "../types";
import { VerificationStatusBadge, targetHref, targetLabel } from "./VerificationPage";
export function VerificationDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
const { t } = useI18n();
const { can } = usePermissions();
const [detail, setDetail] = useState<VerificationApplicationDetail | null>(null);
const [note, setNote] = useState("");
const [conflict, setConflict] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.verificationApplication(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
function refresh() {
setConflict(false);
void load();
}
useEffect(() => {
void load();
}, [id]);
// 409 is the one failure the operator cannot fix by editing the form: another
// reviewer decided against the version this page read. The panel says so in
// plain words and reloads, so the next attempt carries the current version.
function handleActionError(err: unknown): string | undefined {
if (err instanceof APIError && err.status === 409) {
setConflict(true);
void load();
return t("verification.conflict");
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={t("verification.loadingDetail")} />;
}
const app = detail.application;
const events = detail.events ?? [];
const controls = detail.applicant_controls_target;
const verified = detail.target_verified;
const canClaim = app.Status === "submitted";
const canDecide = app.Status === "submitted" || app.Status === "in_review";
const canRevoke = app.Status === "approved" && can(permissionVerificationRevoke);
const trimmedNote = note.trim();
// version is the optimistic-locking token: it goes with every decision, as the
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
function decisionPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = { version: app.Version };
if (trimmedNote) payload.internal_note = trimmedNote;
return payload;
}
function afterDecision() {
setNote("");
setConflict(false);
void load();
}
return (
<PageFrame
title={t("verification.detailTitle", { id: app.ID })}
eyebrow={t("verification.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{t("verification.conflict")}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{targetLabel(app)}</div>
<div className="entity-subtitle mono">
#{app.ID} · {t(`verification.type.${app.TargetType}`)}:{app.TargetID} · v{app.Version}
</div>
</div>
<div className="entity-badges">
<VerificationStatusBadge status={app.Status} />
{verified && <Badge tone="good"><BadgeCheck size={12} /> {t("verification.alreadyVerified")}</Badge>}
<Badge tone={controls ? "good" : "danger"}>
{controls ? t("verification.controlsOk") : t("verification.controlsLost")}
</Badge>
</div>
</section>
<section className="section-block">
<SectionHead
title={t("verification.targetSection")}
text={t("verification.targetHint")}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(targetHref(app))}>
<ExternalLink size={15} /> {t("verification.openTarget")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.type")} value={t(`verification.type.${app.TargetType}`)} />
<Summary label={t("common.username")} value={displayUsername(app.TargetUsername) || "-"} />
<Summary label={t("verification.targetTitle")} value={app.TargetTitle || "-"} />
<Summary label={t("verification.targetID")} value={app.TargetID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={t("verification.applicantSection")}
text={t("verification.applicantHint")}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${app.ApplicantUserID}`)}>
<User size={15} /> {t("verification.openApplicant")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.username")} value={displayUsername(app.ApplicantUsername) || "-"} />
<Summary label={t("common.name")} value={app.ApplicantName || "-"} />
<Summary label={t("verification.applicantID")} value={app.ApplicantUserID} mono />
<Summary label={t("verification.submittedAt")} value={formatDate(app.SubmittedAt) || "-"} />
</div>
{controls
? <p className="bot-create-note">{t("verification.controlsOkHint")}</p>
: <Alert>{t("verification.controlsLostHint")}</Alert>}
</section>
<section className="section-block">
<SectionHead title={t("verification.applicationSection")} text={t("verification.applicationHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("verification.category")} value={app.Category || "-"} />
<Summary label={t("verification.correlationID")} value={app.CorrelationID || "-"} mono />
<Summary label={t("verification.createdAt")} value={formatDate(app.CreatedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(app.UpdatedAt) || "-"} />
</div>
<FieldBlock label={t("verification.description")}>
{app.Description
? <p className="about-text">{app.Description}</p>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("verification.officialWebsite")}>
{app.OfficialWebsite
? <div className="about-text"><SafeLink value={app.OfficialWebsite} /></div>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("verification.socialLinks")}>
<LinkList values={app.SocialLinks} />
</FieldBlock>
<FieldBlock label={t("verification.pressLinks")}>
<LinkList values={app.PressLinks} />
</FieldBlock>
<FieldBlock label={t("verification.additionalNote")}>
{app.AdditionalNote
? <p className="about-text">{app.AdditionalNote}</p>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
<p className="bot-create-note">{t("verification.linkSafetyHint")}</p>
</div>
</section>
<section className="section-block">
<SectionHead title={t("verification.decisionSection")} text={t("verification.decisionHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("verification.reviewer")} value={app.ReviewerAdminID || "-"} />
<Summary label={t("verification.reviewedAt")} value={formatDate(app.ReviewedAt) || "-"} />
<Summary label={t("common.status")} value={t(`verification.status.${app.Status}`)} />
<Summary label={t("verification.version")} value={app.Version} mono />
</div>
<FieldBlock label={t("verification.decisionReason")}>
{app.DecisionReason
? <p className="about-text">{app.DecisionReason}</p>
: <p className="bot-create-note">{t("verification.noDecision")}</p>}
</FieldBlock>
{/* The internal note is the reviewer handover text and is labelled
as admin-only wherever it appears. */}
<FieldBlock label={`${t("verification.internalNote")} · ${t("verification.adminOnly")}`}>
{app.InternalNote
? <p className="about-text">{app.InternalNote}</p>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
</div>
</section>
<section className="section-block">
<SectionHead title={t("verification.eventsSection")} text={t("verification.eventsHint")} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("verification.eventKind")}</th>
<th>{t("verification.transition")}</th>
<th>{t("audit.actor")}</th>
<th>{t("audit.reason")}</th>
<th>{t("verification.eventNote")}</th>
<th>{t("common.time")}</th>
</tr>
</thead>
<tbody>
{events.map((row) => (
<tr key={row.ID}>
<td><EventKind kind={row.Kind} /></td>
<td className="mono">
{row.FromStatus || "-"} {row.ToStatus || "-"}
</td>
<td>{row.Actor || "-"}</td>
<td className="truncate">{row.Reason || "-"}</td>
<td className="truncate">{row.Note || "-"}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
</tr>
))}
{events.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("verification.actionDock")}</div>
{!canClaim && !canDecide && !canRevoke && (
<p className="bot-create-note">{t("verification.noActions")}</p>
)}
{canClaim && (
<>
<div className="action-stack">
<ActionButton
label={t("verification.claim")}
icon={<Handshake size={15} />}
tone="neutral"
path={`/api/verification/applications/${app.ID}/claim`}
payload={() => ({ version: app.Version })}
onDone={afterDecision}
onError={handleActionError}
/>
</div>
<p className="bot-create-note">{t("verification.claimHint")}</p>
</>
)}
{/* One optional note field feeds every decision on this page,
including a revoke. */}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{t("verification.internalNote")}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={t("verification.internalNotePlaceholder")}
/>
</label>
<p className="bot-create-note">{t("verification.internalNoteHint")}</p>
</>
)}
{canDecide && (
<>
{!controls && <Alert>{t("verification.controlsLostHint")}</Alert>}
{verified && <p className="bot-create-note">{t("verification.alreadyVerifiedHint")}</p>}
<div className="action-stack">
<ActionButton
label={t("verification.approve")}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/verification/applications/${app.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={t("verification.reject")}
icon={<XCircle size={15} />}
tone="warn"
path={`/api/verification/applications/${app.ID}/reject`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
</div>
<p className="bot-create-note">{t("verification.approveHint")}</p>
<p className="bot-create-note">{t("verification.rejectHint")}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {t("verification.dangerZone")}</div>
<div className="danger-zone">
<ActionButton
label={t("verification.revoke")}
icon={<Ban size={15} />}
tone="danger"
path="/api/actions/revoke-verification"
payload={() => {
// Revoke addresses the peer, not the application: the
// approved application stays approved as history.
const payload: Record<string, unknown> = {
target_type: app.TargetType,
target_id: app.TargetID
};
if (trimmedNote) payload.internal_note = trimmedNote;
return payload;
}}
onDone={afterDecision}
onError={handleActionError}
/>
<p className="bot-create-note">{t("verification.revokeHint")}</p>
{!verified && <p className="bot-create-note">{t("verification.revokeNotVerified")}</p>}
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="duration-field">
<span>{label}</span>
{children}
</div>
);
}
// Applicant-supplied text is rendered as ordinary React children (escaped by
// React) and only ever linked when it is an http(s) URL. No markup from a
// submission reaches the DOM.
function SafeLink({ value }: { value: string }) {
const href = safeHttpURL(value);
if (!href) {
return <span className="mono">{value}</span>;
}
return (
<a className="row-link" href={href} target="_blank" rel="noopener noreferrer">
{value} <ExternalLink size={13} />
</a>
);
}
function LinkList({ values }: { values: string[] | null }) {
const { t } = useI18n();
const links = (values ?? []).filter((item) => item.trim() !== "");
if (links.length === 0) {
return <p className="bot-create-note">{t("verification.notProvided")}</p>;
}
return (
<div className="about-text">
{links.map((item, index) => (
<div key={`${index}-${item}`}><SafeLink value={item} /></div>
))}
</div>
);
}
function EventKind({ kind }: { kind: VerificationEventKind }) {
const { t } = useI18n();
const tone = kind === "approved"
? "good"
: kind === "rejected" || kind === "revoked" || kind === "cancelled"
? "danger"
: kind === "submitted" || kind === "claimed"
? "warn"
: "neutral";
return <Badge tone={tone}>{t(`verification.kind.${kind}`)}</Badge>;
}

View file

@ -0,0 +1,232 @@
import { BadgeCheck, ChevronDown, ChevronRight, Loader2, RefreshCw, Search, ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type {
VerificationApplicationRow,
VerificationStatus,
VerificationTargetType
} from "../types";
type StatusFilter = "all" | VerificationStatus;
type TargetFilter = "all" | VerificationTargetType;
const statuses: VerificationStatus[] = ["draft", "submitted", "in_review", "approved", "rejected", "cancelled"];
const targetTypes: VerificationTargetType[] = ["bot", "channel", "supergroup", "user"];
export function VerificationPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [status, setStatus] = useState<StatusFilter>("all");
const [targetType, setTargetType] = useState<TargetFilter>("all");
const [reviewer, setReviewer] = useState("");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<VerificationApplicationRow[]>([]);
const [counts, setCounts] = useState<Record<string, string>>({});
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// One free-text field: the backend matches the application id, the target peer
// id and a username (applicant or target), so "@durov", "42" and a peer id all
// work without a mode switch.
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (status !== "all") params.set("status", status);
if (targetType !== "all") params.set("target_type", targetType);
if (reviewer.trim()) params.set("reviewer", reviewer.trim());
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.verificationApplications(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
// The counts are the whole queue, not the current page, so they are fetched
// separately from the keyset listing.
async function loadCounts() {
try {
const result = await api.verificationCounts();
setCounts(result.counts ?? {});
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load(false);
void loadCounts();
}, []);
function refresh() {
void load(false);
void loadCounts();
}
return (
<PageFrame
title={t("verification.pageTitle")}
eyebrow={t("verification.eyebrow")}
actions={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={t(`verification.status.${item}`)}
value={counts[item] ?? "0"}
mono
tone={statusMetricTone(item, counts[item] ?? "0")}
/>
))}
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("verification.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.status")}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{t("verification.statusAll")}</option>
{statuses.map((item) => (
<option key={item} value={item}>{t(`verification.status.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("verification.targetType")}</span>
<select value={targetType} onChange={(event) => setTargetType(event.target.value as TargetFilter)}>
<option value="all">{t("verification.targetTypeAll")}</option>
{targetTypes.map((item) => (
<option key={item} value={item}>{t(`verification.type.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("verification.reviewer")}</span>
<input value={reviewer} onChange={(event) => setReviewer(event.target.value)} placeholder={t("verification.reviewerPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("verification.target")}</th>
<th>{t("verification.applicant")}</th>
<th>{t("verification.category")}</th>
<th>{t("common.status")}</th>
<th>{t("verification.submittedAt")}</th>
<th>{t("verification.reviewer")}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
#{row.ID}
</button>
</td>
<td>
<strong>{targetLabel(row)}</strong>
<div className="entity-subtitle mono">
{t(`verification.type.${row.TargetType}`)} · {row.TargetID}
</div>
{row.TargetVerified && (
<Badge tone="good"><BadgeCheck size={12} /> {t("verification.alreadyVerified")}</Badge>
)}
</td>
<td>
{displayUsername(row.ApplicantUsername) || row.ApplicantName || "-"}
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
</td>
<td>{row.Category || "-"}</td>
<td><VerificationStatusBadge status={row.Status} /></td>
<td>{formatDate(row.SubmittedAt) || "-"}</td>
<td>{row.ReviewerAdminID || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
<ShieldCheck size={14} /> {t("common.detail")} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
</button>
</div>
)}
</PageFrame>
);
}
export function VerificationStatusBadge({ status }: { status: VerificationStatus }) {
const { t } = useI18n();
return <Badge tone={statusTone(status)}>{t(`verification.status.${status}`)}</Badge>;
}
export function statusTone(status: VerificationStatus): "neutral" | "good" | "warn" | "danger" {
if (status === "approved") return "good";
if (status === "submitted" || status === "in_review") return "warn";
if (status === "rejected") return "danger";
return "neutral";
}
// submitted and in_review are the two statuses that need a reviewer; they are
// highlighted only while something actually sits in them.
function statusMetricTone(status: VerificationStatus, count: string): "neutral" | "good" | "warn" {
const waiting = status === "submitted" || status === "in_review";
if (!waiting) return status === "approved" ? "good" : "neutral";
return count !== "0" && count !== "" ? "warn" : "neutral";
}
export function targetLabel(row: VerificationApplicationRow): string {
return displayUsername(row.TargetUsername) || row.TargetTitle || `#${row.TargetID}`;
}
// The panel page that owns the target peer type, so a reviewer can inspect the
// live record rather than only the submission snapshot.
export function targetHref(row: VerificationApplicationRow): string {
if (row.TargetType === "bot") return `/bots/${row.TargetID}`;
if (row.TargetType === "user") return `/accounts/${row.TargetID}`;
return `/channels/${row.TargetID}`;
}

View file

@ -0,0 +1,75 @@
import { ShieldOff } from "lucide-react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { Alert, PageFrame } from "./components/ui";
import { useI18n } from "./i18n";
// Permission names exactly as the backend spells them
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
// a full-access session.
export const permissionAll = "*";
export const permissionVerificationReview = "verification.review";
export const permissionVerificationRevoke = "verification.revoke";
// Third-party verification is a separate mechanism and therefore a separate pair of
// rights: review reads the section and decides applications, manage owns the
// verifier roster, the icon catalogue and taking a granted mark away.
export const permissionBotVerificationReview = "botverification.review";
export const permissionBotVerificationManage = "botverification.manage";
// GET /api/session is read once at boot; the panel keeps the answer here so a
// section the session may not use is hidden instead of rendered into a 403. This
// is a convenience for the operator, not a security boundary: every route is
// checked again server-side.
const PermissionsContext = createContext<readonly string[]>([]);
export function PermissionsProvider({
permissions,
children
}: {
permissions: readonly string[];
children: ReactNode;
}) {
return <PermissionsContext.Provider value={permissions}>{children}</PermissionsContext.Provider>;
}
export function usePermissions(): { permissions: readonly string[]; can: (permission: string) => boolean } {
const permissions = useContext(PermissionsContext);
return useMemo(
() => ({
permissions,
can: (permission: string) => permissions.includes(permissionAll) || permissions.includes(permission)
}),
[permissions]
);
}
export function useCan(permission: string): boolean {
return usePermissions().can(permission);
}
// PermissionGate is what a direct URL hits: without the right the operator gets
// an explanation naming the missing permission, not an empty table that looks
// like "no data".
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) {
const { can } = usePermissions();
if (can(permission)) {
return <>{children}</>;
}
return <PermissionDenied permission={permission} />;
}
export function PermissionDenied({ permission }: { permission: string }) {
const { t } = useI18n();
return (
<PageFrame title={t("permission.deniedTitle")} eyebrow={t("permission.deniedEyebrow")}>
<Alert>{t("permission.deniedBody", { permission })}</Alert>
<section className="section-block">
<div className="entity-head">
<div>
<div className="entity-title"><ShieldOff size={16} /> {t("permission.deniedHeading")}</div>
<div className="entity-subtitle">{t("permission.deniedHint")}</div>
</div>
</div>
</section>
</PageFrame>
);
}

View file

@ -17,6 +17,12 @@ export function currentRoute(): RouteState {
}
export function routeTitle(pathname: string, t: TFunction): string {
// Third-party verification is tested before the official section and before
// "/bots": three different prefixes that all read as "verification of a bot".
if (pathname.startsWith("/bot-verification")) return t("route.botVerification");
if (pathname.startsWith("/verification")) return t("route.verification");
if (pathname.startsWith("/collectible-usernames")) return t("route.collectibleUsernames");
if (pathname.startsWith("/account-ratings")) return t("route.accountRatings");
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/bots")) return t("route.bots");
@ -29,6 +35,10 @@ export function routeTitle(pathname: string, t: TFunction): string {
}
export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/bot-verification")) return t("route.botVerificationSubtitle");
if (pathname.startsWith("/verification")) return t("route.verificationSubtitle");
if (pathname.startsWith("/collectible-usernames")) return t("route.collectibleUsernamesSubtitle");
if (pathname.startsWith("/account-ratings")) return t("route.accountRatingsSubtitle");
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");

View file

@ -349,6 +349,22 @@ select {
select {
min-width: 220px;
height: 34px;
padding: 0 30px 0 10px;
font: inherit;
font-weight: 600;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
select:disabled {
color: var(--muted-2);
cursor: not-allowed;
}
textarea {
@ -621,3 +637,43 @@ textarea:focus {
align-items: stretch;
}
}
/* Level progress bars (account rating leaderboard and detail). */
.progress-cell {
display: grid;
gap: 4px;
min-width: 130px;
}
.progress-cell small,
.progress-note {
color: var(--muted);
font-size: 11px;
}
.progress-bar {
overflow: hidden;
width: 100%;
height: 6px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.progress-bar > span {
display: block;
height: 100%;
background: var(--brand-2);
}
.progress-bar.good > span {
background: var(--good);
}
.progress-bar.danger > span {
background: var(--danger);
}
.progress-wide .progress-cell {
min-width: 0;
}

View file

@ -103,7 +103,8 @@
font-weight: 800;
}
.duration-field input {
.duration-field input,
.duration-field select {
width: 100%;
}
@ -126,6 +127,16 @@
border-top: 1px solid var(--line);
}
/* A .dock-title already draws the rule under itself, so a .danger-zone placed
directly after one must not draw a second: the verification and bot-verification
detail docks label the zone with a dock-title and rendered two lines 10px apart
above the revoke button. */
.dock-title + .danger-zone {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.authorization-block {
display: grid;
gap: 10px;
@ -585,7 +596,8 @@
border-radius: var(--radius-sm);
}
.attr-block .duration-field input {
.attr-block .duration-field input,
.duration-field select {
width: 100%;
}
@ -685,3 +697,113 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Account rating component breakdown. */
.breakdown-list {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.breakdown-row {
display: grid;
grid-template-columns: minmax(140px, 260px) 1fr minmax(80px, auto);
gap: 12px;
align-items: center;
padding: 9px 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.breakdown-row.total {
grid-template-columns: 1fr minmax(80px, auto);
background: transparent;
}
.breakdown-label {
display: grid;
gap: 2px;
min-width: 0;
}
.breakdown-label strong {
color: var(--text);
font-weight: 800;
}
.breakdown-label small {
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.breakdown-value {
color: var(--text);
font-weight: 800;
text-align: right;
}
.breakdown-value.good {
color: var(--good);
}
.breakdown-value.danger {
color: var(--danger-text);
}
@media (max-width: 760px) {
.breakdown-row,
.breakdown-row.total {
grid-template-columns: 1fr;
}
.breakdown-value {
text-align: left;
}
}
/* Collectible usernames branching off the peer's editable one. The guide is drawn
with borders rather than a "↳" character so it lines up at any font size and is
not read out by a screen reader as punctuation. */
.username-branch {
margin: 2px 0 0;
padding: 0;
list-style: none;
}
.username-branch li {
position: relative;
padding-left: 14px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.7;
}
.username-branch li::before {
position: absolute;
top: 0;
left: 3px;
width: 6px;
height: 11px;
border-left: 1px solid var(--line-strong, var(--line));
border-bottom: 1px solid var(--line-strong, var(--line));
content: "";
}
.username-branch li.inactive {
color: var(--muted);
}
.username-branch li.inactive span {
text-decoration: line-through;
}
.username-branch li em {
margin-left: 6px;
font-size: 10px;
font-style: normal;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}

View file

@ -1,9 +1,19 @@
// AccountUsername is one collectible username the peer holds. Active mirrors the
// username#b4073647 flag: an inactive collectible is owned but does not resolve.
export type AccountUsername = {
Username: string;
Active: boolean;
};
export type AccountRow = {
ID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
// Collectible usernames in projection order; never includes the editable slot
// above. Always an array, so it can be iterated unconditionally.
Collectibles: AccountUsername[];
CreatedAt: string;
UpdatedAt: string;
Frozen: boolean;
@ -349,6 +359,347 @@ export type StarGiftCollectiblePreview = {
backdrops?: StarGiftCollectibleAttributeRow[];
};
export type CollectibleUsernameStatus = "vault" | "owned" | "burned";
export type CollectiblePeerType = "" | "user" | "channel";
export type CollectibleCurrency = "XTR" | "TON" | "USD";
// int64 columns arrive as JSON strings to survive the 2^53 boundary.
export type CollectibleUsernameRow = {
ID: string;
Username: string;
Status: CollectibleUsernameStatus;
OwnerPeerType: CollectiblePeerType;
OwnerPeerID: string;
OwnerUsername: string;
OwnerName: string;
PurchaseDate: string;
Currency: CollectibleCurrency;
Amount: string;
CryptoCurrency: string;
CryptoAmount: string;
URL: string;
OriginalOwnerPeerType: string;
OriginalOwnerPeerID: string;
OriginalOwnerUsername: string;
TransferCount: number;
Version: string;
// Mirrors the holder's username-registry row: an owned asset can still be
// hidden from the profile.
RegistryActive: boolean;
RegistrySortOrder: number;
CreatedAt: string;
UpdatedAt: string;
};
export type CollectibleUsernameTransferKind = "mint" | "transfer" | "revoke" | "burn";
export type CollectibleUsernameTransferRow = {
ID: string;
CollectibleID: string;
Kind: CollectibleUsernameTransferKind;
FromPeerType: string;
FromPeerID: string;
FromUsername: string;
ToPeerType: string;
ToPeerID: string;
ToUsername: string;
Currency: string;
Amount: string;
Actor: string;
Reason: string;
CommandKey: string;
CreatedAt: string;
};
export type CollectibleUsernameListResponse = {
rows: CollectibleUsernameRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type CollectibleUsernameDetail = {
asset: CollectibleUsernameRow;
transfers: CollectibleUsernameTransferRow[] | null;
};
export type AccountRatingRow = {
UserID: string;
Username: string;
FirstName: string;
Level: number;
Stars: string;
CurrentLevelStars: string;
NextLevelStars: string;
HasNextLevel: boolean;
StarsComponent: string;
ActivityComponent: string;
PenaltyComponent: string;
ManualComponent: string;
PendingStars: string;
PendingDate: string;
ComputedAt: string;
UpdatedAt: string;
Version: string;
};
export type AccountRatingEventKind = "stars" | "activity" | "moderation" | "manual" | "recompute";
export type AccountRatingEventRow = {
ID: string;
UserID: string;
Kind: AccountRatingEventKind;
Amount: string;
Reason: string;
Actor: string;
CommandKey: string;
CreatedAt: string;
};
export type AccountRatingListResponse = {
rows: AccountRatingRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type AccountRatingDetail = {
rating: AccountRatingRow;
events: AccountRatingEventRow[] | 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
// would send a decision against the wrong revision of the row.
export type VerificationTargetType = "bot" | "channel" | "supergroup" | "user";
export type VerificationStatus =
| "draft"
| "submitted"
| "in_review"
| "approved"
| "rejected"
| "cancelled";
export type VerificationEventKind =
| "created"
| "updated"
| "submitted"
| "claimed"
| "approved"
| "rejected"
| "cancelled"
| "revoked"
| "notified";
export type VerificationApplicationRow = {
ID: string;
ApplicantUserID: string;
ApplicantUsername: string;
ApplicantName: string;
TargetType: VerificationTargetType;
TargetID: string;
TargetTitle: string;
TargetUsername: string;
TargetVerified: boolean;
Category: string;
Description: string;
OfficialWebsite: string;
// Go marshals an empty slice as null, so both shapes have to be tolerated.
SocialLinks: string[] | null;
PressLinks: string[] | null;
AdditionalNote: string;
Status: VerificationStatus;
ReviewerAdminID: string;
DecisionReason: string;
// InternalNote is the reviewer handover note: operator-only, never shown to the
// applicant.
InternalNote: string;
CorrelationID: string;
CreatedAt: string;
UpdatedAt: string;
SubmittedAt: string;
ReviewedAt: string;
Version: string;
};
export type VerificationEventRow = {
ID: string;
Kind: VerificationEventKind;
FromStatus: string;
ToStatus: string;
Actor: string;
Reason: string;
Note: string;
CreatedAt: string;
};
export type VerificationApplicationListResponse = {
rows: VerificationApplicationRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type VerificationApplicationDetail = {
application: VerificationApplicationRow;
events: VerificationEventRow[] | null;
// Both flags describe the target as it is now, not as it was at submission.
applicant_controls_target: boolean;
target_verified: boolean;
};
// Counts are decimal strings for the same exactness reason as the ids; the
// backend always sends all six statuses.
export type VerificationCountsResponse = {
counts: Record<string, string> | null;
};
// Third-party bot verification (core.telegram.org/api/bots/verification): a
// verifier bot marks a peer with its OWN icon and description, rendered before the
// name. It is a different mechanism from the official checkmark above — the two
// never read each other's state — so it gets its own row types rather than reusing
// VerificationApplicationRow.
//
// Every int64 the backend tags `,string` stays a decimal string here: bot ids, peer
// ids, custom emoji document ids and the optimistic-locking version all outgrow the
// exact range of a JSON number.
export type BotVerificationPeerType = "user" | "channel";
export type CustomVerificationRequestStatus = "pending" | "approved" | "rejected" | "revoked";
// MarkCount is tagged `,string` like the ids (it is the count that would cascade
// away with a revocation, read as int64), while VerificationIconRow.UsedByVerifiers
// is a plain number — it counts verifier rows and cannot approach the exactness
// limit. Both are rendered through String(), so neither shape can surprise a cell.
export type BotVerifierRow = {
BotID: string;
BotUsername: string;
BotName: string;
// IconDocumentID is the custom emoji document the verifier marks with. Clients
// resolve it through messages.getCustomEmojiDocuments, so an id naming no
// fetchable document renders as no badge at all.
IconDocumentID: string;
IconName: string;
CompanyName: string;
DefaultDescription: string;
// CanModifyCustomDescription mirrors botVerifierSettings flags.1: when false the
// verifier may only apply DefaultDescription.
CanModifyCustomDescription: boolean;
// Enabled is the operator kill switch: a disabled verifier keeps its granted
// marks but can no longer mark anything new.
Enabled: boolean;
GrantedBy: string;
GrantReason: string;
MarkCount: string;
CreatedAt: string;
UpdatedAt: string;
Version: string;
};
export type VerificationIconRow = {
ID: string;
DocumentID: string;
// OwnerBotID is "0" for a catalogue entry any verifier may use, and a bot id
// when the operator reserved the icon for one verifier.
OwnerBotID: string;
OwnerBotUsername: string;
Name: string;
Active: boolean;
UsedByVerifiers: number;
CreatedAt: string;
UpdatedAt: string;
};
export type CustomVerificationRow = {
ID: string;
VerifierBotID: string;
VerifierBotUsername: string;
CompanyName: string;
PeerType: BotVerificationPeerType;
PeerID: string;
PeerTitle: string;
PeerUsername: string;
// Denormalised at grant time, so a mark keeps the icon it was granted with even
// after the verifier changes its own.
IconDocumentID: string;
Description: string;
CreatedAt: string;
UpdatedAt: string;
Version: string;
};
export type CustomVerificationRequestRow = {
ID: string;
VerifierBotID: string;
VerifierBotUsername: string;
ApplicantUserID: string;
ApplicantUsername: string;
PeerType: BotVerificationPeerType;
PeerID: string;
PeerTitle: string;
PeerUsername: string;
Reason: string;
RequestedDescription: string;
Status: CustomVerificationRequestStatus;
DecidedBy: string;
DecisionReason: string;
// InternalNote is the operator handover note: never shown to the applicant.
InternalNote: string;
CorrelationID: string;
CreatedAt: string;
UpdatedAt: string;
ApprovedAt: string;
RejectedAt: string;
Version: string;
};
export type BotVerifierListResponse = {
rows: BotVerifierRow[] | null;
};
export type VerificationIconListResponse = {
rows: VerificationIconRow[] | null;
};
export type CustomVerificationListResponse = {
rows: CustomVerificationRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type CustomVerificationRequestListResponse = {
rows: CustomVerificationRequestRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type CustomVerificationRequestDetail = {
request: CustomVerificationRequestRow;
// The verifier row as it is now: it can be disabled, or revoked entirely, after
// the application was filed.
verifier: BotVerifierRow | null;
// mark_active describes the peer right now, not the application status: an
// approved application whose mark a verifier later withdrew reads false.
mark_active: boolean;
};
// Counts are decimal strings for the same exactness reason as the ids; the backend
// always sends all four statuses.
export type BotVerificationCountsResponse = {
counts: Record<string, string> | null;
};
export type AdminSession = {
actor: string;
// The right set the signed session was issued with; ["*"] means everything.
permissions?: string[] | null;
};
export type AdminLoginResult = AdminSession & {
csrf_token: string;
};
export type MessageDetail = {
Message: MessageRow;
MessageJSON: string;

View file

@ -29,6 +29,7 @@ import (
"telesrv/internal/app/auth"
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
botsapp "telesrv/internal/app/bots"
botverificationapp "telesrv/internal/app/botverification"
channelapp "telesrv/internal/app/channels"
chatlistsapp "telesrv/internal/app/chatlists"
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
@ -48,6 +49,7 @@ import (
phoneapp "telesrv/internal/app/phone"
pollsapp "telesrv/internal/app/polls"
privacyapp "telesrv/internal/app/privacy"
ratingapp "telesrv/internal/app/rating"
secretchatapp "telesrv/internal/app/secretchat"
"telesrv/internal/app/stargifts"
"telesrv/internal/app/stars"
@ -56,8 +58,10 @@ import (
themesapp "telesrv/internal/app/themes"
translationapp "telesrv/internal/app/translation"
"telesrv/internal/app/updates"
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/app/userprojection"
"telesrv/internal/app/users"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/botapi"
"telesrv/internal/config"
"telesrv/internal/domain"
@ -270,6 +274,172 @@ func liveStreamDep(s *livestream.Service) rpc.LiveStreamsService {
return s
}
// verificationPeerVerifier writes the platform verification flag onto the peer
// record for app/verification.
//
// It is called from *inside* the store transaction that decides the application,
// which is the whole point of the port: "approved" and "target carries the badge"
// must commit together. That is why the transaction is taken from the context
// (postgres.VerificationTxFromContext) and written through — a write on a separate
// pool connection would survive a rollback of the decision and leave a peer
// wearing a badge no approved application backs.
//
// The app-service path is only the fallback for a context that carries no
// transaction (a non-postgres store, or a direct call): there is nothing to join
// then, and going through the services keeps their cache refresh behaviour.
type verificationPeerVerifier struct {
users interface {
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
}
channels interface {
SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
}
// channelRowCache is handed to the transaction-scoped channel store so the
// cached channel row is dropped on the flag write, exactly as the pooled store
// does it.
channelRowCache *postgres.ChannelRowCache
}
func (v verificationPeerVerifier) SetUserVerified(ctx context.Context, userID int64, verified bool) error {
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
_, err := postgres.NewUserStore(tx).SetVerified(ctx, userID, verified)
return err
}
if v.users == nil {
return fmt.Errorf("verification peer verifier: user service is not wired")
}
_, err := v.users.SetVerified(ctx, userID, verified)
return err
}
func (v verificationPeerVerifier) SetChannelVerified(ctx context.Context, channelID int64, verified bool) error {
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
opts := []postgres.ChannelStoreOption(nil)
if v.channelRowCache != nil {
opts = append(opts, postgres.WithChannelRowCache(v.channelRowCache))
}
_, err := postgres.NewChannelStore(tx, opts...).SetChannelVerified(ctx, channelID, verified)
return err
}
if v.channels == nil {
return fmt.Errorf("verification peer verifier: channel service is not wired")
}
_, err := v.channels.SetVerified(ctx, channelID, verified)
return err
}
var _ verificationapp.PeerVerifier = verificationPeerVerifier{}
// botVerificationMarkApplier writes a third-party mark on the decision's own
// transaction when there is one.
//
// postgres.DecideCustomVerificationRequest hands its callback a context carrying
// the transaction, and the pooled store would open a second, independently
// committing one -- so an approval whose mark write failed would leave the request
// approved with no mark. This adapter is what makes "approved implies mark exists"
// survive a rollback, exactly as verificationPeerVerifier does for the official flag.
type botVerificationMarkApplier struct {
store storepkg.BotVerificationStore
}
func (a botVerificationMarkApplier) GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error) {
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
return postgres.NewBotVerificationStore(tx).GrantCustomVerification(ctx, mark)
}
return a.store.GrantCustomVerification(ctx, mark)
}
func (a botVerificationMarkApplier) RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
return postgres.NewBotVerificationStore(tx).RevokeCustomVerification(ctx, verifierBotID, peer)
}
return a.store.RevokeCustomVerification(ctx, verifierBotID, peer)
}
var _ botverificationapp.MarkApplier = botVerificationMarkApplier{}
// compositeBotVerificationNotifier drops the cached peer projections before the
// edge rebuilds and pushes the peer, so a mark change cannot be pushed with a
// stale badge.
type compositeBotVerificationNotifier struct {
cache rpcProjectionVerificationNotifier
edge botverificationapp.PeerNotifier
}
func (n compositeBotVerificationNotifier) NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error {
if err := n.cache.NotifyPeerVerified(ctx, peer); err != nil && n.cache.log != nil {
n.cache.log.Warn("invalidate peer caches after third-party verification change",
zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
}
if n.edge == nil {
return nil
}
return n.edge.NotifyPeerBotVerification(ctx, peer)
}
var _ botverificationapp.PeerNotifier = compositeBotVerificationNotifier{}
// rpcProjectionVerificationNotifier is the fallback badge-change hook, the same
// shape and for the same reason as rpcProjectionUsernameNotifier: the RPC edge
// owns both the cached peer projections and the tg.* push, and until it exposes
// NotifyPeerVerified only the invalidation half can be wired here. Invalidation is
// the half that must not be skipped — a decided application whose peer projection
// still says "not verified" would keep showing the old badge state to every client
// that reads from cache.
type rpcProjectionVerificationNotifier struct {
invalidator interface {
InvalidateRPCProjectionReadModelForUser(userID int64)
InvalidateRPCProjectionReadModelForChannel(channelID int64)
}
users storepkg.UserCache
log *zap.Logger
}
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if n.invalidator == nil {
return nil
}
switch peer.Type {
case domain.PeerTypeUser:
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
// The shared user:base cache is the source the projection rebuilds from, so
// dropping only the projection would let it rebuild from a stale row.
if n.users != nil {
if err := n.users.Delete(ctx, []int64{peer.ID}); err != nil && n.log != nil {
n.log.Warn("invalidate base user cache after verification change",
zap.Int64("user_id", peer.ID), zap.Error(err))
}
}
case domain.PeerTypeChannel:
n.invalidator.InvalidateRPCProjectionReadModelForChannel(peer.ID)
}
return nil
}
// compositeVerificationNotifier drops the cached peer projections first and only
// then lets the protocol edge push the change, so the pushed peer is rebuilt from
// the committed row rather than from a cache entry written before the decision.
// A cache failure must not swallow the push: the push is what online clients see.
type compositeVerificationNotifier struct {
cache rpcProjectionVerificationNotifier
edge verificationapp.PeerNotifier
}
func (n compositeVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if err := n.cache.NotifyPeerVerified(ctx, peer); err != nil && n.cache.log != nil {
n.cache.log.Warn("invalidate peer caches after verification change",
zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
}
if n.edge == nil {
return nil
}
return n.edge.NotifyPeerVerified(ctx, peer)
}
var _ verificationapp.PeerNotifier = compositeVerificationNotifier{}
var _ verificationapp.PeerNotifier = rpcProjectionVerificationNotifier{}
func externalMediaOption(cfg config.Config) filesapp.Option {
if !cfg.ExternalMediaEnable {
return nil
@ -659,6 +829,7 @@ func run(logger *zap.Logger) error {
botsapp.WithStickerSetCreator(filesService),
botsapp.WithUserStickerSets(accountService),
botsapp.WithTelegramLogin(telegramLoginService),
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
botsapp.WithPublicBaseURL(cfg.PublicBaseURL))
groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
@ -859,6 +1030,78 @@ func run(logger *zap.Logger) error {
Store: accountService,
Sender: loginEmailSender,
}))
// Collectible (NFT) usernames are projected at the protocol edge. The
// composite account rating is a separate local admin read model and is never
// projected into Telegram's stars_rating fields.
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
accountRatingStore := postgres.NewAccountRatingStore(pool)
usernamesService := usernamesapp.NewService(
usernamesapp.WithRegistryStore(collectibleUsernameStore),
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
usernamesapp.WithURLTemplate(cfg.CollectibleUsernameURLTemplate),
usernamesapp.WithPublicBaseURL(cfg.PublicBaseURL),
usernamesapp.WithLogger(logger.Named("app").Named("usernames")),
)
ratingService := ratingapp.NewService(
ratingapp.WithStore(accountRatingStore),
ratingapp.WithEnabled(cfg.RatingEnabled),
ratingapp.WithWeights(cfg.AccountRatingWeights()),
ratingapp.WithPendingDelay(cfg.RatingPendingDelay),
ratingapp.WithStaleAfter(cfg.RatingStaleAfter),
ratingapp.WithLogger(logger.Named("app").Named("rating")),
)
// Official platform verification: applications are filed through the built-in
// @verifybot and decided in the admin panel. Every eligibility rule lives in
// this service; the bot and the panel are only its two surfaces.
verificationStore := postgres.NewVerificationStore(pool)
verificationLogger := logger.Named("app").Named("verification")
verificationService := verificationapp.NewService(
verificationapp.WithStore(verificationStore),
verificationapp.WithUserDirectory(usersService),
verificationapp.WithBotDirectory(botsService),
verificationapp.WithChannelDirectory(channelsService),
verificationapp.WithAccountFreezeProvider(adminService),
verificationapp.WithPeerVerifier(verificationPeerVerifier{
users: usersService,
channels: channelsService,
channelRowCache: channelRowCache,
}),
verificationapp.WithRateLimiter(rateLimiter, cfg.VerificationApplyRateLimit, cfg.VerificationApplyRateWindow),
verificationapp.WithEnabled(cfg.VerificationEnabled),
verificationapp.WithAllowUserTargets(cfg.VerificationAllowUserTargets),
verificationapp.WithRejectCooldown(cfg.VerificationRejectCooldown),
verificationapp.WithMaxActivePerUser(cfg.VerificationMaxActivePerUser),
verificationapp.WithLogger(verificationLogger),
)
// @verifybot is the applicant surface, and the notifier that carries decisions
// back to the applicant as ordinary messages. Both directions are deferred
// injections because the bots service is built before the peer directories the
// verification service needs.
botsService.SetVerification(verificationService)
verificationService.SetApplicantNotifier(botsService)
// Third-party verification is a SEPARATE mechanism: a verifier bot marks peers
// with its own custom-emoji icon and description, which clients render before the
// name. It shares no state with the official badge above -- different tables,
// different rights, different TL fields (bot_verification_icon / bot_verification
// versus verified).
botVerificationStore := postgres.NewBotVerificationStore(pool)
botVerificationService := botverificationapp.NewService(
botverificationapp.WithStore(botVerificationStore),
botverificationapp.WithUserDirectory(usersService),
botverificationapp.WithBotDirectory(botsService),
botverificationapp.WithChannelDirectory(channelsService),
// The icon must be a real custom emoji document: an id no client can fetch
// renders as nothing, so the badge would be silently invisible.
botverificationapp.WithIconResolver(filesService),
botverificationapp.WithMarkApplier(botVerificationMarkApplier{store: botVerificationStore}),
botverificationapp.WithRateLimiter(rateLimiter, cfg.BotVerificationRequestRateLimit, cfg.BotVerificationRequestRateWindow),
botverificationapp.WithEnabled(cfg.BotVerificationEnabled),
botverificationapp.WithMaxPerVerifier(cfg.BotVerificationMaxPerVerifier),
botverificationapp.WithLogger(logger.Named("app").Named("botverification")),
)
// @verifierbot files applications with the operator and reports decisions back.
botsService.SetCustomVerification(botVerificationService)
botVerificationService.SetApplicantNotifier(botsService)
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
router := rpc.New(rpc.Config{
DC: cfg.DC,
@ -899,6 +1142,8 @@ func run(logger *zap.Logger) error {
EphemeralPush: ephemeralStore,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
@ -914,6 +1159,7 @@ func run(logger *zap.Logger) error {
Files: filesService,
PremiumPromo: filesService,
Bots: botsService,
ServiceBotCallbacks: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
@ -971,7 +1217,69 @@ func run(logger *zap.Logger) error {
Bots: botsService,
Emoji: filesService,
Moderation: moderationService,
Usernames: usernamesService,
Rating: ratingService,
Verification: verificationService,
BotVerification: botVerificationService,
})
// The RPC edge owns the tg.* projection cache and the standard non-PTS
// updateUser/updateChannel refresh, so committed registry mutations are
// visible to online viewers immediately.
usernamesService.SetPeerUsernameNotifier(router)
// The badge change is a peer fact the protocol edge caches and pushes, so the
// verification service gets the same hook the username registry uses. The
// assertion is deliberately dynamic: NotifyPeerVerified lands with the edge
// agent, and until then only projection invalidation is wired — a decision can
// then never be masked by a stale projection, and clients converge on their next
// authoritative peer read.
if notifier, ok := any(router).(verificationapp.PeerNotifier); ok {
// Compose rather than choose: the decision writes users.verified inside the
// verification transaction (through postgres.VerificationTxFromContext), so it
// bypasses users.Service and its cache refresh. Dropping the shared user:base
// entry before the edge builds the pushed tg.User is what keeps the badge in
// that push from being one beat stale; the cross-instance read-model listener
// would otherwise only catch up asynchronously.
verificationService.SetPeerNotifier(compositeVerificationNotifier{
cache: rpcProjectionVerificationNotifier{
invalidator: router,
users: userCache,
log: verificationLogger,
},
edge: notifier,
})
} else {
verificationService.SetPeerNotifier(rpcProjectionVerificationNotifier{
invalidator: router,
users: userCache,
log: verificationLogger,
})
logger.Warn("verification badge update push is not implemented by the RPC edge; only projection invalidation is wired",
zap.String("expected_hook", "rpc.Router.NotifyPeerVerified"))
}
// The third-party mark lives on the same peer projections as the official flag,
// so it needs the same edge hook. Composed with the cache drop for the same reason:
// the mark can be written on the decision's own transaction, bypassing the app
// services that would otherwise refresh the shared user:base entry.
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
cache: rpcProjectionVerificationNotifier{
invalidator: router,
users: userCache,
log: verificationLogger,
},
edge: notifier,
})
} else {
logger.Warn("third-party verification push is not implemented by the RPC edge",
zap.String("expected_hook", "rpc.Router.NotifyPeerBotVerification"))
}
go ratingapp.NewRecomputeWorker(ratingService, logger.Named("rating").Named("recompute"),
cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch).Run(ctx)
// Applicant notifications are delivered from a durable outbox, never inside the
// decision transaction: @verifybot may be blocked and the panel must not wait on
// a message send.
go verificationapp.NewNotificationWorker(verificationService, logger.Named("verification").Named("notify"),
cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch).Run(ctx)
moderationActionOptions := []moderationapp.ActionExecutorOption{}
if cfg.PublicLinkWebAddr != "" {
moderationActionOptions = append(
@ -1047,7 +1355,21 @@ func run(logger *zap.Logger) error {
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err)
}
if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil {
// Scoped tokens carry a bounded permission set; the master token stays
// unrestricted, so a deployment that configures none behaves exactly as before.
adminScopedTokens := make([]adminapi.ScopedToken, 0, len(cfg.AdminScopedTokens))
for _, item := range cfg.AdminScopedTokens {
adminScopedTokens = append(adminScopedTokens, adminapi.ScopedToken{
Name: item.Name,
Token: item.Token,
Permissions: item.Permissions,
})
}
if _, err := adminapi.Start(ctx, adminapi.Config{
Addr: cfg.AdminAPIAddr,
Token: cfg.AdminAPIToken,
ScopedTokens: adminScopedTokens,
}, adminService, logger.Named("adminapi")); err != nil {
return fmt.Errorf("start admin api: %w", err)
}
if _, err := web.Start(ctx, web.Config{

View file

@ -0,0 +1,45 @@
-- Restore the single-username-per-peer invariant. Collectible registry rows are
-- dropped first so the old unique index can be recreated; the assets and their
-- provenance log are then removed with the tables.
DELETE FROM public.peer_usernames WHERE collectible_id IS NOT NULL OR NOT editable;
DROP INDEX IF EXISTS public.peer_usernames_collectible_idx;
DROP INDEX IF EXISTS public.peer_usernames_peer_order_idx;
DROP INDEX IF EXISTS public.peer_usernames_peer_editable_idx;
ALTER TABLE public.peer_usernames
DROP CONSTRAINT IF EXISTS peer_usernames_sort_order_check,
DROP CONSTRAINT IF EXISTS peer_usernames_collectible_not_editable_check,
DROP CONSTRAINT IF EXISTS peer_usernames_username_case_check;
ALTER TABLE public.peer_usernames
DROP COLUMN IF EXISTS collectible_id,
DROP COLUMN IF EXISTS sort_order,
DROP COLUMN IF EXISTS editable,
DROP COLUMN IF EXISTS active,
DROP COLUMN IF EXISTS username;
CREATE UNIQUE INDEX IF NOT EXISTS peer_usernames_peer_unique_idx
ON public.peer_usernames (peer_type, peer_id);
CREATE OR REPLACE FUNCTION public.delete_user_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM public.peer_usernames WHERE peer_type = 'user' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
CREATE OR REPLACE FUNCTION public.delete_channel_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM public.peer_usernames WHERE peer_type = 'channel' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
DROP TABLE IF EXISTS public.collectible_username_transfers;
DROP TABLE IF EXISTS public.collectible_usernames;

View file

@ -0,0 +1,194 @@
-- Collectible (Fragment-style) usernames.
--
-- A peer keeps exactly one editable username -- the slot that
-- account.updateUsername / channels.updateUsername owns -- plus any number of
-- collectible usernames it holds. peer_usernames stays the single authoritative
-- registry for global uniqueness across users and channels, so ResolveUsername,
-- public landing pages and occupancy checks keep working for collectible names
-- without a second lookup path.
--
-- The pre-existing one-row-per-peer unique index is replaced by a partial index
-- covering only editable rows: the old invariant is preserved exactly for the
-- editable slot while collectible rows are free to accumulate.
--
-- Ownership lives in collectible_usernames (the asset) and is projected into
-- peer_usernames (the registry). A collectible row in the registry always
-- carries collectible_id and is never editable, so client-driven username edits
-- cannot mutate or release an owned asset.
CREATE TABLE public.collectible_usernames (
id bigserial PRIMARY KEY,
username text NOT NULL,
username_lower text NOT NULL CHECK (
username_lower <> '' AND lower(username) = username_lower
),
status text NOT NULL CHECK (status IN ('vault', 'owned', 'burned')),
-- Empty owner is the vault/burned state; 'owned' always has a real peer.
owner_peer_type text NOT NULL CHECK (owner_peer_type IN ('', 'user', 'channel')),
owner_peer_id bigint NOT NULL CHECK (owner_peer_id >= 0),
CHECK (
(status = 'owned' AND owner_peer_type <> '' AND owner_peer_id > 0)
OR (status <> 'owned' AND owner_peer_type = '' AND owner_peer_id = 0)
),
-- fragment.collectibleInfo projection. Amounts are minor units for fiat and
-- nanotons for TON, matching the star gift lifecycle ledger convention.
purchase_date timestamptz NOT NULL,
currency text NOT NULL CHECK (currency IN ('XTR', 'TON', 'USD')),
amount bigint NOT NULL CHECK (amount >= 0),
crypto_currency text NOT NULL DEFAULT '' CHECK (crypto_currency IN ('', 'TON')),
crypto_amount bigint NOT NULL DEFAULT 0 CHECK (crypto_amount >= 0),
CHECK (
(crypto_currency = '' AND crypto_amount = 0)
OR (crypto_currency <> '' AND crypto_amount > 0)
),
url text NOT NULL DEFAULT '' CHECK (octet_length(url) <= 512),
-- Provenance: the first holder, kept even after transfers and burns.
original_owner_peer_type text NOT NULL DEFAULT '' CHECK (
original_owner_peer_type IN ('', 'user', 'channel')
),
original_owner_peer_id bigint NOT NULL DEFAULT 0 CHECK (original_owner_peer_id >= 0),
transfer_count integer NOT NULL DEFAULT 0 CHECK (transfer_count >= 0),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
-- A burned asset releases the name for a fresh issue while retaining immutable
-- provenance. At most one live asset may own a name; any number of burned
-- historical rows may remain.
CREATE UNIQUE INDEX collectible_usernames_live_name_idx
ON public.collectible_usernames (username_lower)
WHERE status <> 'burned';
CREATE INDEX collectible_usernames_name_history_idx
ON public.collectible_usernames (username_lower, id DESC);
CREATE INDEX collectible_usernames_owner_idx
ON public.collectible_usernames (owner_peer_type, owner_peer_id, id DESC)
WHERE status = 'owned';
CREATE INDEX collectible_usernames_status_idx
ON public.collectible_usernames (status, id DESC);
-- Append-only provenance log. command_key makes admin mint/transfer/revoke
-- replay-safe the same way star_gift_admin_grant_commands does for gifts.
CREATE TABLE public.collectible_username_transfers (
id bigserial PRIMARY KEY,
collectible_id bigint NOT NULL REFERENCES public.collectible_usernames(id)
ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('mint', 'transfer', 'revoke', 'burn')),
from_peer_type text NOT NULL CHECK (from_peer_type IN ('', 'user', 'channel')),
from_peer_id bigint NOT NULL CHECK (from_peer_id >= 0),
to_peer_type text NOT NULL CHECK (to_peer_type IN ('', 'user', 'channel')),
to_peer_id bigint NOT NULL CHECK (to_peer_id >= 0),
currency text NOT NULL DEFAULT '' CHECK (currency IN ('', 'XTR', 'TON', 'USD')),
amount bigint NOT NULL DEFAULT 0 CHECK (amount >= 0),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
command_key text CHECK (command_key IS NULL OR octet_length(command_key) BETWEEN 1 AND 128),
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX collectible_username_transfers_command_idx
ON public.collectible_username_transfers (command_key)
WHERE command_key IS NOT NULL;
CREATE INDEX collectible_username_transfers_asset_idx
ON public.collectible_username_transfers (collectible_id, id DESC);
ALTER TABLE public.peer_usernames
ADD COLUMN username text NOT NULL DEFAULT '',
ADD COLUMN active boolean NOT NULL DEFAULT true,
ADD COLUMN editable boolean NOT NULL DEFAULT true,
ADD COLUMN sort_order integer NOT NULL DEFAULT 0,
ADD COLUMN collectible_id bigint REFERENCES public.collectible_usernames(id)
ON DELETE CASCADE;
-- Recover the original-case display form for rows written before the column
-- existed; fall back to the lowercase key when the peer row is already gone.
UPDATE public.peer_usernames pu
SET username = u.username
FROM public.users u
WHERE pu.peer_type = 'user'
AND pu.peer_id = u.id
AND pu.username = ''
AND lower(u.username) = pu.username_lower;
UPDATE public.peer_usernames pu
SET username = c.username
FROM public.channels c
WHERE pu.peer_type = 'channel'
AND pu.peer_id = c.id
AND pu.username = ''
AND lower(COALESCE(c.username, '')) = pu.username_lower;
UPDATE public.peer_usernames
SET username = username_lower
WHERE username = '';
ALTER TABLE public.peer_usernames
ALTER COLUMN username DROP DEFAULT,
ADD CONSTRAINT peer_usernames_username_case_check
CHECK (lower(username) = username_lower),
ADD CONSTRAINT peer_usernames_collectible_not_editable_check
CHECK (collectible_id IS NULL OR NOT editable),
ADD CONSTRAINT peer_usernames_sort_order_check
CHECK (sort_order >= 0 AND sort_order <= 1024);
DROP INDEX IF EXISTS public.peer_usernames_peer_unique_idx;
CREATE UNIQUE INDEX peer_usernames_peer_editable_idx
ON public.peer_usernames (peer_type, peer_id)
WHERE editable;
CREATE INDEX peer_usernames_peer_order_idx
ON public.peer_usernames (peer_type, peer_id, sort_order, username_lower);
CREATE UNIQUE INDEX peer_usernames_collectible_idx
ON public.peer_usernames (collectible_id)
WHERE collectible_id IS NOT NULL;
-- Peer deletion must not destroy a collectible asset: the registry row goes
-- away with the peer, the asset returns to the vault and keeps its provenance.
CREATE OR REPLACE FUNCTION public.delete_user_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE public.collectible_usernames
SET status = 'vault',
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = now()
WHERE status = 'owned'
AND owner_peer_type = 'user'
AND owner_peer_id = OLD.id;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
CREATE OR REPLACE FUNCTION public.delete_channel_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE public.collectible_usernames
SET status = 'vault',
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = now()
WHERE status = 'owned'
AND owner_peer_type = 'channel'
AND owner_peer_id = OLD.id;
DELETE FROM public.peer_usernames
WHERE peer_type = 'channel' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;

View file

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS public.moderation_cases_target_history_idx;
DROP TABLE IF EXISTS public.account_rating_events;
DROP TABLE IF EXISTS public.account_rating;

View file

@ -0,0 +1,73 @@
-- Server-local composite account rating for moderation/operations in the admin
-- panel. This is intentionally not projected into Telegram's userFull
-- stars_rating/stars_my_pending_rating fields: those fields describe official
-- Stars transaction-volume semantics, not this activity/moderation score.
--
-- account_rating is a derived read model: it can always be rebuilt from the
-- contributing sources (stars_transactions, message counts, moderation state)
-- plus the manual adjustments recorded in account_rating_events. Every stored
-- component is kept separately so the admin panel can show why a level was
-- reached, and so recomputing one signal never silently discards another.
--
-- 'stars' is the composite score used by the local admin model, not a wallet
-- balance and not an official Telegram Stars Rating value.
CREATE TABLE public.account_rating (
user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
level integer NOT NULL DEFAULT 0 CHECK (level >= 0),
stars bigint NOT NULL DEFAULT 0,
current_level_stars bigint NOT NULL DEFAULT 0 CHECK (current_level_stars >= 0),
-- NULL means the top local admin level has been reached.
next_level_stars bigint CHECK (next_level_stars IS NULL OR next_level_stars > 0),
CHECK (next_level_stars IS NULL OR next_level_stars > current_level_stars),
-- Signed contributions. penalty_component is stored as a non-negative
-- magnitude and subtracted, so an audit never has to guess the sign.
stars_component bigint NOT NULL DEFAULT 0 CHECK (stars_component >= 0),
activity_component bigint NOT NULL DEFAULT 0 CHECK (activity_component >= 0),
penalty_component bigint NOT NULL DEFAULT 0 CHECK (penalty_component >= 0),
manual_component bigint NOT NULL DEFAULT 0,
-- Rating earned but not yet applied to the visible level.
pending_stars bigint NOT NULL DEFAULT 0,
pending_date timestamptz,
CHECK ((pending_stars = 0 AND pending_date IS NULL) OR (pending_stars <> 0 AND pending_date IS NOT NULL)),
computed_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0)
);
CREATE INDEX account_rating_leaderboard_idx
ON public.account_rating (level DESC, stars DESC, user_id);
CREATE INDEX account_rating_stale_idx
ON public.account_rating (computed_at, user_id);
-- Append-only contribution log. 'manual' rows are admin adjustments and are the
-- only rows that survive a full recompute; command_key gives them the same
-- replay safety as other admin commands.
CREATE TABLE public.account_rating_events (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('stars', 'activity', 'moderation', 'manual', 'recompute')),
amount bigint NOT NULL,
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
command_key text CHECK (command_key IS NULL OR octet_length(command_key) BETWEEN 1 AND 128),
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX account_rating_events_command_idx
ON public.account_rating_events (command_key)
WHERE command_key IS NOT NULL;
CREATE INDEX account_rating_events_user_idx
ON public.account_rating_events (user_id, id DESC);
CREATE INDEX account_rating_events_kind_idx
ON public.account_rating_events (kind, created_at DESC, id DESC);
-- Rating recompute counts upheld cases per target. The existing target index is
-- partial on the undecided states, so without this one the count degrades to a
-- sequential scan on every recompute.
CREATE INDEX moderation_cases_target_history_idx
ON public.moderation_cases (target_peer_type, target_peer_id)
WHERE status IN ('action_pending', 'action_failed', 'resolved');

View file

@ -0,0 +1,9 @@
-- Remove the built-in @verifybot seed. Its private history is left alone: chat
-- rows reference the account, and dropping them would rewrite users' dialogs.
DELETE FROM public.read_model_versions
WHERE owner_user_id = 1250000011 AND peer_type = 'user' AND peer_id = 1250000011;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = 1250000011;
DELETE FROM public.bots WHERE bot_user_id = 1250000011;

View file

@ -0,0 +1,89 @@
-- Built-in @verifybot: the front door for official platform verification.
--
-- Seeded here rather than lazily on first message so the username is occupied
-- from the moment the schema is current: peer_usernames.username_lower is the
-- only thing standing between a reserved bot handle and an ordinary user
-- claiming it, and a lazily created account would leave that window open.
--
-- access_hash is double-written with domain.VerifyBotAccessHash; the two must
-- never drift, exactly as for the other service bots (0044, 0045, 0047).
--
-- The peer_usernames insert carries the multi-username registry columns added in
-- 0149, so the handle occupies the editable slot.
INSERT INTO public.users (
id, access_hash, phone, first_name, last_name, username, country_code,
created_at, updated_at, verified, support, about, last_seen_at,
default_history_ttl_period, is_bot, bot_info_version, premium_expires_at,
emoji_status_document_id, emoji_status_until, color_set, color,
color_background_emoji_id, profile_color_set, profile_color,
profile_color_background_emoji_id
) VALUES (
1250000011, 7802113947355620887, '', 'Verify Bot', '', 'verifybot', '',
now(), now(), true, false,
'Apply for official verification of a public channel, supergroup or bot.',
0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version),
updated_at = now();
INSERT INTO public.bots (
bot_user_id, owner_user_id, token_secret, description, commands,
bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at,
menu_button_type, menu_button_text, menu_button_url, bot_inline_geo
) VALUES (
1250000011, 1250000011, '',
'Apply for official verification of a public channel, supergroup or bot. The bot collects the application and reports the decision back to you.',
'[
{"command": "start", "description": "how verification works"},
{"command": "new", "description": "file a verification application"},
{"command": "status", "description": "check your applications"},
{"command": "cancel", "description": "cancel the current application"},
{"command": "help", "description": "show help"}
]'::jsonb,
false, true, '', now(), now(), 0, '', '', false
)
ON CONFLICT (bot_user_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
token_secret = EXCLUDED.token_secret,
description = EXCLUDED.description,
commands = EXCLUDED.commands,
bot_chat_history = EXCLUDED.bot_chat_history,
bot_nochats = EXCLUDED.bot_nochats,
inline_placeholder = EXCLUDED.inline_placeholder,
menu_button_type = EXCLUDED.menu_button_type,
menu_button_text = EXCLUDED.menu_button_text,
menu_button_url = EXCLUDED.menu_button_url,
bot_inline_geo = EXCLUDED.bot_inline_geo,
updated_at = now();
INSERT INTO public.peer_usernames (
username_lower, username, peer_type, peer_id, active, editable, sort_order, updated_at
)
VALUES ('verifybot', 'verifybot', 'user', 1250000011, true, true, 0, now())
ON CONFLICT (username_lower) DO UPDATE SET
username = EXCLUDED.username,
peer_type = EXCLUDED.peer_type,
peer_id = EXCLUDED.peer_id,
active = EXCLUDED.active,
editable = EXCLUDED.editable,
updated_at = now();
INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash)
VALUES
('contact_account', 1250000011, 'user', 1250000011, 1, now(), 2500001100001),
('channel_active_memberships', 1250000011, 'user', 1250000011, 1, now(), 2500001100002)
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = GREATEST(public.read_model_versions.version, EXCLUDED.version),
updated_at = now(),
hash = EXCLUDED.hash;

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS public.verification_notification_outbox;
DROP TABLE IF EXISTS public.verification_application_events;
DROP TABLE IF EXISTS public.verification_applications;

View file

@ -0,0 +1,138 @@
-- Official platform verification applications.
--
-- The application is the durable audit subject: it is never deleted, only moved
-- through its status machine, and every transition appends an immutable row to
-- verification_application_events. Decisions additionally go through the shared
-- admin command journal (admin_commands / admin_audit_logs), so the panel keeps
-- one audit story for all operator actions.
--
-- The target is addressed by its stable peer id. target_title / target_username
-- are a submission-time snapshot for the review queue and the audit trail,
-- because a username can move between peers and a title can change after filing.
CREATE TABLE public.verification_applications (
id bigserial PRIMARY KEY,
applicant_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
target_type text NOT NULL CHECK (target_type IN ('bot', 'channel', 'supergroup', 'user')),
target_id bigint NOT NULL CHECK (target_id > 0),
target_title text NOT NULL DEFAULT '' CHECK (octet_length(target_title) <= 1024),
target_username text NOT NULL DEFAULT '' CHECK (octet_length(target_username) <= 64),
target_access_hash bigint NOT NULL DEFAULT 0,
category text NOT NULL DEFAULT '' CHECK (octet_length(category) <= 64),
description text NOT NULL DEFAULT '' CHECK (octet_length(description) <= 4096),
official_website text NOT NULL DEFAULT '' CHECK (octet_length(official_website) <= 512),
-- Links are stored as arrays rather than a child table: they are read and
-- written as one whole, are bounded, and never need to be queried across
-- applications.
social_links text[] NOT NULL DEFAULT '{}' CHECK (cardinality(social_links) <= 10),
press_links text[] NOT NULL DEFAULT '{}' CHECK (cardinality(press_links) <= 10),
additional_note text NOT NULL DEFAULT '' CHECK (octet_length(additional_note) <= 4096),
status text NOT NULL CHECK (status IN (
'draft', 'submitted', 'in_review', 'approved', 'rejected', 'cancelled'
)),
reviewer_admin_id text NOT NULL DEFAULT '' CHECK (octet_length(reviewer_admin_id) <= 128),
decision_reason text NOT NULL DEFAULT '' CHECK (octet_length(decision_reason) <= 4096),
-- internal_note is operator-only and must never be projected to the applicant.
internal_note text NOT NULL DEFAULT '' CHECK (octet_length(internal_note) <= 8192),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
submitted_at timestamptz,
reviewed_at timestamptz,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at),
-- A decided application always carries its reviewer and timestamp; a rejected
-- one additionally carries the reason the applicant is told.
CHECK (
(status IN ('approved', 'rejected')) =
(reviewed_at IS NOT NULL AND reviewer_admin_id <> '')
),
CHECK (status <> 'rejected' OR decision_reason <> ''),
CHECK (status = 'draft' OR submitted_at IS NOT NULL)
);
-- Exactly one live application per target. Draft, submitted and in_review are
-- the occupying states; decided and cancelled ones are history and do not block
-- a fresh attempt.
CREATE UNIQUE INDEX verification_applications_active_target_idx
ON public.verification_applications (target_type, target_id)
WHERE status IN ('draft', 'submitted', 'in_review');
-- One draft per applicant: the bot dialog is a single conversation, so a second
-- draft would have no way to be addressed.
CREATE UNIQUE INDEX verification_applications_applicant_draft_idx
ON public.verification_applications (applicant_user_id)
WHERE status = 'draft';
CREATE INDEX verification_applications_queue_idx
ON public.verification_applications (status, created_at DESC, id DESC);
CREATE INDEX verification_applications_applicant_idx
ON public.verification_applications (applicant_user_id, id DESC);
CREATE INDEX verification_applications_target_idx
ON public.verification_applications (target_type, target_id, id DESC);
CREATE INDEX verification_applications_reviewer_idx
ON public.verification_applications (reviewer_admin_id, reviewed_at DESC, id DESC)
WHERE reviewer_admin_id <> '';
-- Username search in the review queue is a prefix match on the snapshot.
CREATE INDEX verification_applications_username_idx
ON public.verification_applications (lower(target_username))
WHERE target_username <> '';
-- Cooldown lookups after a rejection: newest decision per applicant+target.
CREATE INDEX verification_applications_cooldown_idx
ON public.verification_applications (applicant_user_id, target_type, target_id, reviewed_at DESC)
WHERE status = 'rejected';
-- Immutable per-application history. Rows are append-only: there is no UPDATE or
-- DELETE path in the store, and the panel renders this as the application
-- timeline.
CREATE TABLE public.verification_application_events (
id bigserial PRIMARY KEY,
application_id bigint NOT NULL REFERENCES public.verification_applications(id)
ON DELETE RESTRICT,
kind text NOT NULL CHECK (kind IN (
'created', 'updated', 'submitted', 'claimed', 'approved', 'rejected',
'cancelled', 'revoked', 'notified'
)),
from_status text NOT NULL DEFAULT '' CHECK (octet_length(from_status) <= 32),
to_status text NOT NULL DEFAULT '' CHECK (octet_length(to_status) <= 32),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 4096),
note text NOT NULL DEFAULT '' CHECK (octet_length(note) <= 8192),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL
);
CREATE INDEX verification_application_events_app_idx
ON public.verification_application_events (application_id, id DESC);
CREATE INDEX verification_application_events_actor_idx
ON public.verification_application_events (actor, created_at DESC, id DESC)
WHERE actor <> '';
-- Applicant notifications are delivered by @verifybot after the decision commits.
-- The outbox keeps that delivery exactly-once across restarts and makes a
-- repeated approve idempotent: the unique key is the decision, not the attempt.
CREATE TABLE public.verification_notification_outbox (
id bigserial PRIMARY KEY,
application_id bigint NOT NULL REFERENCES public.verification_applications(id)
ON DELETE CASCADE,
recipient_user_id bigint NOT NULL CHECK (recipient_user_id > 0),
kind text NOT NULL CHECK (kind IN ('approved', 'rejected', 'revoked')),
payload jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (
jsonb_typeof(payload) = 'object' AND octet_length(payload::text) <= 8192
),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
delivered_at timestamptz,
last_error text NOT NULL DEFAULT '' CHECK (octet_length(last_error) <= 1024),
created_at timestamptz NOT NULL,
CONSTRAINT verification_notification_once UNIQUE (application_id, kind)
);
CREATE INDEX verification_notification_pending_idx
ON public.verification_notification_outbox (created_at, id)
WHERE delivered_at IS NULL;

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS public.custom_verification_requests;
DROP TABLE IF EXISTS public.custom_verifications;
DROP TABLE IF EXISTS public.bot_verifier_settings;
DROP TABLE IF EXISTS public.verification_icons;

View file

@ -0,0 +1,140 @@
-- Third-party bot verification (core.telegram.org/api/bots/verification).
--
-- This is a SEPARATE mechanism from the official platform badge implemented in
-- 0153/0154. Official verification is a boolean on the peer that only the
-- operator can set and that clients render as the standard checkmark. Third-party
-- verification is an attributed mark granted by a verifier bot: it carries that
-- verifier's own custom-emoji icon and a human-readable description, renders
-- BEFORE the name, and never becomes the official checkmark. Both can coexist on
-- one peer, and neither reads the other's tables.
--
-- Layer 228 surfaces:
-- user#b1b8cc83 bot_verification_icon:flags2.14?long
-- channel#d49f34c6 bot_verification_icon:flags2.13?long
-- userFull#6cbe645 bot_verification:flags2.12?BotVerification
-- channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
-- chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
-- botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
-- The icon catalogue. An icon is a custom emoji document the client resolves with
-- messages.getCustomEmojiDocuments, so document_id must name a real document:
-- clients render nothing for an id they cannot fetch.
CREATE TABLE public.verification_icons (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL UNIQUE CHECK (document_id > 0),
-- owner_bot_id is 0 for a shared catalogue entry any verifier may use, and a
-- bot id when the operator reserved the icon for one verifier.
owner_bot_id bigint NOT NULL DEFAULT 0 CHECK (owner_bot_id >= 0),
name text NOT NULL CHECK (octet_length(name) BETWEEN 1 AND 512),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
CREATE INDEX verification_icons_active_idx
ON public.verification_icons (active, id DESC);
CREATE INDEX verification_icons_owner_idx
ON public.verification_icons (owner_bot_id, id DESC)
WHERE owner_bot_id <> 0;
-- Verifier status. A row here is what makes a bot a verifier: it is projected as
-- botInfo.verifier_settings and is the only authority bots.setCustomVerification
-- consults, which is why granting it is an operator action and never a bot one.
CREATE TABLE public.bot_verifier_settings (
bot_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
icon_document_id bigint NOT NULL CHECK (icon_document_id > 0),
company_name text NOT NULL CHECK (octet_length(company_name) BETWEEN 1 AND 512),
default_description text NOT NULL DEFAULT '' CHECK (octet_length(default_description) <= 280),
-- can_modify_custom_description mirrors the TL flag: when false the verifier
-- may only apply default_description, so a per-peer description cannot be
-- smuggled past the operator.
can_modify_custom_description boolean NOT NULL DEFAULT false,
enabled boolean NOT NULL DEFAULT true,
granted_by text NOT NULL DEFAULT '' CHECK (octet_length(granted_by) <= 128),
grant_reason text NOT NULL DEFAULT '' CHECK (octet_length(grant_reason) <= 4096),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at)
);
CREATE INDEX bot_verifier_settings_enabled_idx
ON public.bot_verifier_settings (enabled, bot_id);
-- Granted marks. The wire model carries one BotVerification per peer, so a new
-- verifier replaces the previous mark instead of leaving hidden rows that could
-- reappear after a later revocation.
CREATE TABLE public.custom_verifications (
id bigserial PRIMARY KEY,
verifier_bot_id bigint NOT NULL REFERENCES public.bot_verifier_settings(bot_id)
ON DELETE CASCADE,
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
-- icon_document_id is denormalised from the verifier at grant time: the mark
-- must keep rendering the icon it was granted with even if the verifier later
-- changes its own.
icon_document_id bigint NOT NULL CHECK (icon_document_id > 0),
description text NOT NULL DEFAULT '' CHECK (octet_length(description) <= 4096),
granted_by_user_id bigint NOT NULL DEFAULT 0 CHECK (granted_by_user_id >= 0),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CONSTRAINT custom_verifications_peer_once UNIQUE (peer_type, peer_id),
CHECK (updated_at >= created_at)
);
-- Projection and ownership lookup by peer.
CREATE INDEX custom_verifications_peer_idx
ON public.custom_verifications (peer_type, peer_id, id DESC);
CREATE INDEX custom_verifications_verifier_idx
ON public.custom_verifications (verifier_bot_id, id DESC);
-- Applications a peer files with a verifier bot. The mark itself lives in
-- custom_verifications; this is the review queue in front of it, so a rejected or
-- revoked application stays as history without implying a mark.
CREATE TABLE public.custom_verification_requests (
id bigserial PRIMARY KEY,
verifier_bot_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
applicant_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
peer_title text NOT NULL DEFAULT '' CHECK (octet_length(peer_title) <= 1024),
peer_username text NOT NULL DEFAULT '' CHECK (octet_length(peer_username) <= 64),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 16384),
requested_description text NOT NULL DEFAULT '' CHECK (octet_length(requested_description) <= 280),
status text NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'revoked')),
decided_by text NOT NULL DEFAULT '' CHECK (octet_length(decided_by) <= 128),
decision_reason text NOT NULL DEFAULT '' CHECK (octet_length(decision_reason) <= 4096),
internal_note text NOT NULL DEFAULT '' CHECK (octet_length(internal_note) <= 32768),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
approved_at timestamptz,
rejected_at timestamptz,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at),
CHECK ((status = 'approved') = (approved_at IS NOT NULL)),
CHECK ((status = 'rejected') = (rejected_at IS NOT NULL)),
CHECK (status <> 'rejected' OR decision_reason <> '')
);
-- One live application per (verifier, peer): a second pending row would let two
-- decisions race for one mark.
CREATE UNIQUE INDEX custom_verification_requests_pending_idx
ON public.custom_verification_requests (verifier_bot_id, peer_type, peer_id)
WHERE status = 'pending';
CREATE INDEX custom_verification_requests_queue_idx
ON public.custom_verification_requests (status, created_at DESC, id DESC);
CREATE INDEX custom_verification_requests_verifier_idx
ON public.custom_verification_requests (verifier_bot_id, id DESC);
CREATE INDEX custom_verification_requests_applicant_idx
ON public.custom_verification_requests (applicant_user_id, id DESC);
CREATE INDEX custom_verification_requests_peer_idx
ON public.custom_verification_requests (peer_type, peer_id, id DESC);

View file

@ -0,0 +1,12 @@
-- Remove the built-in @verifierbot seed. Its private history is left alone: chat
-- rows reference the account, and dropping them would rewrite users' dialogs.
--
-- Any verifier status an operator granted this bot lives in bot_verifier_settings
-- (0155) and cascades when this account seed is removed.
DELETE FROM public.read_model_versions
WHERE owner_user_id = 1250000013 AND peer_type = 'user' AND peer_id = 1250000013;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = 1250000013;
DELETE FROM public.bots WHERE bot_user_id = 1250000013;

View file

@ -0,0 +1,103 @@
-- Built-in @verifierbot: the applicant front door for THIRD-PARTY verification
-- (core.telegram.org/api/bots/verification).
--
-- This is the first verifier bot of a deployment, shipped so the feature has a
-- working reference: it collects applications for its own icon+description mark
-- and reports the operator's decision back to the applicant. It is deliberately
-- NOT a second way to get the platform checkmark -- @verifybot (0153) owns that
-- mechanism, and the two never read each other's state.
--
-- verified = false on purpose. The official flag is granted by the operator to
-- peers that passed platform review; a verifier bot carrying it would blur exactly
-- the distinction this bot has to explain to every applicant. What makes this
-- account a verifier is a row in bot_verifier_settings (0155), which an operator
-- grants by hand in the admin panel -- never this migration: seeding verifier
-- status here would hand out a badge printer with the schema.
--
-- Seeded rather than created lazily on first message so the username is occupied
-- from the moment the schema is current: peer_usernames.username_lower is the only
-- thing standing between a reserved bot handle and an ordinary user claiming it,
-- and a lazily created account would leave that window open.
--
-- access_hash is double-written with domain.VerifierBotAccessHash; the two must
-- never drift, exactly as for the other service bots (0044, 0045, 0047, 0153).
--
-- The peer_usernames insert carries the multi-username registry columns added in
-- 0149, so the handle occupies the editable slot.
INSERT INTO public.users (
id, access_hash, phone, first_name, last_name, username, country_code,
created_at, updated_at, verified, support, about, last_seen_at,
default_history_ttl_period, is_bot, bot_info_version, premium_expires_at,
emoji_status_document_id, emoji_status_until, color_set, color,
color_background_emoji_id, profile_color_set, profile_color,
profile_color_background_emoji_id
) VALUES (
1250000013, 6913402578811563729, '', 'Verifier Bot', '', 'verifierbot', '',
now(), now(), false, false,
'Third-party verification: my icon before your name and a line in your profile. Not the official checkmark.',
0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version),
updated_at = now();
INSERT INTO public.bots (
bot_user_id, owner_user_id, token_secret, description, commands,
bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at,
menu_button_type, menu_button_text, menu_button_url, bot_inline_geo
) VALUES (
1250000013, 1250000013, '',
'I grant third-party verification marks: my own icon before the name of your bot, channel or account, plus a description in its profile. This is not the official platform checkmark. I collect the application, an operator decides, and I message you here with the outcome.',
'[
{"command": "start", "description": "what a third-party mark is"},
{"command": "verify", "description": "apply for the mark"},
{"command": "status", "description": "your applications and marks"},
{"command": "revoke", "description": "remove a mark from your peer"},
{"command": "help", "description": "show help"}
]'::jsonb,
false, true, '', now(), now(), 0, '', '', false
)
ON CONFLICT (bot_user_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
token_secret = EXCLUDED.token_secret,
description = EXCLUDED.description,
commands = EXCLUDED.commands,
bot_chat_history = EXCLUDED.bot_chat_history,
bot_nochats = EXCLUDED.bot_nochats,
inline_placeholder = EXCLUDED.inline_placeholder,
menu_button_type = EXCLUDED.menu_button_type,
menu_button_text = EXCLUDED.menu_button_text,
menu_button_url = EXCLUDED.menu_button_url,
bot_inline_geo = EXCLUDED.bot_inline_geo,
updated_at = now();
INSERT INTO public.peer_usernames (
username_lower, username, peer_type, peer_id, active, editable, sort_order, updated_at
)
VALUES ('verifierbot', 'verifierbot', 'user', 1250000013, true, true, 0, now())
ON CONFLICT (username_lower) DO UPDATE SET
username = EXCLUDED.username,
peer_type = EXCLUDED.peer_type,
peer_id = EXCLUDED.peer_id,
active = EXCLUDED.active,
editable = EXCLUDED.editable,
updated_at = now();
INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash)
VALUES
('contact_account', 1250000013, 'user', 1250000013, 1, now(), 2500001300001),
('channel_active_memberships', 1250000013, 'user', 1250000013, 1, now(), 2500001300002)
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = GREATEST(public.read_model_versions.version, EXCLUDED.version),
updated_at = now(),
hash = EXCLUDED.hash;

View file

@ -0,0 +1,23 @@
-- Narrowing back can only succeed once no custom-emoji reaction rows remain, so
-- drop them first: a CHECK that a stored row violates cannot be added.
DELETE FROM public.channel_message_reactions WHERE (reaction_type)::text <> 'emoji';
DELETE FROM public.user_top_reactions WHERE (reaction_type)::text <> 'emoji';
DELETE FROM public.user_recent_reactions WHERE (reaction_type)::text <> 'emoji';
ALTER TABLE public.channel_message_reactions
DROP CONSTRAINT IF EXISTS channel_message_reactions_type_check;
ALTER TABLE public.channel_message_reactions
ADD CONSTRAINT channel_message_reactions_type_check
CHECK ((reaction_type)::text = 'emoji'::text);
ALTER TABLE public.user_top_reactions
DROP CONSTRAINT IF EXISTS user_top_reactions_reaction_type_check;
ALTER TABLE public.user_top_reactions
ADD CONSTRAINT user_top_reactions_reaction_type_check
CHECK ((reaction_type)::text = 'emoji'::text);
ALTER TABLE public.user_recent_reactions
DROP CONSTRAINT IF EXISTS user_recent_reactions_reaction_type_check;
ALTER TABLE public.user_recent_reactions
ADD CONSTRAINT user_recent_reactions_reaction_type_check
CHECK ((reaction_type)::text = 'emoji'::text);

View file

@ -0,0 +1,28 @@
-- Custom-emoji reactions on channel messages.
--
-- The squashed 0001_init schema constrains channel_message_reactions.reaction_type,
-- user_top_reactions.reaction_type and user_recent_reactions.reaction_type to
-- 'emoji' only, while the saved-tag tables already allow 'custom_emoji' and the
-- store writes reactionCustomEmoji through all of them. On a database built from
-- these migrations a custom-emoji reaction in a channel therefore fails with
-- "violates check constraint ..._type_check"; upstream's own
-- TestChannelStoreCustomEmojiReactionPolicyRoundTrips fails for exactly this
-- reason. Widen the three CHECKs to the pair the saved-tag tables already use.
ALTER TABLE public.channel_message_reactions
DROP CONSTRAINT IF EXISTS channel_message_reactions_type_check;
ALTER TABLE public.channel_message_reactions
ADD CONSTRAINT channel_message_reactions_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));
ALTER TABLE public.user_top_reactions
DROP CONSTRAINT IF EXISTS user_top_reactions_reaction_type_check;
ALTER TABLE public.user_top_reactions
ADD CONSTRAINT user_top_reactions_reaction_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));
ALTER TABLE public.user_recent_reactions
DROP CONSTRAINT IF EXISTS user_recent_reactions_reaction_type_check;
ALTER TABLE public.user_recent_reactions
ADD CONSTRAINT user_recent_reactions_reaction_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));

View file

@ -0,0 +1,5 @@
-- Nothing to restore: the rating read model is derived from live signals, so a
-- deleted projection is recomputed rather than recovered, and the seeding pass
-- deliberately never offers these accounts again. Down is a no-op rather than a
-- lie about being able to bring the rows back.
SELECT 1;

View file

@ -0,0 +1,20 @@
-- Service accounts carry no composite rating.
--
-- The rating measures what an account did with Stars, so a bot -- which does not
-- transact on its own behalf -- and the built-in service accounts -- which are
-- infrastructure rather than participants -- have no meaningful score. The seeding
-- pass now excludes both, but the platform account (777000) is not flagged is_bot,
-- so a deployment that already ran a recompute cycle has a projection row for it
-- and would show a level badge on the platform account's profile.
--
-- Drop those rows and their ledger. The read model is derived, so deleting a row
-- loses nothing that cannot be recomputed; the ledger rows go with it because a
-- manual adjustment for an account that can no longer be rated is unreachable
-- bookkeeping.
DELETE FROM public.account_rating_events
WHERE user_id IN (777000, 93372553, 1063110917, 1250000007, 1250000011, 1250000013)
OR user_id IN (SELECT id FROM public.users WHERE is_bot);
DELETE FROM public.account_rating
WHERE user_id IN (777000, 93372553, 1063110917, 1250000007, 1250000011, 1250000013)
OR user_id IN (SELECT id FROM public.users WHERE is_bot);

359
docs/bot_verification.md Normal file
View file

@ -0,0 +1,359 @@
# Third-party bot verification
Third-party verification is an **attributed** mark: a bot that the operator
appointed as a *verifier* attaches its own custom-emoji icon and one line of
description to a peer. Official clients draw that icon **before** the peer's name
and show the description in the profile, together with the name of the company the
verifier vouches under.
Reference material:
- <https://core.telegram.org/api/bots/verification>
- <https://telegram.org/verify#third-party-verification>
Applications are collected by the built-in `@verifierbot`, decided in the admin
panel, and the decision commits together with the mark write. The protocol edge then
drops the cached peer projections and pushes the ordinary peer-refresh update, so an
online client shows the icon without a restart.
## What this is not
This is **not** the platform checkmark. That one is a single boolean on the peer
(`users.verified` / `channels.verified`), granted by the operator after platform
review, collected by `@verifybot`, and documented in [`verification.md`](verification.md).
The two mechanisms are deliberately disjoint:
| | Official verification | Third-party verification |
| --- | --- | --- |
| Stored as | `users.verified`, `channels.verified` (boolean) | `bot_verifier_settings`, `custom_verifications` (attributed rows) |
| Granted by | the platform operator | a verifier bot the operator appointed |
| Rendered as | the standard checkmark **after** the name | the verifier's custom emoji **before** the name, plus a profile description |
| Front door | `@verifybot` | `@verifierbot` (or `bots.setCustomVerification` directly) |
| Panel section | *Official verification* (`/verification`) | *Third-party verification* (`/bot-verification`) |
| Permissions | `verification.review`, `verification.revoke` | `botverification.review`, `botverification.manage` |
Neither reads the other's tables. Both can sit on one peer at the same time, an
approval on one side never writes the other side's state, and revoking one leaves
the other alone. The admin panel repeats that distinction in the section header and
on every decision page, because "verified" in a ticket almost always means the other
one.
## TL constructors and flags (Layer 228)
Checked against the schema snapshot the server is built for,
`/tmp/td/_schema/layers/layer-228.tl`.
| Constructor / method | Field |
| --- | --- |
| `botVerification#f93cd45c` | `bot_id:long icon:long description:string` |
| `botVerifierSettings#b0cd6617` | `can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string` |
| `bots.setCustomVerification#8b89dfbd` | `enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool` |
| `user#b1b8cc83` | `bot_verification_icon:flags2.14?long` |
| `channel#d49f34c6` | `bot_verification_icon:flags2.13?long` |
| `userFull#6cbe645` | `bot_verification:flags2.12?BotVerification` |
| `channelFull#a04e8d3a` | `bot_verification:flags2.17?BotVerification` |
| `chatInvite#5c9d3702` | `bot_verification:flags.13?BotVerification` |
| `botInfo#4d8a0299` | `verifier_settings:flags.9?BotVerifierSettings` |
One fact — "verifier *B* marked peer *P* with icon *I* and description *D*" — is
spread over six unrelated constructors, and a client renders the badge only when the
exact bit is set. Every projection therefore goes through the generated `Set*`
helpers (`internal/rpc/bot_verification_projection.go`): a struct field assigned
without its flag bit encodes as an absent field, and the badge silently disappears.
Note the asymmetry inside `botVerifierSettings`: `custom_description:flags.0` is the
operator-configured *default* description, while
`can_modify_custom_description:flags.1` is the permission that lets the verifier
override it per peer. `botVerification.description` is the resolved text actually
shown on a marked peer.
## The icon is a custom emoji document
`botVerification.icon` and `botVerifierSettings.icon` are custom emoji **document
ids**. A client resolves them through `messages.getCustomEmojiDocuments` — exactly
the reader `files.Service.GetDocuments` answers from on this server.
Consequences that shape the whole feature:
- An id that names no fetchable document renders as **nothing at all**: the peer is
marked in the database and the client draws an empty space. Nothing errors, nothing
logs on the client, and the operator sees a granted mark that users cannot see.
- Therefore the icon is never a free-form number. `verification_icons` is a
catalogue, `botverification.Service.UpsertIcon` resolves the document before
writing the row and refuses anything that is not a custom emoji
(`domain.Document.IsCustomEmoji`), and a grant may only reference a catalogue entry
that is `active` and either shared or reserved for that bot
(`VerificationIcon.UsableBy`).
- The mark denormalises the icon at grant time (`custom_verifications.icon_document_id`),
so a verifier changing its own icon later does not silently re-brand the peers it
already marked.
The panel exposes the same rule: the icon catalogue tab is where document ids are
registered and named, and the grant form only offers active catalogue entries.
## End-to-end path
1. **Icon catalogue.** An operator adds a custom emoji document to
`verification_icons` (panel: *Third-party verification → Icon catalogue*, or
`POST /api/actions/upsert-verification-icon`). Entries can be shared or reserved
for one bot, and retiring an entry (`set-verification-icon-active`) stops new
grants without touching marks that already carry it.
2. **Verifier status.** The operator grants a bot verifier status — an icon from the
catalogue, a company name, an optional default description and
`can_modify_custom_description` (panel: *Verifiers*, or
`POST /api/actions/grant-bot-verifier`). The row in `bot_verifier_settings` *is*
verifier status: it is the only authority `bots.setCustomVerification` consults,
and it is projected as `botInfo.verifier_settings`. Nothing seeds it — not even
migration `0155` for the built-in bot — because seeding verifier status would ship
a badge printer with the schema.
3. **Two ways to reach a mark.**
- **Direct RPC.** The verifier bot (or the user who owns it) calls
`bots.setCustomVerification`. `internal/rpc/bots_longtail.go` resolves the two
TL branches — `bot:flags.0` unset means "the caller is the bot", set means "a
user acting through a bot it owns" — validates shape, and hands a
`domain.SetCustomVerificationRequest` to the service. A missing or disabled
verifier row answers `403 BOT_VERIFIER_FORBIDDEN`, and the error deliberately
does not distinguish "never was a verifier" from "switched off".
- **Application queue.** A peer owner talks to `@verifierbot`
(`/verify`, `/status`, `/revoke`, `/cancel`, `/help`), picks one of their own
bots, channels or their own account, states a reason and optionally a wanted
description. The bot writes `custom_verification_requests` with status
`pending`; a partial unique index keeps one pending row per
(verifier, peer) pair. `@verifierbot` decides nothing — it says so in `/start`.
4. **Review.** The panel lists the queue, the verifier roster, the icon catalogue and
every granted mark. BFF routes are `GET /api/botverification/{verifiers,icons,marks,requests,counts}`,
`GET /api/botverification/requests/{id}` and
`POST /api/botverification/requests/{id}/{approve,reject,revoke}`; the manage-only
mutations are the `/api/actions/...` commands listed above plus
`set-bot-verifier-enabled`, `revoke-bot-verifier` and
`revoke-custom-verification`. Every mutation goes through the shared admin command
journal (reason → dry run → confirm), so it lands in `admin_commands` /
`admin_audit_logs`.
5. **Second gate at approval.** `botverification.Service.Approve` re-loads a fresh
snapshot: the verifier must still exist and be enabled, the peer must still
resolve, and the per-verifier quota is spent only when the approval would create a
mark rather than update one. A queue that sat for days cannot launder a state the
RPC path would refuse. `version` is an optimistic lock — a stale panel gets a
`409` and the page reloads instead of overwriting a fresher decision.
6. **Decision transaction.** The status transition and the mark write commit
together: `DecideCustomVerificationRequest` runs the grant (or the revoke)
through a callback whose context carries the decision's own transaction. "Approved"
and "the peer carries the mark" can never disagree. The description is resolved by
`BotVerifierSettings.DescriptionFor` — the applicant's wording only when
`can_modify_custom_description` is set, otherwise the operator default — which is
the single place that rule lives, so the RPC edge, the bot dialog and the panel
preview cannot drift.
7. **Protocol edge.** After the commit the service calls
`rpc.Router.NotifyPeerBotVerification(ctx, domain.Peer)`
(`internal/rpc/bot_verification_notify.go`), which:
- drops the cached peer projections for the peer, and for a channel also the
`channelFull` bot-info cache that carries `botInfo.verifier_settings`;
- for a user or bot, reuses `NotifyUserModerationFlagsChanged` — the audience-wide,
non-PTS `updateUser` fan-out the scam/fake flags use, filtered to online
sessions, with the peer re-projected per recipient;
- for a channel, reuses `NotifyChannelChanged``updateChannel` plus the refreshed
`channel#d49f34c6` object to members (and a linked monoforum when there is one);
- is a no-op on a nil receiver and reports an error rather than panicking. A push
failure never invalidates the committed decision.
8. **Applicant notification.** `@verifierbot` messages the applicant with the
outcome (`SendVerificationDecision`). `internal_note` is never rendered there
under any status — only `decision_reason` reaches the applicant.
9. **What the client sees.** The icon appears before the name in the dialog list,
search results, message headers and the profile, and the description appears in the
profile. `bots.setCustomVerification` returns `BoolTrue` for every successful
application, including an idempotent re-apply or revoke; official clients
treat `BoolFalse` as failure.
## Where the mark surfaces
| Surface | Method | Field |
| --- | --- | --- |
| Dialog list, search, history, difference | `messages.getDialogs`, `contacts.search`, `contacts.resolveUsername`, `messages.getHistory`, `updates.getDifference`, … | `user.bot_verification_icon`, `channel.bot_verification_icon` |
| User profile | `users.getFullUser` | `userFull.bot_verification` |
| Channel / supergroup info | `channels.getFullChannel` | `channelFull.bot_verification` |
| Invite preview (non-member) | `messages.checkChatInvite` | `chatInvite.bot_verification` |
| Verifier bot's own profile | `users.getFullUser`, `channels.getFullChannel` bot list | `botInfo.verifier_settings` |
| Live updates | pushed `updates` envelopes | `updateUser` / `updateChannel` plus the peer object |
The icon overlay runs at the **response boundary**, not inside `tgUser`/`tgChannel`:
`applyPeerReadModels` (`internal/rpc/story_peer_projection.go`) is the single hook
every handler funnels through, so all ~40 call sites get the field with one batched
read per response instead of an N+1 per peer. The `userFull` / `channelFull` /
`chatInvite` variants are post-cache overlays for the same reason — a cached full
object is still stamped with the current mark. A nil service or any read error leaves
every flag unset, which is byte-identical to the pre-feature wire shape.
## Migrations
- **`0155_bot_verification`** creates the four tables:
- `verification_icons` — the catalogue. `document_id` is unique and positive,
`owner_bot_id = 0` means shared, `active` retires an entry without deleting it.
- `bot_verifier_settings` — verifier status, keyed by `bot_id` with an optimistic
`version`, `enabled` as the per-verifier kill switch, and the operator's
`granted_by` / `grant_reason` for the audit trail.
- `custom_verifications` — granted marks. `UNIQUE (peer_type, peer_id)`
matches the single `BotVerification` value on the wire: a different verifier
replaces the current mark rather than leaving hidden fallback rows. It also has
`peer_type IN ('user','channel')`,
`icon_document_id` denormalised from the verifier, `ON DELETE CASCADE` from the
verifier row.
- `custom_verification_requests` — the review queue. `status IN ('pending','approved','rejected','revoked')`,
a partial unique index for one `pending` row per (verifier, peer), and check
constraints that pair each stamp with its status
(`(status = 'approved') = (approved_at IS NOT NULL)`) and refuse a rejection
without a reason.
- **`0156_verifier_service_bot`** seeds `@verifierbot` (id `1250000013`, fixed
`access_hash` double-written with `domain.VerifierBotAccessHash`), its `bots` row
and command list, and its `peer_usernames` registry entry, so the handle is occupied
from the moment the schema is current. `verified = false` on purpose: a third-party
verifier wearing the platform checkmark would blur the exact distinction it has to
explain to every applicant. The seed grants **no** verifier status — an operator
does that by hand in the panel.
Neither migration adds read-model triggers: the marks are read live at the response
boundary rather than cached in a peer read model.
## Configuration
Third-party verification (`internal/config/config.go`, `.env.example`):
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_BOT_VERIFICATION_ENABLED` | `true` | Master switch. When off, every third-party mutation is refused (grants, revocations, applications, catalogue edits) while marks already granted keep rendering — blanking one verifier's badges is what its per-verifier kill switch is for. |
| `TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` | `10000` | Peers one verifier may mark. `0` disables the service bound and leaves only the storage bound (`domain.MaxCustomVerificationsPerVerifier`), which is also the maximum this key accepts. |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT` | `5` | Applications one applicant may file per window, across all verifier bots. `0` disables the budget. Looser than the official `3` on purpose: a deployment can run several verifier companies, and filing with a second one is not a retry of the first. |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW` | `24h` | That window. A positive limit requires a positive window. |
Operator access:
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_ADMIN_UI_PERMISSIONS` | `*` | Permissions of an Admin UI session. Reading the section and deciding applications needs `botverification.review`; the verifier roster, the icon catalogue and stripping a granted mark need `botverification.manage`. |
| `TELESRV_ADMIN_SCOPED_TOKENS` | *(empty)* | `name:token:perm1,perm2` entries separated by `;`, for integrations that should get `botverification.review` and nothing else. |
The two rights are independent of the official ones: a reviewer may hold
`verification.review` without `botverification.review`, and vice versa. The panel
hides the nav entry, gates the route and hides the manage-only buttons accordingly;
every route is checked again server-side.
## Manual check
### Telegram Desktop
1. In the panel, open *Third-party verification → Icon catalogue* and add a custom
emoji document id. The *Emoji* section lists the documents this deployment holds
with their ids; pick one that a client can actually fetch.
2. Open *Verifiers*, grant `@verifierbot` verifier status with that icon, a company
name (say `Acme Verification Ltd`), a default description
(`Verified by Acme`) and `can_modify_custom_description` off for the first pass.
Confirm the audit entry appeared.
3. Log in to the deployment with official Telegram Desktop and open `@verifierbot`.
Its profile now carries a **"verified by" block** built from
`botInfo.verifier_settings` — the company and the icon — while its name has **no**
platform checkmark. That contrast is the point.
4. Send `/verify`, pick one of your channels from the inline picker, state a reason,
confirm. `/status` lists the application as pending.
5. In the panel open the queue, open the application, read the *Description the mark
would carry* preview (with `can_modify_custom_description` off it shows the
verifier default, not what you asked for), and approve.
6. Within a moment `@verifierbot` messages you the decision.
7. Without restarting the client, check the icon on the approved channel:
- **Profile** — the icon sits immediately **before** the title, and the
description line ("Verified by Acme") appears in the profile body
(`channelFull.bot_verification`).
- **Dialog list** — the chat row shows the icon before the title.
- **Message header** — open the chat; the header title carries the icon.
- **Search** — type the `@username` in global search; the result row carries it.
- **Invite preview** — from a second account that is **not** a member, open an
invite link to that channel: the join box carries the icon
(`chatInvite.bot_verification`).
8. If the peer also holds the platform checkmark, both are visible at once: the
custom icon before the name, the checkmark after it.
9. In the panel, revoke from the application's danger zone (or *Granted marks →
Remove mark*). The icon disappears from all of those surfaces on the next push or
read, and the platform checkmark stays untouched.
10. To check the invisible-badge failure mode on purpose, retire the icon and grant
a verifier a catalogue entry whose document was deleted: the peer is marked in
the database and the client draws nothing. That is why the catalogue validates
documents up front.
### Telegram Android
1. Log in with the official Android client, force-close it and reopen it after the
grant so the profile cache is cold.
2. `@verifierbot` profile: the verifier block ("verified by *company*" with the
icon) is rendered under the bot's info, and the bot's name has no checkmark.
3. Approve an application for a **user account** (your own) and open that account's
profile from a second device: the icon is drawn before the name in the profile
header and in the chat header, and the description is a line in the profile
(`userFull.bot_verification`).
4. Chat list and global search rows carry the icon before the name
(`user.bot_verification_icon`).
5. Custom emoji rendering follows the client's animated-emoji setting: with animated
emoji disabled the icon shows as a static frame, and while the document is still
being fetched the slot is briefly empty. Neither is a server-side problem.
6. Revoke from the panel and pull-to-refresh the profile: the icon is gone.
## Limitations
- **A verifier can mark a peer that never asked.** `bots.setCustomVerification`
authorises the *caller* (the bot itself, or a user who owns it) and the *verifier
status*, not the target's consent. Verifier status is the trust boundary; that is
why granting it is an operator-only action, why it has a kill switch, and why
`TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` bounds it. A peer cannot refuse or
remove a mark itself — only the verifier (`/revoke` in the bot dialog, or the RPC
with `enabled` unset) or an operator can.
- **No per-application event history.** Unlike official verification, there is no
`*_events` table: an application keeps only `decided_by`, `decision_reason`,
`internal_note` and its stamps. The full trail lives in the shared
`admin_commands` / `admin_audit_logs` journal, so the panel's decision page shows a
decision, not a timeline.
- **A revocation clears `approved_at`.** `0155` pairs each stamp with its status, so
leaving the approved state nulls the approval stamp. After a revoke, "when was this
approved?" can only be answered from the audit journal.
- **Applicant notifications are best-effort.** They are sent directly by
`@verifierbot` after the decision commits, not through a durable outbox like the
official flow's `verification_notification_outbox`. A delivery failure is logged
and swallowed (`notifyApplicant`); the decision itself stands, and nothing retries
the message, so an applicant can end up with a decided application they were never
told about.
- **Only users and channels can be marked.** `peer_type` is constrained to
`user`/`channel`, matching the TL surface: legacy basic groups (`chat#…`) have no
`bot_verification` field in Layer 228, so a non-migrated basic group can never
show a mark.
- **Only bots can be verifiers, and system bots cannot** — except the built-in
`@verifierbot`. `botInfo.verifier_settings` exists only on a bot, so a user account
granted verifier status would carry a status no client can see; seeded service
accounts are refused outright (`@verifybot` in particular, which owns the *other*
mechanism).
- **One icon per verifier, one mark per peer.** A verifier cannot vary
its icon per peer, there are no verification tiers, and no expiry: a mark lives
until somebody removes it. Nothing re-validates a marked peer over time — losing
its username or picking up a scam flag later does not clear the mark.
- **A new verifier replaces the current peer mark.** `user.bot_verification_icon`
and `channel.bot_verification_icon` are single `long` fields, so the database
stores one matching mark. Replacing it cannot leave an older hidden mark that
unexpectedly reappears after a revoke or kill-switch change.
- **A retired icon keeps rendering on existing marks.** Retiring a catalogue entry
only blocks new grants, because the mark copied the document id at grant time.
Blanking an already-granted icon means revoking the marks (or the verifier).
- **An unresolvable document is an invisible badge.** The catalogue validates the
document when the entry is written, not continuously. A document deleted afterwards
leaves marks that render as nothing, and the server has no way to notice.
- **The live push only reaches online sessions**, and the user fan-out is bounded by
the same capped moderation audience the scam/fake flags use. Everybody else
converges on their next authoritative read, which is always correct: the icon is an
overlay read live at the response boundary rather than a cached read-model column.
- **`updateUser` / `updateChannel` carry no `pts`.** They are not persisted as
message-box events, so a session that was offline during the decision never replays
the push; it picks the mark up as *state* on its next read, not as an *event*.
- **`channelFull`'s bot-info cache is per process.** `NotifyPeerBotVerification`
drops it on the instance that handled the decision. On other instances a cached
`channelFull.bot_info` can still carry a stale `verifier_settings` block (the
company/icon *of the verifier bot*, not the peer's mark) until that entry expires.
The peer's own `bot_verification` fields are overlaid post-cache and are not
affected.
- **`TELESRV_BOT_VERIFICATION_ENABLED=false` is not a badge switch.** It refuses new
mutations; the marks already granted keep being projected. Use the per-verifier kill
switch, or revoke, to actually clear badges.

View file

@ -60,6 +60,8 @@ This document describes every setting loaded by `internal/config`. Defaults and
| `TELESRV_ADMIN_UI_PASSWORD` | secret string / empty | Admin UI login password. Configure this or `TELESRV_ADMIN_UI_TOKEN`. |
| `TELESRV_ADMIN_UI_TOKEN` | secret string / empty | Alternative Admin UI login credential. Admin write calls still use the separate `TELESRV_ADMIN_API_TOKEN`. |
| `TELESRV_ADMIN_SESSION_KEY` | secret string / empty | Encrypts/signs Admin UI session cookies. Production should use at least 32 random bytes; changing it invalidates sessions. |
| `TELESRV_ADMIN_UI_PERMISSIONS` | comma-separated list / `*` | Permissions granted to an Admin UI session authenticated with `TELESRV_ADMIN_UI_PASSWORD` / `_TOKEN`. `*` grants every permission and is the default, so enabling RBAC never locks an operator out of a panel that worked before. Names use letters, digits and `._:-`, at most 64 characters, and may end in `namespace.*` to grant a whole namespace. An empty list or an unparsable name fails startup. |
| `TELESRV_ADMIN_SCOPED_TOKENS` | `name:token:perm1,perm2` entries separated by `;` / empty | Additional Admin API bearer tokens carrying a bounded permission set each, so an integration gets exactly the rights it needs instead of the unrestricted `TELESRV_ADMIN_API_TOKEN`. A token may contain neither `:` nor whitespace, every entry must list at least one permission, names and tokens must be unique, and reusing `TELESRV_ADMIN_API_TOKEN` as a scoped token is refused because it would silently widen it to every permission. Any malformed entry fails startup rather than silently granting or dropping rights. |
| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | Client-visible canonical public-link root. Paths are allowed; credentials, query, and fragment are rejected. Local example: `http://127.0.0.1:2401`. |
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. |
| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / empty | Optional host-based root for multi-server clients, for example `owpg://example.com`. When set, links use `owpg://example.com/oauth`, `owpg://example.com/<username>`, and equivalent route paths. Only exact `<custom-scheme>://<host>` values are accepted; ports, paths, queries, and fragments are rejected. `TELESRV_PUBLIC_APP_SCHEME` remains an accepted legacy input. |
@ -540,6 +542,116 @@ path. `TELESRV_PUBLIC_BASE_URL` must resolve to that proxy for moderation freeze
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | Delay snapshotted into `can_craft_at`. |
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | Per-input local craft success contribution, capped at 1000 permille. |
### Composite account rating and collectible usernames
The account rating is a server-local admin score combining Stars received and spent, bounded account activity, and
moderation penalties. It is intentionally **not** projected into Telegram's `userFull.stars_rating` or
`stars_my_pending_rating`: those fields represent official Stars transaction-volume semantics, which this composite
does not implement. Every component is stored separately so operators can explain and reproduce a level. Collectible
(NFT) usernames are minted by the operator; no external marketplace, wallet or chain node is configured or contacted.
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_RATING_ENABLED` | bool / `true` | Enables the local admin composite rating. Disabled refuses rating writes; client-facing Telegram rating fields remain unset in either mode. |
| `TELESRV_RATING_PENDING_DELAY` | duration / `24h` | How long a local rating increase stays pending before it becomes the visible admin level. A decrease is always applied immediately, so a penalty is never delayed. `0` applies every change at once; must be `0..720h`. |
| `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | Background recompute worker interval; must be positive. |
| `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | Stale projections recomputed per cycle; must be `1..10000`. |
| `TELESRV_RATING_STALE_AFTER` | duration / `6h` | Projection age after which the worker recomputes a user; must be positive. |
| `TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE` | int64 / `1000` | Weight of Stars credited to the account (gifts, reactions, paid messages received), in permille of the raw amount. |
| `TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE` | int64 / `250` | Weight of Stars the account spent, in permille. Spending is a weaker signal than receiving. |
| `TELESRV_RATING_WEIGHT_MESSAGE_SENT` | int64 / `1` | Score per sent message. |
| `TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY` | int64 / `2` | Score per day of account age. |
| `TELESRV_RATING_WEIGHT_GIFT_RECEIVED` | int64 / `25` | Score per collectible gift held. |
| `TELESRV_RATING_WEIGHT_MODERATION_CASE` | int64 / `150` | Penalty magnitude per upheld moderation case; the formula subtracts it. |
| `TELESRV_RATING_WEIGHT_SCAM_PENALTY` | int64 / `5000` | Flat penalty magnitude for the scam flag. |
| `TELESRV_RATING_WEIGHT_FAKE_PENALTY` | int64 / `5000` | Flat penalty magnitude for the fake flag. |
| `TELESRV_RATING_ACTIVITY_CAP` | int64 / `5000` | Upper bound of the activity component so activity alone cannot outweigh Stars and moderation; `0` leaves it uncapped. |
| `TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE` | URL template / empty | Landing URL recorded on a minted collectible username when the mint command carries no explicit URL. Empty derives `<TELESRV_PUBLIC_BASE_URL>/nft/username/<username>`. A configured template must be an absolute http(s) URL without userinfo; it may carry the `{username}` placeholder, and without it the name is appended as the last path segment. |
All rating weights are non-negative magnitudes and are validated even when the feature is disabled, so enabling it
later is not the moment a typo is discovered. The defaults above are exactly the shipped domain formula, so behaviour
is identical whether or not these keys are set. The final score is clamped at zero: penalties can erase a rating but
never invert it.
**Collectible username prices are stored in the smallest units of their currency**, because that is what
`fragment.collectibleInfo` carries: `amount` is "the total price in the smallest units of the currency (integer, not
float/double)" and `crypto_amount` likewise. So `USD 1000` is ten dollars, `TON 900` is 900 nanotons, and `XTR 1000` is
a thousand Stars, since Stars have no subunit. Clients divide by that exponent before drawing the price. The admin panel
is the conversion boundary: prices are typed and displayed there in whole currency units and converted on the way to the
API, so an operator never has to count zeros. An integration writing to `/api/actions/mint-collectible-username`
directly is talking to the API, not the panel, and must send smallest units itself.
### Official platform verification
Official verification is the platform badge (`user.verified` / `channel.verified`): an application is filed through the
built-in `@verifybot`, decided in the admin panel, and an approval flips that one flag on that one peer record. It is
deliberately not the third-party `botVerification` icon, where an outside organisation attaches its own mark. The
application row is the durable audit subject and is never deleted, only moved through its status machine.
Every eligibility check runs twice: once when the application is filed and again, against a freshly loaded snapshot, at
the moment of approval. A target must exist, carry a public username, be controlled by the applicant (bot owner, or
channel creator/administrator), not already be verified, not be deleted/frozen/scam/fake, and not be a built-in system
entity. A target that changed between submission and review is refused at the second gate, so the review queue cannot
be used to grant a state the submission path forbids. The flag itself is written inside the decision transaction, so
"approved" and "target verified" commit together.
Submitted links (website, social, press) are validated as plain http(s) URLs to public hosts: credentials, non-web
schemes, non-standard ports, loopback, link-local, private and other reserved address space are all refused. **The
server never fetches a submitted link** — not at submission, not during review, not from the admin panel. That is a
deliberate anti-SSRF decision, and validation is the only thing ever done to an applicant-controlled URL.
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_VERIFICATION_ENABLED` | bool / `true` | Enables official verification. Disabled refuses every verification use case explicitly; peers already carrying the badge keep it, because the flag lives on the peer record. |
| `TELESRV_VERIFICATION_ALLOW_USER_TARGETS` | bool / `false` | Accepts plain user accounts as verification subjects. Off by default: the official process verifies a public presence (bot, public channel, public supergroup), and a private account has nothing to check. |
| `TELESRV_VERIFICATION_REJECT_COOLDOWN` | duration / `720h` | Wait imposed on an applicant/target pair after a rejection, measured from the decision so a slow review never shortens it. `0` disables it; must be `0..8760h`. |
| `TELESRV_VERIFICATION_APPLY_RATE_LIMIT` | int / `3` | Applications one applicant may create per window. `0` disables the budget; must be non-negative. |
| `TELESRV_VERIFICATION_APPLY_RATE_WINDOW` | duration / `24h` | Window for the creation budget. Must be positive whenever the limit is set: a positive limit with a zero window is a limiter that never refills. |
| `TELESRV_VERIFICATION_BOT_RATE_LIMIT` | int / `30` | `@verifybot` dialog rate per applicant, independent of how many applications are actually created. `0` disables it. |
| `TELESRV_VERIFICATION_BOT_RATE_WINDOW` | duration / `1m` | Window for the bot dialog rate; must be positive whenever that limit is set. |
| `TELESRV_VERIFICATION_NOTIFY_INTERVAL` | duration / `15s` | Applicant-notification worker interval; must be positive. A decision commits with its outbox row, never with a message send, so delivery is a separate retrying cycle over durable rows. |
| `TELESRV_VERIFICATION_NOTIFY_BATCH` | int / `50` | Outbox rows delivered per cycle; must be `1..500`. |
| `TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER` | int / `3` | Applications one applicant may keep open (draft, submitted, in review) at once. `0` disables the cap; must be `0..50`. |
The defaults ship the feature on with the official bar in place, so no existing deployment changes behaviour: user
accounts are not accepted, a rejection costs a month, and an applicant can neither flood the queue nor keep an
unbounded number of applications open. Every value is validated even when the feature is disabled, so enabling it
later is not the moment a typo is discovered.
### Third-party bot verification
Third-party verification is the other badge (`botVerification`, projected onto `user.bot_verification_icon`,
`channel.bot_verification_icon`, `userFull.bot_verification`, `channelFull.bot_verification`,
`chatInvite.bot_verification`, and advertised by `botInfo.verifier_settings`): an outside organisation running a
**verifier bot** marks peers with its own icon and description. The operator grants verifier status to a bot; the bot
then applies its mark through `bots.setCustomVerification`, or through the application queue its own dialog drives. It is
never a second route to the platform checkmark above, and the two mechanisms never read or write each other's state.
The icon is a custom emoji **document id**, and clients resolve it through `messages.getCustomEmojiDocuments`. An id
that names no fetchable custom emoji document therefore renders as *nothing at all*: the badge is invisible, the peer
looks unverified, and the only place the mark exists is the database. That is why the icon catalogue is operator-curated
and validated against real documents before anything is written — both when an entry is added and when a verifier is
granted an icon.
Every mutation re-derives "may this bot verify right now?" from the stored verifier row, so the per-verifier kill switch
takes effect immediately on the RPC path *and* on the review path: an application approved after the switch was flipped
is refused rather than granted. A verifier bot may only be driven by itself or by its owner, and an applicant may only
file for a peer it controls (bot owner, channel creator, or an administrator carrying `change_info`). Approvals and
revocations move the mark inside the store's decision transaction, so an approved application can never exist without
its mark.
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_BOT_VERIFICATION_ENABLED` | bool / `true` | Enables third-party bot verification. Disabled refuses every mutation explicitly (grants, revocations, applications, catalogue edits) while marks already granted keep projecting: blanking one verifier's badges is what its per-verifier kill switch is for. A deployment that wants the pre-feature wire shape leaves the service unwired instead. |
| `TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` | int / `10000` | Peers one verifier bot may mark. Verifier status is granted per deployment rather than earned per peer, so an unbounded verifier would be an unbounded badge printer. Spent only on a *new* mark — an existing one stays re-describable at the bound. `0` disables the service bound and leaves only the storage bound, which is also the maximum accepted (`10000`). |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT` | int / `5` | Verification applications one applicant may file per window, across all verifier bots. Spent last among the creation checks, so a refused application costs no budget. `0` disables it; must be non-negative. |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW` | duration / `24h` | Window for the application budget. Must be positive whenever the limit is set: a positive limit with a zero window is a limiter that never refills. |
The application budget is deliberately looser than the official one (`TELESRV_VERIFICATION_APPLY_RATE_LIMIT=3`): a
deployment can run several verifier companies, and filing with a second one is not a retry of the first. Both keys are
validated even when the feature is disabled, and the per-verifier bound is checked against the storage bound, so a key
that cannot do what it says fails startup instead of being silently unreachable.
## 11. Private calls, group calls, TURN, SFU, and livestream
| Setting | Type / code default | Description and constraints |

View file

@ -517,6 +517,58 @@ active key。不要手工编辑 manifest 或 PEM不要在各实例上分别
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | 签发时固化到 `can_craft_at` 的等待期;可 Craft 礼物即使为 `0s` 也写升级时间这一正数能力边界0 只表示不具备 Craft 能力或已终结。 |
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | 每份输入礼物贡献的本地合成成功概率,累计上限 1000‰。 |
### 本地账号评分与 collectible username
账号评分是供管理后台使用的本地风控/信誉复合分,组合 Stars 收支、账号活跃和管理处罚。它**不会**投影到 Telegram 的 `userFull.stars_rating` / `stars_my_pending_rating`:官方字段表达 Stars 交易量当前复合公式不具备同等语义。Collectible username 由管理员签发,本功能不访问外部市场、钱包或区块链节点。
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_RATING_ENABLED` | bool / `true` | 启用本地后台复合评分;关闭时拒绝评分写入,两种模式都不设置客户端官方 Stars Rating 字段。 |
| `TELESRV_RATING_PENDING_DELAY` | duration / `24h` | 本地评分上涨进入可见后台等级前的等待期;下降立即生效。允许 `0..720h``0` 表示立即应用。 |
| `TELESRV_RATING_RECOMPUTE_INTERVAL` | duration / `15m` | 后台重算周期,必须为正数。 |
| `TELESRV_RATING_RECOMPUTE_BATCH` | int / `500` | 每轮重算的 stale projection 数,必须为 `1..10000`。 |
| `TELESRV_RATING_STALE_AFTER` | duration / `6h` | 超过该年龄的评分进入重算,必须为正数。 |
| `TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE` | int64 / `1000` | Stars 收入权重(千分比)。 |
| `TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE` | int64 / `250` | Stars 支出权重(千分比)。 |
| `TELESRV_RATING_WEIGHT_MESSAGE_SENT` | int64 / `1` | 每条已发送消息贡献分。 |
| `TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY` | int64 / `2` | 每个账号存续日贡献分。 |
| `TELESRV_RATING_WEIGHT_GIFT_RECEIVED` | int64 / `25` | 每份持有 collectible gift 贡献分。 |
| `TELESRV_RATING_WEIGHT_MODERATION_CASE` | int64 / `150` | 每个成立管理案件的扣分幅度。 |
| `TELESRV_RATING_WEIGHT_SCAM_PENALTY` | int64 / `5000` | scam 标记固定扣分。 |
| `TELESRV_RATING_WEIGHT_FAKE_PENALTY` | int64 / `5000` | fake 标记固定扣分。 |
| `TELESRV_RATING_ACTIVITY_CAP` | int64 / `5000` | 活跃分上限;`0` 表示不封顶。 |
| `TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE` | URL template / 空 | 管理员未显式给 URL 时写入资产的落地页模板;空值派生 `<TELESRV_PUBLIC_BASE_URL>/nft/username/<username>`。配置值须为无 userinfo 的绝对 http(s) URL可含 `{username}`;无占位符时把 username 追加为最后路径段。 |
`fragment.collectibleInfo.amount``crypto_amount` 使用币种最小单位:例如 USD 1000 表示 10 美元TON 900 表示 900 nanotonsXTR 无子单位。后台 UI 负责整币单位与最小单位转换;直接调用 Admin API 的集成必须自行传最小单位。
### 官方平台认证
官方认证对应 `user.verified` / `channel.verified`。申请由内置 `@verifybot` 收集、管理后台审核;提交和批准时都会重新校验目标存在、公开 username、申请者控制权、账号状态与系统账号禁入badge 写入和决定状态在同一事务提交。申请 URL 只做 public http(s) 形状校验,服务端不会抓取,避免 SSRF。
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_VERIFICATION_ENABLED` | bool / `true` | 启用官方认证;关闭时拒绝新操作,既有 badge 保留。 |
| `TELESRV_VERIFICATION_ALLOW_USER_TARGETS` | bool / `false` | 是否允许普通用户账号成为认证目标。 |
| `TELESRV_VERIFICATION_REJECT_COOLDOWN` | duration / `720h` | 同申请者/目标被拒后的等待期;允许 `0..8760h`。 |
| `TELESRV_VERIFICATION_APPLY_RATE_LIMIT` | int / `3` | 每个申请者在窗口内可新建的申请数;`0` 关闭。 |
| `TELESRV_VERIFICATION_APPLY_RATE_WINDOW` | duration / `24h` | 申请预算窗口limit>0 时必须为正数。 |
| `TELESRV_VERIFICATION_BOT_RATE_LIMIT` | int / `30` | `@verifybot` 每个申请者的对话限流;`0` 关闭。 |
| `TELESRV_VERIFICATION_BOT_RATE_WINDOW` | duration / `1m` | bot 对话限流窗口limit>0 时必须为正数。 |
| `TELESRV_VERIFICATION_NOTIFY_INTERVAL` | duration / `15s` | durable 通知 outbox worker 周期,必须为正数。 |
| `TELESRV_VERIFICATION_NOTIFY_BATCH` | int / `50` | 每轮投递通知数,必须为 `1..500`。 |
| `TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER` | int / `3` | 每个申请者可保持的 active 申请数;允许 `0..50`。 |
### 第三方 bot 认证
第三方认证对应 `botVerification`,由管理员授权的 verifier bot 使用自己的 custom emoji document 与描述标记 user/channel不会授予官方 checkmark。Icon 必须是客户端可通过 `messages.getCustomEmojiDocuments` 读取的真实 document。每个 peer 只保留一个 wire-visible mark新的 verifier 替换旧 mark禁止旧 badge 在撤销或 kill switch 后意外复活。客户端自定义描述上限通过 appConfig `bot_verification_description_length_limit=70` 发布。
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_BOT_VERIFICATION_ENABLED` | bool / `true` | 启用第三方认证;关闭时拒绝新 mutation既有 mark 仍投影。 |
| `TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER` | int / `10000` | 单 verifier 可标记的 peer 数;`0` 仅关闭 service capstorage 硬上限仍为 10000。 |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT` | int / `5` | 每个申请者跨 verifier 的申请预算;`0` 关闭。 |
| `TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW` | duration / `24h` | 第三方认证申请预算窗口limit>0 时必须为正数。 |
## 11. 私聊通话、群通话、TURN、SFU 与直播
| 参数 | 类型 / 代码默认值 | 说明与约束 |

291
docs/verification.md Normal file
View file

@ -0,0 +1,291 @@
# Official platform verification
Official verification is the platform badge shown next to the name of a bot,
public channel or public supergroup whose identity a human reviewer has
confirmed. In this server it is exactly one boolean on one peer record:
`users.verified` or `channels.verified`. Nothing else about the peer changes.
Applications are filed through the built-in `@verifybot`, decided in the admin
panel, and the decision commits together with the flag write. The protocol edge
then makes the new flag observable: it drops the cached peer projections and
pushes the ordinary peer-refresh update to online clients.
## What this is not
The badge here is the **platform** flag: `user#b1b8cc83 verified:flags.17` and
`channel#d49f34c6 verified:flags.7`.
Telegram also has a second, unrelated mechanism — **third-party bot
verification** — where an ordinary bot that a platform operator has appointed as
a "verifier" attaches its own custom icon and description to arbitrary peers.
That is `botVerification#f93cd45c`, `botVerifierSettings#b0cd6617`,
`bots.setCustomVerification#8b89dfbd`, `user.bot_verification_icon:flags2.14?long`,
`channel.bot_verification_icon:flags2.13?long` and
`channelFull.bot_verification:flags2.17?BotVerification`.
The two are deliberately kept apart:
- an approval in this flow never writes `bot_verification*`, and never issues
`botVerifierSettings` to anybody;
- `bots.setCustomVerification` is routed and argument-checked at the RPC edge and
then refused with `403 BOT_VERIFIER_FORBIDDEN`
(`internal/rpc/bots_longtail.go`), because no bot on this deployment is a
verifier;
- a client that renders `bot_verification_icon` renders it from data this flow
never produces, so a third-party icon can neither stand in for the platform
badge nor be shadowed by it.
## TL constructors and flags (Layer 228)
Checked against the schema snapshot the server is built for,
`/tmp/td/_schema/layers/layer-228.tl`.
Platform verification:
| Constructor | Field |
| --- | --- |
| `user#b1b8cc83` | `verified:flags.17?true` |
| `channel#d49f34c6` | `verified:flags.7?true` |
| `chatInvite#5c9d3702` | `verified:flags.7?true`, `scam:flags.8?true`, `fake:flags.9?true` |
`chatInviteAlready#5a686d7c` carries a whole `Chat`, so a member's preview gets
the badge through `channel#d49f34c6` rather than through invite-level flags.
Third-party bot verification, for contrast — none of these are written by this
flow:
| Constructor / method | Field |
| --- | --- |
| `botVerification#f93cd45c` | `bot_id:long icon:long description:string` |
| `botVerifierSettings#b0cd6617` | `icon:long company:string custom_description:flags.0?string` |
| `bots.setCustomVerification#8b89dfbd` | `enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string` |
| `user#b1b8cc83` | `bot_verification_icon:flags2.14?long` |
| `channel#d49f34c6` | `bot_verification_icon:flags2.13?long` |
| `channelFull#a04e8d3a` | `bot_verification:flags2.17?BotVerification` |
`chatInvite#5c9d3702` also has `bot_verification:flags.13?BotVerification`; it is
never populated, for the same reason.
## End-to-end path
1. **`@verifybot`** (user id `1250000011`, seeded by migration `0152`) collects
the application in a step-by-step dialog: subject, category, description,
official website, optional social links, independent press links, optional
note. Commands are `/new`, `/status`, `/cancel`, `/help`.
2. **Eligibility, first gate.** The subject must be a bot, public channel or
public supergroup with a public `@username`, created or administered by the
applicant, not a built-in system entity, not already verified, and not
scam/fake/frozen/deleted. Per-applicant rate limits, an open-application cap
and a post-rejection cooldown also apply.
3. **Submission** writes `verification_applications` (status `submitted`) and an
immutable `verification_application_events` row.
4. **Admin panel** lists the queue and lets a reviewer claim, approve or reject.
Panel BFF routes are under `/api/verification/...`; the Admin API routes are
`GET /v1/verification/applications`, `.../{id}`, `.../counts`,
`POST .../{id}/claim|approve|reject` and `POST /v1/verification/revoke`.
Every decision also goes through the shared admin command journal, so it lands
in `admin_commands` / `admin_audit_logs`.
5. **Eligibility, second gate.** At approval time the target is re-loaded and
re-evaluated against a fresh snapshot, so a target that turned scam, lost its
username, was frozen or got verified by another route between filing and
review is refused. The review queue cannot launder a state the submission path
forbids.
6. **Decision transaction.** The status transition, the audit event, the
applicant-notification outbox row and the `verified` flag write on the peer
commit in one transaction (`verification.PeerVerifier` is invoked with the
store transaction taken from the context). "Approved" and "the peer carries
the badge" can never disagree.
7. **Protocol edge.** After the commit the service calls
`rpc.Router.NotifyPeerVerified(ctx, domain.Peer)`
(`internal/rpc/verification_notify.go`), which:
- drops the cached peer projections for the target
(`invalidateRPCProjectionForUser` / `invalidateRPCProjectionForChannel`);
- for a user or bot, reuses `NotifyUserModerationFlagsChanged` — the same
audience-wide, non-PTS `updateUser` fan-out the scam/fake flags use. The
audience is `ModerationFlagAudience` (accounts that already see the peer),
filtered to the ones currently online; each recipient gets the peer
re-projected for itself;
- for a channel, reuses `NotifyChannelChanged`
`channelStateMutationUpdates``pushChannelStateToMembersWithLinkedMonoforum`,
i.e. `updateChannel` plus the refreshed `channel#d49f34c6` object to the
channel's members (and the linked monoforum when there is one);
- reports a clear error instead of panicking when the peer cannot be resolved,
and is a no-op on a nil receiver. A push failure never invalidates the
committed decision; the caller logs it and moves on.
8. **Applicant notification.** `@verifybot` messages the applicant from a durable
outbox drained by a retrying worker, never from inside the decision
transaction. Kinds are `approved`, `rejected`, `revoked`.
9. **What the client sees.** An online client applies the flag from the pushed
`User`/`Channel` object immediately: the badge appears in the dialog list,
profile, search results and message headers without a restart. An offline
client converges on reconnect — see below.
## Offline convergence
`updateUser` and `updateChannel` carry no `pts`, so they are not stored as
message-box events and are not replayed by `updates.getDifference`. Offline
sessions converge because `verified` is part of the peer's **base read model**,
whose version is bumped by the triggers shipped in `0001_init`:
- `users.verified` is listed in `telesrv_notify_user_base_read_model` (trigger
`users_read_model_changed`), which bumps `user_base`, `contact_account` and the
private dialog-light models, and in
`telesrv_notify_user_channel_participants_read_model` (trigger
`users_channel_participants_read_model_changed`), which bumps
`channel_participants`;
- `channels` bumps `channel_base` on every row change (trigger
`channels_read_model_changed`) and additionally fires
`pg_notify('telesrv_channel_changed')`.
The `user_base` notification is consumed by the read-model listener
(`internal/store/postgres/read_model_listener.go`), which invalidates the RPC
projections **and** the shared Redis `user:base` row across instances. So any
later authoritative read — `users.getUsers`, `users.getFullUser`,
`channels.getChannels`, `channels.getFullChannel`, `messages.getDialogs`, or the
`users`/`chats` vectors attached to a `getDifference` answer — already carries the
new flag. No migration is needed for this, and none was added.
## Where the flag surfaces
`verified` is projected wherever a `User` or `Channel` object is projected, which
is every one of these:
| Surface | Method | Constructor |
| --- | --- | --- |
| Dialog list | `messages.getDialogs`, `messages.getPeerDialogs` | `user`, `channel` in `users`/`chats` |
| Search | `contacts.search`, `contacts.resolveUsername`, `messages.searchGlobal`, `channels.getAdminedPublicChannels` | `user`, `channel` |
| Profile | `users.getUsers`, `users.getFullUser` | `user` in `users.userFull.users` |
| Channel info | `channels.getChannels`, `channels.getFullChannel` | `channel` in `chats` |
| Message history | `messages.getHistory`, `messages.getMessages`, channel history | `user`, `channel` in `users`/`chats` |
| Invite preview | `messages.checkChatInvite` | `chatInvite` (`verified:flags.7`) or `chatInviteAlready.chat` |
| Live updates | pushed `updates` envelopes | `updateUser` / `updateChannel` plus the peer object |
| Difference | `updates.getDifference`, `updates.getChannelDifference` | `user`, `channel` in `users`/`chats` |
The invite preview is the one that used to be missing: before, a non-member saw
an unbadged preview and the badge only appeared after joining. It is now set from
the persistent channel record in `internal/rpc/channels_invites.go`, through the
generated `Set*` helpers so the `flags` word and the struct field stay in step,
and left entirely unset for an unflagged peer.
## Migrations
- **`0153_verify_service_bot`** seeds `@verifybot` (id `1250000011`, fixed
`access_hash` double-written with `domain.VerifyBotAccessHash`), its `bots` row
and command list, and its `peer_usernames` registry entry. The handle is
occupied from the moment the schema is current, so an ordinary user cannot claim
`@verifybot` in the window before first use.
- **`0154_verification_applications`** creates
`verification_applications` (the durable audit subject, never deleted, moved
through `draft → submitted → in_review → approved|rejected|cancelled` under an
optimistic-locking `version`), the append-only
`verification_application_events` history, and
`verification_notification_outbox` with
`UNIQUE (application_id, kind)` so a repeated approve delivers one message.
Partial unique indexes enforce one live application per target and one draft per
applicant.
Neither migration touches the `verified` columns or the read-model triggers:
`users.verified` and `channels.verified` already existed and were already covered.
## Configuration
Verification (`internal/config/config.go`, `.env.example`):
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_VERIFICATION_ENABLED` | `true` | Master switch. When off every use case refuses explicitly; peers already badged keep the badge. |
| `TELESRV_VERIFICATION_ALLOW_USER_TARGETS` | `false` | Whether plain user accounts may be subjects. |
| `TELESRV_VERIFICATION_REJECT_COOLDOWN` | `720h` | Wait before re-filing the same target after a rejection, measured from the decision. `0` disables; max `8760h`. |
| `TELESRV_VERIFICATION_APPLY_RATE_LIMIT` | `3` | Applications one applicant may create per window. `0` disables. |
| `TELESRV_VERIFICATION_APPLY_RATE_WINDOW` | `24h` | That window. |
| `TELESRV_VERIFICATION_BOT_RATE_LIMIT` | `30` | `@verifybot` dialog rate per applicant, independent of applications created. `0` disables. |
| `TELESRV_VERIFICATION_BOT_RATE_WINDOW` | `1m` | That window. |
| `TELESRV_VERIFICATION_NOTIFY_INTERVAL` | `15s` | Applicant-notification worker cadence. Must be positive. |
| `TELESRV_VERIFICATION_NOTIFY_BATCH` | `50` | Rows per cycle, `1..500`. |
| `TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER` | `3` | Open applications per applicant. `0` disables, max `50`. |
Reviewer access:
| Key | Default | Meaning |
| --- | --- | --- |
| `TELESRV_ADMIN_UI_PERMISSIONS` | `*` | Permissions of an Admin UI session. Reviewing needs `verification.review`; clearing an existing badge needs `verification.revoke` on top of it. |
| `TELESRV_ADMIN_SCOPED_TOKENS` | *(empty)* | `name:token:perm1,perm2` entries separated by `;`, for Admin API integrations that should get `verification.review` and nothing else. |
## Manual check with official Telegram Desktop
1. Log in to the deployment with official Telegram Desktop.
2. Open `@verifybot` — it resolves by username and its own profile already shows
the badge (the seed row is `verified`).
3. Send `/new`, pick the subject from the inline picker, and answer the steps:
category, description, official website, social links (or *Skip*), at least the
required number of independent press links, optional note. Press
*Submit application*.
4. Send `/status`; the application is listed as submitted.
5. In the admin panel open the verification queue, claim the application and
approve it. Confirm the audit entry appeared.
6. Within one notification-worker interval `@verifybot` messages the applicant
with the decision.
7. Without restarting the client, check the badge on the approved subject:
- **Profile** — open the peer's profile; the badge sits next to the title.
- **Search** — type the `@username` in global search; the result row is badged.
- **Dialog list** — the chat row in the main list is badged.
- **Message header** — open the chat; the header title is badged.
- **Invite preview** — from a *second* account that is **not** a member, open
an invite link to the approved channel. The join-confirmation box is badged
before joining. (This is the `chatInvite#5c9d3702 verified:flags.7` path.)
8. To check offline convergence, quit the client before approving, approve, then
start it again: the badge is present on the first read, delivered by
`getDifference` and the peer reads it triggers rather than by a live push.
9. Revoking from the panel takes the badge away by the same route.
## Limitations
- **Plain user accounts are off by default.** With
`TELESRV_VERIFICATION_ALLOW_USER_TARGETS=false` (the shipped default) an
application whose subject is an ordinary account is refused
(`ErrVerificationUserTargetsDisabled`). Turning it on does not add any extra
identity checks; it only stops refusing the target type.
- **Third-party bot verification is not part of this mechanism and is not
implemented.** `bots.setCustomVerification` never succeeds: it validates its
arguments and then refuses with `403 BOT_VERIFIER_FORBIDDEN`. No
`botVerifierSettings` are issued, and
`user.bot_verification_icon` / `channel.bot_verification_icon` /
`channelFull.bot_verification` / `chatInvite.bot_verification` are never
populated. A client asking for a custom verifier icon gets nothing.
- **The badge has no attributes.** It is a single boolean: no verifier company, no
custom description, no per-peer icon, no expiry, no verification tier. There is
nothing in the TL surface to carry them for the platform flag.
- **Built-in system entities cannot be applied for.** Service accounts are refused
with `ErrVerificationTargetSystem`; their badge is set by the seed migrations.
- **A subject with no public `@username` is refused**
(`ErrVerificationTargetNotPublic`), so private channels and usernameless bots
cannot be verified at all — not even by an operator using the panel's revoke
route in reverse.
- **Legacy basic groups (`chat#…`) have no `verified` field** in Layer 228, so a
non-migrated basic group can never show a badge regardless of what is stored.
- **The live push only reaches online sessions.** `NotifyPeerVerified` filters the
audience by the online index; everybody else converges on their next
authoritative read. The user fan-out is additionally bounded (the moderation
audience is capped, currently at 4096 accounts) and the channel fan-out is
bounded by the online-member index, so on a very large peer some sessions get
the flag from their next read rather than from a push.
- **`updateUser` / `updateChannel` carry no `pts`.** They are not persisted as
message-box events, so a session that was offline during the decision never
replays the push itself; it depends on the read-model bump. That is by design
(a badge change is not a message), but it means the badge is not guaranteed to
arrive as an *event* — only as *state*.
- **The live push can be one beat behind the shared base-user cache.** The
decision writes the user row inside the verification transaction, bypassing the
`users` service and therefore its Redis `user:base` refresh; that cache is
dropped cross-instance by the asynchronous `user_base` read-model
notification. `NotifyPeerVerified` runs synchronously right after commit, so in
the small window before the listener processes the event the pushed `user`
object can still carry the pre-decision flag. The persisted state is always
correct, the projections are always invalidated, and the client repairs itself
on the next read, so this shows up at worst as a badge that appears a moment
late rather than instantly. The channel path is not affected: the
transaction-scoped channel store is handed the channel row cache and drops it
on the flag write.
- **Nothing re-checks a verified peer over time.** Once badged, a peer keeps the
badge until an operator revokes it. There is no periodic re-validation, and
losing the username or picking up a scam flag later does not clear it.

View file

@ -0,0 +1,994 @@
package admin
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
)
// Third-party bot verification for the operator (core.telegram.org/api/bots/verification).
//
// This is NOT the official platform badge. The official review lives in
// verification.go behind the verification.* permissions and writes
// verification_applications / the peer's `verified` flag; this file drives the
// third-party mechanism, whose state is verification_icons,
// bot_verifier_settings, custom_verifications and custom_verification_requests,
// behind the botverification.* permissions. Neither section reads the other's
// tables, and a third-party verifier must never be able to mint a platform
// checkmark -- so nothing here ever touches domain.User.Verified.
//
// The operator surface has two halves, and they carry different rights:
//
// - Configuration (botverification.manage): who is a verifier at all, which
// icons exist, and stripping a granted mark. These are the actions that
// decide how much a mark is worth, so they are deliberately not implied by
// the right to work the queue.
// - Review (botverification.review): deciding the applications filed with a
// verifier bot.
//
// Every mutation goes through runCommand, so a decision is journalled in
// admin_commands / admin_audit_logs, replayable by command id and rehearsable
// with a dry run. A dry run never mutates and never predicts an outcome stricter
// than the real command's: where a check can only run inside the use-case layer
// (the icon's custom emoji document is resolved against the document store) the
// dry run says so in its details instead of guessing.
// BotVerificationService is the operator-facing slice of the third-party
// verification use cases. It is the exact method set *app/botverification.Service
// exposes for this surface, so the admin layer never reaches into the store.
type BotVerificationService interface {
// Icon catalogue.
Icons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
UpsertIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
SetIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
// Verifier status.
Verifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
VerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
GrantVerifier(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
SetVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
RevokeVerifier(ctx context.Context, botID int64) (bool, error)
// Granted marks.
Marks(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
RevokeMark(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
// Application queue.
Requests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
Request(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
RequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
Approve(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
Reject(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
RevokeRequest(ctx context.Context, requestID, version int64, decidedBy, reason, note string) (domain.CustomVerificationRequest, bool, error)
}
// botVerificationCatalogueScan bounds the catalogue page a dry run reads to
// pre-check an icon. The catalogue is operator-curated and small; the check is
// advisory precisely because this bound may not cover it (see grantIconPreflight).
const botVerificationCatalogueScan = 200
// ---------------------------------------------------------------------------
// Command payloads
// ---------------------------------------------------------------------------
// GrantBotVerifierRequest grants or reconfigures a bot's verifier status.
//
// Version is the optimistic-locking token: 0 means "this is a new grant" and a
// positive value means "update the row I read", so two operators editing the same
// verifier cannot clobber each other. Enabled is deliberately absent -- the kill
// switch is its own action, so reconfiguring a switched-off verifier does not
// quietly switch it back on.
type GrantBotVerifierRequest struct {
CommandMeta
BotID int64 `json:"bot_id"`
IconDocumentID int64 `json:"icon_document_id"`
CompanyName string `json:"company_name"`
DefaultDescription string `json:"default_description"`
CanModifyCustomDescription bool `json:"can_modify_custom_description"`
Version int64 `json:"version"`
}
// SetBotVerifierEnabledRequest flips the operator kill switch. The verifier keeps
// its row and the marks it granted, so flipping it back restores exactly what was
// there.
type SetBotVerifierEnabledRequest struct {
CommandMeta
BotID int64 `json:"bot_id"`
Enabled bool `json:"enabled"`
}
// RevokeBotVerifierRequest removes verifier status entirely. Its marks cascade
// away with it in the store, because a mark whose verifier no longer exists has
// nothing to render.
type RevokeBotVerifierRequest struct {
CommandMeta
BotID int64 `json:"bot_id"`
}
// UpsertVerificationIconRequest adds or updates a catalogue entry, keyed by custom
// emoji document id. OwnerBotID is zero for a shared entry and a bot id when the
// operator reserves the icon for one verifier.
type UpsertVerificationIconRequest struct {
CommandMeta
DocumentID int64 `json:"document_id"`
Name string `json:"name"`
OwnerBotID int64 `json:"owner_bot_id,omitempty"`
}
// SetVerificationIconActiveRequest retires or restores a catalogue entry. Marks
// already granted with it keep rendering -- the icon id is denormalised onto the
// mark -- so retiring an entry stops new grants without blanking existing badges.
type SetVerificationIconActiveRequest struct {
CommandMeta
IconID int64 `json:"icon_id"`
Active bool `json:"active"`
}
// RevokeCustomVerificationRequest strips one verifier's mark from a peer on the
// operator's behalf. It addresses the (verifier, peer) pair rather than an
// application, because the operator may have to strip a mark that no application
// ever produced.
type RevokeCustomVerificationRequest struct {
CommandMeta
VerifierBotID int64 `json:"verifier_bot_id"`
PeerType domain.PeerType `json:"peer_type"`
PeerID int64 `json:"peer_id"`
}
// ApproveBotVerificationRequest grants the mark an application asked for. The
// mark and the approved status commit together in the use-case layer, so an
// approved application without its mark is not a reachable state.
type ApproveBotVerificationRequest struct {
CommandMeta
RequestID int64 `json:"request_id"`
Version int64 `json:"version"`
// InternalNote is operator-only: it is journalled and stored on the
// application, and it is never part of what the applicant is told.
InternalNote string `json:"internal_note,omitempty"`
}
// RejectBotVerificationRequest closes an application against the applicant.
// Reason is mandatory: it is the text the applicant receives.
type RejectBotVerificationRequest struct {
CommandMeta
RequestID int64 `json:"request_id"`
Version int64 `json:"version"`
InternalNote string `json:"internal_note,omitempty"`
}
// RevokeBotVerificationRequest withdraws a granted mark through the application it
// came from. The application stays as history -- revoked is reachable only from
// approved, so it keeps meaning "was verified once". Reason is mandatory for the
// same reason it is on rejection.
type RevokeBotVerificationRequest struct {
CommandMeta
RequestID int64 `json:"request_id"`
Version int64 `json:"version"`
InternalNote string `json:"internal_note,omitempty"`
}
// ---------------------------------------------------------------------------
// Reads
// ---------------------------------------------------------------------------
// BotVerifiers lists verifier bots.
func (s *Service) BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Verifiers(ctx, enabledOnly, limit)
}
// BotVerifier resolves one verifier's status, enabled or not: the panel needs the
// disabled row too, to render the kill switch.
func (s *Service) BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error) {
if s == nil || s.botVerification == nil {
return domain.BotVerifierSettings{}, errBotVerificationNotConfigured
}
if botID <= 0 {
return domain.BotVerifierSettings{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
return s.botVerification.VerifierSettings(ctx, botID)
}
// VerificationIcons lists the icon catalogue, newest first.
func (s *Service) VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Icons(ctx, activeOnly, limit)
}
// CustomVerifications lists granted third-party marks with keyset paging.
func (s *Service) CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Marks(ctx, filter)
}
// CustomVerificationRequests is the third-party review queue.
func (s *Service) CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.Requests(ctx, filter)
}
// CustomVerificationRequest resolves one application by identity.
func (s *Service) CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
if s == nil || s.botVerification == nil {
return domain.CustomVerificationRequest{}, errBotVerificationNotConfigured
}
if requestID <= 0 {
return domain.CustomVerificationRequest{}, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
}
return s.botVerification.Request(ctx, requestID)
}
// CustomVerificationRequestCounts is the queue summary rendered above the list.
func (s *Service) CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
if s == nil || s.botVerification == nil {
return nil, errBotVerificationNotConfigured
}
return s.botVerification.RequestCounts(ctx)
}
// CustomVerificationMarkActive reports whether a (verifier, peer) pair currently
// carries a mark. The request detail view needs it to tell "approved" apart from
// "approved and since stripped by the operator".
func (s *Service) CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
if s == nil || s.botVerification == nil {
return false, errBotVerificationNotConfigured
}
if verifierBotID <= 0 || peer.ID <= 0 {
return false, nil
}
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
VerifierBotID: verifierBotID,
PeerType: peer.Type,
PeerID: peer.ID,
Limit: 1,
})
if err != nil {
return false, botVerificationError(err)
}
return len(marks) > 0, nil
}
// ---------------------------------------------------------------------------
// Commands: verifier status
// ---------------------------------------------------------------------------
// GrantBotVerifier grants or reconfigures verifier status.
//
// The dry run predicts everything it can without writing: the payload shape, the
// optimistic lock against the row as it is now, and -- when the icon is visible in
// the catalogue page it reads -- that the icon is active and usable by this bot.
// It deliberately does not fail an icon it cannot see: the use-case layer resolves
// the icon's custom emoji document against the document store, and a dry run that
// refused more than the real command would make a rehearsal useless.
func (s *Service) GrantBotVerifier(ctx context.Context, req GrantBotVerifierRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.BotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
if req.Version < 0 {
return CommandResult{}, botVerifierInvalid("version must not be negative")
}
settings := domain.BotVerifierSettings{
BotID: req.BotID,
IconDocumentID: req.IconDocumentID,
CompanyName: strings.TrimSpace(req.CompanyName),
DefaultDescription: strings.TrimSpace(req.DefaultDescription),
CanModifyCustomDescription: req.CanModifyCustomDescription,
GrantedBy: strings.TrimSpace(req.Actor),
GrantReason: strings.TrimSpace(req.Reason),
Enabled: true,
Version: req.Version,
}
if err := settings.Validate(); err != nil {
return CommandResult{}, botVerificationError(err)
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
return s.runCommand(ctx, req.CommandMeta, ActionGrantBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
details := map[string]any{
"bot_id": strconv.FormatInt(req.BotID, 10),
"icon_document_id": strconv.FormatInt(req.IconDocumentID, 10),
"company_name": settings.CompanyName,
"can_modify_custom_description": req.CanModifyCustomDescription,
"correlation_id": strings.TrimSpace(req.CommandID),
}
current, exists, err := s.botVerifierState(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, err
}
details["created"] = !exists
if exists {
details["previous_version"] = strconv.FormatInt(current.Version, 10)
details["previous_icon_document_id"] = strconv.FormatInt(current.IconDocumentID, 10)
details["previous_enabled"] = current.Enabled
// The kill switch is its own action, so reconfiguring a switched-off
// verifier must not switch it back on.
settings.Enabled = current.Enabled
if req.Version != current.Version {
return CommandResult{Details: details}, codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
}
} else if req.Version != 0 {
// The operator is editing a row that is no longer there.
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
}
details["enabled"] = settings.Enabled
if err := s.grantIconPreflight(ctx, details, req.BotID, req.IconDocumentID); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
message := "bot verifier grant validated"
if exists {
message = "bot verifier update validated"
}
return CommandResult{Message: message, Details: details}, nil
}
stored, err := s.botVerification.GrantVerifier(ctx, settings)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["version"] = strconv.FormatInt(stored.Version, 10)
details["enabled"] = stored.Enabled
details["changed"] = true
message := "bot verifier status granted"
if exists {
message = "bot verifier status updated"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// SetBotVerifierEnabled flips the operator kill switch.
func (s *Service) SetBotVerifierEnabled(ctx context.Context, req SetBotVerifierEnabledRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.BotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
return s.runCommand(ctx, req.CommandMeta, ActionSetBotVerifierEnabled, req.BotID, target, req, func() (CommandResult, error) {
details := map[string]any{
"bot_id": strconv.FormatInt(req.BotID, 10),
"enabled": req.Enabled,
"correlation_id": strings.TrimSpace(req.CommandID),
}
current, exists, err := s.botVerifierState(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, err
}
if !exists {
return CommandResult{Details: details}, botVerificationCoded(domain.ErrVerifierNotFound)
}
details["previous_enabled"] = current.Enabled
details["previous_version"] = strconv.FormatInt(current.Version, 10)
details["company_name"] = current.CompanyName
// A no-op flip neither burns a version nor pushes an update, so the audit
// trail does not claim a change that never happened.
noop := current.Enabled == req.Enabled
details["changed"] = !noop
if req.DryRun {
message := "bot verifier switch validated"
if noop {
message = "bot verifier switch validated; already in that state"
}
return CommandResult{Message: message, Details: details}, nil
}
stored, err := s.botVerification.SetVerifierEnabled(ctx, req.BotID, req.Enabled)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["version"] = strconv.FormatInt(stored.Version, 10)
details["enabled"] = stored.Enabled
message := "bot verifier enabled"
if !req.Enabled {
message = "bot verifier disabled"
}
if noop {
message = "bot verifier was already in that state"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeBotVerifier removes verifier status entirely.
//
// A missing row is not an error: the command answers changed=false, so a panel
// retry after a lost response is harmless and the dry run never refuses what the
// real command accepts.
func (s *Service) RevokeBotVerifier(ctx context.Context, req RevokeBotVerifierRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.BotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
target := domain.Peer{Type: domain.PeerTypeUser, ID: req.BotID}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerifier, req.BotID, target, req, func() (CommandResult, error) {
details := map[string]any{
"bot_id": strconv.FormatInt(req.BotID, 10),
"correlation_id": strings.TrimSpace(req.CommandID),
}
current, exists, err := s.botVerifierState(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, err
}
details["present"] = exists
if exists {
details["previous_version"] = strconv.FormatInt(current.Version, 10)
details["previous_enabled"] = current.Enabled
details["company_name"] = current.CompanyName
// The marks this verifier granted cascade away with the row, which is
// the fact an operator most needs stated before confirming.
details["mark_count"] = s.botVerifierMarkCount(ctx, req.BotID)
}
if req.DryRun {
message := "bot verifier revoke validated"
if !exists {
message = "bot verifier revoke validated; the bot is not a verifier"
}
return CommandResult{Message: message, Details: details}, nil
}
removed, err := s.botVerification.RevokeVerifier(ctx, req.BotID)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["changed"] = removed
message := "bot verifier status revoked"
if !removed {
message = "bot was not a verifier"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Commands: icon catalogue
// ---------------------------------------------------------------------------
// UpsertVerificationIcon adds or updates a catalogue entry.
//
// The use-case layer resolves the custom emoji document before writing, because an
// entry pointing at a document no client can fetch renders as *nothing*: the peer
// looks unverified while the server insists it is marked. That probe needs the
// document store, so it runs on execution only; the dry run validates the shape.
func (s *Service) UpsertVerificationIcon(ctx context.Context, req UpsertVerificationIconRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
icon := domain.VerificationIcon{
DocumentID: req.DocumentID,
OwnerBotID: req.OwnerBotID,
Name: strings.TrimSpace(req.Name),
Active: true,
}
if err := icon.Validate(); err != nil {
return CommandResult{}, botVerificationError(err)
}
return s.runCommand(ctx, req.CommandMeta, ActionUpsertVerificationIcon, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"document_id": strconv.FormatInt(req.DocumentID, 10),
"owner_bot_id": strconv.FormatInt(req.OwnerBotID, 10),
"name": icon.Name,
"correlation_id": strings.TrimSpace(req.CommandID),
}
if req.DryRun {
return CommandResult{Message: "verification icon validated", Details: details}, nil
}
stored, err := s.botVerification.UpsertIcon(ctx, icon)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["icon_id"] = strconv.FormatInt(stored.ID, 10)
details["active"] = stored.Active
details["changed"] = true
return CommandResult{Message: "verification icon stored", Details: details}, nil
})
}
// SetVerificationIconActive retires or restores a catalogue entry.
func (s *Service) SetVerificationIconActive(ctx context.Context, req SetVerificationIconActiveRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.IconID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerificationIconNotFound)
}
return s.runCommand(ctx, req.CommandMeta, ActionSetVerificationIconActive, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"icon_id": strconv.FormatInt(req.IconID, 10),
"active": req.Active,
"correlation_id": strings.TrimSpace(req.CommandID),
}
if req.DryRun {
return CommandResult{Message: "verification icon switch validated", Details: details}, nil
}
stored, err := s.botVerification.SetIconActive(ctx, req.IconID, req.Active)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["document_id"] = strconv.FormatInt(stored.DocumentID, 10)
details["name"] = stored.Name
details["active"] = stored.Active
details["changed"] = true
message := "verification icon activated"
if !req.Active {
// Marks already granted with it keep rendering: the icon id is
// denormalised onto the mark.
message = "verification icon retired"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Commands: granted marks
// ---------------------------------------------------------------------------
// RevokeCustomVerification strips one verifier's mark from a peer.
//
// The peer is checked for shape only, never for existence: an operator must still
// be able to strip a mark from a peer that has since been deleted or become
// unresolvable, which is exactly when stripping it matters most.
func (s *Service) RevokeCustomVerification(ctx context.Context, req RevokeCustomVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if req.VerifierBotID <= 0 {
return CommandResult{}, botVerificationCoded(domain.ErrVerifierNotFound)
}
peer := domain.Peer{Type: req.PeerType, ID: req.PeerID}
if !markableAdminPeer(peer) {
return CommandResult{}, botVerificationCoded(domain.ErrCustomVerificationTargetInvalid)
}
targetUserID := int64(0)
if peer.Type == domain.PeerTypeUser {
targetUserID = peer.ID
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCustomVerification, targetUserID, peer, req, func() (CommandResult, error) {
details := map[string]any{
"verifier_bot_id": strconv.FormatInt(req.VerifierBotID, 10),
"peer_type": string(peer.Type),
"peer_id": strconv.FormatInt(peer.ID, 10),
"correlation_id": strings.TrimSpace(req.CommandID),
}
present, err := s.CustomVerificationMarkActive(ctx, req.VerifierBotID, peer)
if err != nil {
return CommandResult{Details: details}, err
}
details["mark_present"] = present
if req.DryRun {
message := "custom verification revoke validated"
if !present {
message = "custom verification revoke validated; the peer carries no mark from this verifier"
}
return CommandResult{Message: message, Details: details}, nil
}
removed, err := s.botVerification.RevokeMark(ctx, req.VerifierBotID, peer)
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
details["changed"] = removed
message := "custom verification revoked"
if !removed {
message = "custom verification was already absent"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Commands: application queue
// ---------------------------------------------------------------------------
// ApproveBotVerification grants the mark an application asked for.
func (s *Service) ApproveBotVerification(ctx context.Context, req ApproveBotVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
return s.runCommand(ctx, req.CommandMeta, ActionApproveBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationApproved, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationApproved); err != nil {
return CommandResult{Details: details}, err
}
// An application that is already approved is a replay: the use-case layer
// answers changed=false without re-running the verifier gate, so the dry run
// must not re-run it either.
replay := current.Status == domain.CustomVerificationApproved
if !replay {
if err := s.mergeVerifierDecisionDetails(ctx, details, current.VerifierBotID, true); err != nil {
return CommandResult{Details: details}, err
}
}
if req.DryRun {
return CommandResult{Message: "bot verification approve validated", Details: details}, nil
}
stored, changed, err := s.botVerification.Approve(ctx, req.RequestID, req.Version,
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
mergeBotVerificationDetails(details, stored, changed)
message := "bot verification application approved"
if !changed {
message = "bot verification application already approved"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RejectBotVerification closes an application against the applicant. The reason is
// mandatory and is the text the applicant is shown; the internal note stays
// operator-side.
func (s *Service) RejectBotVerification(ctx context.Context, req RejectBotVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
// Refused before the journal is touched: the audit trail must never contain a
// decision nobody can explain.
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
return s.runCommand(ctx, req.CommandMeta, ActionRejectBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRejected, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRejected); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "bot verification reject validated", Details: details}, nil
}
stored, changed, err := s.botVerification.Reject(ctx, req.RequestID, req.Version,
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
mergeBotVerificationDetails(details, stored, changed)
message := "bot verification application rejected"
if !changed {
message = "bot verification application already rejected"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeBotVerification withdraws a granted mark through the application it came
// from. A reason is mandatory for the same reason it is on rejection.
func (s *Service) RevokeBotVerification(ctx context.Context, req RevokeBotVerificationRequest) (CommandResult, error) {
if s == nil || s.botVerification == nil {
return CommandResult{}, errBotVerificationNotConfigured
}
if err := validateBotVerificationDecisionShape(req.RequestID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeCustomVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeBotVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
current, details, err := s.botVerificationSubject(ctx, req.CommandMeta, req.RequestID, domain.CustomVerificationRevoked, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := botVerificationTransition(current, req.Version, domain.CustomVerificationRevoked); err != nil {
return CommandResult{Details: details}, err
}
// The mark may already be gone -- the operator can strip one directly -- and
// that is not an error: the application still has to reach "revoked".
present, err := s.CustomVerificationMarkActive(ctx, current.VerifierBotID, current.Peer)
if err != nil {
return CommandResult{Details: details}, err
}
details["mark_present"] = present
if req.DryRun {
return CommandResult{Message: "bot verification revoke validated", Details: details}, nil
}
stored, changed, err := s.botVerification.RevokeRequest(ctx, req.RequestID, req.Version,
strings.TrimSpace(req.Actor), strings.TrimSpace(req.Reason), strings.TrimSpace(req.InternalNote))
if err != nil {
return CommandResult{Details: details}, botVerificationError(err)
}
mergeBotVerificationDetails(details, stored, changed)
message := "bot verification mark revoked"
if !changed {
message = "bot verification application already revoked"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
var errBotVerificationNotConfigured = errors.New("admin bot verification dependency is not configured")
// botVerifierState loads a verifier row and reports whether it exists at all,
// because "not a verifier" is a normal answer for several of these commands
// rather than a failure.
func (s *Service) botVerifierState(ctx context.Context, botID int64) (domain.BotVerifierSettings, bool, error) {
settings, err := s.botVerification.VerifierSettings(ctx, botID)
switch {
case err == nil:
return settings, settings.BotID == botID, nil
case errors.Is(err, domain.ErrVerifierNotFound):
return domain.BotVerifierSettings{}, false, nil
default:
return domain.BotVerifierSettings{}, false, botVerificationError(err)
}
}
// botVerifierMarkCount reports how many marks a verifier holds, bounded by the
// listing page. It is advisory audit detail, so a read failure is reported as an
// unknown count rather than failing the command it is describing.
func (s *Service) botVerifierMarkCount(ctx context.Context, botID int64) any {
marks, err := s.botVerification.Marks(ctx, domain.CustomVerificationFilter{
VerifierBotID: botID,
Limit: botVerificationCatalogueScan,
})
if err != nil {
return "unknown"
}
if len(marks) >= botVerificationCatalogueScan {
return strconv.Itoa(len(marks)) + "+"
}
return strconv.Itoa(len(marks))
}
// grantIconPreflight records what the catalogue says about the icon and refuses
// the definite failures early.
//
// The check is deliberately one-sided. An icon found in the page and unusable is
// a certain failure and is refused with its own code; an icon the page does not
// cover is left to the use-case layer, which reads the entry by document id and
// additionally resolves the custom emoji document. So this can only ever make the
// dry run *more* informative, never stricter than the command it rehearses.
func (s *Service) grantIconPreflight(ctx context.Context, details map[string]any, botID, documentID int64) error {
icons, err := s.botVerification.Icons(ctx, false, botVerificationCatalogueScan)
if err != nil {
details["icon_catalogue_checked"] = false
return nil
}
for _, icon := range icons {
if icon.DocumentID != documentID {
continue
}
details["icon_catalogue_checked"] = true
details["icon_id"] = strconv.FormatInt(icon.ID, 10)
details["icon_name"] = icon.Name
details["icon_active"] = icon.Active
details["icon_owner_bot_id"] = strconv.FormatInt(icon.OwnerBotID, 10)
if !icon.Active {
return botVerificationCoded(domain.ErrVerificationIconInactive)
}
if !icon.UsableBy(botID) {
// A reserved entry belongs to one verifier; for anybody else it does
// not exist, which is why this is "not found" and not "forbidden".
return botVerificationCoded(domain.ErrVerificationIconNotFound)
}
return nil
}
details["icon_catalogue_checked"] = false
return nil
}
// botVerificationSubject loads the application under decision and seeds the
// command details with everything the audit entry must state even when the command
// then fails: which application, whose, which verifier, which peer, the status it
// is leaving and the status it was asked to reach.
func (s *Service) botVerificationSubject(
ctx context.Context,
meta CommandMeta,
requestID int64,
next domain.CustomVerificationRequestStatus,
internalNote string,
) (domain.CustomVerificationRequest, map[string]any, error) {
details := map[string]any{
"request_id": strconv.FormatInt(requestID, 10),
"next_status": string(next),
"correlation_id": strings.TrimSpace(meta.CommandID),
}
if note := strings.TrimSpace(internalNote); note != "" {
details["internal_note"] = note
}
current, err := s.botVerification.Request(ctx, requestID)
if err != nil {
return domain.CustomVerificationRequest{}, details, botVerificationError(err)
}
if current.ID != requestID {
return domain.CustomVerificationRequest{}, details, botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
}
details["verifier_bot_id"] = strconv.FormatInt(current.VerifierBotID, 10)
details["applicant_user_id"] = strconv.FormatInt(current.ApplicantUserID, 10)
details["peer_type"] = string(current.Peer.Type)
details["peer_id"] = strconv.FormatInt(current.Peer.ID, 10)
details["peer_username"] = current.PeerUsername
details["previous_status"] = string(current.Status)
details["previous_version"] = strconv.FormatInt(current.Version, 10)
return current, details, nil
}
// mergeVerifierDecisionDetails records the verifier behind an application and,
// for a decision that would grant a mark, refuses a verifier that may not verify
// right now.
//
// An application can sit in the queue for days: a verifier switched off in the
// meantime must not be able to grant through the review path what the RPC path
// would refuse. "No row" and "switched off" answer the same code on purpose --
// that is the distinction BOTVERIFIER_FORBIDDEN deliberately hides.
func (s *Service) mergeVerifierDecisionDetails(ctx context.Context, details map[string]any, botID int64, requireEnabled bool) error {
settings, exists, err := s.botVerifierState(ctx, botID)
if err != nil {
return err
}
details["verifier_present"] = exists
if exists {
details["verifier_enabled"] = settings.Enabled
details["verifier_company_name"] = settings.CompanyName
details["verifier_icon_document_id"] = strconv.FormatInt(settings.IconDocumentID, 10)
details["verifier_can_modify_custom_description"] = settings.CanModifyCustomDescription
}
if requireEnabled && (!exists || !settings.Enabled) {
return botVerificationCoded(domain.ErrVerifierForbidden)
}
return nil
}
// mergeBotVerificationDetails records the decided state.
func mergeBotVerificationDetails(details map[string]any, req domain.CustomVerificationRequest, changed bool) {
details["status"] = string(req.Status)
details["version"] = strconv.FormatInt(req.Version, 10)
details["changed"] = changed
details["decided_by"] = req.DecidedBy
if req.CorrelationID != "" {
details["correlation_id"] = req.CorrelationID
}
if req.DecisionReason != "" {
details["decision_reason"] = req.DecisionReason
}
}
// validateBotVerificationDecisionShape rejects a malformed decision before the
// command journal is touched.
func validateBotVerificationDecisionShape(requestID, version int64, internalNote string) error {
if requestID <= 0 {
return botVerificationCoded(domain.ErrCustomVerificationRequestNotFound)
}
if version <= 0 {
// Without the version the reviewer never read the row, so the optimistic
// lock could not protect a concurrent decision.
return botVerificationInvalid("version is required")
}
if utf8.RuneCountInString(internalNote) > domain.MaxCustomVerificationNoteLength {
return botVerificationInvalid("internal_note is too long")
}
return nil
}
// botVerificationTransition checks the status machine and the optimistic lock in
// the order the use-case layer does, so a dry run predicts the real outcome
// exactly.
//
// A request already in the target status is a replay the service answers as a
// no-op, so it passes without consulting the version: re-sending a decision whose
// response was lost must not turn into a spurious conflict.
func botVerificationTransition(req domain.CustomVerificationRequest, version int64, next domain.CustomVerificationRequestStatus) error {
if req.Status == next {
return nil
}
if !domain.CanTransitionCustomVerificationStatus(req.Status, next) {
return codedError(CodeCustomVerificationStatusInvalid,
fmt.Errorf("%w: %s -> %s", domain.ErrCustomVerificationRequestInvalid, req.Status, next))
}
if req.Version != version {
return codedError(CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict)
}
return nil
}
// markableAdminPeer mirrors the store's peer_type CHECK: only users (bots
// included) and channels can carry a third-party mark.
func markableAdminPeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}
// BotVerificationErrorCode maps a third-party verification failure onto the stable
// code the admin panel switches on. An unmapped error returns "" so the caller can
// report it verbatim instead of inventing a code.
//
// The codes are separate from the official verification ones by design: the two
// mechanisms fail for different reasons and the panel renders them in different
// sections, so sharing a token would make a message land in the wrong place.
func BotVerificationErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrVerifierNotFound):
return CodeBotVerifierNotFound
case errors.Is(err, domain.ErrVerifierForbidden):
return CodeBotVerifierForbidden
case errors.Is(err, domain.ErrVerifierDescriptionForbidden):
return CodeBotVerifierDescriptionForbidden
case errors.Is(err, domain.ErrVerifierSettingsInvalid):
return CodeBotVerifierInvalid
case errors.Is(err, domain.ErrVerificationIconNotFound):
return CodeVerificationIconNotFound
case errors.Is(err, domain.ErrVerificationIconInactive):
return CodeVerificationIconInactive
case errors.Is(err, domain.ErrVerificationIconInvalid):
return CodeVerificationIconInvalid
case errors.Is(err, domain.ErrCustomVerificationVersionConflict):
return CodeCustomVerificationConflict
case errors.Is(err, domain.ErrCustomVerificationLimit):
return CodeCustomVerificationLimit
case errors.Is(err, domain.ErrCustomVerificationNotFound):
return CodeCustomVerificationNotFound
case errors.Is(err, domain.ErrCustomVerificationRequestNotFound):
return CodeCustomVerificationRequestNotFound
case errors.Is(err, domain.ErrCustomVerificationRequestExists):
return CodeCustomVerificationRequestExists
case errors.Is(err, domain.ErrCustomVerificationTargetInvalid):
return CodeCustomVerificationTargetInvalid
case errors.Is(err, domain.ErrVerificationTargetSystem):
return CodeCustomVerificationTargetSystem
case errors.Is(err, domain.ErrVerificationReasonRequired):
return CodeCustomVerificationReasonRequired
case errors.Is(err, domain.ErrVerificationRateLimited):
return CodeCustomVerificationRateLimited
case errors.Is(err, domain.ErrBotNotFound):
return CodeBotVerifierBotNotFound
case errors.Is(err, domain.ErrCustomVerificationRequestInvalid):
return CodeCustomVerificationInvalid
default:
return ""
}
}
// botVerificationError prefixes a recognised failure with its stable code, the way
// verificationError does for the official review.
func botVerificationError(err error) error {
if code := BotVerificationErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func botVerificationCoded(err error) error {
return codedError(BotVerificationErrorCode(err), err)
}
func botVerificationInvalid(message string) error {
return codedError(CodeCustomVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrCustomVerificationRequestInvalid))
}
func botVerifierInvalid(message string) error {
return codedError(CodeBotVerifierInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerifierSettingsInvalid))
}

File diff suppressed because it is too large Load diff

View file

@ -45,6 +45,34 @@ const (
ActionGiveGift = "gifts.give"
ActionCreateBot = "bot.create"
ActionDeleteBot = "bot.delete"
// Collectible (Fragment-style) username lifecycle.
ActionMintCollectibleUsername = "usernames.collectible.mint"
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
// Composite account rating.
ActionRecomputeAccountRating = "rating.recompute"
ActionAdjustAccountRating = "rating.adjust"
// 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.
ActionClaimVerification = "verification.claim"
ActionApproveVerification = "verification.approve"
ActionRejectVerification = "verification.reject"
ActionRevokeVerification = "verification.revoke"
// Third-party bot verification (see botverification.go). A namespace of its own
// on purpose: these actions write the verifier catalogue and the attributed
// marks, never the platform checkmark, and the audit trail has to keep the two
// mechanisms apart at a glance.
ActionGrantBotVerifier = "botverification.grant_verifier"
ActionSetBotVerifierEnabled = "botverification.set_verifier_enabled"
ActionRevokeBotVerifier = "botverification.revoke_verifier"
ActionUpsertVerificationIcon = "botverification.upsert_icon"
ActionSetVerificationIconActive = "botverification.set_icon_active"
ActionRevokeCustomVerification = "botverification.revoke_mark"
ActionApproveBotVerification = "botverification.approve"
ActionRejectBotVerification = "botverification.reject"
ActionRevokeBotVerification = "botverification.revoke_request"
maxCommandIDLength = 128
maxActorLength = 128
@ -53,6 +81,79 @@ const (
maxPremiumMonths = 120
maxStarsGrant = 1_000_000_000
maxFreezeAppealURLLength = 2048
// maxAccountRatingAdjustment bounds one manual rating adjustment in either
// direction. The domain only rejects a zero delta, so the operator-facing
// bound lives here next to the other grant limits.
maxAccountRatingAdjustment = 1_000_000_000
)
// Stable admin error codes for the collectible-username and account-rating
// subsystems. The panel switches on the code, so a command failure and a read
// failure describe the same condition with the same token instead of leaking a
// Go error string the UI would have to pattern-match.
const (
CodeUsernameOccupied = "USERNAME_OCCUPIED"
CodeUsernameInvalid = "USERNAME_INVALID"
CodeUsernameNotCollectible = "USERNAME_NOT_COLLECTIBLE"
CodeCollectibleNotFound = "COLLECTIBLE_NOT_FOUND"
CodeCollectibleNotOwned = "COLLECTIBLE_NOT_OWNED"
CodeCollectibleBurned = "COLLECTIBLE_BURNED"
CodeCollectiblePeerLimit = "COLLECTIBLE_PEER_LIMIT"
CodeCollectibleCurrencyInvalid = "COLLECTIBLE_CURRENCY_INVALID"
CodeCollectibleStateInvalid = "COLLECTIBLE_STATE_INVALID"
CodeRatingNotFound = "RATING_NOT_FOUND"
CodeRatingAdjustmentInvalid = "RATING_ADJUSTMENT_INVALID"
CodeRatingWeightsInvalid = "RATING_WEIGHTS_INVALID"
// Official platform verification review. CodeVerificationConflict is the lost
// optimistic-locking race -- two reviewers deciding at once -- and is the one
// the panel must render as "reload and look again" rather than as a bad
// request.
CodeVerificationNotFound = "VERIFICATION_NOT_FOUND"
CodeVerificationConflict = "VERIFICATION_CONFLICT"
CodeVerificationStatusInvalid = "VERIFICATION_STATUS_INVALID"
CodeVerificationReasonRequired = "VERIFICATION_REASON_REQUIRED"
CodeVerificationTargetInvalid = "VERIFICATION_TARGET_INVALID"
CodeVerificationTargetOccupied = "VERIFICATION_TARGET_OCCUPIED"
CodeVerificationTargetVerified = "VERIFICATION_TARGET_ALREADY_VERIFIED"
CodeVerificationTargetNotPublic = "VERIFICATION_TARGET_NOT_PUBLIC"
CodeVerificationTargetRestricted = "VERIFICATION_TARGET_RESTRICTED"
CodeVerificationTargetSystem = "VERIFICATION_TARGET_SYSTEM"
CodeVerificationNotOwner = "VERIFICATION_NOT_OWNER"
CodeVerificationUserTargetsDisabled = "VERIFICATION_USER_TARGETS_DISABLED"
CodeVerificationInvalid = "VERIFICATION_INVALID"
)
// Stable admin error codes for third-party bot verification (see
// botverification.go). They are a separate set from the official verification
// codes above: the two mechanisms own separate tables and fail for separate
// reasons, and the panel renders them in separate sections, so one shared token
// would land a message in the wrong place.
//
// CodeCustomVerificationConflict is the lost optimistic-locking race -- two
// operators deciding at once -- and is the one the panel must render as "reload
// and look again" rather than as a bad request.
const (
CodeBotVerifierNotFound = "BOTVERIFIER_NOT_FOUND"
CodeBotVerifierForbidden = "BOTVERIFIER_FORBIDDEN"
CodeBotVerifierInvalid = "BOTVERIFIER_INVALID"
CodeBotVerifierBotNotFound = "BOTVERIFIER_BOT_NOT_FOUND"
CodeBotVerifierDescriptionForbidden = "BOTVERIFIER_DESCRIPTION_FORBIDDEN"
CodeVerificationIconNotFound = "VERIFICATION_ICON_NOT_FOUND"
CodeVerificationIconInactive = "VERIFICATION_ICON_INACTIVE"
CodeVerificationIconInvalid = "VERIFICATION_ICON_INVALID"
CodeCustomVerificationNotFound = "CUSTOM_VERIFICATION_NOT_FOUND"
CodeCustomVerificationRequestNotFound = "CUSTOM_VERIFICATION_REQUEST_NOT_FOUND"
CodeCustomVerificationRequestExists = "CUSTOM_VERIFICATION_REQUEST_EXISTS"
CodeCustomVerificationConflict = "CUSTOM_VERIFICATION_CONFLICT"
CodeCustomVerificationLimit = "CUSTOM_VERIFICATION_LIMIT"
CodeCustomVerificationStatusInvalid = "CUSTOM_VERIFICATION_STATUS_INVALID"
CodeCustomVerificationReasonRequired = "CUSTOM_VERIFICATION_REASON_REQUIRED"
CodeCustomVerificationTargetInvalid = "CUSTOM_VERIFICATION_TARGET_INVALID"
CodeCustomVerificationTargetSystem = "CUSTOM_VERIFICATION_TARGET_SYSTEM"
CodeCustomVerificationRateLimited = "CUSTOM_VERIFICATION_RATE_LIMITED"
CodeCustomVerificationInvalid = "CUSTOM_VERIFICATION_INVALID"
)
type CommandRepository interface {
@ -179,6 +280,39 @@ type ModerationService interface {
ReviewAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
}
// CollectibleUsernamesService is the operator-facing slice of the collectible
// username use cases: the mint/transfer/revoke lifecycle plus the reads the
// admin panel explains an asset with. It is deliberately narrow -- the client
// facing toggle/reorder entry points stay out of the admin surface, because the
// editable slot and the row order belong to the peer, not to the operator.
type CollectibleUsernamesService interface {
Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error)
Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error)
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, 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.
type collectibleUsernameByIDLookup interface {
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
}
// AccountRatingService is the operator-facing slice of the composite account
// rating use cases: read the stored projection, force a recompute, adjust the
// manual component and page the ledger that explains a level.
type AccountRatingService interface {
Rating(ctx context.Context, userID int64) (domain.AccountRating, error)
Recompute(ctx context.Context, userID int64) (domain.AccountRating, error)
Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error)
List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
}
// GiftGranter delivers a catalog gift to a recipient peer on behalf of a sender
// without charging Stars. Implemented by the RPC router, it reuses the standard
// gift-delivery path (service message for users, saved-gift + admin log for
@ -207,7 +341,13 @@ type Dependencies struct {
Bots BotService
Emoji EmojiService
Moderation ModerationService
Now func() time.Time
Usernames CollectibleUsernamesService
Rating AccountRatingService
Verification VerificationService
// BotVerification is the third-party mechanism, wired separately from
// Verification: the two never read each other's state.
BotVerification BotVerificationService
Now func() time.Time
}
type Service struct {
@ -230,6 +370,10 @@ type Service struct {
bots BotService
emoji EmojiService
moderation ModerationService
usernames CollectibleUsernamesService
rating AccountRatingService
verification VerificationService
botVerification BotVerificationService
now func() time.Time
}
@ -296,6 +440,18 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.Moderation != nil {
s.moderation = deps.Moderation
}
if deps.Usernames != nil {
s.usernames = deps.Usernames
}
if deps.Rating != nil {
s.rating = deps.Rating
}
if deps.Verification != nil {
s.verification = deps.Verification
}
if deps.BotVerification != nil {
s.botVerification = deps.BotVerification
}
if deps.Now != nil {
s.now = deps.Now
}
@ -360,6 +516,87 @@ func (s *Service) ReviewModerationAppeal(ctx context.Context, request domain.Mod
return s.moderation.ReviewAppeal(ctx, request)
}
// CollectibleUsernames is the admin listing read. The filter is passed through
// unchanged: the use-case layer owns normalisation and the page bound, so the
// admin API and the RPC edge page the registry identically.
func (s *Service) CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if s == nil || s.usernames == nil {
return nil, fmt.Errorf("collectible username dependency is not configured")
}
return s.usernames.List(ctx, filter)
}
// CollectibleUsername resolves one asset by name.
func (s *Service) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
if s == nil || s.usernames == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
}
return s.usernames.Collectible(ctx, username)
}
// CollectibleUsernameByID resolves one asset by identity, which is how the admin
// panel links a row to its detail view.
//
// A service exposing the direct by-identity read is used as-is. Otherwise the
// bounded keyset listing answers it: the listing is ordered by descending id, so
// the single row taken before id+1 is the asset itself whenever it exists, and
// any other id proves the asset is gone.
func (s *Service) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
if s == nil || s.usernames == nil {
return domain.CollectibleUsername{}, fmt.Errorf("collectible username dependency is not configured")
}
if id <= 0 {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
if lookup, ok := s.usernames.(collectibleUsernameByIDLookup); ok {
return lookup.CollectibleUsernameByID(ctx, id)
}
before := int64(0)
if id < math.MaxInt64 {
before = id + 1
}
items, err := s.usernames.List(ctx, domain.CollectibleUsernameFilter{BeforeID: before, Limit: 1})
if err != nil {
return domain.CollectibleUsername{}, err
}
if len(items) == 0 || items[0].ID != id {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return items[0], nil
}
// CollectibleUsernameTransfers returns one asset's provenance log, newest first.
func (s *Service) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if s == nil || s.usernames == nil {
return nil, fmt.Errorf("collectible username dependency is not configured")
}
return s.usernames.Transfers(ctx, collectibleID, limit)
}
// AccountRating returns one user's stored composite rating projection.
func (s *Service) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || s.rating == nil {
return domain.AccountRating{}, fmt.Errorf("account rating dependency is not configured")
}
return s.rating.Rating(ctx, userID)
}
// AccountRatings is the admin leaderboard read.
func (s *Service) AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || s.rating == nil {
return nil, fmt.Errorf("account rating dependency is not configured")
}
return s.rating.List(ctx, filter)
}
// AccountRatingEvents returns the contribution ledger that explains a level.
func (s *Service) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || s.rating == nil {
return nil, fmt.Errorf("account rating dependency is not configured")
}
return s.rating.Events(ctx, userID, limit)
}
type CommandMeta struct {
CommandID string `json:"command_id"`
Actor string `json:"actor"`
@ -599,6 +836,67 @@ type DeleteBotRequest struct {
BotUserID int64 `json:"bot_user_id"`
}
// MintCollectibleUsernameRequest mints a collectible username asset. At most one
// of OwnerUserID / OwnerChannelID may be set: neither mints into the operator
// vault, one assigns the asset to that holder in the same command.
//
// Amount and CryptoAmount are minor units (nanotons for TON), so they cross the
// JSON boundary as decimal strings and stay exact. PurchaseDate is an optional
// Unix timestamp; zero is stamped with the command clock.
type MintCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
OwnerUserID int64 `json:"owner_user_id,string,omitempty"`
OwnerChannelID int64 `json:"owner_channel_id,string,omitempty"`
Currency string `json:"currency"`
Amount int64 `json:"amount,string"`
CryptoCurrency string `json:"crypto_currency,omitempty"`
CryptoAmount int64 `json:"crypto_amount,string,omitempty"`
URL string `json:"url,omitempty"`
PurchaseDate int64 `json:"purchase_date,omitempty"`
}
// TransferCollectibleUsernameRequest moves an asset out of the vault or between
// holders. Exactly one of ToUserID / ToChannelID identifies the new holder.
type TransferCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
ToUserID int64 `json:"to_user_id,string,omitempty"`
ToChannelID int64 `json:"to_channel_id,string,omitempty"`
}
// RevokeCollectibleUsernameRequest returns an asset to the vault, or retires it
// permanently when Burn is set.
type RevokeCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
Burn bool `json:"burn"`
}
// DeleteCollectibleUsernameRequest erases a collectible asset entirely. Unlike a
// burn, which retires the asset and keeps its provenance, this drops the record
// and frees the name for a fresh issue -- the escape hatch for a mistaken mint.
type DeleteCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
}
// RecomputeAccountRatingRequest forces one user's composite rating to be
// recomputed from the current contribution signals.
type RecomputeAccountRatingRequest struct {
CommandMeta
UserID int64 `json:"user_id,string"`
}
// AdjustAccountRatingRequest moves one user's manual rating component by a
// signed delta. The delta survives recomputes, so it is the operator's durable
// override rather than a one-off nudge.
type AdjustAccountRatingRequest struct {
CommandMeta
UserID int64 `json:"user_id,string"`
Amount int64 `json:"amount,string"`
}
type RevokeSessionsRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
@ -1314,6 +1612,467 @@ func (s *Service) DeleteBot(ctx context.Context, req DeleteBotRequest) (CommandR
})
}
// MintCollectibleUsername creates a collectible username asset and optionally
// assigns it in the same command. Shape validation runs before the command is
// journalled; occupancy is checked inside it, so a dry-run reports a taken name
// without minting and a replay of a completed command stays idempotent.
func (s *Service) MintCollectibleUsername(ctx context.Context, req MintCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
owner, err := collectibleOwnerPeer(req.OwnerUserID, req.OwnerChannelID)
if err != nil {
return CommandResult{}, err
}
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
req.CryptoCurrency = strings.ToUpper(strings.TrimSpace(req.CryptoCurrency))
if err := domain.ValidateCollectibleAmounts(req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount); err != nil {
return CommandResult{}, codedError(CodeCollectibleCurrencyInvalid, err)
}
req.URL = strings.TrimSpace(req.URL)
if len(req.URL) > domain.MaxCollectibleUsernameURLLength {
return CommandResult{}, fmt.Errorf("url must be <= %d bytes", domain.MaxCollectibleUsernameURLLength)
}
purchase, err := collectiblePurchaseDate(req.PurchaseDate, s.now)
if err != nil {
return CommandResult{}, err
}
req.PurchaseDate = purchase.Unix()
return s.runCommand(ctx, req.CommandMeta, ActionMintCollectibleUsername, req.OwnerUserID, owner, req, func() (CommandResult, error) {
details := map[string]any{
"username": req.Username,
"owner_type": string(owner.Type),
"owner_id": strconv.FormatInt(owner.ID, 10),
"currency": req.Currency,
"amount": strconv.FormatInt(req.Amount, 10),
"crypto_currency": req.CryptoCurrency,
"crypto_amount": strconv.FormatInt(req.CryptoAmount, 10),
"purchase_date": purchase.Format(time.RFC3339),
"url": req.URL,
}
existing, err := s.usernames.Collectible(ctx, req.Username)
switch {
case err == nil:
details["existing_collectible_id"] = strconv.FormatInt(existing.ID, 10)
details["existing_status"] = string(existing.Status)
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
case !errors.Is(err, domain.ErrCollectibleUsernameNotFound):
return CommandResult{Details: details}, collectibleUsernameError(err)
}
if req.DryRun {
return CommandResult{Message: "collectible username mint validated", Details: details}, nil
}
asset, created, err := s.usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: req.Username,
Owner: owner,
PurchaseDate: purchase,
Currency: req.Currency,
Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency,
CryptoAmount: req.CryptoAmount,
URL: req.URL,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-mint:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["status"] = string(asset.Status)
details["url"] = asset.URL
details["created"] = created
message := "collectible username minted"
if !created {
message = "collectible username mint replayed"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// TransferCollectibleUsername moves an asset to a new holder. The asset must
// exist and must not be burned; the store keeps the move atomic with the
// receiving peer's username registry row.
func (s *Service) TransferCollectibleUsername(ctx context.Context, req TransferCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
to, err := collectibleOwnerPeer(req.ToUserID, req.ToChannelID)
if err != nil {
return CommandResult{}, err
}
if to.Type == "" {
return CommandResult{}, fmt.Errorf("exactly one of to_user_id or to_channel_id is required")
}
return s.runCommand(ctx, req.CommandMeta, ActionTransferCollectibleUsername, req.ToUserID, to, req, func() (CommandResult, error) {
details := map[string]any{
"username": req.Username,
"to_type": string(to.Type),
"to_id": strconv.FormatInt(to.ID, 10),
}
asset, err := s.usernames.Collectible(ctx, req.Username)
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["previous_status"] = string(asset.Status)
details["previous_owner_type"] = string(asset.Owner.Type)
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
details["transfer_count"] = asset.TransferCount
if asset.Status == domain.CollectibleUsernameStatusBurned {
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
}
details["would_change"] = !asset.Owned() || asset.Owner != to
if req.DryRun {
return CommandResult{Message: "collectible username transfer validated", Details: details}, nil
}
updated, changed, err := s.usernames.Transfer(ctx, domain.TransferCollectibleUsernameRequest{
Username: req.Username,
To: to,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-transfer:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["status"] = string(updated.Status)
details["owner_type"] = string(updated.Owner.Type)
details["owner_id"] = strconv.FormatInt(updated.Owner.ID, 10)
details["changed"] = changed
message := "collectible username transferred"
if !changed {
message = "collectible username transfer was a no-op"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeCollectibleUsername returns an asset to the operator vault, or burns it
// permanently when Burn is set. Revoking an asset nobody holds is rejected:
// there is nothing to take back, and a silent no-op would read as success.
func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username, "burn": req.Burn}
asset, err := s.usernames.Collectible(ctx, req.Username)
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["previous_status"] = string(asset.Status)
details["previous_owner_type"] = string(asset.Owner.Type)
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
if asset.Status == domain.CollectibleUsernameStatusBurned {
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
}
if !req.Burn && !asset.Owned() {
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
}
if req.DryRun {
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
}
updated, changed, err := s.usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
Username: req.Username,
Burn: req.Burn,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-revoke:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["status"] = string(updated.Status)
details["changed"] = changed
message := "collectible username returned to vault"
if req.Burn {
message = "collectible username burned"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// DeleteCollectibleUsername erases the asset and its provenance, releasing the
// name. Because the history disappears with the record, the command journal is
// the only remaining trace: the details below are captured before the delete so
// the entry still says what was removed and from whom.
func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteCollectibleUsernameRequest) (CommandResult, error) {
if s == nil || s.usernames == nil {
return CommandResult{}, fmt.Errorf("admin collectible username dependency is not configured")
}
req.Username = domain.NormalizeUsername(req.Username)
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username}
asset, err := s.usernames.Collectible(ctx, req.Username)
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["collectible_id"] = strconv.FormatInt(asset.ID, 10)
details["previous_status"] = string(asset.Status)
details["previous_owner_type"] = string(asset.Owner.Type)
details["previous_owner_id"] = strconv.FormatInt(asset.Owner.ID, 10)
details["transfer_count"] = asset.TransferCount
details["currency"] = asset.Currency
details["amount"] = strconv.FormatInt(asset.Amount, 10)
if asset.Status == domain.CollectibleUsernameStatusBurned {
// Only live assets can be deleted; burned rows are history and are
// released by re-issuing the name instead.
return CommandResult{Details: details}, codedError(CodeCollectibleBurned, domain.ErrCollectibleUsernameBurned)
}
if req.DryRun {
return CommandResult{Message: "collectible username delete validated", Details: details}, nil
}
deleted, err := s.usernames.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
Username: req.Username,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: "admin-collectible-delete:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, collectibleUsernameError(err)
}
details["deleted"] = deleted
if !deleted {
return CommandResult{Message: "collectible username already absent", Details: details}, nil
}
return CommandResult{Message: "collectible username deleted", Details: details}, nil
})
}
// RecomputeAccountRating rebuilds one user's composite rating from the current
// contribution signals. A dry-run only reports the stored projection, so the
// operator can see what a recompute would start from without writing.
func (s *Service) RecomputeAccountRating(ctx context.Context, req RecomputeAccountRatingRequest) (CommandResult, error) {
if s == nil || s.rating == nil {
return CommandResult{}, fmt.Errorf("admin account rating dependency is not configured")
}
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
return s.runCommand(ctx, req.CommandMeta, ActionRecomputeAccountRating, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"user_id": strconv.FormatInt(req.UserID, 10)}
previous, err := s.rating.Rating(ctx, req.UserID)
switch {
case err == nil:
details["previous_found"] = true
details["previous_level"] = previous.Level
details["previous_stars"] = strconv.FormatInt(previous.Stars, 10)
case errors.Is(err, domain.ErrAccountRatingNotFound):
details["previous_found"] = false
default:
return CommandResult{Details: details}, accountRatingError(err)
}
if req.DryRun {
return CommandResult{Message: "account rating recompute validated", Details: details}, nil
}
rating, err := s.rating.Recompute(ctx, req.UserID)
if err != nil {
return CommandResult{Details: details}, accountRatingError(err)
}
mergeAccountRatingDetails(details, rating)
return CommandResult{Message: "account rating recomputed", Details: details}, nil
})
}
// AdjustAccountRating moves the manual component of one user's rating by a
// signed delta and recomputes the projection so the change is visible at once.
// The command id doubles as the ledger key, so a retried command records the
// adjustment exactly once.
func (s *Service) AdjustAccountRating(ctx context.Context, req AdjustAccountRatingRequest) (CommandResult, error) {
if s == nil || s.rating == nil {
return CommandResult{}, fmt.Errorf("admin account rating dependency is not configured")
}
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if req.Amount == 0 || req.Amount < -maxAccountRatingAdjustment || req.Amount > maxAccountRatingAdjustment {
return CommandResult{}, codedError(CodeRatingAdjustmentInvalid, domain.ErrAccountRatingAdjustmentInvalid)
}
return s.runCommand(ctx, req.CommandMeta, ActionAdjustAccountRating, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"user_id": strconv.FormatInt(req.UserID, 10),
"amount": strconv.FormatInt(req.Amount, 10),
}
previous, err := s.rating.Rating(ctx, req.UserID)
switch {
case err == nil:
details["previous_found"] = true
details["previous_level"] = previous.Level
details["previous_stars"] = strconv.FormatInt(previous.Stars, 10)
details["previous_manual_component"] = strconv.FormatInt(previous.ManualComponent, 10)
case errors.Is(err, domain.ErrAccountRatingNotFound):
details["previous_found"] = false
default:
return CommandResult{Details: details}, accountRatingError(err)
}
if req.DryRun {
return CommandResult{Message: "account rating adjustment validated", Details: details}, nil
}
rating, applied, err := s.rating.Adjust(ctx, domain.AdjustAccountRatingRequest{
UserID: req.UserID,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: "admin-rating-adjust:" + req.CommandID,
})
if err != nil {
return CommandResult{Details: details}, accountRatingError(err)
}
details["applied"] = applied
mergeAccountRatingDetails(details, rating)
message := "account rating adjusted"
if !applied {
message = "account rating adjustment replayed"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// collectibleOwnerPeer resolves the optional owner of a collectible asset. At
// most one identifier may be set; neither yields the zero peer, which the
// lifecycle reads as "the operator vault".
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
if userID < 0 || channelID < 0 {
return domain.Peer{}, fmt.Errorf("owner id must be positive")
}
if userID > 0 && channelID > 0 {
return domain.Peer{}, fmt.Errorf("at most one of user id or channel id is allowed")
}
switch {
case userID > 0:
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, nil
case channelID > 0:
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, nil
default:
return domain.Peer{}, nil
}
}
// collectiblePurchaseDate resolves the optional Unix purchase timestamp. Zero
// means "now", so a mint always records a complete, reproducible provenance
// entry; the int32 bound matches the TL date field clients render.
func collectiblePurchaseDate(unix int64, now func() time.Time) (time.Time, error) {
if unix == 0 {
return now().UTC(), nil
}
if unix < 0 || unix > math.MaxInt32 {
return time.Time{}, fmt.Errorf("purchase_date must be a non-negative int32 Unix timestamp")
}
return time.Unix(unix, 0).UTC(), nil
}
// mergeAccountRatingDetails records the computed projection in command details.
// Every score component crosses the JSON boundary as a decimal string so an
// audit entry reproduces the exact int64 the store holds.
func mergeAccountRatingDetails(details map[string]any, rating domain.AccountRating) {
details["level"] = rating.Level
details["stars"] = strconv.FormatInt(rating.Stars, 10)
details["current_level_stars"] = strconv.FormatInt(rating.CurrentLevelStars, 10)
details["has_next_level"] = rating.HasNextLevel
if rating.HasNextLevel {
details["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
}
details["stars_component"] = strconv.FormatInt(rating.StarsComponent, 10)
details["activity_component"] = strconv.FormatInt(rating.ActivityComponent, 10)
details["penalty_component"] = strconv.FormatInt(rating.PenaltyComponent, 10)
details["manual_component"] = strconv.FormatInt(rating.ManualComponent, 10)
details["pending_stars"] = strconv.FormatInt(rating.PendingStars, 10)
if !rating.PendingDate.IsZero() {
details["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
}
details["version"] = strconv.FormatInt(rating.Version, 10)
}
// CollectibleUsernameErrorCode maps a collectible-username domain error onto the
// stable code the admin panel switches on. An unmapped error returns "" so the
// caller can fall back to a generic failure instead of inventing a code.
func CollectibleUsernameErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrUsernameOccupied):
return CodeUsernameOccupied
case errors.Is(err, domain.ErrCollectibleUsernameNotFound), errors.Is(err, domain.ErrUsernameNotOccupied):
return CodeCollectibleNotFound
case errors.Is(err, domain.ErrCollectibleUsernameBurned):
return CodeCollectibleBurned
case errors.Is(err, domain.ErrCollectibleUsernameLimit):
return CodeCollectiblePeerLimit
case errors.Is(err, domain.ErrCollectibleUsernameNotOwned):
return CodeCollectibleNotOwned
case errors.Is(err, domain.ErrCollectibleCurrencyInvalid):
return CodeCollectibleCurrencyInvalid
case errors.Is(err, domain.ErrUsernameNotCollectible), errors.Is(err, domain.ErrUsernameNotEditable):
return CodeUsernameNotCollectible
case errors.Is(err, domain.ErrUsernameInvalid):
return CodeUsernameInvalid
case errors.Is(err, domain.ErrCollectibleUsernameStateInvalid), errors.Is(err, domain.ErrUsernameOrderInvalid):
return CodeCollectibleStateInvalid
default:
return ""
}
}
// AccountRatingErrorCode maps an account-rating domain error onto its stable
// admin code. An unmapped error returns "".
func AccountRatingErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrAccountRatingNotFound):
return CodeRatingNotFound
case errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid):
return CodeRatingAdjustmentInvalid
case errors.Is(err, domain.ErrAccountRatingWeightsInvalid):
return CodeRatingWeightsInvalid
default:
return ""
}
}
// collectibleUsernameError / accountRatingError prefix a recognised domain error
// with its stable code, so the journalled command result and the operator both
// see "CODE: message" instead of a bare Go string. Unrecognised errors are
// returned untouched: inventing a code for an unknown failure would be worse
// than reporting it verbatim.
func collectibleUsernameError(err error) error {
if code := CollectibleUsernameErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func accountRatingError(err error) error {
if code := AccountRatingErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func codedError(code string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", code, err)
}
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")

View file

@ -7,16 +7,28 @@ import (
"encoding/hex"
"errors"
"reflect"
"sort"
"strings"
"testing"
"time"
ratingapp "telesrv/internal/app/rating"
stargiftapp "telesrv/internal/app/stargifts"
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/domain"
"telesrv/internal/officialgifts"
"telesrv/internal/store/memory"
)
// Compile-time proof that the shipped use-case services satisfy the admin ports.
// cmd/telesrv wires *usernames.Service and *rating.Service into
// Dependencies.Usernames / Dependencies.Rating directly, so a drifting method set
// has to fail here rather than at integration time.
var (
_ CollectibleUsernamesService = (*usernamesapp.Service)(nil)
_ AccountRatingService = (*ratingapp.Service)(nil)
)
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
@ -1311,3 +1323,653 @@ func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.
f.channels = append(f.channels, ch.ID)
return nil
}
// fakeCollectibleUsernamesService is an in-memory collectible lifecycle: enough
// state to observe occupancy, replay-by-command-key and the burned terminal
// state, which is exactly what the admin commands are expected to reason about.
type fakeCollectibleUsernamesService struct {
assets map[string]domain.CollectibleUsername
log map[int64][]domain.CollectibleUsernameTransfer
commandKeys map[string]int64
nextID int64
mintCalls int
transferCalls int
revokeCalls int
deleteCalls int
}
func newFakeCollectibleUsernames() *fakeCollectibleUsernamesService {
return &fakeCollectibleUsernamesService{
assets: map[string]domain.CollectibleUsername{},
log: map[int64][]domain.CollectibleUsernameTransfer{},
commandKeys: map[string]int64{},
nextID: 100,
}
}
func (f *fakeCollectibleUsernamesService) Mint(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.mintCalls++
if id, ok := f.commandKeys[req.CommandKey]; ok && req.CommandKey != "" {
return f.byID(id), false, nil
}
key := strings.ToLower(req.Username)
if _, ok := f.assets[key]; ok {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
f.nextID++
asset := domain.CollectibleUsername{
ID: f.nextID, Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency, CryptoAmount: req.CryptoAmount, URL: req.URL,
Version: 1,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
asset.OriginalOwner = req.Owner
}
f.assets[key] = asset
f.commandKeys[req.CommandKey] = asset.ID
f.log[asset.ID] = append(f.log[asset.ID], domain.CollectibleUsernameTransfer{
ID: asset.ID, CollectibleID: asset.ID, Kind: domain.CollectibleUsernameKindMint,
To: req.Owner, Actor: req.Actor, Reason: req.Reason, CommandKey: req.CommandKey,
})
return asset, true, nil
}
func (f *fakeCollectibleUsernamesService) Transfer(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.transferCalls++
key := strings.ToLower(req.Username)
asset, ok := f.assets[key]
if !ok {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if asset.Owned() && asset.Owner == req.To {
return asset, false, nil
}
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
if asset.OriginalOwner.Type == "" {
asset.OriginalOwner = req.To
}
asset.TransferCount++
asset.Version++
f.assets[key] = asset
return asset, true, nil
}
func (f *fakeCollectibleUsernamesService) Delete(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
f.deleteCalls++
key := strings.ToLower(req.Username)
asset, ok := f.assets[key]
if !ok {
return false, nil
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return false, nil
}
delete(f.assets, key)
return true, nil
}
func (f *fakeCollectibleUsernamesService) Revoke(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.revokeCalls++
key := strings.ToLower(req.Username)
asset, ok := f.assets[key]
if !ok {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotFound
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
asset.Owner = domain.Peer{}
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
asset.Status = domain.CollectibleUsernameStatusBurned
}
asset.Version++
f.assets[key] = asset
return asset, true, nil
}
func (f *fakeCollectibleUsernamesService) Collectible(_ context.Context, username string) (domain.CollectibleUsername, error) {
asset, ok := f.assets[strings.ToLower(username)]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
func (f *fakeCollectibleUsernamesService) List(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
out := make([]domain.CollectibleUsername, 0, len(f.assets))
for _, asset := range f.assets {
if filter.Status != "" && asset.Status != filter.Status {
continue
}
if filter.BeforeID != 0 && asset.ID >= filter.BeforeID {
continue
}
out = append(out, asset)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if filter.Limit > 0 && len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, nil
}
func (f *fakeCollectibleUsernamesService) Transfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
return f.log[collectibleID], nil
}
func (f *fakeCollectibleUsernamesService) byID(id int64) domain.CollectibleUsername {
for _, asset := range f.assets {
if asset.ID == id {
return asset
}
}
return domain.CollectibleUsername{}
}
// fakeAccountRatingService recomputes from the manual component only, which
// keeps the arithmetic in the domain and the fake focused on ledger replay.
type fakeAccountRatingService struct {
ratings map[int64]domain.AccountRating
manual map[int64]int64
commandKeys map[string]bool
recomputeCalls int
adjustCalls int
}
func newFakeAccountRating() *fakeAccountRatingService {
return &fakeAccountRatingService{
ratings: map[int64]domain.AccountRating{},
manual: map[int64]int64{},
commandKeys: map[string]bool{},
}
}
func (f *fakeAccountRatingService) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating, ok := f.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
func (f *fakeAccountRatingService) Recompute(_ context.Context, userID int64) (domain.AccountRating, error) {
f.recomputeCalls++
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{
UserID: userID, StarsReceived: 5000, Manual: f.manual[userID],
}, domain.DefaultAccountRatingWeights(), fixedNow())
rating.Version = f.ratings[userID].Version + 1
f.ratings[userID] = rating
return rating, nil
}
func (f *fakeAccountRatingService) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
f.adjustCalls++
if err := req.Validate(); err != nil {
return domain.AccountRating{}, false, err
}
applied := true
if f.commandKeys[req.CommandKey] {
applied = false
} else {
f.commandKeys[req.CommandKey] = true
f.manual[req.UserID] += req.Amount
}
rating, err := f.Recompute(ctx, req.UserID)
if err != nil {
return domain.AccountRating{}, applied, err
}
return rating, applied, nil
}
func (f *fakeAccountRatingService) List(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
out := make([]domain.AccountRating, 0, len(f.ratings))
for _, rating := range f.ratings {
if rating.Level < filter.MinLevel {
continue
}
out = append(out, rating)
}
sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID })
return out, nil
}
func (f *fakeAccountRatingService) Events(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
if f.manual[userID] == 0 {
return nil, nil
}
return []domain.AccountRatingEvent{{
ID: 1, UserID: userID, Kind: domain.AccountRatingEventManual, Amount: f.manual[userID],
}}, nil
}
func TestMintCollectibleUsernameDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
dry, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "dry-mint", Actor: "ops", Reason: "fragment import", DryRun: true},
Username: "@Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
})
if err != nil {
t.Fatalf("dry-run mint: %v", err)
}
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 0 {
t.Fatalf("dry-run result=%+v mintCalls=%d, want validation without mutation", dry, usernames.mintCalls)
}
if dry.Details["username"] != "Durov" || dry.Details["purchase_date"] != fixedNow().Format(time.RFC3339) {
t.Fatalf("dry-run details=%+v, want a normalised name and a stamped purchase date", dry.Details)
}
execReq := MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "exec-mint", Actor: "ops", Reason: "fragment import"},
Username: "Durov", OwnerUserID: 1001, Currency: domain.CollectibleCurrencyTON,
Amount: 250_000_000_000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 250_000_000_000,
}
exec, err := svc.MintCollectibleUsername(ctx, execReq)
if err != nil {
t.Fatalf("execute mint: %v", err)
}
if exec.Status != string(domain.AdminCommandCompleted) || usernames.mintCalls != 1 ||
exec.Details["status"] != string(domain.CollectibleUsernameStatusOwned) {
t.Fatalf("execute result=%+v mintCalls=%d", exec, usernames.mintCalls)
}
again, err := svc.MintCollectibleUsername(ctx, execReq)
if err != nil {
t.Fatalf("replay mint: %v", err)
}
if !again.AlreadyExecuted || usernames.mintCalls != 1 {
t.Fatalf("replay result=%+v mintCalls=%d, want idempotent replay", again, usernames.mintCalls)
}
occupied, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "exec-mint-2", Actor: "ops", Reason: "duplicate"},
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
})
if err == nil || !strings.Contains(err.Error(), CodeUsernameOccupied) {
t.Fatalf("duplicate mint err=%v, want %s", err, CodeUsernameOccupied)
}
if occupied.Status != string(domain.AdminCommandFailed) || usernames.mintCalls != 1 {
t.Fatalf("duplicate result=%+v mintCalls=%d, want a journalled failure without mutation", occupied, usernames.mintCalls)
}
}
func TestMintCollectibleUsernameValidatesBeforeJournallingCommand(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
cases := []struct {
name string
req MintCollectibleUsernameRequest
code string
}{
{
name: "short username",
req: MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "bad-1", Actor: "ops", Reason: "invalid"},
Username: "ab", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
},
code: CodeUsernameInvalid,
},
{
name: "unsupported currency",
req: MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "bad-2", Actor: "ops", Reason: "invalid"},
Username: "durov", Currency: "EUR", Amount: 1,
},
code: CodeCollectibleCurrencyInvalid,
},
{
name: "crypto amount without currency",
req: MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "bad-3", Actor: "ops", Reason: "invalid"},
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CryptoAmount: 5,
},
code: CodeCollectibleCurrencyInvalid,
},
}
for _, item := range cases {
if _, err := svc.MintCollectibleUsername(ctx, item.req); err == nil || !strings.Contains(err.Error(), item.code) {
t.Fatalf("%s err=%v, want %s", item.name, err, item.code)
}
if _, journalled := repo.items[item.req.CommandID]; journalled {
t.Fatalf("%s journalled a rejected command", item.name)
}
}
if usernames.mintCalls != 0 {
t.Fatalf("rejected requests reached the lifecycle: mintCalls=%d", usernames.mintCalls)
}
}
func TestTransferCollectibleUsernameRequiresRecipientAndRejectsBurned(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-none", Actor: "ops", Reason: "sold"}, Username: "durov",
}); err == nil || !strings.Contains(err.Error(), "to_user_id") {
t.Fatalf("transfer without recipient err=%v", err)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-both", Actor: "ops", Reason: "sold"},
Username: "durov", ToUserID: 1001, ToChannelID: 2002,
}); err == nil {
t.Fatal("transfer accepted two recipients")
}
dry, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-dry", Actor: "ops", Reason: "sold", DryRun: true},
Username: "durov", ToUserID: 1001,
})
if err != nil {
t.Fatalf("dry-run transfer: %v", err)
}
if usernames.transferCalls != 0 || dry.Details["would_change"] != true {
t.Fatalf("dry-run transfer result=%+v transferCalls=%d", dry, usernames.transferCalls)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-exec", Actor: "ops", Reason: "sold"},
Username: "durov", ToUserID: 1001,
}); err != nil {
t.Fatalf("execute transfer: %v", err)
}
if usernames.transferCalls != 1 {
t.Fatalf("transferCalls=%d, want one mutation", usernames.transferCalls)
}
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "burn-1", Actor: "ops", Reason: "fraud"},
Username: "durov", Burn: true,
}); err != nil {
t.Fatalf("burn: %v", err)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-burned", Actor: "ops", Reason: "sold"},
Username: "durov", ToUserID: 1002,
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
t.Fatalf("transfer of a burned asset err=%v, want %s", err, CodeCollectibleBurned)
}
if _, err := svc.TransferCollectibleUsername(ctx, TransferCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "t-missing", Actor: "ops", Reason: "sold"},
Username: "nobody_holds_this", ToUserID: 1002,
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotFound) {
t.Fatalf("transfer of a missing asset err=%v, want %s", err, CodeCollectibleNotFound)
}
}
func TestRevokeCollectibleUsernameRejectsVaultAssetWithoutBurn(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed",
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
if _, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "rv-vault", Actor: "ops", Reason: "nothing to take"},
Username: "durov",
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleNotOwned) {
t.Fatalf("revoke of a vault asset err=%v, want %s", err, CodeCollectibleNotOwned)
}
if usernames.revokeCalls != 0 {
t.Fatalf("revokeCalls=%d, want no mutation", usernames.revokeCalls)
}
burnDry, err := svc.RevokeCollectibleUsername(ctx, RevokeCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "rv-burn-dry", Actor: "ops", Reason: "fraud", DryRun: true},
Username: "durov", Burn: true,
})
if err != nil {
t.Fatalf("dry-run burn: %v", err)
}
if usernames.revokeCalls != 0 || burnDry.Details["burn"] != true {
t.Fatalf("dry-run burn result=%+v revokeCalls=%d", burnDry, usernames.revokeCalls)
}
}
func TestCollectibleUsernameByIDUsesKeysetFallback(t *testing.T) {
ctx := context.Background()
usernames := newFakeCollectibleUsernames()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Usernames: usernames, Now: fixedNow})
first, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-1",
})
if err != nil {
t.Fatalf("seed mint: %v", err)
}
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "telegram", Currency: domain.CollectibleCurrencyUSD, Amount: 1, CommandKey: "seed-2",
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
got, err := svc.CollectibleUsernameByID(ctx, first.ID)
if err != nil || got.ID != first.ID || got.Username != "durov" {
t.Fatalf("CollectibleUsernameByID(%d) = %+v err=%v", first.ID, got, err)
}
if _, err := svc.CollectibleUsernameByID(ctx, first.ID-1); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("missing id err=%v, want ErrCollectibleUsernameNotFound", err)
}
}
func TestAdjustAccountRatingDryRunExecuteAndIdempotency(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
rating := newFakeAccountRating()
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
dry, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "dry-adjust", Actor: "ops", Reason: "penalty", DryRun: true},
UserID: 1001, Amount: -2500,
})
if err != nil {
t.Fatalf("dry-run adjust: %v", err)
}
if rating.adjustCalls != 0 || dry.Details["previous_found"] != false || dry.Details["amount"] != "-2500" {
t.Fatalf("dry-run adjust result=%+v adjustCalls=%d", dry, rating.adjustCalls)
}
execReq := AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "exec-adjust", Actor: "ops", Reason: "penalty"},
UserID: 1001, Amount: -2500,
}
exec, err := svc.AdjustAccountRating(ctx, execReq)
if err != nil {
t.Fatalf("execute adjust: %v", err)
}
if rating.adjustCalls != 1 || exec.Details["applied"] != true ||
exec.Details["manual_component"] != "-2500" || exec.Details["stars"] != "2500" {
t.Fatalf("execute adjust result=%+v adjustCalls=%d", exec, rating.adjustCalls)
}
again, err := svc.AdjustAccountRating(ctx, execReq)
if err != nil {
t.Fatalf("replay adjust: %v", err)
}
if !again.AlreadyExecuted || rating.adjustCalls != 1 || rating.manual[1001] != -2500 {
t.Fatalf("replay adjust result=%+v adjustCalls=%d manual=%d", again, rating.adjustCalls, rating.manual[1001])
}
for _, amount := range []int64{0, maxAccountRatingAdjustment + 1, -maxAccountRatingAdjustment - 1} {
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "bad-adjust", Actor: "ops", Reason: "invalid"},
UserID: 1001, Amount: amount,
}); err == nil || !strings.Contains(err.Error(), CodeRatingAdjustmentInvalid) {
t.Fatalf("adjust by %d err=%v, want %s", amount, err, CodeRatingAdjustmentInvalid)
}
}
if _, journalled := repo.items["bad-adjust"]; journalled {
t.Fatal("journalled a rejected adjustment")
}
}
func TestRecomputeAccountRatingDryRunAndExecute(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
rating := newFakeAccountRating()
svc := NewService(Dependencies{Commands: repo, Rating: rating, Now: fixedNow})
if _, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-invalid", Actor: "ops", Reason: "support"},
}); err == nil || !strings.Contains(err.Error(), "user_id") {
t.Fatalf("recompute without user err=%v", err)
}
dry, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-dry", Actor: "ops", Reason: "support", DryRun: true},
UserID: 1001,
})
if err != nil {
t.Fatalf("dry-run recompute: %v", err)
}
if rating.recomputeCalls != 0 || dry.Details["previous_found"] != false {
t.Fatalf("dry-run recompute result=%+v recomputeCalls=%d", dry, rating.recomputeCalls)
}
exec, err := svc.RecomputeAccountRating(ctx, RecomputeAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "rc-exec", Actor: "ops", Reason: "support"},
UserID: 1001,
})
if err != nil {
t.Fatalf("execute recompute: %v", err)
}
if rating.recomputeCalls != 1 || exec.Details["stars"] != "5000" || exec.Details["version"] != "1" {
t.Fatalf("execute recompute result=%+v recomputeCalls=%d", exec, rating.recomputeCalls)
}
stored, err := svc.AccountRating(ctx, 1001)
if err != nil || stored.Stars != 5000 {
t.Fatalf("AccountRating = %+v err=%v", stored, err)
}
if events, err := svc.AccountRatingEvents(ctx, 1001, 10); err != nil || len(events) != 0 {
t.Fatalf("AccountRatingEvents = %+v err=%v, want an empty ledger", events, err)
}
}
func TestCollectibleAndRatingCommandsRequireConfiguredDependencies(t *testing.T) {
ctx := context.Background()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
if _, err := svc.MintCollectibleUsername(ctx, MintCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "c-1", Actor: "ops", Reason: "x"},
Username: "durov", Currency: domain.CollectibleCurrencyUSD, Amount: 1,
}); err == nil || !strings.Contains(err.Error(), "collectible username dependency") {
t.Fatalf("mint without dependency err=%v", err)
}
if _, err := svc.AdjustAccountRating(ctx, AdjustAccountRatingRequest{
CommandMeta: CommandMeta{CommandID: "c-2", Actor: "ops", Reason: "x"},
UserID: 1001, Amount: 5,
}); err == nil || !strings.Contains(err.Error(), "account rating dependency") {
t.Fatalf("adjust without dependency err=%v", err)
}
if _, err := svc.CollectibleUsernames(ctx, domain.CollectibleUsernameFilter{}); err == nil {
t.Fatal("listing without dependency succeeded")
}
if _, err := svc.AccountRatings(ctx, domain.AccountRatingFilter{}); err == nil {
t.Fatal("rating listing without dependency succeeded")
}
}
// TestDeleteCollectibleUsernameCommand covers the hard-delete command: the
// journal captures what was removed before the record disappears, a dry-run
// mutates nothing, and a burned asset is refused.
func TestDeleteCollectibleUsernameCommand(t *testing.T) {
ctx := context.Background()
usernames := newFakeCollectibleUsernames()
repo := newMemoryCommandRepo()
svc := NewService(Dependencies{Commands: repo, Usernames: usernames, Now: fixedNow})
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 8801}
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "wrongname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
}); err != nil {
t.Fatalf("seed mint: %v", err)
}
dry, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-dry", Actor: "ops", Reason: "mistake", DryRun: true},
Username: "@wrongname",
})
if err != nil {
t.Fatalf("dry run: %v", err)
}
if !dry.DryRun || usernames.deleteCalls != 0 {
t.Fatalf("dry run mutated: result=%+v calls=%d", dry, usernames.deleteCalls)
}
if dry.Details["previous_owner_id"] != "8801" {
t.Fatalf("dry run details = %+v, want the holder captured", dry.Details)
}
exec, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
Username: "wrongname",
})
if err != nil {
t.Fatalf("exec: %v", err)
}
if usernames.deleteCalls != 1 || exec.Details["deleted"] != true {
t.Fatalf("exec = %+v calls=%d", exec, usernames.deleteCalls)
}
if exec.Details["previous_status"] != string(domain.CollectibleUsernameStatusOwned) {
t.Fatalf("journal lost the pre-delete state: %+v", exec.Details)
}
// Replaying the same command id must not touch the store again.
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-exec", Actor: "ops", Reason: "mistake"},
Username: "wrongname",
}); err != nil {
t.Fatalf("replay: %v", err)
}
if usernames.deleteCalls != 1 {
t.Fatalf("replay called the store again: calls=%d", usernames.deleteCalls)
}
// A burned asset is history: it is released by re-issuing the name, not deleted.
if _, _, err := usernames.Mint(ctx, domain.MintCollectibleUsernameRequest{
Username: "burnedname", Owner: holder, Currency: domain.CollectibleCurrencyStars, Amount: 100,
}); err != nil {
t.Fatalf("seed burned mint: %v", err)
}
if _, _, err := usernames.Revoke(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnedname", Burn: true,
}); err != nil {
t.Fatalf("seed burn: %v", err)
}
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-burned", Actor: "ops", Reason: "cleanup"},
Username: "burnedname",
}); err == nil || !strings.Contains(err.Error(), CodeCollectibleBurned) {
t.Fatalf("delete of burned asset err = %v, want %s", err, CodeCollectibleBurned)
}
// A short name is rejected before a command is journalled at all.
if _, err := svc.DeleteCollectibleUsername(ctx, DeleteCollectibleUsernameRequest{
CommandMeta: CommandMeta{CommandID: "del-short", Actor: "ops", Reason: "cleanup"},
Username: "no",
}); err == nil {
t.Fatalf("delete of invalid name = nil error, want rejection")
}
}

View file

@ -0,0 +1,529 @@
package admin
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
)
// Official platform verification review.
//
// The review actions live behind the same command journal as every other
// operator write: a decision is auditable, replayable by command id and
// rehearsable with a dry run. The application record itself is the durable audit
// subject, so the details captured here name the application, its target and the
// status it moved between, and they carry the correlation id that ties the
// command journal entry to the immutable application event.
// VerificationService is the operator-facing slice of the official verification
// use cases. It is the exact method set *app/verification.Service exposes for the
// reviewer side, so the admin layer never reaches into the store.
type VerificationService interface {
List(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
Counts(ctx context.Context) (domain.VerificationStatusCounts, error)
Events(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
TargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
Claim(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error)
Approve(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
Reject(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error)
Revoke(ctx context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error)
}
// ClaimVerificationRequest assigns a reviewer to a submitted application.
type ClaimVerificationRequest struct {
CommandMeta
ApplicationID int64 `json:"application_id"`
// Version is the optimistic-locking token the reviewer read. Two reviewers
// opening the same row submit the same version and exactly one wins.
Version int64 `json:"version"`
// InternalNote is optional on a claim: a reviewer picking a case up may want to
// record why ("waiting on legal") without deciding it yet.
InternalNote string `json:"internal_note,omitempty"`
}
// ApproveVerificationRequest grants the platform badge.
type ApproveVerificationRequest struct {
CommandMeta
ApplicationID int64 `json:"application_id"`
Version int64 `json:"version"`
// InternalNote is operator-only. It is journalled and appended to the
// application history, and it is never part of what the applicant is told.
InternalNote string `json:"internal_note,omitempty"`
}
// RejectVerificationRequest closes an application against the applicant. Reason
// is mandatory: it is the text the applicant receives.
type RejectVerificationRequest struct {
CommandMeta
ApplicationID int64 `json:"application_id"`
Version int64 `json:"version"`
InternalNote string `json:"internal_note,omitempty"`
}
// RevokeVerificationRequest clears the badge of a previously approved target. It
// addresses the target rather than an application, because a revocation is not a
// decision on the application: the application stays approved as history.
type RevokeVerificationRequest struct {
CommandMeta
TargetType domain.VerificationTargetType `json:"target_type"`
TargetID int64 `json:"target_id"`
InternalNote string `json:"internal_note,omitempty"`
}
// ---------------------------------------------------------------------------
// Reads
// ---------------------------------------------------------------------------
// VerificationApplications is the review-queue listing. The filter is passed
// through unchanged: the use-case layer owns normalisation and the page bound.
func (s *Service) VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
if s == nil || s.verification == nil {
return nil, errVerificationNotConfigured
}
return s.verification.List(ctx, filter)
}
// VerificationCounts is the queue summary rendered above the list.
func (s *Service) VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error) {
if s == nil || s.verification == nil {
return nil, errVerificationNotConfigured
}
return s.verification.Counts(ctx)
}
// VerificationApplication resolves one application by identity.
func (s *Service) VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
if s == nil || s.verification == nil {
return domain.VerificationApplication{}, errVerificationNotConfigured
}
if applicationID <= 0 {
return domain.VerificationApplication{}, verificationCoded(domain.ErrVerificationApplicationNotFound)
}
return s.verification.Application(ctx, applicationID)
}
// VerificationApplicationEvents returns one application's immutable history.
func (s *Service) VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
if s == nil || s.verification == nil {
return nil, errVerificationNotConfigured
}
return s.verification.Events(ctx, applicationID, limit)
}
// VerificationTargetSnapshot returns the target's state as it is now: title,
// username, badge, and whether it would pass the eligibility checks today.
func (s *Service) VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error) {
if s == nil || s.verification == nil {
return domain.VerificationTarget{}, errVerificationNotConfigured
}
return s.verification.TargetSnapshot(ctx, targetType, targetID)
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
// ClaimVerification takes ownership of a submitted application.
//
// Claiming an application that somebody else already claimed is not idempotent:
// the status machine has no in_review -> in_review edge, so the second reviewer
// is told the row is taken instead of silently stealing it.
func (s *Service) ClaimVerification(ctx context.Context, req ClaimVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
return s.runCommand(ctx, req.CommandMeta, ActionClaimVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusInReview, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := verificationTransition(app, req.Version, domain.VerificationStatusInReview, false); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "verification claim validated", Details: details}, nil
}
claimed, err := s.verification.Claim(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
mergeVerificationDetails(details, claimed, true)
return CommandResult{Message: "verification application claimed", Details: details}, nil
})
}
// ApproveVerification grants the platform badge.
//
// The dry run reloads the target snapshot and refuses in advance whatever the
// real approval would refuse: the snapshot re-runs every eligibility check
// except the ownership probe, and a missing ownership can only make the real run
// stricter, never more permissive. So a passing dry run never turns into a
// surprise, and a failing one names the reason before anything is written.
func (s *Service) ApproveVerification(ctx context.Context, req ApproveVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
return s.runCommand(ctx, req.CommandMeta, ActionApproveVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusApproved, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := verificationTransition(app, req.Version, domain.VerificationStatusApproved, true); err != nil {
return CommandResult{Details: details}, err
}
// An application that is already approved is a replay: the use-case layer
// returns it untouched without re-running the target checks, so the target
// gate must not fire here either -- otherwise a retry would fail a dry run
// that the real command answers as a no-op.
replay := app.Status == domain.VerificationStatusApproved
if err := s.mergeVerificationTargetDetails(ctx, details, app, !replay); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "verification approve validated", Details: details}, nil
}
approved, changed, err := s.verification.Approve(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
mergeVerificationDetails(details, approved, changed)
message := "verification application approved"
if !changed {
message = "verification application already approved"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RejectVerification closes an application against the applicant. The reason is
// mandatory and is the text the applicant is shown; the internal note stays
// operator-side.
func (s *Service) RejectVerification(ctx context.Context, req RejectVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if err := validateVerificationDecisionShape(req.ApplicationID, req.Version, req.InternalNote); err != nil {
return CommandResult{}, err
}
// A rejection without a stated reason is refused before the journal is
// touched: the audit trail must never contain a decision nobody can explain.
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
return s.runCommand(ctx, req.CommandMeta, ActionRejectVerification, 0, domain.Peer{}, req, func() (CommandResult, error) {
app, details, err := s.verificationSubject(ctx, req.CommandMeta, req.ApplicationID, domain.VerificationStatusRejected, req.InternalNote)
if err != nil {
return CommandResult{Details: details}, err
}
if err := verificationTransition(app, req.Version, domain.VerificationStatusRejected, true); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "verification reject validated", Details: details}, nil
}
rejected, changed, err := s.verification.Reject(ctx, verificationDecision(req.CommandMeta, req.ApplicationID, req.Version, req.InternalNote))
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
mergeVerificationDetails(details, rejected, changed)
message := "verification application rejected"
if !changed {
message = "verification application already rejected"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// RevokeVerification clears the platform badge from a target.
//
// It addresses the peer, not an application: the approved application stays
// approved as history and the revocation is its own audit event. A reason is
// mandatory for the same reason a rejection needs one.
func (s *Service) RevokeVerification(ctx context.Context, req RevokeVerificationRequest) (CommandResult, error) {
if s == nil || s.verification == nil {
return CommandResult{}, errVerificationNotConfigured
}
if req.TargetID <= 0 || !req.TargetType.Valid() {
return CommandResult{}, codedError(CodeVerificationTargetInvalid, domain.ErrVerificationTargetInvalid)
}
if utf8.RuneCountInString(req.InternalNote) > domain.MaxVerificationNoteLength {
return CommandResult{}, verificationInvalid("internal_note is too long")
}
if strings.TrimSpace(req.Reason) == "" {
return CommandResult{}, codedError(CodeVerificationReasonRequired, domain.ErrVerificationReasonRequired)
}
targetPeer := domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}
targetUserID := int64(0)
if targetPeer.Type == domain.PeerTypeUser {
targetUserID = req.TargetID
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeVerification, targetUserID, targetPeer, req, func() (CommandResult, error) {
details := map[string]any{
"target_type": string(req.TargetType),
"target_id": strconv.FormatInt(req.TargetID, 10),
"correlation_id": strings.TrimSpace(req.CommandID),
}
if note := strings.TrimSpace(req.InternalNote); note != "" {
details["internal_note"] = note
}
// A built-in system account carries its badge by construction, so revoking
// it would desynchronise the seeded record from domain.SystemUserByID.
if targetPeer.Type == domain.PeerTypeUser && domain.IsSystemUserID(req.TargetID) {
return CommandResult{Details: details}, codedError(CodeVerificationTargetSystem, domain.ErrVerificationTargetSystem)
}
target, err := s.verification.TargetSnapshot(ctx, req.TargetType, req.TargetID)
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
details["target_title"] = target.Title
details["target_username"] = target.Username
details["target_verified"] = target.Verified
if req.DryRun {
message := "verification revoke validated"
if !target.Verified {
message = "verification revoke validated; target carries no badge"
}
return CommandResult{Message: message, Details: details}, nil
}
app, changed, err := s.verification.Revoke(ctx, domain.VerificationRevocation{
TargetType: req.TargetType,
TargetID: req.TargetID,
Reviewer: strings.TrimSpace(req.Actor),
Reason: strings.TrimSpace(req.Reason),
InternalNote: strings.TrimSpace(req.InternalNote),
CorrelationID: strings.TrimSpace(req.CommandID),
})
if err != nil {
return CommandResult{Details: details}, verificationError(err)
}
details["changed"] = changed
details["target_verified"] = false
if app.ID > 0 {
// The revoked target usually has an approved application behind it; it
// stays approved, which is why previous_status and status match here.
details["application_id"] = strconv.FormatInt(app.ID, 10)
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
details["previous_status"] = string(app.Status)
details["status"] = string(app.Status)
details["version"] = strconv.FormatInt(app.Version, 10)
}
message := "verification badge revoked"
if !changed {
message = "verification badge was already absent"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
var errVerificationNotConfigured = errors.New("admin verification dependency is not configured")
// verificationSubject loads the application under decision and seeds the command
// details with everything the audit entry must state even when the command then
// fails: which application, whose, which target, the status it is leaving and the
// status it was asked to reach.
func (s *Service) verificationSubject(
ctx context.Context,
meta CommandMeta,
applicationID int64,
next domain.VerificationStatus,
internalNote string,
) (domain.VerificationApplication, map[string]any, error) {
details := map[string]any{
"application_id": strconv.FormatInt(applicationID, 10),
"next_status": string(next),
"correlation_id": strings.TrimSpace(meta.CommandID),
}
if note := strings.TrimSpace(internalNote); note != "" {
details["internal_note"] = note
}
app, err := s.verification.Application(ctx, applicationID)
if err != nil {
return domain.VerificationApplication{}, details, verificationError(err)
}
details["applicant_user_id"] = strconv.FormatInt(app.ApplicantUserID, 10)
details["target_type"] = string(app.TargetType)
details["target_id"] = strconv.FormatInt(app.TargetID, 10)
details["target_username"] = app.TargetUsername
details["previous_status"] = string(app.Status)
details["previous_version"] = strconv.FormatInt(app.Version, 10)
return app, details, nil
}
// mergeVerificationTargetDetails records the current target snapshot and, for a
// decision that flips the badge, refuses a target the platform may not verify.
func (s *Service) mergeVerificationTargetDetails(ctx context.Context, details map[string]any, app domain.VerificationApplication, requireEligible bool) error {
target, err := s.verification.TargetSnapshot(ctx, app.TargetType, app.TargetID)
if err != nil {
return verificationError(err)
}
details["target_title"] = target.Title
details["target_current_username"] = target.Username
details["target_verified"] = target.Verified
details["target_eligible"] = target.Eligible
if target.Reason != "" {
details["target_reason"] = target.Reason
}
if requireEligible && !target.Eligible {
return verificationError(verificationTargetReasonError(target.Reason))
}
return nil
}
// mergeVerificationDetails records the decided state.
func mergeVerificationDetails(details map[string]any, app domain.VerificationApplication, changed bool) {
details["status"] = string(app.Status)
details["version"] = strconv.FormatInt(app.Version, 10)
details["changed"] = changed
details["reviewer_admin_id"] = app.ReviewerAdminID
if app.CorrelationID != "" {
details["correlation_id"] = app.CorrelationID
}
if app.DecisionReason != "" {
details["decision_reason"] = app.DecisionReason
}
}
// verificationDecision builds the domain decision.
//
// The admin command id doubles as the correlation id, so one token links the
// command journal entry, the immutable application event and the applicant
// notification. The internal note travels in its own field and never in Reason,
// which is what the applicant is shown.
func verificationDecision(meta CommandMeta, applicationID, version int64, internalNote string) domain.VerificationDecision {
return domain.VerificationDecision{
ApplicationID: applicationID,
Version: version,
Reviewer: strings.TrimSpace(meta.Actor),
Reason: strings.TrimSpace(meta.Reason),
InternalNote: strings.TrimSpace(internalNote),
CorrelationID: strings.TrimSpace(meta.CommandID),
}
}
// validateVerificationDecisionShape rejects a malformed decision before the
// command journal is touched.
func validateVerificationDecisionShape(applicationID, version int64, internalNote string) error {
if applicationID <= 0 {
return verificationCoded(domain.ErrVerificationApplicationNotFound)
}
if version <= 0 {
// Without the version the reviewer never read the row, so the optimistic
// lock could not protect a concurrent decision.
return verificationInvalid("version is required")
}
if utf8.RuneCountInString(internalNote) > domain.MaxVerificationNoteLength {
return verificationInvalid("internal_note is too long")
}
return nil
}
// verificationTransition checks the status machine and the optimistic lock in the
// order the use-case layer does, so a dry run predicts the real outcome exactly.
//
// idempotent marks the decisions the service treats as a no-op on replay
// (approve/reject of an already decided application). A claim is not among them:
// there is no in_review -> in_review edge.
func verificationTransition(app domain.VerificationApplication, version int64, next domain.VerificationStatus, idempotent bool) error {
if idempotent && app.Status == next {
return nil
}
if !domain.CanTransitionVerificationStatus(app.Status, next) {
return codedError(CodeVerificationStatusInvalid, fmt.Errorf("%w: %s -> %s", domain.ErrVerificationStatusInvalid, app.Status, next))
}
if app.Version != version {
return codedError(CodeVerificationConflict, domain.ErrVerificationVersionConflict)
}
return nil
}
// verificationTargetReasonError maps the snapshot's rendered ineligibility reason
// back onto its domain sentinel. VerificationTarget.Reason is a string by
// design -- it is rendered to applicants by the bot -- so the reverse lookup is
// what lets the admin layer answer with a stable code instead of a bare message.
func verificationTargetReasonError(reason string) error {
for _, candidate := range []error{
domain.ErrVerificationTargetAlreadyVerified,
domain.ErrVerificationTargetRestricted,
domain.ErrVerificationTargetNotPublic,
domain.ErrVerificationTargetSystem,
domain.ErrVerificationUserTargetsDisabled,
domain.ErrVerificationTargetInvalid,
} {
if candidate.Error() == reason {
return candidate
}
}
return fmt.Errorf("%w: %s", domain.ErrVerificationTargetInvalid, reason)
}
// VerificationErrorCode maps a verification failure onto the stable code the
// admin panel switches on. An unmapped error returns "" so the caller can report
// it verbatim instead of inventing a code.
func VerificationErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
return CodeVerificationNotFound
case errors.Is(err, domain.ErrVerificationVersionConflict):
return CodeVerificationConflict
case errors.Is(err, domain.ErrVerificationApplicationExists):
return CodeVerificationTargetOccupied
case errors.Is(err, domain.ErrVerificationStatusInvalid):
return CodeVerificationStatusInvalid
case errors.Is(err, domain.ErrVerificationReasonRequired):
return CodeVerificationReasonRequired
case errors.Is(err, domain.ErrVerificationTargetAlreadyVerified):
return CodeVerificationTargetVerified
case errors.Is(err, domain.ErrVerificationTargetNotPublic):
return CodeVerificationTargetNotPublic
case errors.Is(err, domain.ErrVerificationTargetRestricted):
return CodeVerificationTargetRestricted
case errors.Is(err, domain.ErrVerificationTargetSystem):
return CodeVerificationTargetSystem
case errors.Is(err, domain.ErrVerificationNotOwner):
return CodeVerificationNotOwner
case errors.Is(err, domain.ErrVerificationUserTargetsDisabled):
return CodeVerificationUserTargetsDisabled
case errors.Is(err, domain.ErrVerificationTargetInvalid):
return CodeVerificationTargetInvalid
case errors.Is(err, domain.ErrVerificationURLInvalid),
errors.Is(err, domain.ErrVerificationApplicationInvalid):
return CodeVerificationInvalid
default:
return ""
}
}
// verificationError prefixes a recognised verification error with its stable
// code, the way collectibleUsernameError does for the username registry.
func verificationError(err error) error {
if code := VerificationErrorCode(err); code != "" {
return codedError(code, err)
}
return err
}
func verificationCoded(err error) error {
return codedError(VerificationErrorCode(err), err)
}
func verificationInvalid(message string) error {
return codedError(CodeVerificationInvalid, fmt.Errorf("%s: %w", message, domain.ErrVerificationApplicationInvalid))
}

View file

@ -0,0 +1,629 @@
package admin
import (
"context"
"errors"
"strings"
"testing"
"time"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
)
// Compile-time proof that the shipped use-case service satisfies the admin port.
// cmd/telesrv wires *verification.Service into Dependencies.Verification
// directly, so a drifting method set has to fail here rather than at integration
// time.
var _ VerificationService = (*verificationapp.Service)(nil)
type fakeVerificationService struct {
app domain.VerificationApplication
target domain.VerificationTarget
events []domain.VerificationApplicationEvent
counts domain.VerificationStatusCounts
appErr error
targetErr error
decideErr error
claimCalls int
approveCalls int
rejectCalls int
revokeCalls int
claimed domain.VerificationDecision
approved domain.VerificationDecision
rejected domain.VerificationDecision
revoked domain.VerificationRevocation
}
func (f *fakeVerificationService) List(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
return []domain.VerificationApplication{f.app}, nil
}
func (f *fakeVerificationService) Counts(context.Context) (domain.VerificationStatusCounts, error) {
return f.counts, nil
}
func (f *fakeVerificationService) Events(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
return f.events, nil
}
func (f *fakeVerificationService) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if f.appErr != nil {
return domain.VerificationApplication{}, f.appErr
}
if f.app.ID != applicationID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return f.app, nil
}
func (f *fakeVerificationService) TargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
if f.targetErr != nil {
return domain.VerificationTarget{}, f.targetErr
}
return f.target, nil
}
func (f *fakeVerificationService) Claim(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
f.claimCalls++
f.claimed = decision
if f.decideErr != nil {
return domain.VerificationApplication{}, f.decideErr
}
f.app.Status = domain.VerificationStatusInReview
f.app.ReviewerAdminID = decision.Reviewer
f.app.Version++
f.app.CorrelationID = decision.CorrelationID
return f.app, nil
}
func (f *fakeVerificationService) Approve(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
f.approveCalls++
f.approved = decision
if f.decideErr != nil {
return domain.VerificationApplication{}, false, f.decideErr
}
f.app.Status = domain.VerificationStatusApproved
f.app.ReviewerAdminID = decision.Reviewer
f.app.InternalNote = decision.InternalNote
f.app.Version++
f.app.CorrelationID = decision.CorrelationID
return f.app, true, nil
}
func (f *fakeVerificationService) Reject(_ context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
f.rejectCalls++
f.rejected = decision
if f.decideErr != nil {
return domain.VerificationApplication{}, false, f.decideErr
}
f.app.Status = domain.VerificationStatusRejected
f.app.ReviewerAdminID = decision.Reviewer
f.app.DecisionReason = decision.Reason
f.app.InternalNote = decision.InternalNote
f.app.Version++
f.app.CorrelationID = decision.CorrelationID
return f.app, true, nil
}
func (f *fakeVerificationService) Revoke(_ context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error) {
f.revokeCalls++
f.revoked = req
if f.decideErr != nil {
return domain.VerificationApplication{}, false, f.decideErr
}
f.target.Verified = false
return f.app, true, nil
}
func submittedVerificationApplication() domain.VerificationApplication {
return domain.VerificationApplication{
ID: 77,
ApplicantUserID: 1001,
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
TargetTitle: "Example News",
TargetUsername: "examplenews",
Category: "media",
Status: domain.VerificationStatusSubmitted,
SubmittedAt: fixedNow(),
Version: 3,
}
}
func newVerificationFixture() (*Service, *fakeVerificationService, *memoryCommandRepo) {
repo := newMemoryCommandRepo()
verification := &fakeVerificationService{
app: submittedVerificationApplication(),
target: domain.VerificationTarget{
Type: domain.VerificationTargetChannel, ID: 5005,
Title: "Example News", Username: "examplenews", Eligible: true,
},
}
svc := NewService(Dependencies{Commands: repo, Verification: verification, Now: fixedNow})
return svc, verification, repo
}
func TestClaimVerificationDryRunExecuteAndIdempotentReplay(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
dry, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "dry-claim", Actor: "alice", Reason: "picking up the queue", DryRun: true},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("dry-run claim: %v", err)
}
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || verification.claimCalls != 0 {
t.Fatalf("dry-run result=%+v claimCalls=%d, want a completed dry run without mutation", dry, verification.claimCalls)
}
if dry.Details["previous_status"] != string(domain.VerificationStatusSubmitted) ||
dry.Details["next_status"] != string(domain.VerificationStatusInReview) ||
dry.Details["application_id"] != "77" || dry.Details["target_id"] != "5005" ||
dry.Details["target_type"] != string(domain.VerificationTargetChannel) ||
dry.Details["correlation_id"] != "dry-claim" {
t.Fatalf("dry-run details=%+v, want the audit facts seeded before execution", dry.Details)
}
execReq := ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "exec-claim", Actor: "alice", Reason: "picking up the queue"},
ApplicationID: 77,
Version: 3,
}
exec, err := svc.ClaimVerification(ctx, execReq)
if err != nil {
t.Fatalf("execute claim: %v", err)
}
if verification.claimCalls != 1 || exec.Status != string(domain.AdminCommandCompleted) {
t.Fatalf("execute result=%+v claimCalls=%d", exec, verification.claimCalls)
}
if exec.Details["status"] != string(domain.VerificationStatusInReview) ||
exec.Details["reviewer_admin_id"] != "alice" || exec.Details["version"] != "4" {
t.Fatalf("execute details=%+v", exec.Details)
}
// The command id is the correlation id, so the journal entry and the
// application event can be matched up afterwards.
if verification.claimed.CorrelationID != "exec-claim" || verification.claimed.Reviewer != "alice" {
t.Fatalf("claim decision=%+v", verification.claimed)
}
if _, ok := repo.items["exec-claim"]; !ok {
t.Fatal("claim was not journalled")
}
replay, err := svc.ClaimVerification(ctx, execReq)
if err != nil {
t.Fatalf("replay claim: %v", err)
}
if !replay.AlreadyExecuted || verification.claimCalls != 1 {
t.Fatalf("replay result=%+v claimCalls=%d, want an idempotent replay", replay, verification.claimCalls)
}
}
func TestClaimVerificationRefusesAlreadyClaimedApplication(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
_, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-taken", Actor: "bob", Reason: "second reviewer"},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("claim of a claimed application err=%v, want ErrVerificationStatusInvalid", err)
}
if !strings.Contains(err.Error(), CodeVerificationStatusInvalid) || verification.claimCalls != 0 {
t.Fatalf("err=%v claimCalls=%d, want the stable code and no mutation", err, verification.claimCalls)
}
}
func TestApproveVerificationVersionConflictIsNotAMutation(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
_, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-stale", Actor: "alice", Reason: "docs check out"},
ApplicationID: 77,
// The reviewer read version 3 but somebody else already advanced the row.
Version: 2,
})
if !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale approve err=%v, want ErrVerificationVersionConflict", err)
}
if !strings.Contains(err.Error(), CodeVerificationConflict) || verification.approveCalls != 0 {
t.Fatalf("err=%v approveCalls=%d", err, verification.approveCalls)
}
}
func TestApproveVerificationRefusesIneligibleTargetBeforeWriting(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
verification.target.Eligible = false
verification.target.Verified = true
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-verified", Actor: "alice", Reason: "docs check out", DryRun: true},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationTargetAlreadyVerified) {
t.Fatalf("approve of a verified target err=%v, want ErrVerificationTargetAlreadyVerified", err)
}
if !strings.Contains(err.Error(), CodeVerificationTargetVerified) {
t.Fatalf("err=%v, want the stable code", err)
}
if result.Details["target_eligible"] != false || result.Details["target_verified"] != true {
t.Fatalf("details=%+v, want the snapshot recorded on the failed command", result.Details)
}
if verification.approveCalls != 0 {
t.Fatalf("approveCalls=%d, want the dry run to predict the refusal", verification.approveCalls)
}
}
func TestApproveVerificationKeepsInternalNoteOutOfTheApplicantReason(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
result, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-77", Actor: "alice", Reason: "press coverage verified"},
ApplicationID: 77,
Version: 3,
InternalNote: "contact reached us through the press office; do not quote",
})
if err != nil {
t.Fatalf("approve: %v", err)
}
if verification.approved.Reason != "press coverage verified" {
t.Fatalf("applicant-facing reason=%q", verification.approved.Reason)
}
if verification.approved.InternalNote != "contact reached us through the press office; do not quote" {
t.Fatalf("internal note=%q, want it carried in its own field", verification.approved.InternalNote)
}
if strings.Contains(verification.approved.Reason, "do not quote") {
t.Fatal("internal note leaked into the applicant-facing reason")
}
// The note is operator-only but must still be auditable.
if result.Details["internal_note"] != "contact reached us through the press office; do not quote" {
t.Fatalf("details=%+v, want the internal note journalled", result.Details)
}
if stored := repo.items["approve-77"].ResultJSON; !strings.Contains(string(stored), "do not quote") {
t.Fatalf("journalled result=%s, want the internal note persisted", stored)
}
}
// A retried approval of an already approved application is a no-op in the
// use-case layer, so the dry run must not fail it on the "already verified"
// target gate the first approval passed.
func TestApproveVerificationReplayIsNotBlockedByTheTargetGate(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.app.Status = domain.VerificationStatusApproved
verification.app.ReviewerAdminID = "alice"
verification.target.Verified = true
verification.target.Eligible = false
verification.target.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
dry, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-replay-dry", Actor: "alice", Reason: "docs check out", DryRun: true},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("dry-run replay approve: %v", err)
}
if verification.approveCalls != 0 || dry.Details["target_verified"] != true {
t.Fatalf("dry-run replay details=%+v approveCalls=%d", dry.Details, verification.approveCalls)
}
exec, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-replay", Actor: "alice", Reason: "docs check out"},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("replay approve: %v", err)
}
if exec.Details["changed"] != true {
// The fake always reports a change; the point is that the command reached
// the service instead of being refused by the pre-check.
t.Fatalf("replay approve details=%+v", exec.Details)
}
if verification.approveCalls != 1 {
t.Fatalf("approveCalls=%d, want the replay handed to the use-case layer", verification.approveCalls)
}
}
func TestRejectVerificationRequiresReason(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.app.Status = domain.VerificationStatusInReview
_, err := svc.RejectVerification(ctx, RejectVerificationRequest{
CommandMeta: CommandMeta{CommandID: "reject-no-reason", Actor: "alice", Reason: " "},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err=%v, want ErrVerificationReasonRequired", err)
}
if !strings.Contains(err.Error(), CodeVerificationReasonRequired) {
t.Fatalf("err=%v, want the stable code", err)
}
if verification.rejectCalls != 0 || len(repo.items) != 0 {
t.Fatalf("rejectCalls=%d journalled=%d, want a refusal before the journal is touched",
verification.rejectCalls, len(repo.items))
}
ok, err := svc.RejectVerification(ctx, RejectVerificationRequest{
CommandMeta: CommandMeta{CommandID: "reject-77", Actor: "alice", Reason: "press links are self-published"},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("reject: %v", err)
}
if verification.rejectCalls != 1 || verification.rejected.Reason != "press links are self-published" {
t.Fatalf("rejectCalls=%d decision=%+v", verification.rejectCalls, verification.rejected)
}
if ok.Details["status"] != string(domain.VerificationStatusRejected) ||
ok.Details["decision_reason"] != "press links are self-published" {
t.Fatalf("details=%+v", ok.Details)
}
}
func TestRevokeVerificationRequiresReasonAndTargetShape(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.target.Verified = true
_, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "revoke-no-reason", Actor: "alice"},
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
})
if !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err=%v, want ErrVerificationReasonRequired", err)
}
_, err = svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "revoke-bad-target", Actor: "alice", Reason: "impersonation"},
TargetType: domain.VerificationTargetType("group"),
TargetID: 5005,
})
if !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("revoke of an unmodelled target err=%v, want ErrVerificationTargetInvalid", err)
}
if verification.revokeCalls != 0 || len(repo.items) != 0 {
t.Fatalf("revokeCalls=%d journalled=%d, want refusals before the journal", verification.revokeCalls, len(repo.items))
}
dry, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "dry-revoke", Actor: "alice", Reason: "impersonation confirmed", DryRun: true},
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
})
if err != nil {
t.Fatalf("dry-run revoke: %v", err)
}
if verification.revokeCalls != 0 || dry.Details["target_verified"] != true {
t.Fatalf("dry-run revoke details=%+v revokeCalls=%d", dry.Details, verification.revokeCalls)
}
exec, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "exec-revoke", Actor: "alice", Reason: "impersonation confirmed"},
TargetType: domain.VerificationTargetChannel,
TargetID: 5005,
InternalNote: "legal asked for the takedown",
})
if err != nil {
t.Fatalf("execute revoke: %v", err)
}
if verification.revokeCalls != 1 || verification.revoked.Reason != "impersonation confirmed" ||
verification.revoked.InternalNote != "legal asked for the takedown" ||
verification.revoked.CorrelationID != "exec-revoke" {
t.Fatalf("revocation=%+v", verification.revoked)
}
if exec.Details["target_verified"] != false || exec.Details["changed"] != true ||
exec.Details["application_id"] != "77" {
t.Fatalf("execute revoke details=%+v", exec.Details)
}
if exec.TargetPeer != (domain.Peer{Type: domain.PeerTypeChannel, ID: 5005}) {
t.Fatalf("journalled target peer=%+v", exec.TargetPeer)
}
}
func TestRevokeVerificationRefusesSystemAccount(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
result, err := svc.RevokeVerification(ctx, RevokeVerificationRequest{
CommandMeta: CommandMeta{CommandID: "revoke-system", Actor: "alice", Reason: "cleanup"},
TargetType: domain.VerificationTargetBot,
TargetID: domain.BotFatherUserID,
})
if !errors.Is(err, domain.ErrVerificationTargetSystem) {
t.Fatalf("revoke of a system account err=%v, want ErrVerificationTargetSystem", err)
}
if verification.revokeCalls != 0 || result.Details["target_id"] == nil {
t.Fatalf("revokeCalls=%d details=%+v", verification.revokeCalls, result.Details)
}
}
func TestVerificationDecisionShapeIsValidatedBeforeTheJournal(t *testing.T) {
ctx := context.Background()
svc, _, repo := newVerificationFixture()
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-no-id", Actor: "alice", Reason: "queue"},
ApplicationID: 0,
Version: 3,
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim without an application err=%v", err)
}
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-no-version", Actor: "alice", Reason: "queue"},
ApplicationID: 77,
Version: 0,
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("claim without a version err=%v", err)
}
if _, err := svc.ApproveVerification(ctx, ApproveVerificationRequest{
CommandMeta: CommandMeta{CommandID: "approve-long-note", Actor: "alice", Reason: "queue"},
ApplicationID: 77,
Version: 3,
InternalNote: strings.Repeat("x", domain.MaxVerificationNoteLength+1),
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("approve with an oversized note err=%v", err)
}
if len(repo.items) != 0 {
t.Fatalf("journalled=%d, want malformed decisions refused before the journal", len(repo.items))
}
}
func TestVerificationMissingApplicationIsJournalledAsNotFound(t *testing.T) {
ctx := context.Background()
svc, verification, repo := newVerificationFixture()
verification.appErr = domain.ErrVerificationApplicationNotFound
result, err := svc.RejectVerification(ctx, RejectVerificationRequest{
CommandMeta: CommandMeta{CommandID: "reject-missing", Actor: "alice", Reason: "not eligible"},
ApplicationID: 77,
Version: 3,
})
if !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("reject of a missing application err=%v", err)
}
if VerificationErrorCode(err) != CodeVerificationNotFound {
t.Fatalf("code=%q", VerificationErrorCode(err))
}
if result.Status != string(domain.AdminCommandFailed) {
t.Fatalf("result=%+v, want a failed command", result)
}
if cmd, ok := repo.items["reject-missing"]; !ok || cmd.Status != domain.AdminCommandFailed {
t.Fatalf("journalled command=%+v ok=%v, want the failure recorded", cmd, ok)
}
}
func TestVerificationReadsRequireTheDependency(t *testing.T) {
ctx := context.Background()
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Now: fixedNow})
if _, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{}); err == nil {
t.Fatal("listing without the dependency succeeded")
}
if _, err := svc.VerificationCounts(ctx); err == nil {
t.Fatal("counting without the dependency succeeded")
}
if _, err := svc.ClaimVerification(ctx, ClaimVerificationRequest{ApplicationID: 1, Version: 1}); err == nil {
t.Fatal("claiming without the dependency succeeded")
}
}
func TestVerificationReadsPassThrough(t *testing.T) {
ctx := context.Background()
svc, verification, _ := newVerificationFixture()
verification.counts = domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3}
verification.events = []domain.VerificationApplicationEvent{{
ID: 9, ApplicationID: 77, Kind: domain.VerificationEventSubmitted,
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: fixedNow(),
}}
items, err := svc.VerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 10})
if err != nil || len(items) != 1 || items[0].ID != 77 {
t.Fatalf("applications=%+v err=%v", items, err)
}
counts, err := svc.VerificationCounts(ctx)
if err != nil || counts[domain.VerificationStatusSubmitted] != 3 {
t.Fatalf("counts=%+v err=%v", counts, err)
}
events, err := svc.VerificationApplicationEvents(ctx, 77, 10)
if err != nil || len(events) != 1 || events[0].ID != 9 {
t.Fatalf("events=%+v err=%v", events, err)
}
app, err := svc.VerificationApplication(ctx, 77)
if err != nil || app.Version != 3 {
t.Fatalf("application=%+v err=%v", app, err)
}
if _, err := svc.VerificationApplication(ctx, 0); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("application(0) err=%v", err)
}
target, err := svc.VerificationTargetSnapshot(ctx, domain.VerificationTargetChannel, 5005)
if err != nil || target.ID != 5005 {
t.Fatalf("target=%+v err=%v", target, err)
}
}
func TestVerificationErrorCodeCoversTheDomainSentinels(t *testing.T) {
cases := map[error]string{
domain.ErrVerificationApplicationNotFound: CodeVerificationNotFound,
domain.ErrVerificationVersionConflict: CodeVerificationConflict,
domain.ErrVerificationApplicationExists: CodeVerificationTargetOccupied,
domain.ErrVerificationStatusInvalid: CodeVerificationStatusInvalid,
domain.ErrVerificationReasonRequired: CodeVerificationReasonRequired,
domain.ErrVerificationTargetAlreadyVerified: CodeVerificationTargetVerified,
domain.ErrVerificationTargetNotPublic: CodeVerificationTargetNotPublic,
domain.ErrVerificationTargetRestricted: CodeVerificationTargetRestricted,
domain.ErrVerificationTargetSystem: CodeVerificationTargetSystem,
domain.ErrVerificationNotOwner: CodeVerificationNotOwner,
domain.ErrVerificationUserTargetsDisabled: CodeVerificationUserTargetsDisabled,
domain.ErrVerificationTargetInvalid: CodeVerificationTargetInvalid,
domain.ErrVerificationApplicationInvalid: CodeVerificationInvalid,
errors.New("some transport failure nobody mapped"): "",
}
for err, want := range cases {
if got := VerificationErrorCode(err); got != want {
t.Fatalf("VerificationErrorCode(%v) = %q, want %q", err, got, want)
}
}
if got := VerificationErrorCode(nil); got != "" {
t.Fatalf("VerificationErrorCode(nil) = %q", got)
}
}
func TestVerificationTargetReasonErrorRoundTripsTheSnapshotString(t *testing.T) {
for _, sentinel := range []error{
domain.ErrVerificationTargetAlreadyVerified,
domain.ErrVerificationTargetRestricted,
domain.ErrVerificationTargetNotPublic,
domain.ErrVerificationTargetSystem,
domain.ErrVerificationUserTargetsDisabled,
domain.ErrVerificationTargetInvalid,
} {
if err := verificationTargetReasonError(sentinel.Error()); !errors.Is(err, sentinel) {
t.Fatalf("verificationTargetReasonError(%q) = %v", sentinel.Error(), err)
}
}
// An unrecognised reason must still be a target failure rather than a panic or
// a silent pass.
if err := verificationTargetReasonError("something new"); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("unknown reason mapped to %v", err)
}
}
func TestVerificationClockIsNotRequiredForDetails(t *testing.T) {
// The details are pure projections of the application, so a service without a
// wall clock still produces a complete audit entry.
svc, verification, _ := newVerificationFixture()
verification.app.UpdatedAt = time.Time{}
result, err := svc.ClaimVerification(context.Background(), ClaimVerificationRequest{
CommandMeta: CommandMeta{CommandID: "claim-clockless", Actor: "alice", Reason: "queue"},
ApplicationID: 77,
Version: 3,
})
if err != nil {
t.Fatalf("claim: %v", err)
}
for _, key := range []string{"application_id", "applicant_user_id", "target_type", "target_id", "previous_status", "status", "correlation_id"} {
if _, ok := result.Details[key]; !ok {
t.Fatalf("details %+v missing %q", result.Details, key)
}
}
}

View file

@ -0,0 +1,561 @@
package adminapi
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// Third-party bot verification over the admin API
// (core.telegram.org/api/bots/verification).
//
// These are the mirror routes of the panel's own /api/botverification endpoints:
// the panel reads straight from PostgreSQL for speed, while an integration holding
// a scoped token reads it here. Every mutation only ever travels this way, so the
// command journal, the status machine and the optimistic lock are enforced in one
// place.
//
// This is NOT the official platform verification surface in verification.go. The
// two mechanisms own separate tables, separate permissions (botverification.* vs
// verification.*) and separate routes, and neither reads the other's state: a
// third-party verifier must never be able to mint a platform checkmark.
//
// Every int64 crosses the JSON boundary as a decimal string. Bot ids, peer ids,
// custom emoji document ids and the optimistic-locking version all exceed the
// range a JSON number holds exactly, and a rounded version would decide the wrong
// revision of a row.
// handleBotVerifiers lists verifier bots.
func (s *Server) handleBotVerifiers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.BotVerifiers(r.Context(), queryBool(query.Get("enabled_only")), limit)
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, botVerifierResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
// handleVerificationIcons lists the icon catalogue.
func (s *Server) handleVerificationIcons(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.VerificationIcons(r.Context(), queryBool(query.Get("active_only")), limit)
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, verificationIconResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
// handleCustomVerifications lists granted marks with keyset paging.
func (s *Server) handleCustomVerifications(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
if !ok {
return
}
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
if !ok {
return
}
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.CustomVerifications(r.Context(), domain.CustomVerificationFilter{
VerifierBotID: verifierBotID,
PeerType: peerType,
Query: query.Get("q"),
BeforeID: beforeID,
Limit: limit,
})
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, customVerificationResponse(item))
}
// The page bound is the use-case layer's, so has_more is derived from what came
// back rather than from the limit the caller asked for.
hasMore := limit > 0 && len(items) >= limit
nextBeforeID := ""
if hasMore && len(items) > 0 {
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
// handleCustomVerificationRequests is the third-party review queue.
func (s *Server) handleCustomVerificationRequests(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
if !ok {
return
}
filter := domain.CustomVerificationRequestFilter{
PeerType: peerType,
Query: query.Get("q"),
}
// status accepts a comma-separated list, so a "pending,approved" view is one
// request rather than two.
for _, raw := range strings.Split(query.Get("status"), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
status := domain.CustomVerificationRequestStatus(raw)
if !status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationStatusInvalid, "invalid status "+raw)
return
}
filter.Statuses = append(filter.Statuses, status)
}
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
if !ok {
return
}
filter.VerifierBotID = verifierBotID
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
items, err := s.svc.CustomVerificationRequests(r.Context(), filter)
if err != nil {
writeBotVerificationError(w, err)
return
}
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
rows = append(rows, customVerificationRequestResponse(item))
}
hasMore := limit > 0 && len(items) >= limit
nextBeforeID := ""
if hasMore && len(items) > 0 {
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
// handleCustomVerificationRequest is one application with the verifier behind it
// and whether the mark is on the peer right now.
func (s *Server) handleCustomVerificationRequest(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
req, err := s.svc.CustomVerificationRequest(r.Context(), id)
if err != nil {
writeBotVerificationError(w, err)
return
}
body := map[string]any{"request": customVerificationRequestResponse(req)}
// The verifier row is advisory: it may have been revoked since the application
// was filed, and that must not turn the audit record into a 500. An absent row
// is reported as a verifier with only its id, so the reviewer can see which bot
// it was.
if settings, err := s.svc.BotVerifier(r.Context(), req.VerifierBotID); err == nil {
body["verifier"] = botVerifierResponse(settings)
} else if errors.Is(err, domain.ErrVerifierNotFound) {
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
} else {
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
body["verifier_error"] = err.Error()
}
// mark_active tells "approved" apart from "approved and since stripped by the
// operator", which is the one thing the status alone cannot say.
if active, err := s.svc.CustomVerificationMarkActive(r.Context(), req.VerifierBotID, req.Peer); err == nil {
body["mark_active"] = active
} else {
body["mark_active"] = false
body["mark_error"] = err.Error()
}
writeJSON(w, http.StatusOK, body)
}
// handleCustomVerificationCounts is the queue summary.
func (s *Server) handleCustomVerificationCounts(w http.ResponseWriter, r *http.Request) {
counts, err := s.svc.CustomVerificationRequestCounts(r.Context())
if err != nil {
writeBotVerificationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"counts": customVerificationCountsResponse(counts)})
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
func (s *Server) handleGrantBotVerifier(w http.ResponseWriter, r *http.Request) {
var req admin.GrantBotVerifierRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.GrantBotVerifier(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleSetBotVerifierEnabled(w http.ResponseWriter, r *http.Request) {
var req admin.SetBotVerifierEnabledRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.SetBotVerifierEnabled(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeBotVerifier(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeBotVerifierRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeBotVerifier(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleUpsertVerificationIcon(w http.ResponseWriter, r *http.Request) {
var req admin.UpsertVerificationIconRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.UpsertVerificationIcon(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleSetVerificationIconActive(w http.ResponseWriter, r *http.Request) {
var req admin.SetVerificationIconActiveRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.SetVerificationIconActive(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeCustomVerification(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeCustomVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeCustomVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleApproveBotVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.ApproveBotVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
// The path is the authority on which application is decided: a body naming a
// different one would make the URL lie to the audit trail.
req.RequestID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.ApproveBotVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRejectBotVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.RejectBotVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.RequestID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RejectBotVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeBotVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.RevokeBotVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.RequestID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeBotVerification(r.Context(), req)
writeBotVerificationCommandResult(w, result, err)
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
// botVerifierResponse renders one verifier bot. The keys are the panel's row
// field names, so the same shape reaches the browser whether it came from here or
// from the panel's direct read.
func botVerifierResponse(settings domain.BotVerifierSettings) map[string]any {
out := map[string]any{
"BotID": strconv.FormatInt(settings.BotID, 10),
"IconDocumentID": strconv.FormatInt(settings.IconDocumentID, 10),
"CompanyName": settings.CompanyName,
"DefaultDescription": settings.DefaultDescription,
"CanModifyCustomDescription": settings.CanModifyCustomDescription,
"Enabled": settings.Enabled,
"GrantedBy": settings.GrantedBy,
"GrantReason": settings.GrantReason,
"Version": strconv.FormatInt(settings.Version, 10),
}
if !settings.CreatedAt.IsZero() {
out["CreatedAt"] = settings.CreatedAt.UTC().Format(time.RFC3339)
}
if !settings.UpdatedAt.IsZero() {
out["UpdatedAt"] = settings.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func verificationIconResponse(icon domain.VerificationIcon) map[string]any {
out := map[string]any{
"ID": strconv.FormatInt(icon.ID, 10),
"DocumentID": strconv.FormatInt(icon.DocumentID, 10),
"OwnerBotID": strconv.FormatInt(icon.OwnerBotID, 10),
"Name": icon.Name,
"Active": icon.Active,
}
if !icon.CreatedAt.IsZero() {
out["CreatedAt"] = icon.CreatedAt.UTC().Format(time.RFC3339)
}
if !icon.UpdatedAt.IsZero() {
out["UpdatedAt"] = icon.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func customVerificationResponse(mark domain.CustomVerification) map[string]any {
out := map[string]any{
"ID": strconv.FormatInt(mark.ID, 10),
"VerifierBotID": strconv.FormatInt(mark.VerifierBotID, 10),
"PeerType": string(mark.Peer.Type),
"PeerID": strconv.FormatInt(mark.Peer.ID, 10),
"IconDocumentID": strconv.FormatInt(mark.IconDocumentID, 10),
"Description": mark.Description,
"Version": strconv.FormatInt(mark.Version, 10),
}
if !mark.CreatedAt.IsZero() {
out["CreatedAt"] = mark.CreatedAt.UTC().Format(time.RFC3339)
}
if !mark.UpdatedAt.IsZero() {
out["UpdatedAt"] = mark.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func customVerificationRequestResponse(req domain.CustomVerificationRequest) map[string]any {
out := map[string]any{
"ID": strconv.FormatInt(req.ID, 10),
"VerifierBotID": strconv.FormatInt(req.VerifierBotID, 10),
"ApplicantUserID": strconv.FormatInt(req.ApplicantUserID, 10),
"PeerType": string(req.Peer.Type),
"PeerID": strconv.FormatInt(req.Peer.ID, 10),
"PeerTitle": req.PeerTitle,
"PeerUsername": req.PeerUsername,
"Reason": req.Reason,
"RequestedDescription": req.RequestedDescription,
"Status": string(req.Status),
"DecidedBy": req.DecidedBy,
"DecisionReason": req.DecisionReason,
// InternalNote is operator-only. It is exposed here because every caller of
// this route already holds botverification.review, and it is the reviewer's
// own handover note; it is never part of the applicant-facing projection.
"InternalNote": req.InternalNote,
"CorrelationID": req.CorrelationID,
"Version": strconv.FormatInt(req.Version, 10),
}
if !req.CreatedAt.IsZero() {
out["CreatedAt"] = req.CreatedAt.UTC().Format(time.RFC3339)
}
if !req.UpdatedAt.IsZero() {
out["UpdatedAt"] = req.UpdatedAt.UTC().Format(time.RFC3339)
}
if !req.ApprovedAt.IsZero() {
out["ApprovedAt"] = req.ApprovedAt.UTC().Format(time.RFC3339)
}
if !req.RejectedAt.IsZero() {
out["RejectedAt"] = req.RejectedAt.UTC().Format(time.RFC3339)
}
return out
}
// customVerificationCountsResponse renders the queue summary with every modelled
// status present, so the panel never has to distinguish "zero" from "absent". The
// values are decimal strings for the same exactness reason as the ids.
func customVerificationCountsResponse(counts map[domain.CustomVerificationRequestStatus]int64) map[string]string {
out := make(map[string]string, len(customVerificationStatusOrder))
for _, status := range customVerificationStatusOrder {
out[string(status)] = strconv.FormatInt(counts[status], 10)
}
for status, count := range counts {
if _, ok := out[string(status)]; !ok {
out[string(status)] = strconv.FormatInt(count, 10)
}
}
return out
}
// customVerificationStatusOrder is the closed status set, in lifecycle order.
var customVerificationStatusOrder = []domain.CustomVerificationRequestStatus{
domain.CustomVerificationPending,
domain.CustomVerificationApproved,
domain.CustomVerificationRejected,
domain.CustomVerificationRevoked,
}
// botVerificationPeerType validates the peer filter against the peer kinds a
// third-party mark can sit on. An unmodelled value is a 400 rather than an empty
// result, so a typo is reported instead of silently returning nothing.
func botVerificationPeerType(w http.ResponseWriter, raw string) (domain.PeerType, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", true
}
peerType := domain.PeerType(raw)
if peerType != domain.PeerTypeUser && peerType != domain.PeerTypeChannel {
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationTargetInvalid, "invalid peer_type")
return "", false
}
return peerType, true
}
// queryBool reads a boolean flag the way the panel writes it: an absent or empty
// value is false, and "1"/"true"/"yes" are true.
func queryBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// botVerificationErrorStatus maps a third-party verification failure onto its HTTP
// status.
//
// The version conflict is 409, not 400, because nothing about the request was
// wrong -- another operator simply decided first, and the panel has to answer that
// by reloading rather than by correcting input. The per-verifier bound is 409 for
// the same reason: the request was well formed and the state refused it.
func botVerificationErrorStatus(code string) int {
switch code {
case admin.CodeBotVerifierNotFound,
admin.CodeBotVerifierBotNotFound,
admin.CodeVerificationIconNotFound,
admin.CodeCustomVerificationNotFound,
admin.CodeCustomVerificationRequestNotFound:
return http.StatusNotFound
case admin.CodeCustomVerificationConflict,
admin.CodeCustomVerificationLimit,
admin.CodeCustomVerificationRequestExists:
return http.StatusConflict
case admin.CodeCustomVerificationRateLimited:
return http.StatusTooManyRequests
case admin.CodeBotVerifierForbidden,
admin.CodeBotVerifierDescriptionForbidden,
admin.CodeBotVerifierInvalid,
admin.CodeVerificationIconInactive,
admin.CodeVerificationIconInvalid,
admin.CodeCustomVerificationStatusInvalid,
admin.CodeCustomVerificationReasonRequired,
admin.CodeCustomVerificationTargetInvalid,
admin.CodeCustomVerificationTargetSystem,
admin.CodeCustomVerificationInvalid:
// BOTVERIFIER_FORBIDDEN is 400 rather than 403 on purpose: the caller is
// authorised (403 is reserved for the permission gate), it is the *subject*
// that may not verify, which the operator fixes by enabling the verifier.
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
func writeBotVerificationError(w http.ResponseWriter, err error) {
code := admin.BotVerificationErrorCode(err)
writeCodedError(w, botVerificationErrorStatus(code), code, err.Error())
}
// writeBotVerificationCommandResult answers a command.
//
// The body stays a CommandResult so the panel parses one shape for every operator
// action, but the status is derived from the failure: a lost optimistic-locking
// race must reach the browser as 409, because that is the one failure the panel
// resolves by reloading the row instead of by asking the operator to fix the form.
func writeBotVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
if err == nil {
writeJSON(w, http.StatusOK, result)
return
}
code := admin.BotVerificationErrorCode(err)
status := botVerificationErrorStatus(code)
if status == http.StatusInternalServerError {
// An unmapped command failure is a bad request, as everywhere else in this
// API, rather than a server fault.
status = http.StatusBadRequest
}
if result.CommandID == "" {
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
}
if result.Error == "" {
result.Error = err.Error()
}
if code == admin.CodeCustomVerificationConflict {
result.Message = "another operator changed this row first; reload it and try again"
}
writeJSON(w, status, result)
}

View file

@ -0,0 +1,826 @@
package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// fakeService gains the third-party verification surface here so the shared fake
// keeps satisfying Service without touching the existing test files.
func (fakeService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) BotVerifiers(context.Context, bool, int) ([]domain.BotVerifierSettings, error) {
return nil, nil
}
func (fakeService) BotVerifier(context.Context, int64) (domain.BotVerifierSettings, error) {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
func (fakeService) VerificationIcons(context.Context, bool, int) ([]domain.VerificationIcon, error) {
return nil, nil
}
func (fakeService) CustomVerifications(context.Context, domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
return nil, nil
}
func (fakeService) CustomVerificationRequests(context.Context, domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
return nil, nil
}
func (fakeService) CustomVerificationRequest(context.Context, int64) (domain.CustomVerificationRequest, error) {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
func (fakeService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
return nil, nil
}
func (fakeService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
return false, nil
}
type captureBotVerificationService struct {
fakeService
verifier domain.BotVerifierSettings
hasRow bool
icons []domain.VerificationIcon
marks []domain.CustomVerification
request domain.CustomVerificationRequest
counts map[domain.CustomVerificationRequestStatus]int64
markActive bool
iconFilterActiveOnly bool
verifierFilterEnabled bool
verifierFilterLimit int
markFilter domain.CustomVerificationFilter
requestFilter domain.CustomVerificationRequestFilter
grant admin.GrantBotVerifierRequest
setEnabled admin.SetBotVerifierEnabledRequest
revokeVerifier admin.RevokeBotVerifierRequest
upsertIcon admin.UpsertVerificationIconRequest
setIconActive admin.SetVerificationIconActiveRequest
revokeMark admin.RevokeCustomVerificationRequest
approve admin.ApproveBotVerificationRequest
reject admin.RejectBotVerificationRequest
revokeRequest admin.RevokeBotVerificationRequest
commandErr error
}
func (s *captureBotVerificationService) BotVerifiers(_ context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
s.verifierFilterEnabled = enabledOnly
s.verifierFilterLimit = limit
if !s.hasRow {
return nil, nil
}
return []domain.BotVerifierSettings{s.verifier}, nil
}
func (s *captureBotVerificationService) BotVerifier(_ context.Context, botID int64) (domain.BotVerifierSettings, error) {
if !s.hasRow || s.verifier.BotID != botID {
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
}
return s.verifier, nil
}
func (s *captureBotVerificationService) VerificationIcons(_ context.Context, activeOnly bool, _ int) ([]domain.VerificationIcon, error) {
s.iconFilterActiveOnly = activeOnly
return s.icons, nil
}
func (s *captureBotVerificationService) CustomVerifications(_ context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
s.markFilter = filter
return s.marks, nil
}
func (s *captureBotVerificationService) CustomVerificationRequests(_ context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
s.requestFilter = filter
return []domain.CustomVerificationRequest{s.request}, nil
}
func (s *captureBotVerificationService) CustomVerificationRequest(_ context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
if s.request.ID != requestID {
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
}
return s.request, nil
}
func (s *captureBotVerificationService) CustomVerificationRequestCounts(context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
return s.counts, nil
}
func (s *captureBotVerificationService) CustomVerificationMarkActive(context.Context, int64, domain.Peer) (bool, error) {
return s.markActive, nil
}
func (s *captureBotVerificationService) commandResult(commandID string, dryRun bool) (admin.CommandResult, error) {
if s.commandErr != nil {
return admin.CommandResult{CommandID: commandID, Status: "failed", Error: s.commandErr.Error()}, s.commandErr
}
return admin.CommandResult{CommandID: commandID, Status: "completed", DryRun: dryRun}, nil
}
func (s *captureBotVerificationService) GrantBotVerifier(_ context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error) {
s.grant = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) SetBotVerifierEnabled(_ context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error) {
s.setEnabled = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeBotVerifier(_ context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error) {
s.revokeVerifier = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) UpsertVerificationIcon(_ context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error) {
s.upsertIcon = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) SetVerificationIconActive(_ context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error) {
s.setIconActive = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeCustomVerification(_ context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error) {
s.revokeMark = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) ApproveBotVerification(_ context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error) {
s.approve = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RejectBotVerification(_ context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error) {
s.reject = req
return s.commandResult(req.CommandID, req.DryRun)
}
func (s *captureBotVerificationService) RevokeBotVerification(_ context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error) {
s.revokeRequest = req
return s.commandResult(req.CommandID, req.DryRun)
}
// botVerificationServer is the deployment shape the permission model exists for:
// one master token plus bounded tokens that can review, manage, or neither.
func botVerificationServer(svc Service) *Server {
return &Server{
token: "master",
scoped: []ScopedToken{
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionBotVerificationReview}},
{Name: "trust-and-safety", Token: "scoped-manage", Permissions: []string{PermissionBotVerificationManage}},
{Name: "both", Token: "scoped-both", Permissions: []string{
PermissionBotVerificationReview, PermissionBotVerificationManage,
}},
// A token for the *official* review surface: it must not reach this one.
{Name: "official-review", Token: "scoped-official", Permissions: []string{PermissionVerificationReview}},
},
svc: svc,
}
}
// botVerificationRoute is one route with a body the handler accepts, so an
// authorisation test cannot pass by accident on a malformed payload.
type botVerificationRoute struct {
method string
path string
body string
}
const decisionBody = `{"command_id":"c1","actor":"ops","reason":"decided","version":2}`
var botVerificationReadRoutes = []botVerificationRoute{
{http.MethodGet, "/v1/botverification/verifiers", ""},
{http.MethodGet, "/v1/botverification/icons", ""},
{http.MethodGet, "/v1/botverification/marks", ""},
{http.MethodGet, "/v1/botverification/requests", ""},
{http.MethodGet, "/v1/botverification/requests/7", ""},
{http.MethodGet, "/v1/botverification/counts", ""},
{http.MethodPost, "/v1/botverification/requests/7/approve", decisionBody},
{http.MethodPost, "/v1/botverification/requests/7/reject", decisionBody},
{http.MethodPost, "/v1/botverification/requests/7/revoke", decisionBody},
}
var botVerificationManageRoutes = []botVerificationRoute{
{http.MethodPost, "/v1/botverification/verifiers/grant",
`{"command_id":"c1","actor":"ops","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`},
{http.MethodPost, "/v1/botverification/verifiers/set-enabled",
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":3003,"enabled":false}`},
{http.MethodPost, "/v1/botverification/verifiers/revoke",
`{"command_id":"c1","actor":"ops","reason":"programme ended","bot_id":3003}`},
{http.MethodPost, "/v1/botverification/icons/upsert",
`{"command_id":"c1","actor":"ops","reason":"new icon","document_id":900,"name":"blue check"}`},
{http.MethodPost, "/v1/botverification/icons/set-active",
`{"command_id":"c1","actor":"ops","reason":"retired","icon_id":501,"active":false}`},
{http.MethodPost, "/v1/botverification/marks/revoke",
`{"command_id":"c1","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":5005}`},
}
// botVerificationRoutes is every route in the section.
func botVerificationRoutes() []botVerificationRoute {
out := make([]botVerificationRoute, 0, len(botVerificationReadRoutes)+len(botVerificationManageRoutes))
out = append(out, botVerificationReadRoutes...)
return append(out, botVerificationManageRoutes...)
}
func TestBotVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
srv := botVerificationServer(fakeService{})
for _, item := range botVerificationRoutes() {
for _, token := range []string{"", "not-a-configured-token"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
}
}
}
}
func TestBotVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
srv := botVerificationServer(fakeService{})
// The official-verification token is the interesting negative: the two
// mechanisms are separate, so verification.review must not open this surface.
for _, token := range []string{"scoped-official", "scoped-manage"} {
for _, item := range botVerificationReadRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["code"] != CodeForbidden || body["permission"] != PermissionBotVerificationReview {
t.Fatalf("403 body=%+v, want botverification.review named", body)
}
}
}
// And the review right alone does not reach the configuration half.
for _, token := range []string{"scoped-official", "scoped-review"} {
for _, item := range botVerificationManageRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s token=%q status=%d body=%s, want 403", item.method, item.path, token, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["permission"] != PermissionBotVerificationManage {
t.Fatalf("403 body=%+v, want botverification.manage named", body)
}
}
}
}
// A token holding the third-party rights must not reach the official queue either:
// the separation is symmetric.
func TestBotVerificationTokenCannotReachTheOfficialVerificationSurface(t *testing.T) {
srv := botVerificationServer(fakeService{})
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "scoped-both", ""))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
}
}
// Nor the legacy surface that predates permissions.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-both",
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
if rec.Code != http.StatusForbidden {
t.Fatalf("legacy surface status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
}
func TestBotVerificationScopedTokensReachTheirOwnHalf(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
srv := botVerificationServer(svc)
for _, item := range botVerificationReadRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-review", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
for _, item := range botVerificationManageRoutes {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-manage", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
}
func TestMasterTokenReachesTheBotVerificationSurface(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 7, Version: 2}}
srv := botVerificationServer(svc)
for _, item := range botVerificationRoutes() {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "master", item.body))
if rec.Code != http.StatusOK {
t.Fatalf("master on %s %s status=%d body=%s", item.method, item.path, rec.Code, rec.Body.String())
}
}
}
func TestBotVerifierListRendersInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{
hasRow: true,
verifier: domain.BotVerifierSettings{
BotID: maxInt64,
IconDocumentID: maxInt64,
CompanyName: "Example Trust",
DefaultDescription: "verified by Example Trust",
CanModifyCustomDescription: true,
Enabled: true,
GrantedBy: "alice",
GrantReason: "partner programme",
Version: maxInt64,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
UpdatedAt: time.Unix(1_700_000_000, 0).UTC(),
},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/verifiers?enabled_only=1&limit=25", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.verifierFilterEnabled || svc.verifierFilterLimit != 25 {
t.Fatalf("enabledOnly=%v limit=%d, want the query honoured", svc.verifierFilterEnabled, svc.verifierFilterLimit)
}
var body struct {
Rows []map[string]any `json:"rows"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode verifiers: %v", err)
}
if len(body.Rows) != 1 {
t.Fatalf("rows=%+v", body.Rows)
}
for _, field := range []string{"BotID", "IconDocumentID", "Version"} {
if body.Rows[0][field] != "9223372036854775807" {
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Rows[0][field])
}
}
if body.Rows[0]["CanModifyCustomDescription"] != true || body.Rows[0]["Enabled"] != true {
t.Fatalf("row=%+v, want the booleans as booleans", body.Rows[0])
}
}
func TestVerificationIconAndMarkListingsRenderInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{
icons: []domain.VerificationIcon{{
ID: maxInt64, DocumentID: maxInt64, OwnerBotID: maxInt64, Name: "blue check", Active: true,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}},
marks: []domain.CustomVerification{{
ID: maxInt64, VerifierBotID: maxInt64,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: maxInt64},
IconDocumentID: maxInt64, Description: "verified partner", Version: maxInt64,
}},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/icons?active_only=true", "scoped-review", ""))
if rec.Code != http.StatusOK || !svc.iconFilterActiveOnly {
t.Fatalf("icons status=%d activeOnly=%v body=%s", rec.Code, svc.iconFilterActiveOnly, rec.Body.String())
}
var icons struct {
Rows []map[string]any `json:"rows"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &icons); err != nil {
t.Fatalf("decode icons: %v", err)
}
for _, field := range []string{"ID", "DocumentID", "OwnerBotID"} {
if icons.Rows[0][field] != "9223372036854775807" {
t.Fatalf("icon %s = %#v", field, icons.Rows[0][field])
}
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/marks?verifier_bot_id=9223372036854775807&peer_type=channel&q=news&limit=1&before_id=99",
"scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("marks status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.markFilter.VerifierBotID != maxInt64 || svc.markFilter.PeerType != domain.PeerTypeChannel ||
svc.markFilter.Query != "news" || svc.markFilter.Limit != 1 || svc.markFilter.BeforeID != 99 {
t.Fatalf("mark filter=%+v", svc.markFilter)
}
var marks struct {
Rows []map[string]any `json:"rows"`
HasMore bool `json:"has_more"`
NextBeforeID string `json:"next_before_id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &marks); err != nil {
t.Fatalf("decode marks: %v", err)
}
for _, field := range []string{"ID", "VerifierBotID", "PeerID", "IconDocumentID", "Version"} {
if marks.Rows[0][field] != "9223372036854775807" {
t.Fatalf("mark %s = %#v", field, marks.Rows[0][field])
}
}
// A full page reports more, and the cursor is the last id as a decimal string.
if !marks.HasMore || marks.NextBeforeID != "9223372036854775807" {
t.Fatalf("paging hasMore=%v next=%q", marks.HasMore, marks.NextBeforeID)
}
}
func TestBotVerificationQueueFilterAndUnmodelledValues(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
Status: domain.CustomVerificationPending, Version: 3,
}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet,
"/v1/botverification/requests?status=pending,approved&verifier_bot_id=3003&peer_type=channel&q=news&limit=25&before_id=99",
"scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if len(svc.requestFilter.Statuses) != 2 ||
svc.requestFilter.Statuses[0] != domain.CustomVerificationPending ||
svc.requestFilter.Statuses[1] != domain.CustomVerificationApproved ||
svc.requestFilter.VerifierBotID != 3003 || svc.requestFilter.PeerType != domain.PeerTypeChannel ||
svc.requestFilter.Query != "news" || svc.requestFilter.Limit != 25 || svc.requestFilter.BeforeID != 99 {
t.Fatalf("filter=%+v", svc.requestFilter)
}
// An unmodelled status or peer type is a 400 rather than an empty result, so a
// typo is reported instead of silently returning nothing.
for _, query := range []string{"?status=in_review", "?peer_type=chat", "?verifier_bot_id=abc", "?before_id=-1"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
}
}
for _, query := range []string{"?peer_type=chat"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/marks"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("marks %s status=%d, want 400", query, rec.Code)
}
}
}
func TestBotVerificationRequestDetailAndCounts(t *testing.T) {
svc := &captureBotVerificationService{
hasRow: true,
verifier: domain.BotVerifierSettings{
BotID: 3003, IconDocumentID: 900, CompanyName: "Example Trust", Enabled: true, Version: 4,
},
request: domain.CustomVerificationRequest{
ID: 88, VerifierBotID: 3003, ApplicantUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 5005},
PeerTitle: "Example News",
PeerUsername: "examplenews",
InternalNote: "operator only",
Status: domain.CustomVerificationApproved, Version: 5,
ApprovedAt: time.Unix(1_700_000_000, 0).UTC(),
},
markActive: true,
counts: map[domain.CustomVerificationRequestStatus]int64{domain.CustomVerificationPending: 3, domain.CustomVerificationApproved: 1},
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
}
var detail struct {
Request map[string]any `json:"request"`
Verifier map[string]any `json:"verifier"`
MarkActive bool `json:"mark_active"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Request["ID"] != "88" || detail.Request["PeerID"] != "5005" || detail.Request["Version"] != "5" ||
detail.Request["InternalNote"] != "operator only" || detail.Request["ApprovedAt"] == nil {
t.Fatalf("request=%+v", detail.Request)
}
if detail.Verifier["BotID"] != "3003" || detail.Verifier["CompanyName"] != "Example Trust" || !detail.MarkActive {
t.Fatalf("verifier=%+v markActive=%v", detail.Verifier, detail.MarkActive)
}
// A verifier revoked since the application was filed must not turn the audit
// record into a 500: the row is reported with only its id.
svc.hasRow = false
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/88", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail without a verifier status=%d body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Verifier["BotID"] != "3003" || detail.Verifier["Enabled"] != false {
t.Fatalf("verifier=%+v, want the bot named and no status claimed", detail.Verifier)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/requests/89", "scoped-review", ""))
if rec.Code != http.StatusNotFound {
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/botverification/counts", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
}
var counts struct {
Counts map[string]string `json:"counts"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
t.Fatalf("decode counts: %v", err)
}
// Every modelled status is present so the panel never tells "zero" from
// "absent", and the values are decimal strings.
if counts.Counts["pending"] != "3" || counts.Counts["approved"] != "1" ||
counts.Counts["rejected"] != "0" || counts.Counts["revoked"] != "0" || len(counts.Counts) != 4 {
t.Fatalf("counts=%+v", counts.Counts)
}
}
func TestBotVerificationDecisionTakesTheRequestIDFromThePath(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
// The body names a different application on purpose: the path has to win, or
// the URL would lie to the audit trail.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","request_id":99,"version":3,"internal_note":"handover"}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.approve.RequestID != 88 || svc.approve.Version != 3 ||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
t.Fatalf("forwarded approval=%+v", svc.approve)
}
}
func TestBotVerificationDryRunIsForwardedAndEchoed(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/reject", "scoped-review",
`{"command_id":"dry-1","actor":"alice","reason":"not an outlet","dry_run":true,"version":3}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.reject.DryRun || svc.reject.Reason != "not an outlet" {
t.Fatalf("forwarded rejection=%+v", svc.reject)
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
}
// Also on the manage half: appointing a verifier is rehearsable too.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"dry-2","actor":"alice","reason":"partner","dry_run":true,
"bot_id":3003,"icon_document_id":900,"company_name":"Example Trust","version":4}`))
if rec.Code != http.StatusOK {
t.Fatalf("grant status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.grant.DryRun || svc.grant.BotID != 3003 || svc.grant.IconDocumentID != 900 || svc.grant.Version != 4 {
t.Fatalf("forwarded grant=%+v, want the exact int64s from decimal strings", svc.grant)
}
}
func TestBotVerificationVersionConflictIsAnswered409(t *testing.T) {
svc := &captureBotVerificationService{
request: domain.CustomVerificationRequest{ID: 88, Version: 5},
// The shape admin.codedError produces for a lost race.
commandErr: fmt.Errorf("%s: %w", admin.CodeCustomVerificationConflict, domain.ErrCustomVerificationVersionConflict),
}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
}
var result admin.CommandResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if !strings.Contains(result.Error, admin.CodeCustomVerificationConflict) {
t.Fatalf("result=%+v, want the stable conflict code", result)
}
if !strings.Contains(result.Message, "reload") {
t.Fatalf("result message=%q, want an actionable message", result.Message)
}
// The same on the manage half, where two operators can race a verifier row.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"c2","actor":"alice","reason":"partner","bot_id":3003,"icon_document_id":900,"company_name":"x","version":3}`))
if rec.Code != http.StatusConflict {
t.Fatalf("grant conflict status=%d body=%s, want 409", rec.Code, rec.Body.String())
}
}
func TestBotVerificationErrorStatusMapping(t *testing.T) {
cases := map[string]int{
admin.CodeBotVerifierNotFound: http.StatusNotFound,
admin.CodeBotVerifierBotNotFound: http.StatusNotFound,
admin.CodeVerificationIconNotFound: http.StatusNotFound,
admin.CodeCustomVerificationNotFound: http.StatusNotFound,
admin.CodeCustomVerificationRequestNotFound: http.StatusNotFound,
admin.CodeCustomVerificationConflict: http.StatusConflict,
admin.CodeCustomVerificationLimit: http.StatusConflict,
admin.CodeCustomVerificationRequestExists: http.StatusConflict,
admin.CodeCustomVerificationRateLimited: http.StatusTooManyRequests,
admin.CodeBotVerifierForbidden: http.StatusBadRequest,
admin.CodeBotVerifierInvalid: http.StatusBadRequest,
admin.CodeVerificationIconInactive: http.StatusBadRequest,
admin.CodeVerificationIconInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationStatusInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationReasonRequired: http.StatusBadRequest,
admin.CodeCustomVerificationTargetInvalid: http.StatusBadRequest,
admin.CodeCustomVerificationInvalid: http.StatusBadRequest,
"": http.StatusInternalServerError,
}
for code, want := range cases {
if got := botVerificationErrorStatus(code); got != want {
t.Fatalf("botVerificationErrorStatus(%q) = %d, want %d", code, got, want)
}
}
}
func TestBotVerificationActionsForwardTheirPayloads(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureBotVerificationService{}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/set-enabled", "scoped-manage",
`{"command_id":"c1","actor":"ops","reason":"abuse","bot_id":9223372036854775807,"enabled":false}`))
if rec.Code != http.StatusOK || svc.setEnabled.BotID != maxInt64 || svc.setEnabled.Enabled {
t.Fatalf("set-enabled status=%d req=%+v", rec.Code, svc.setEnabled)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/revoke", "scoped-manage",
`{"command_id":"c2","actor":"ops","reason":"programme ended","bot_id":3003}`))
if rec.Code != http.StatusOK || svc.revokeVerifier.BotID != 3003 {
t.Fatalf("revoke-verifier status=%d req=%+v", rec.Code, svc.revokeVerifier)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/upsert", "scoped-manage",
`{"command_id":"c3","actor":"ops","reason":"new icon","document_id":9223372036854775807,"name":"blue check","owner_bot_id":3003}`))
if rec.Code != http.StatusOK || svc.upsertIcon.DocumentID != maxInt64 ||
svc.upsertIcon.Name != "blue check" || svc.upsertIcon.OwnerBotID != 3003 {
t.Fatalf("upsert-icon status=%d req=%+v", rec.Code, svc.upsertIcon)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/icons/set-active", "scoped-manage",
`{"command_id":"c4","actor":"ops","reason":"retired","icon_id":501,"active":false}`))
if rec.Code != http.StatusOK || svc.setIconActive.IconID != 501 || svc.setIconActive.Active {
t.Fatalf("set-icon-active status=%d req=%+v", rec.Code, svc.setIconActive)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/marks/revoke", "scoped-manage",
`{"command_id":"c5","actor":"ops","reason":"impersonation","verifier_bot_id":3003,"peer_type":"channel","peer_id":9223372036854775807}`))
if rec.Code != http.StatusOK || svc.revokeMark.VerifierBotID != 3003 ||
svc.revokeMark.PeerType != domain.PeerTypeChannel || svc.revokeMark.PeerID != maxInt64 {
t.Fatalf("revoke-mark status=%d req=%+v", rec.Code, svc.revokeMark)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/revoke", "scoped-review",
`{"command_id":"c6","actor":"ops","reason":"licence withdrawn","version":9223372036854775807}`))
if rec.Code != http.StatusOK || svc.revokeRequest.RequestID != 88 || svc.revokeRequest.Version != maxInt64 {
t.Fatalf("revoke-request status=%d req=%+v", rec.Code, svc.revokeRequest)
}
}
func TestBotVerificationScopedTokenNameBecomesTheAuditActor(t *testing.T) {
svc := &captureBotVerificationService{request: domain.CustomVerificationRequest{ID: 88, Version: 3}}
srv := botVerificationServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c1","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "queue-bot" {
t.Fatalf("status=%d actor=%q, want the scoped token name", rec.Code, svc.approve.Actor)
}
// A stated actor is never overwritten, which is how the panel attributes an
// action to the signed-in operator.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "scoped-review",
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "alice" {
t.Fatalf("status=%d actor=%q", rec.Code, svc.approve.Actor)
}
// The master token has no name, so the caller keeps having to say who acts.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/requests/88/approve", "master",
`{"command_id":"c3","reason":"queue sweep","version":3}`))
if rec.Code != http.StatusOK || svc.approve.Actor != "" {
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.approve.Actor)
}
}
func TestBotVerificationCommandsRejectUnknownFields(t *testing.T) {
srv := botVerificationServer(&captureBotVerificationService{})
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/botverification/verifiers/grant", "scoped-manage",
`{"command_id":"c1","actor":"ops","reason":"x","bot_id":3003,"icon_document_id":900,"company_name":"y","enabled":true}`))
// enabled is not part of the grant payload: the kill switch is its own action,
// and a silently ignored field would hide that from the operator.
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enabled") {
t.Fatalf("status=%d body=%s, want 400 naming the unknown field", rec.Code, rec.Body.String())
}
}
func TestBotVerificationPermissionNamesAreDistinctFromTheOfficialOnes(t *testing.T) {
// The permission model's whole point here: appointing verifiers is not implied
// by reviewing the official queue, in either direction.
bounded := newPermissionSet([]string{PermissionBotVerificationReview})
if !bounded.Has(PermissionBotVerificationReview) {
t.Fatal("bounded set dropped its own permission")
}
if bounded.Has(PermissionBotVerificationManage) || bounded.Has(PermissionVerificationReview) {
t.Fatalf("botverification.review leaked into another right")
}
manage := newPermissionSet([]string{PermissionBotVerificationManage})
if manage.Has(PermissionBotVerificationReview) {
t.Fatal("botverification.manage implied the review right")
}
all := newPermissionSet([]string{PermissionAll})
if !all.Has(PermissionBotVerificationReview) || !all.Has(PermissionBotVerificationManage) {
t.Fatal("the wildcard refused a third-party permission")
}
}

196
internal/adminapi/rbac.go Normal file
View file

@ -0,0 +1,196 @@
package adminapi
import (
"context"
"crypto/subtle"
"net/http"
"strings"
)
// Admin API authorisation.
//
// Every request arrives with a bearer token, and the token decides which
// permissions the request carries:
//
// - TELESRV_ADMIN_API_TOKEN is the master token and carries every permission.
// This is what keeps the existing surface working unchanged: all the routes
// that predate permissions stay mounted through authenticated(), which is
// defined as "requires every permission", so the master token reaches them
// exactly as before.
// - A scoped token from TELESRV_ADMIN_SCOPED_TOKENS carries only the
// permissions its entry lists. A scoped token is therefore *not* a weaker
// master token: it authenticates successfully and is then refused with 403 on
// anything outside its list, including every legacy route. Widening a scoped
// token to the legacy surface would be a silent privilege escalation, so the
// wildcard has to be spelled out in configuration to get it.
//
// The two-step answer matters for diagnosis: 401 means "I do not know this
// token", 403 means "I know you and you may not do this".
// Permission names. They are the same strings the operator writes into
// TELESRV_ADMIN_UI_PERMISSIONS / TELESRV_ADMIN_SCOPED_TOKENS.
const (
// PermissionAll is the wildcard: a principal carrying it passes every check.
PermissionAll = "*"
// PermissionVerificationReview guards the whole official-verification review
// surface: the queue, one application, the counters, and the claim/approve/
// reject decisions.
PermissionVerificationReview = "verification.review"
// PermissionVerificationRevoke is required *in addition* to
// PermissionVerificationReview to clear a badge that was already granted.
// Taking a badge away is visible to every client of a public peer, so it is
// deliberately not implied by the right to review new applications.
PermissionVerificationRevoke = "verification.revoke"
// PermissionBotVerificationReview guards the third-party verification read
// surface -- verifiers, icons, granted marks, the queue and its counters -- plus
// the decisions on the applications filed with a verifier bot.
//
// This is NOT verification.review. Third-party verification is a separate
// mechanism over separate tables (verification_icons, bot_verifier_settings,
// custom_verifications, custom_verification_requests), so a token trusted to
// work one queue is not thereby trusted with the other: neither permission
// implies the other.
PermissionBotVerificationReview = "botverification.review"
// PermissionBotVerificationManage guards the configuration half: granting,
// switching and revoking verifier status, the icon catalogue, and stripping a
// granted mark.
//
// It is separate from the review right because these are the actions that
// decide how much a third-party mark is worth. Handing out the queue is
// routine; handing out the ability to appoint verifiers is not.
PermissionBotVerificationManage = "botverification.manage"
)
// CodeForbidden is the stable code for a permission failure, so the panel can
// tell an authorisation refusal apart from a domain refusal.
const CodeForbidden = "FORBIDDEN"
// ScopedToken is one bearer token restricted to a permission set. It mirrors
// config.AdminScopedToken; the adminapi package keeps its own shape so it does
// not depend on the configuration loader.
type ScopedToken struct {
// Name is the audit identity of actions performed with this token.
Name string
Token string
Permissions []string
}
// permissionSet is a resolved permission list.
type permissionSet struct {
all bool
names map[string]struct{}
}
func newPermissionSet(permissions []string) permissionSet {
set := permissionSet{names: make(map[string]struct{}, len(permissions))}
for _, permission := range permissions {
permission = strings.TrimSpace(permission)
if permission == "" {
continue
}
if permission == PermissionAll {
set.all = true
continue
}
set.names[permission] = struct{}{}
}
return set
}
// Has reports whether the set grants the permission.
func (p permissionSet) Has(permission string) bool {
if p.all {
return true
}
_, ok := p.names[permission]
return ok
}
// principal is the authenticated caller.
type principal struct {
// name is the scoped token's audit identity, or "" for the master token,
// whose actions are attributed by the actor the caller states in the body.
name string
permissions permissionSet
}
type principalKey struct{}
// principalName returns the scoped-token identity behind the request, or "" when
// the request came in on the master token.
func principalName(ctx context.Context) string {
if p, ok := ctx.Value(principalKey{}).(principal); ok {
return p.name
}
return ""
}
// principalFor resolves the bearer token to a principal.
//
// Every configured token is compared, and every comparison is constant time and
// unconditional: returning as soon as one matches would leak, through timing,
// which token position a guess collided with.
func (s *Server) principalFor(r *http.Request) (principal, bool) {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if got == "" {
return principal{}, false
}
matched := false
resolved := principal{}
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1 && s.token != "" {
matched = true
resolved = principal{permissions: permissionSet{all: true}}
}
for _, scoped := range s.scoped {
if subtle.ConstantTimeCompare([]byte(got), []byte(scoped.Token)) == 1 && scoped.Token != "" && !matched {
matched = true
resolved = principal{name: scoped.Name, permissions: newPermissionSet(scoped.Permissions)}
}
}
return resolved, matched
}
// authenticated guards a route that requires unrestricted rights.
//
// This is every route that predates the permission model. Keeping them here is
// the documented behaviour: the master token carries every permission, so nothing
// about the existing surface changes, while a bounded scoped token cannot use one
// of them as a side door.
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
return s.authorized(PermissionAll, next)
}
// authorized guards a route behind one permission.
func (s *Server) authorized(permission string, next http.HandlerFunc) http.HandlerFunc {
return s.authorizedAll([]string{permission}, next)
}
// authorizedAll guards a route behind every listed permission. Revocation uses it
// to require the review right and the revoke right together, so the revoke right
// alone cannot be handed out as a way into the review surface.
func (s *Server) authorizedAll(permissions []string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, ok := s.principalFor(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
for _, permission := range permissions {
if !caller.permissions.Has(permission) {
writeForbidden(w, permission)
return
}
}
next(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, caller)))
}
}
// writeForbidden names the missing permission, so an operator configuring a
// scoped token is told what to add instead of having to guess.
func writeForbidden(w http.ResponseWriter, permission string) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "permission " + permission + " is required",
"code": CodeForbidden,
"permission": permission,
})
}

View file

@ -2,12 +2,13 @@ package adminapi
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -22,6 +23,20 @@ import (
type Config struct {
Addr string
Token string
// ScopedTokens are additional bearer tokens with a bounded permission set
// each. Token stays the unrestricted master token, so a deployment that
// configures no scoped token behaves exactly as it did before.
//
// The shape mirrors config.AdminScopedToken without importing the loader --
// only the main packages depend on internal/config -- so the caller converts:
//
// scoped := make([]adminapi.ScopedToken, 0, len(cfg.AdminScopedTokens))
// for _, item := range cfg.AdminScopedTokens {
// scoped = append(scoped, adminapi.ScopedToken{
// Name: item.Name, Token: item.Token, Permissions: item.Permissions,
// })
// }
ScopedTokens []ScopedToken
}
type Service interface {
@ -64,6 +79,47 @@ type Service interface {
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error)
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
MintCollectibleUsername(ctx context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error)
TransferCollectibleUsername(ctx context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error)
RevokeCollectibleUsername(ctx context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error)
DeleteCollectibleUsername(ctx context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error)
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)
RecomputeAccountRating(ctx context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error)
AdjustAccountRating(ctx context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error)
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, 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)
RevokeVerification(ctx context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error)
VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error)
VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
// Third-party bot verification. A separate mechanism from the official
// verification methods above, over separate tables and separate permissions;
// see botverification.go.
GrantBotVerifier(ctx context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error)
SetBotVerifierEnabled(ctx context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error)
RevokeBotVerifier(ctx context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error)
UpsertVerificationIcon(ctx context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error)
SetVerificationIconActive(ctx context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error)
RevokeCustomVerification(ctx context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error)
ApproveBotVerification(ctx context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error)
RejectBotVerification(ctx context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error)
RevokeBotVerification(ctx context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error)
BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
}
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
@ -80,7 +136,7 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
if log == nil {
log = zap.NewNop()
}
server := &Server{token: cfg.Token, svc: svc, log: log}
server := &Server{token: cfg.Token, scoped: cfg.ScopedTokens, svc: svc, log: log}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: server.routes(),
@ -102,9 +158,10 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
}
type Server struct {
token string
svc Service
log *zap.Logger
token string
scoped []ScopedToken
svc Service
log *zap.Logger
}
func (s *Server) routes() http.Handler {
@ -151,20 +208,50 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/moderation/cases/{id}/decide", s.authenticated(s.handleDecideModerationCase))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals", s.authenticated(s.handleSubmitModerationAppeal))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals/{appeal_id}/review", s.authenticated(s.handleReviewModerationAppeal))
mux.HandleFunc("POST /v1/collectible-usernames/mint", s.authenticated(s.handleMintCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/transfer", s.authenticated(s.handleTransferCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/revoke", s.authenticated(s.handleRevokeCollectibleUsername))
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/account-ratings/recompute", s.authenticated(s.handleRecomputeAccountRating))
mux.HandleFunc("POST /v1/account-ratings/adjust", s.authenticated(s.handleAdjustAccountRating))
mux.HandleFunc("GET /v1/account-ratings", s.authenticated(s.handleAccountRatings))
mux.HandleFunc("GET /v1/account-ratings/{id}", s.authenticated(s.handleAccountRating))
// 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.
mux.HandleFunc("GET /v1/verification/applications", s.authorized(PermissionVerificationReview, s.handleVerificationApplications))
mux.HandleFunc("GET /v1/verification/applications/{id}", s.authorized(PermissionVerificationReview, s.handleVerificationApplication))
mux.HandleFunc("GET /v1/verification/counts", s.authorized(PermissionVerificationReview, s.handleVerificationCounts))
mux.HandleFunc("POST /v1/verification/applications/{id}/claim", s.authorized(PermissionVerificationReview, s.handleClaimVerification))
mux.HandleFunc("POST /v1/verification/applications/{id}/approve", s.authorized(PermissionVerificationReview, s.handleApproveVerification))
mux.HandleFunc("POST /v1/verification/applications/{id}/reject", s.authorized(PermissionVerificationReview, s.handleRejectVerification))
mux.HandleFunc("POST /v1/verification/revoke", s.authorizedAll(
[]string{PermissionVerificationReview, PermissionVerificationRevoke}, s.handleRevokeVerification))
// Third-party bot verification. Separate routes, separate permissions and
// separate tables from the official verification block above -- the two
// mechanisms never read each other's state. Reads and queue decisions need
// botverification.review; appointing verifiers, curating icons and stripping a
// granted mark need botverification.manage.
mux.HandleFunc("GET /v1/botverification/verifiers", s.authorized(PermissionBotVerificationReview, s.handleBotVerifiers))
mux.HandleFunc("GET /v1/botverification/icons", s.authorized(PermissionBotVerificationReview, s.handleVerificationIcons))
mux.HandleFunc("GET /v1/botverification/marks", s.authorized(PermissionBotVerificationReview, s.handleCustomVerifications))
mux.HandleFunc("GET /v1/botverification/requests", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequests))
mux.HandleFunc("GET /v1/botverification/requests/{id}", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequest))
mux.HandleFunc("GET /v1/botverification/counts", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationCounts))
mux.HandleFunc("POST /v1/botverification/requests/{id}/approve", s.authorized(PermissionBotVerificationReview, s.handleApproveBotVerification))
mux.HandleFunc("POST /v1/botverification/requests/{id}/reject", s.authorized(PermissionBotVerificationReview, s.handleRejectBotVerification))
mux.HandleFunc("POST /v1/botverification/requests/{id}/revoke", s.authorized(PermissionBotVerificationReview, s.handleRevokeBotVerification))
mux.HandleFunc("POST /v1/botverification/verifiers/grant", s.authorized(PermissionBotVerificationManage, s.handleGrantBotVerifier))
mux.HandleFunc("POST /v1/botverification/verifiers/set-enabled", s.authorized(PermissionBotVerificationManage, s.handleSetBotVerifierEnabled))
mux.HandleFunc("POST /v1/botverification/verifiers/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeBotVerifier))
mux.HandleFunc("POST /v1/botverification/icons/upsert", s.authorized(PermissionBotVerificationManage, s.handleUpsertVerificationIcon))
mux.HandleFunc("POST /v1/botverification/icons/set-active", s.authorized(PermissionBotVerificationManage, s.handleSetVerificationIconActive))
mux.HandleFunc("POST /v1/botverification/marks/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeCustomVerification))
return mux
}
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountFrozenRequest
if !decodeJSON(w, r, &req) {
@ -938,6 +1025,372 @@ func writeModerationError(w http.ResponseWriter, err error) {
}
}
func (s *Server) handleMintCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.MintCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.MintCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleTransferCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.TransferCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.TransferCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRevokeCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RevokeCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRecomputeAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.RecomputeAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RecomputeAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleAdjustAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.AdjustAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.AdjustAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus(strings.TrimSpace(query.Get("status"))),
Query: query.Get("q"),
}
if filter.Status != "" && !filter.Status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeCollectibleStateInvalid, "invalid status")
return
}
owner, ok := collectibleOwnerFilter(w, query)
if !ok {
return
}
filter.Owner = owner
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
items, err := s.svc.CollectibleUsernames(r.Context(), filter)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
assets := make([]map[string]any, 0, len(items))
for _, item := range items {
assets = append(assets, collectibleUsernameResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"assets": assets})
}
func (s *Server) handleCollectibleUsername(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
asset, err := s.svc.CollectibleUsernameByID(r.Context(), id)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
transfers, err := s.svc.CollectibleUsernameTransfers(r.Context(), asset.ID, limit)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
log := make([]map[string]any, 0, len(transfers))
for _, item := range transfers {
log = append(log, collectibleUsernameTransferResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"asset": collectibleUsernameResponse(asset), "transfers": log,
})
}
func (s *Server) handleAccountRatings(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
minLevel, ok := optionalQueryInt(w, query, "min_level")
if !ok {
return
}
userID, ok := optionalQueryInt64(w, query, "user_id")
if !ok {
return
}
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.AccountRatings(r.Context(), domain.AccountRatingFilter{
MinLevel: minLevel, UserID: userID, BeforeID: beforeID, Limit: limit,
})
if err != nil {
writeAccountRatingError(w, err)
return
}
ratings := make([]map[string]any, 0, len(items))
for _, item := range items {
ratings = append(ratings, accountRatingResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"ratings": ratings})
}
func (s *Server) handleAccountRating(w http.ResponseWriter, r *http.Request) {
userID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
rating, err := s.svc.AccountRating(r.Context(), userID)
if err != nil {
writeAccountRatingError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
events, err := s.svc.AccountRatingEvents(r.Context(), userID, limit)
if err != nil {
writeAccountRatingError(w, err)
return
}
ledger := make([]map[string]any, 0, len(events))
for _, item := range events {
ledger = append(ledger, accountRatingEventResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"rating": accountRatingResponse(rating), "events": ledger,
})
}
// collectibleOwnerFilter reads the optional owner filter. At most one of the two
// identifiers may be present, mirroring the mint/transfer request shape.
func collectibleOwnerFilter(w http.ResponseWriter, query url.Values) (domain.Peer, bool) {
userID, ok := optionalQueryInt64(w, query, "owner_user_id")
if !ok {
return domain.Peer{}, false
}
channelID, ok := optionalQueryInt64(w, query, "owner_channel_id")
if !ok {
return domain.Peer{}, false
}
switch {
case userID > 0 && channelID > 0:
writeError(w, http.StatusBadRequest, "at most one owner filter is allowed")
return domain.Peer{}, false
case userID > 0:
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true
case channelID > 0:
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, true
default:
return domain.Peer{}, true
}
}
func optionalQueryInt64(w http.ResponseWriter, query url.Values, name string) (int64, bool) {
raw := strings.TrimSpace(query.Get(name))
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return value, true
}
func optionalQueryInt(w http.ResponseWriter, query url.Values, name string) (int, bool) {
value, ok := optionalQueryInt64(w, query, name)
if !ok {
return 0, false
}
if value > math.MaxInt32 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return int(value), true
}
// collectibleUsernameResponse renders one asset. Every int64 crosses the JSON
// boundary as a decimal string: asset ids and nanoton amounts exceed the exact
// range of a JSON number, and a rounded id would address the wrong asset.
func collectibleUsernameResponse(asset domain.CollectibleUsername) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(asset.ID, 10),
"username": asset.Username,
"status": string(asset.Status),
"owner_type": string(asset.Owner.Type),
"owner_id": strconv.FormatInt(asset.Owner.ID, 10),
"purchase_date": asset.Info().PurchaseDate,
"currency": asset.Currency,
"amount": strconv.FormatInt(asset.Amount, 10),
"crypto_currency": asset.CryptoCurrency,
"crypto_amount": strconv.FormatInt(asset.CryptoAmount, 10),
"url": asset.URL,
"original_owner_type": string(asset.OriginalOwner.Type),
"original_owner_id": strconv.FormatInt(asset.OriginalOwner.ID, 10),
"transfer_count": asset.TransferCount,
"version": strconv.FormatInt(asset.Version, 10),
}
if !asset.CreatedAt.IsZero() {
out["created_at"] = asset.CreatedAt.UTC().Format(time.RFC3339)
}
if !asset.UpdatedAt.IsZero() {
out["updated_at"] = asset.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func collectibleUsernameTransferResponse(item domain.CollectibleUsernameTransfer) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(item.ID, 10),
"collectible_id": strconv.FormatInt(item.CollectibleID, 10),
"kind": string(item.Kind),
"from_type": string(item.From.Type),
"from_id": strconv.FormatInt(item.From.ID, 10),
"to_type": string(item.To.Type),
"to_id": strconv.FormatInt(item.To.ID, 10),
"currency": item.Currency,
"amount": strconv.FormatInt(item.Amount, 10),
"actor": item.Actor,
"reason": item.Reason,
"command_key": item.CommandKey,
}
if !item.CreatedAt.IsZero() {
out["created_at"] = item.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// accountRatingResponse renders one composite rating. The score and every
// component stay decimal strings for the same exactness reason as the asset ids.
func accountRatingResponse(rating domain.AccountRating) map[string]any {
out := map[string]any{
"user_id": strconv.FormatInt(rating.UserID, 10),
"level": rating.Level,
"stars": strconv.FormatInt(rating.Stars, 10),
"current_level_stars": strconv.FormatInt(rating.CurrentLevelStars, 10),
"has_next_level": rating.HasNextLevel,
"stars_component": strconv.FormatInt(rating.StarsComponent, 10),
"activity_component": strconv.FormatInt(rating.ActivityComponent, 10),
"penalty_component": strconv.FormatInt(rating.PenaltyComponent, 10),
"manual_component": strconv.FormatInt(rating.ManualComponent, 10),
"pending_stars": strconv.FormatInt(rating.PendingStars, 10),
"version": strconv.FormatInt(rating.Version, 10),
}
if rating.HasNextLevel {
out["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
}
if !rating.PendingDate.IsZero() {
out["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
}
if !rating.ComputedAt.IsZero() {
out["computed_at"] = rating.ComputedAt.UTC().Format(time.RFC3339)
}
if !rating.UpdatedAt.IsZero() {
out["updated_at"] = rating.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func accountRatingEventResponse(event domain.AccountRatingEvent) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(event.ID, 10),
"user_id": strconv.FormatInt(event.UserID, 10),
"kind": string(event.Kind),
"amount": strconv.FormatInt(event.Amount, 10),
"reason": event.Reason,
"actor": event.Actor,
"command_key": event.CommandKey,
}
if !event.CreatedAt.IsZero() {
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// writeCollectibleUsernameError maps a collectible-username failure onto its
// stable admin code and the matching HTTP status, the way writeModerationError
// does for moderation. An unmapped failure stays a 500 with its own text rather
// than being dressed up as a client error.
func writeCollectibleUsernameError(w http.ResponseWriter, err error) {
code := admin.CollectibleUsernameErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeCollectibleNotFound:
status = http.StatusNotFound
case admin.CodeUsernameOccupied, admin.CodeCollectibleBurned,
admin.CodeCollectiblePeerLimit, admin.CodeCollectibleNotOwned:
status = http.StatusConflict
case admin.CodeUsernameInvalid, admin.CodeUsernameNotCollectible,
admin.CodeCollectibleCurrencyInvalid, admin.CodeCollectibleStateInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeAccountRatingError(w http.ResponseWriter, err error) {
code := admin.AccountRatingErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeRatingNotFound:
status = http.StatusNotFound
case admin.CodeRatingAdjustmentInvalid, admin.CodeRatingWeightsInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeCodedError(w http.ResponseWriter, status int, code, msg string) {
body := map[string]string{"error": msg}
if code != "" {
body["code"] = code
}
writeJSON(w, status, body)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))

View file

@ -606,3 +606,369 @@ func (fakeService) SubmitModerationAppeal(context.Context, int64, int64, string)
func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error) {
return domain.ModerationCaseDetail{}, true, nil
}
type captureCollectibleUsernameService struct {
fakeService
mint admin.MintCollectibleUsernameRequest
transfer admin.TransferCollectibleUsernameRequest
revoke admin.RevokeCollectibleUsernameRequest
del admin.DeleteCollectibleUsernameRequest
filter domain.CollectibleUsernameFilter
assetID int64
}
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
}
func (s *captureCollectibleUsernameService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
s.transfer = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
s.revoke = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
s.del = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
s.filter = filter
return []domain.CollectibleUsername{maxInt64Collectible()}, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.assetID = id
asset := maxInt64Collectible()
asset.ID = id
return asset, nil
}
func (s *captureCollectibleUsernameService) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, _ int) ([]domain.CollectibleUsernameTransfer, error) {
return []domain.CollectibleUsernameTransfer{{
ID: 9223372036854775807,
CollectibleID: collectibleID,
Kind: domain.CollectibleUsernameKindMint,
To: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
Actor: "ops",
}}, nil
}
func maxInt64Collectible() domain.CollectibleUsername {
return domain.CollectibleUsername{
ID: 9223372036854775807,
Username: "durov",
Status: domain.CollectibleUsernameStatusOwned,
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Currency: domain.CollectibleCurrencyTON,
Amount: 9223372036854775807,
CryptoCurrency: domain.CollectibleCryptoCurrencyTON,
CryptoAmount: 9223372036854775807,
Version: 9223372036854775807,
}
}
type captureAccountRatingService struct {
fakeService
recompute admin.RecomputeAccountRatingRequest
adjust admin.AdjustAccountRatingRequest
filter domain.AccountRatingFilter
}
func (s *captureAccountRatingService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
s.recompute = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
s.adjust = req
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureAccountRatingService) AccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
s.filter = filter
return []domain.AccountRating{maxInt64Rating()}, nil
}
func (s *captureAccountRatingService) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating := maxInt64Rating()
rating.UserID = userID
return rating, nil
}
func (s *captureAccountRatingService) AccountRatingEvents(_ context.Context, userID int64, _ int) ([]domain.AccountRatingEvent, error) {
return []domain.AccountRatingEvent{{
ID: 9223372036854775807, UserID: userID,
Kind: domain.AccountRatingEventManual, Amount: -9223372036854775807,
Actor: "ops", Reason: "abuse",
}}, nil
}
func maxInt64Rating() domain.AccountRating {
return domain.AccountRating{
UserID: 1001,
Level: 7,
Stars: 9223372036854775807,
CurrentLevelStars: 4900,
NextLevelStars: 6400,
HasNextLevel: true,
StarsComponent: 9223372036854775807,
ManualComponent: -1500,
Version: 9223372036854775807,
}
}
func TestAdminAPICollectibleUsernameCommandsRequireToken(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
for _, path := range []string{
"/v1/collectible-usernames/mint",
"/v1/collectible-usernames/transfer",
"/v1/collectible-usernames/revoke",
"/v1/account-ratings/recompute",
"/v1/account-ratings/adjust",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
for _, path := range []string{
"/v1/collectible-usernames",
"/v1/collectible-usernames/7",
"/v1/account-ratings",
"/v1/account-ratings/7",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d, want 401", path, rec.Code)
}
}
}
func TestAdminAPIMintCollectibleUsernameForwardsExactInt64AndDryRun(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
req := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/mint", strings.NewReader(`{
"command_id":"mint-1","actor":"ops","reason":"fragment import","dry_run":true,
"username":"durov","owner_user_id":"1001","currency":"TON","amount":"9223372036854775807",
"crypto_currency":"TON","crypto_amount":"250000000000",
"url":"https://fragment.example/durov","purchase_date":1700000000
}`))
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"command_id":"mint-1"`) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("dry-run was not propagated: %s", rec.Body.String())
}
if svc.mint.Username != "durov" || svc.mint.OwnerUserID != 1001 || svc.mint.Amount != maxInt64 ||
svc.mint.CryptoAmount != 250000000000 || svc.mint.PurchaseDate != 1700000000 || !svc.mint.DryRun {
t.Fatalf("decoded mint request = %+v", svc.mint)
}
}
func TestAdminAPITransferAndRevokeCollectibleUsername(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
transfer := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/transfer", strings.NewReader(
`{"command_id":"t-1","actor":"ops","reason":"sold","username":"durov","to_channel_id":"2002"}`))
transfer.Header.Set("Authorization", "Bearer secret")
transferRec := httptest.NewRecorder()
srv.routes().ServeHTTP(transferRec, transfer)
if transferRec.Code != http.StatusOK || svc.transfer.ToChannelID != 2002 || svc.transfer.Username != "durov" {
t.Fatalf("transfer status=%d request=%+v", transferRec.Code, svc.transfer)
}
revoke := httptest.NewRequest(http.MethodPost, "/v1/collectible-usernames/revoke", strings.NewReader(
`{"command_id":"r-1","actor":"ops","reason":"fraud","username":"durov","burn":true}`))
revoke.Header.Set("Authorization", "Bearer secret")
revokeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(revokeRec, revoke)
if revokeRec.Code != http.StatusOK || !svc.revoke.Burn || svc.revoke.CommandID != "r-1" {
t.Fatalf("revoke status=%d request=%+v", revokeRec.Code, svc.revoke)
}
}
func TestAdminAPIAccountRatingCommands(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
recompute := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/recompute", strings.NewReader(
`{"command_id":"rc-1","actor":"ops","reason":"support ticket","dry_run":true,"user_id":"1001"}`))
recompute.Header.Set("Authorization", "Bearer secret")
recomputeRec := httptest.NewRecorder()
srv.routes().ServeHTTP(recomputeRec, recompute)
if recomputeRec.Code != http.StatusOK || svc.recompute.UserID != 1001 || !svc.recompute.DryRun {
t.Fatalf("recompute status=%d request=%+v body=%s", recomputeRec.Code, svc.recompute, recomputeRec.Body.String())
}
adjust := httptest.NewRequest(http.MethodPost, "/v1/account-ratings/adjust", strings.NewReader(
`{"command_id":"adj-1","actor":"ops","reason":"manual penalty","user_id":"1001","amount":"-2500"}`))
adjust.Header.Set("Authorization", "Bearer secret")
adjustRec := httptest.NewRecorder()
srv.routes().ServeHTTP(adjustRec, adjust)
if adjustRec.Code != http.StatusOK || svc.adjust.Amount != -2500 || svc.adjust.DryRun {
t.Fatalf("adjust status=%d request=%+v body=%s", adjustRec.Code, svc.adjust, adjustRec.Body.String())
}
}
func TestAdminAPICollectibleUsernameReadsUseDecimalStrings(t *testing.T) {
svc := &captureCollectibleUsernameService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet,
"/v1/collectible-usernames?status=owned&owner_user_id=1001&q=%40Durov&limit=25&before_id=42", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.Status != domain.CollectibleUsernameStatusOwned ||
svc.filter.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: 1001}) ||
svc.filter.Query != "@Durov" || svc.filter.Limit != 25 || svc.filter.BeforeID != 42 {
t.Fatalf("collectible filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"id":"9223372036854775807"`) ||
!strings.Contains(listRec.Body.String(), `"amount":"9223372036854775807"`) {
t.Fatalf("list body lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/77", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK || svc.assetID != 77 {
t.Fatalf("detail status=%d assetID=%d body=%s", detailRec.Code, svc.assetID, detailRec.Body.String())
}
var payload struct {
Asset map[string]any `json:"asset"`
Transfers []map[string]any `json:"transfers"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Asset["id"] != "77" || len(payload.Transfers) != 1 ||
payload.Transfers[0]["amount"] != "9223372036854775807" ||
payload.Transfers[0]["collectible_id"] != "77" {
t.Fatalf("detail payload = %+v", payload)
}
}
func TestAdminAPIAccountRatingReadsUseDecimalStrings(t *testing.T) {
svc := &captureAccountRatingService{}
srv := &Server{token: "secret", svc: svc}
list := httptest.NewRequest(http.MethodGet, "/v1/account-ratings?min_level=3&user_id=1001&limit=10&before_id=99", nil)
list.Header.Set("Authorization", "Bearer secret")
listRec := httptest.NewRecorder()
srv.routes().ServeHTTP(listRec, list)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
}
if svc.filter.MinLevel != 3 || svc.filter.UserID != 1001 || svc.filter.Limit != 10 || svc.filter.BeforeID != 99 {
t.Fatalf("rating filter = %+v", svc.filter)
}
if !strings.Contains(listRec.Body.String(), `"stars":"9223372036854775807"`) {
t.Fatalf("rating list lost int64 precision: %s", listRec.Body.String())
}
detail := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/1001", nil)
detail.Header.Set("Authorization", "Bearer secret")
detailRec := httptest.NewRecorder()
srv.routes().ServeHTTP(detailRec, detail)
if detailRec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", detailRec.Code, detailRec.Body.String())
}
var payload struct {
Rating map[string]any `json:"rating"`
Events []map[string]any `json:"events"`
}
if err := json.Unmarshal(detailRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode detail: %v", err)
}
if payload.Rating["user_id"] != "1001" || payload.Rating["stars"] != "9223372036854775807" ||
len(payload.Events) != 1 || payload.Events[0]["amount"] != "-9223372036854775807" {
t.Fatalf("rating detail payload = %+v", payload)
}
}
func TestAdminAPIMissingCollectibleAndRatingReportCodedErrors(t *testing.T) {
srv := &Server{token: "secret", svc: fakeService{}}
asset := httptest.NewRequest(http.MethodGet, "/v1/collectible-usernames/5", nil)
asset.Header.Set("Authorization", "Bearer secret")
assetRec := httptest.NewRecorder()
srv.routes().ServeHTTP(assetRec, asset)
if assetRec.Code != http.StatusNotFound ||
!strings.Contains(assetRec.Body.String(), `"code":"`+admin.CodeCollectibleNotFound+`"`) {
t.Fatalf("missing asset status=%d body=%s", assetRec.Code, assetRec.Body.String())
}
rating := httptest.NewRequest(http.MethodGet, "/v1/account-ratings/5", nil)
rating.Header.Set("Authorization", "Bearer secret")
ratingRec := httptest.NewRecorder()
srv.routes().ServeHTTP(ratingRec, rating)
if ratingRec.Code != http.StatusNotFound ||
!strings.Contains(ratingRec.Body.String(), `"code":"`+admin.CodeRatingNotFound+`"`) {
t.Fatalf("missing rating status=%d body=%s", ratingRec.Code, ratingRec.Body.String())
}
}
func (fakeService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) TransferCollectibleUsername(_ context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeCollectibleUsername(_ context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteCollectibleUsername(_ context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) CollectibleUsernames(context.Context, domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
return nil, nil
}
func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.CollectibleUsername, error) {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
return nil, nil
}
func (fakeService) RecomputeAccountRating(_ context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AdjustAccountRating(_ context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) AccountRating(context.Context, int64) (domain.AccountRating, error) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
func (fakeService) AccountRatings(context.Context, domain.AccountRatingFilter) ([]domain.AccountRating, error) {
return nil, nil
}
func (fakeService) AccountRatingEvents(context.Context, int64, int) ([]domain.AccountRatingEvent, error) {
return nil, nil
}

View file

@ -0,0 +1,361 @@
package adminapi
import (
"net/http"
"strconv"
"strings"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// Official platform verification review over the admin API.
//
// These are the mirror routes of the panel's own endpoints: the panel reads the
// queue straight from PostgreSQL for speed, while an integration holding a scoped
// token reads it here. Decisions only ever travel this way, so the command
// journal and the status machine are enforced in one place.
// handleVerificationApplications is the review queue.
func (s *Server) handleVerificationApplications(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetType(strings.TrimSpace(query.Get("target_type"))),
Reviewer: strings.TrimSpace(query.Get("reviewer")),
Query: query.Get("q"),
}
if filter.TargetType != "" && !filter.TargetType.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationTargetInvalid, "invalid target_type")
return
}
// status accepts a comma-separated list, so the queue view ("submitted,
// in_review") is one request rather than two.
for _, raw := range strings.Split(query.Get("status"), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
status := domain.VerificationStatus(raw)
if !status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeVerificationStatusInvalid, "invalid status "+raw)
return
}
filter.Statuses = append(filter.Statuses, status)
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
items, err := s.svc.VerificationApplications(r.Context(), filter)
if err != nil {
writeVerificationError(w, err)
return
}
applications := make([]map[string]any, 0, len(items))
for _, item := range items {
applications = append(applications, verificationApplicationResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"applications": applications})
}
// handleVerificationApplication is one application with its history and the
// target as it looks right now.
func (s *Server) handleVerificationApplication(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
app, err := s.svc.VerificationApplication(r.Context(), id)
if err != nil {
writeVerificationError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
events, err := s.svc.VerificationApplicationEvents(r.Context(), app.ID, limit)
if err != nil {
writeVerificationError(w, err)
return
}
history := make([]map[string]any, 0, len(events))
for _, event := range events {
history = append(history, verificationEventResponse(event))
}
body := map[string]any{
"application": verificationApplicationResponse(app),
"events": history,
}
// The snapshot is advisory: a target that vanished must not turn the audit
// record into a 500, so a snapshot failure is reported next to the record
// instead of replacing it.
if target, err := s.svc.VerificationTargetSnapshot(r.Context(), app.TargetType, app.TargetID); err == nil {
body["target"] = verificationTargetResponse(target)
} else {
body["target_error"] = err.Error()
}
writeJSON(w, http.StatusOK, body)
}
// handleVerificationCounts is the queue summary.
func (s *Server) handleVerificationCounts(w http.ResponseWriter, r *http.Request) {
counts, err := s.svc.VerificationCounts(r.Context())
if err != nil {
writeVerificationError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"counts": verificationCountsResponse(counts)})
}
func (s *Server) handleClaimVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.ClaimVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
// The path is the authority on which application is decided: a body naming a
// different one would make the URL lie to the audit trail.
req.ApplicationID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.ClaimVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
func (s *Server) handleApproveVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.ApproveVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.ApplicationID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.ApproveVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
func (s *Server) handleRejectVerification(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
var req admin.RejectVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
req.ApplicationID = id
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RejectVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
func (s *Server) handleRevokeVerification(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
s.applyVerificationPrincipal(r, &req.CommandMeta)
result, err := s.svc.RevokeVerification(r.Context(), req)
writeVerificationCommandResult(w, result, err)
}
// applyVerificationPrincipal fills in the audit actor for a scoped token that did
// not state one.
//
// A scoped token's configured name *is* its audit identity, so an integration
// does not have to invent an actor string. The master token has no name, so a
// caller using it keeps having to state who is acting -- which is what the panel
// does with the signed-in operator.
func (s *Server) applyVerificationPrincipal(r *http.Request, meta *admin.CommandMeta) {
if strings.TrimSpace(meta.Actor) != "" {
return
}
if name := principalName(r.Context()); name != "" {
meta.Actor = name
}
}
// verificationApplicationResponse renders one application. Every int64 crosses
// the JSON boundary as a decimal string: application ids, peer ids and the
// optimistic-locking version exceed the range a JSON number holds exactly, and a
// rounded id would decide the wrong application.
func verificationApplicationResponse(app domain.VerificationApplication) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(app.ID, 10),
"applicant_user_id": strconv.FormatInt(app.ApplicantUserID, 10),
"target_type": string(app.TargetType),
"target_id": strconv.FormatInt(app.TargetID, 10),
"target_title": app.TargetTitle,
"target_username": app.TargetUsername,
"category": app.Category,
"description": app.Description,
"official_website": app.OfficialWebsite,
"social_links": stringList(app.SocialLinks),
"press_links": stringList(app.PressLinks),
"additional_note": app.AdditionalNote,
"status": string(app.Status),
"reviewer_admin_id": app.ReviewerAdminID,
"decision_reason": app.DecisionReason,
// internal_note is operator-only. It is exposed here because every caller
// of this route already holds verification.review, and it is the reviewer's
// own handover note; it is never part of the applicant-facing projection.
"internal_note": app.InternalNote,
"correlation_id": app.CorrelationID,
"version": strconv.FormatInt(app.Version, 10),
}
if !app.CreatedAt.IsZero() {
out["created_at"] = app.CreatedAt.UTC().Format(time.RFC3339)
}
if !app.UpdatedAt.IsZero() {
out["updated_at"] = app.UpdatedAt.UTC().Format(time.RFC3339)
}
if !app.SubmittedAt.IsZero() {
out["submitted_at"] = app.SubmittedAt.UTC().Format(time.RFC3339)
}
if !app.ReviewedAt.IsZero() {
out["reviewed_at"] = app.ReviewedAt.UTC().Format(time.RFC3339)
}
return out
}
func verificationEventResponse(event domain.VerificationApplicationEvent) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(event.ID, 10),
"application_id": strconv.FormatInt(event.ApplicationID, 10),
"kind": string(event.Kind),
"from_status": string(event.FromStatus),
"to_status": string(event.ToStatus),
"actor": event.Actor,
"reason": event.Reason,
"note": event.Note,
"correlation_id": event.CorrelationID,
}
if !event.CreatedAt.IsZero() {
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
func verificationTargetResponse(target domain.VerificationTarget) map[string]any {
return map[string]any{
"type": string(target.Type),
"id": strconv.FormatInt(target.ID, 10),
"title": target.Title,
"username": target.Username,
"verified": target.Verified,
"eligible": target.Eligible,
"reason": target.Reason,
}
}
// verificationCountsResponse renders the queue summary with every modelled status
// present, so the panel never has to distinguish "zero" from "absent". The values
// are decimal strings for the same exactness reason as the ids.
func verificationCountsResponse(counts domain.VerificationStatusCounts) map[string]string {
out := make(map[string]string, len(verificationStatusOrder))
for _, status := range verificationStatusOrder {
out[string(status)] = strconv.FormatInt(counts[status], 10)
}
for status, count := range counts {
if _, ok := out[string(status)]; !ok {
out[string(status)] = strconv.FormatInt(count, 10)
}
}
return out
}
// verificationStatusOrder is the closed status set, in lifecycle order.
var verificationStatusOrder = []domain.VerificationStatus{
domain.VerificationStatusDraft,
domain.VerificationStatusSubmitted,
domain.VerificationStatusInReview,
domain.VerificationStatusApproved,
domain.VerificationStatusRejected,
domain.VerificationStatusCancelled,
}
// stringList normalises a nil slice to an empty JSON array, so the panel can
// iterate without a null check.
func stringList(items []string) []string {
if items == nil {
return []string{}
}
return items
}
// verificationErrorStatus maps a verification failure onto its HTTP status.
//
// The version conflict is the interesting one: it is 409, not 400, because
// nothing about the request was wrong -- another reviewer simply decided first,
// and the panel has to answer that by reloading rather than by correcting input.
func verificationErrorStatus(code string) int {
switch code {
case admin.CodeVerificationNotFound:
return http.StatusNotFound
case admin.CodeVerificationConflict,
admin.CodeVerificationTargetOccupied,
admin.CodeVerificationTargetVerified:
return http.StatusConflict
case admin.CodeVerificationStatusInvalid,
admin.CodeVerificationReasonRequired,
admin.CodeVerificationTargetInvalid,
admin.CodeVerificationTargetNotPublic,
admin.CodeVerificationTargetRestricted,
admin.CodeVerificationTargetSystem,
admin.CodeVerificationNotOwner,
admin.CodeVerificationUserTargetsDisabled,
admin.CodeVerificationInvalid:
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
func writeVerificationError(w http.ResponseWriter, err error) {
code := admin.VerificationErrorCode(err)
writeCodedError(w, verificationErrorStatus(code), code, err.Error())
}
// writeVerificationCommandResult answers a decision.
//
// The body stays a CommandResult so the panel parses one shape for every
// operator action, but the status is derived from the failure: a lost
// optimistic-locking race must reach the browser as 409, because that is the one
// failure the panel resolves by reloading the application instead of by asking
// the operator to fix the form.
func writeVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
if err == nil {
writeJSON(w, http.StatusOK, result)
return
}
code := admin.VerificationErrorCode(err)
status := verificationErrorStatus(code)
if status == http.StatusInternalServerError {
// An unmapped command failure is a bad request, as everywhere else in this
// API, rather than a server fault.
status = http.StatusBadRequest
}
if result.CommandID == "" {
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
}
if result.Error == "" {
result.Error = err.Error()
}
if code == admin.CodeVerificationConflict {
result.Message = "another reviewer changed this application first; reload it and decide again"
}
writeJSON(w, status, result)
}

View file

@ -0,0 +1,546 @@
package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
// fakeService gains the verification surface here so the shared fake keeps
// satisfying Service without touching the existing test file.
func (fakeService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) VerificationApplications(context.Context, domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
return nil, nil
}
func (fakeService) VerificationApplication(context.Context, int64) (domain.VerificationApplication, error) {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (fakeService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
return nil, nil
}
func (fakeService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
return domain.VerificationStatusCounts{}, nil
}
func (fakeService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
return domain.VerificationTarget{}, nil
}
type captureVerificationService struct {
fakeService
filter domain.VerificationApplicationFilter
claim admin.ClaimVerificationRequest
approve admin.ApproveVerificationRequest
reject admin.RejectVerificationRequest
revoke admin.RevokeVerificationRequest
app domain.VerificationApplication
events []domain.VerificationApplicationEvent
counts domain.VerificationStatusCounts
target domain.VerificationTarget
decideOn error
}
func (s *captureVerificationService) VerificationApplications(_ context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
s.filter = filter
return []domain.VerificationApplication{s.app}, nil
}
func (s *captureVerificationService) VerificationApplication(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if s.app.ID != applicationID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
return s.app, nil
}
func (s *captureVerificationService) VerificationApplicationEvents(context.Context, int64, int) ([]domain.VerificationApplicationEvent, error) {
return s.events, nil
}
func (s *captureVerificationService) VerificationCounts(context.Context) (domain.VerificationStatusCounts, error) {
return s.counts, nil
}
func (s *captureVerificationService) VerificationTargetSnapshot(context.Context, domain.VerificationTargetType, int64) (domain.VerificationTarget, error) {
return s.target, nil
}
func (s *captureVerificationService) ClaimVerification(_ context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error) {
s.claim = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureVerificationService) ApproveVerification(_ context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error) {
s.approve = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureVerificationService) RejectVerification(_ context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error) {
s.reject = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (s *captureVerificationService) RevokeVerification(_ context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error) {
s.revoke = req
if s.decideOn != nil {
return admin.CommandResult{CommandID: req.CommandID, Status: "failed", Error: s.decideOn.Error()}, s.decideOn
}
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
// reviewOnlyServer is the deployment shape the permission model exists for: one
// unrestricted master token plus two bounded tokens, one able to review and one
// able to review and revoke.
func reviewOnlyServer(svc Service) *Server {
return &Server{
token: "master",
scoped: []ScopedToken{
{Name: "queue-bot", Token: "scoped-review", Permissions: []string{PermissionVerificationReview}},
{Name: "trust-and-safety", Token: "scoped-revoke", Permissions: []string{
PermissionVerificationReview, PermissionVerificationRevoke,
}},
{Name: "gift-importer", Token: "scoped-other", Permissions: []string{"gifts.import"}},
},
svc: svc,
}
}
func verificationRequest(method, path, token, body string) *http.Request {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req
}
func TestVerificationRoutesRejectMissingAndUnknownTokens(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
cases := []struct {
method string
path string
body string
}{
{http.MethodGet, "/v1/verification/applications", ""},
{http.MethodGet, "/v1/verification/applications/7", ""},
{http.MethodGet, "/v1/verification/counts", ""},
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
{http.MethodPost, "/v1/verification/applications/7/approve", `{}`},
{http.MethodPost, "/v1/verification/applications/7/reject", `{}`},
{http.MethodPost, "/v1/verification/revoke", `{}`},
}
for _, item := range cases {
for _, token := range []string{"", "not-a-configured-token"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, token, item.body))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s token=%q status=%d, want 401", item.method, item.path, token, rec.Code)
}
}
}
}
func TestVerificationRoutesRefuseScopedTokenWithoutThePermission(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
cases := []struct {
method string
path string
body string
}{
{http.MethodGet, "/v1/verification/applications", ""},
{http.MethodGet, "/v1/verification/counts", ""},
{http.MethodPost, "/v1/verification/applications/7/claim", `{}`},
}
for _, item := range cases {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(item.method, item.path, "scoped-other", item.body))
if rec.Code != http.StatusForbidden {
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["code"] != CodeForbidden || body["permission"] != PermissionVerificationReview {
t.Fatalf("403 body=%+v, want the missing permission named", body)
}
}
}
func TestVerificationRevokeNeedsTheRevokePermissionOnTopOfReview(t *testing.T) {
svc := &captureVerificationService{}
srv := reviewOnlyServer(svc)
// A review-only token reaches the queue but not the revocation.
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("review token on counts status=%d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-review",
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
if rec.Code != http.StatusForbidden {
t.Fatalf("review token on revoke status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body["permission"] != PermissionVerificationRevoke {
t.Fatalf("403 body=%+v, want verification.revoke named", body)
}
// The token that carries both rights gets through.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "scoped-revoke",
`{"command_id":"c2","actor":"ops","reason":"impersonation","target_type":"channel","target_id":5005}`))
if rec.Code != http.StatusOK {
t.Fatalf("revoke token status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.revoke.TargetType != domain.VerificationTargetChannel || svc.revoke.TargetID != 5005 ||
svc.revoke.Reason != "impersonation" {
t.Fatalf("forwarded revocation=%+v", svc.revoke)
}
}
func TestMasterTokenKeepsEveryPermissionIncludingTheLegacySurface(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 2}}
srv := reviewOnlyServer(svc)
// The new permissioned routes.
for _, path := range []string{"/v1/verification/applications", "/v1/verification/counts"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, path, "master", ""))
if rec.Code != http.StatusOK {
t.Fatalf("master token on %s status=%d body=%s", path, rec.Code, rec.Body.String())
}
}
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/revoke", "master",
`{"command_id":"c1","actor":"ops","reason":"impersonation","target_type":"bot","target_id":2002}`))
if rec.Code != http.StatusOK {
t.Fatalf("master token on revoke status=%d body=%s", rec.Code, rec.Body.String())
}
// And every route that predates permissions.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "master",
`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
if rec.Code != http.StatusOK {
t.Fatalf("master token on the legacy surface status=%d body=%s", rec.Code, rec.Body.String())
}
}
// A bounded token must not inherit the routes that predate the permission model:
// that would turn "give the queue bot the review right" into "give it everything".
func TestScopedTokenCannotUseTheLegacySurfaceAsASideDoor(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
for _, path := range []string{"/v1/accounts/set-verified", "/v1/accounts/set-frozen", "/v1/bots/delete"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, path, "scoped-review",
`{"command_id":"c1","actor":"ops","reason":"x","user_id":1001,"verified":true}`))
if rec.Code != http.StatusForbidden {
t.Fatalf("scoped token on %s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
}
}
// A scoped token that spells out the wildcard is the operator's explicit
// choice and does reach it.
wide := &Server{
token: "master",
scoped: []ScopedToken{{Name: "everything", Token: "scoped-all", Permissions: []string{PermissionAll}}},
svc: fakeService{},
}
rec := httptest.NewRecorder()
wide.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/accounts/set-verified", "scoped-all",
`{"command_id":"c1","actor":"ops","reason":"x","dry_run":true,"user_id":1001,"verified":true}`))
if rec.Code != http.StatusOK {
t.Fatalf("wildcard scoped token status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestVerificationQueueFilterAndInt64Rendering(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
svc := &captureVerificationService{app: domain.VerificationApplication{
ID: maxInt64,
ApplicantUserID: maxInt64,
TargetType: domain.VerificationTargetChannel,
TargetID: maxInt64,
TargetTitle: "Example News",
TargetUsername: "examplenews",
Category: "media",
Status: domain.VerificationStatusSubmitted,
SocialLinks: []string{"https://example.test/social"},
Version: maxInt64,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(
http.MethodGet,
"/v1/verification/applications?status=submitted,in_review&target_type=channel&reviewer=alice&q=examplenews&limit=25&before_id=99",
"scoped-review", "",
))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if len(svc.filter.Statuses) != 2 ||
svc.filter.Statuses[0] != domain.VerificationStatusSubmitted ||
svc.filter.Statuses[1] != domain.VerificationStatusInReview ||
svc.filter.TargetType != domain.VerificationTargetChannel ||
svc.filter.Reviewer != "alice" || svc.filter.Query != "examplenews" ||
svc.filter.Limit != 25 || svc.filter.BeforeID != 99 {
t.Fatalf("filter=%+v", svc.filter)
}
var body struct {
Applications []map[string]any `json:"applications"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode queue: %v", err)
}
if len(body.Applications) != 1 {
t.Fatalf("applications=%+v", body.Applications)
}
for _, field := range []string{"id", "applicant_user_id", "target_id", "version"} {
if body.Applications[0][field] != "9223372036854775807" {
t.Fatalf("%s = %#v, want an exact decimal string", field, body.Applications[0][field])
}
}
}
func TestVerificationQueueRejectsUnmodelledFilters(t *testing.T) {
srv := reviewOnlyServer(fakeService{})
for _, query := range []string{"?status=pending", "?target_type=group"} {
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications"+query, "scoped-review", ""))
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
}
}
}
func TestVerificationApplicationDetailAndCounts(t *testing.T) {
svc := &captureVerificationService{
app: domain.VerificationApplication{ID: 7, TargetType: domain.VerificationTargetBot, TargetID: 2002, Version: 4},
events: []domain.VerificationApplicationEvent{{
ID: 11, ApplicationID: 7, Kind: domain.VerificationEventSubmitted,
ToStatus: domain.VerificationStatusSubmitted, CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}},
target: domain.VerificationTarget{Type: domain.VerificationTargetBot, ID: 2002, Verified: true, Eligible: false, Reason: "already verified"},
counts: domain.VerificationStatusCounts{domain.VerificationStatusSubmitted: 3},
}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/7", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
}
var detail struct {
Application map[string]any `json:"application"`
Events []map[string]any `json:"events"`
Target map[string]any `json:"target"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Application["id"] != "7" || len(detail.Events) != 1 || detail.Events[0]["id"] != "11" {
t.Fatalf("detail=%+v", detail)
}
if detail.Target["verified"] != true || detail.Target["eligible"] != false {
t.Fatalf("target=%+v, want the live snapshot alongside the record", detail.Target)
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/applications/8", "scoped-review", ""))
if rec.Code != http.StatusNotFound {
t.Fatalf("missing application status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodGet, "/v1/verification/counts", "scoped-review", ""))
if rec.Code != http.StatusOK {
t.Fatalf("counts status=%d body=%s", rec.Code, rec.Body.String())
}
var counts struct {
Counts map[string]string `json:"counts"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil {
t.Fatalf("decode counts: %v", err)
}
// Every modelled status is present so the panel never tells "zero" from
// "absent", and the values are decimal strings.
if counts.Counts["submitted"] != "3" || counts.Counts["draft"] != "0" ||
counts.Counts["cancelled"] != "0" || len(counts.Counts) != 6 {
t.Fatalf("counts=%+v", counts.Counts)
}
}
func TestVerificationDecisionTakesTheApplicationIDFromThePath(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
// The body names a different application on purpose: the path has to win, or
// the URL would lie to the audit trail.
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","application_id":99,"version":4,"internal_note":"handover"}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if svc.approve.ApplicationID != 7 || svc.approve.Version != 4 ||
svc.approve.InternalNote != "handover" || svc.approve.Actor != "alice" {
t.Fatalf("forwarded approval=%+v", svc.approve)
}
}
func TestVerificationDecisionDryRunIsForwarded(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/reject", "scoped-review",
`{"command_id":"dry-1","actor":"alice","reason":"press links are self-published","dry_run":true,"version":4}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !svc.reject.DryRun || svc.reject.Reason != "press links are self-published" {
t.Fatalf("forwarded rejection=%+v", svc.reject)
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Fatalf("body=%s, want the dry run echoed", rec.Body.String())
}
}
func TestVerificationVersionConflictIsAnswered409(t *testing.T) {
svc := &captureVerificationService{
app: domain.VerificationApplication{ID: 7, Version: 5},
// The shape admin.codedError produces for a lost race.
decideOn: fmt.Errorf("%s: %w", admin.CodeVerificationConflict, domain.ErrVerificationVersionConflict),
}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/approve", "scoped-review",
`{"command_id":"c1","actor":"alice","reason":"verified","version":4}`))
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s, want 409 for a lost optimistic-locking race", rec.Code, rec.Body.String())
}
var result admin.CommandResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if !strings.Contains(result.Error, admin.CodeVerificationConflict) {
t.Fatalf("result=%+v, want the stable conflict code", result)
}
if !strings.Contains(result.Message, "reload") {
t.Fatalf("result message=%q, want an actionable message", result.Message)
}
}
func TestVerificationErrorStatusMapping(t *testing.T) {
cases := map[string]int{
admin.CodeVerificationNotFound: http.StatusNotFound,
admin.CodeVerificationConflict: http.StatusConflict,
admin.CodeVerificationTargetOccupied: http.StatusConflict,
admin.CodeVerificationTargetVerified: http.StatusConflict,
admin.CodeVerificationStatusInvalid: http.StatusBadRequest,
admin.CodeVerificationReasonRequired: http.StatusBadRequest,
admin.CodeVerificationTargetInvalid: http.StatusBadRequest,
admin.CodeVerificationTargetRestricted: http.StatusBadRequest,
admin.CodeVerificationTargetSystem: http.StatusBadRequest,
admin.CodeVerificationNotOwner: http.StatusBadRequest,
admin.CodeVerificationInvalid: http.StatusBadRequest,
"": http.StatusInternalServerError,
}
for code, want := range cases {
if got := verificationErrorStatus(code); got != want {
t.Fatalf("verificationErrorStatus(%q) = %d, want %d", code, got, want)
}
}
}
func TestScopedTokenNameBecomesTheAuditActorWhenNoneIsStated(t *testing.T) {
svc := &captureVerificationService{app: domain.VerificationApplication{ID: 7, Version: 4}}
srv := reviewOnlyServer(svc)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
`{"command_id":"c1","reason":"queue sweep","version":4}`))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
// The scoped token's configured name is its audit identity.
if svc.claim.Actor != "queue-bot" {
t.Fatalf("actor=%q, want the scoped token name", svc.claim.Actor)
}
// A stated actor is never overwritten, which is how the panel attributes an
// action to the signed-in operator.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "scoped-review",
`{"command_id":"c2","actor":"alice","reason":"queue sweep","version":4}`))
if rec.Code != http.StatusOK || svc.claim.Actor != "alice" {
t.Fatalf("status=%d actor=%q", rec.Code, svc.claim.Actor)
}
// The master token has no name, so the caller keeps having to say who acts.
rec = httptest.NewRecorder()
srv.routes().ServeHTTP(rec, verificationRequest(http.MethodPost, "/v1/verification/applications/7/claim", "master",
`{"command_id":"c3","reason":"queue sweep","version":4}`))
if rec.Code != http.StatusOK || svc.claim.Actor != "" {
t.Fatalf("master token status=%d actor=%q, want no invented identity", rec.Code, svc.claim.Actor)
}
}
func TestPermissionSetWildcardAndMembership(t *testing.T) {
all := newPermissionSet([]string{PermissionAll})
if !all.Has(PermissionVerificationReview) || !all.Has("anything.at.all") {
t.Fatal("wildcard set refused a permission")
}
bounded := newPermissionSet([]string{" verification.review ", ""})
if !bounded.Has(PermissionVerificationReview) {
t.Fatal("bounded set dropped a padded permission")
}
if bounded.Has(PermissionVerificationRevoke) {
t.Fatal("bounded set granted an unlisted permission")
}
if newPermissionSet(nil).Has(PermissionVerificationReview) {
t.Fatal("empty set granted a permission")
}
}

View file

@ -74,15 +74,26 @@ You can control me by sending these commands:
/cancel - cancel the current operation
/help - show this message`
// botReply 是 BotFather 的一条回复。
// botReply 是内置 service bot 的一条回复。ReplyMarkup 为可选 inline keyboard
// 快照(@verifybot 的按钮式对话使用);落库前经 domain.ValidateReplyMarkup 校验。
type botReply struct {
Text string
Entities []domain.MessageEntity
Text string
Entities []domain.MessageEntity
ReplyMarkup *domain.MessageReplyMarkup
}
// HandlesBot 报告该收件人是否为内置应答 botmessages.BotResponder 实现)。
func (s *Service) HandlesBot(botUserID int64) bool {
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID || botUserID == domain.ChatBotUserID)
if s == nil {
return false
}
switch botUserID {
case domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID,
domain.VerifyBotUserID, domain.VerifierBotUserID:
return true
default:
return false
}
}
// OnPrivateMessage 处理投递给内置 bot 的私聊消息messages.BotResponder 实现)。
@ -103,6 +114,10 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
go s.respondAsStickers(userID, msg)
case domain.ChatBotUserID:
go s.respondAsChatBot(userID, msg)
case domain.VerifyBotUserID:
go s.respondAsVerify(userID, msg)
case domain.VerifierBotUserID:
go s.respondAsVerifier(userID, msg)
}
}
@ -145,12 +160,24 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
if s == nil || s.messages == nil || reply.Text == "" {
return domain.SendPrivateTextResult{}, false
}
markup := reply.ReplyMarkup
if err := domain.ValidateReplyMarkup(markup); err != nil {
// 键盘校验必须先于落库I9结构非法的 markup 绝不写库,但正文仍然发出
// ——用户至少收到提示文本,不会因为一颗坏按钮而完全失联。
s.log.Error("service bot: invalid reply markup",
zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
markup = nil
}
if markup.IsZero() {
markup = nil
}
res, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: botUserID,
RecipientUserID: userID,
RandomID: s.botReplyRandomID(),
Message: reply.Text,
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
ReplyMarkup: markup,
Date: int(s.now().Unix()),
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
})

View file

@ -48,6 +48,30 @@ type aiChatGenerator interface {
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
}
// verificationApplications is the applicant-side surface of official platform
// verification used by the built-in @verifybot (app/verification.Service
// satisfies it as-is).
//
// It is declared as a narrow port rather than taken as a concrete service for the
// usual reason plus one specific to this feature: every verification rule --
// ownership, public username, restrictions, already-verified, cooldown, rate
// limit, the status machine -- belongs to that service, and the bot must not be
// able to reach past it. Nothing here can write a peer's verified flag.
type verificationApplications interface {
EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error)
StartDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error)
SaveDraft(ctx context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error)
Submit(ctx context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error)
Cancel(ctx context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error)
Draft(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error)
ApplicantApplications(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error)
Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
}
// The third-party verification ports live in verifierbot.go
// (customVerifications, verifierBotTargets): they are the built-in @verifierbot's
// only way to reach the feature, and are kept next to the dialog that uses them.
// RouterHooks 是 rpc 层回调router 创建后经 SetRouterHooks 延迟注入,打破
// router↔bots 的构造循环;这些能力都依赖 TL/连接层边界,不能在 app 层实现):
// - RevokeBotSessionstoken revoke 后撤销 bot 的全部已登录 session
@ -83,6 +107,9 @@ type Service struct {
stickers stickerSetCreator
installer userStickerSetInstaller
aiChat aiChatGenerator
verification verificationApplications
customVerification customVerifications
verifierTargets verifierBotTargets
telegramLogin *telegramloginapp.Service
hooks RouterHooks
textDrafts TextDraftPusher
@ -92,6 +119,13 @@ type Service struct {
now func() time.Time
chatBotStreamThrottle time.Duration
publicBaseURL string
// dialogLimiter bounds how often one applicant can drive a service-bot dialog.
// The verification service already rate-limits application creation; this is the
// separate bound on dialog traffic itself, so a script cannot spin the state
// machine (and its writes) even without ever submitting anything.
dialogLimiter store.RateLimiter
dialogRateLimit int
dialogRateWindow time.Duration
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
replySeq atomic.Int64
replyLocks [replyLockStripes]sync.Mutex
@ -177,6 +211,56 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
}
}
// WithVerification injects the official verification service used by the
// built-in @verifybot. Without it the bot still answers, but every command
// reports that verification is unavailable rather than half-running the dialog.
func WithVerification(v verificationApplications) Option {
return func(s *Service) {
if v != nil {
s.verification = v
}
}
}
// WithCustomVerification injects the third-party verification service used by the
// built-in @verifierbot. Without it the bot still answers, but every command
// reports that third-party verification is unavailable rather than half-running the
// dialog.
func WithCustomVerification(v customVerifications) Option {
return func(s *Service) {
if v != nil {
s.customVerification = v
}
}
}
// WithVerifierTargets injects the directory of an applicant's own peers used by
// @verifierbot's subject picker. It is optional: with nothing injected the bot
// falls back to the official verification service's EligibleTargets, which
// enumerates exactly the same peers (only its eligibility verdicts, which answer a
// different question, are ignored).
func WithVerifierTargets(t verifierBotTargets) Option {
return func(s *Service) {
if t != nil {
s.verifierTargets = t
}
}
}
// WithDialogRateLimiter bounds service-bot dialog traffic per user. A zero limit
// or a nil limiter disables the bound, which is what a deployment without Redis
// gets.
func WithDialogRateLimiter(limiter store.RateLimiter, limit int, window time.Duration) Option {
return func(s *Service) {
if limiter == nil || limit <= 0 || window <= 0 {
return
}
s.dialogLimiter = limiter
s.dialogRateLimit = limit
s.dialogRateWindow = window
}
}
// WithTelegramLogin injects the OIDC application service used by BotFather.
// BotFather never writes the login tables directly.
func WithTelegramLogin(login *telegramloginapp.Service) Option {
@ -262,6 +346,33 @@ func (s *Service) SetAIChatGenerator(g aiChatGenerator) {
}
}
// SetVerification injects the official verification service after construction.
// The bots service is built before the peer directories that service depends on,
// so in the shipped process this is the wiring order that actually exists (same
// deferred-injection pattern as SetRouterHooks).
func (s *Service) SetVerification(v verificationApplications) {
if s != nil && v != nil {
s.verification = v
}
}
// SetCustomVerification injects the third-party verification service after
// construction. The bots service is built before the stores and directories that
// service depends on, so in the shipped process this is the wiring order that
// actually exists (same deferred-injection pattern as SetVerification).
func (s *Service) SetCustomVerification(v customVerifications) {
if s != nil && v != nil {
s.customVerification = v
}
}
// SetVerifierTargets injects @verifierbot's subject directory after construction.
func (s *Service) SetVerifierTargets(t verifierBotTargets) {
if s != nil && t != nil {
s.verifierTargets = t
}
}
// NewService 创建 bots 服务。
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
s := &Service{

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,984 @@
package bots
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"testing"
"time"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// ---------------------------------------------------------------------------
// Fake verification service
// ---------------------------------------------------------------------------
// fakeVerification is an in-memory stand-in for app/verification.Service. It
// keeps the properties the bot dialog actually leans on: one draft per applicant,
// StartDraft resuming instead of duplicating, optimistic-locking versions, and
// the domain validation of the payload.
type fakeVerification struct {
targets []domain.VerificationTarget
apps map[int64]domain.VerificationApplication
nextID int64
starts int
submits int
targetsErr error
startErr error
}
func newFakeVerification(targets ...domain.VerificationTarget) *fakeVerification {
return &fakeVerification{
targets: targets,
apps: make(map[int64]domain.VerificationApplication),
nextID: 100,
}
}
func (f *fakeVerification) EligibleTargets(_ context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
if f.targetsErr != nil {
return nil, f.targetsErr
}
if applicantUserID <= 0 {
return nil, domain.ErrVerificationApplicationInvalid
}
return append([]domain.VerificationTarget(nil), f.targets...), nil
}
func (f *fakeVerification) draftFor(applicantUserID int64) (domain.VerificationApplication, bool) {
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID && app.Status == domain.VerificationStatusDraft {
return app, true
}
}
return domain.VerificationApplication{}, false
}
func (f *fakeVerification) StartDraft(_ context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
f.starts++
if app, found := f.draftFor(req.ApplicantUserID); found {
return app, false, nil
}
if f.startErr != nil {
return domain.VerificationApplication{}, false, f.startErr
}
var target domain.VerificationTarget
for _, candidate := range f.targets {
if candidate.Type == req.TargetType && candidate.ID == req.TargetID {
target = candidate
}
}
if target.ID == 0 {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetInvalid
}
if !target.Eligible {
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetAlreadyVerified
}
f.nextID++
app := domain.VerificationApplication{
ID: f.nextID,
ApplicantUserID: req.ApplicantUserID,
TargetType: target.Type,
TargetID: target.ID,
TargetTitle: target.Title,
TargetUsername: target.Username,
Status: domain.VerificationStatusDraft,
CreatedAt: time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC),
Version: 1,
}
f.apps[app.ID] = app
return app, true, nil
}
func (f *fakeVerification) SaveDraft(_ context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if app.Status != domain.VerificationStatusDraft {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := draft.ValidateDraft(); err != nil {
return domain.VerificationApplication{}, err
}
draft = draft.Normalize()
app.Category = draft.Category
app.Description = draft.Description
app.OfficialWebsite = draft.OfficialWebsite
app.SocialLinks = draft.SocialLinks
app.PressLinks = draft.PressLinks
app.AdditionalNote = draft.AdditionalNote
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Submit(_ context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusSubmitted) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
if err := (domain.VerificationDraftInput{
Category: app.Category,
Description: app.Description,
OfficialWebsite: app.OfficialWebsite,
SocialLinks: app.SocialLinks,
PressLinks: app.PressLinks,
AdditionalNote: app.AdditionalNote,
}).ValidateForSubmission(); err != nil {
return domain.VerificationApplication{}, err
}
f.submits++
app.Status = domain.VerificationStatusSubmitted
app.SubmittedAt = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Cancel(_ context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
app, found := f.apps[applicationID]
if !found || app.ApplicantUserID != applicantUserID {
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
if app.Version != version {
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
}
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusCancelled) {
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
}
app.Status = domain.VerificationStatusCancelled
app.DecisionReason = reason
app.Version++
f.apps[applicationID] = app
return app, nil
}
func (f *fakeVerification) Draft(_ context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
if app, found := f.draftFor(applicantUserID); found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
func (f *fakeVerification) ApplicantApplications(_ context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
out := make([]domain.VerificationApplication, 0, len(f.apps))
for _, app := range f.apps {
if app.ApplicantUserID == applicantUserID {
out = append(out, app)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeVerification) Application(_ context.Context, applicationID int64) (domain.VerificationApplication, error) {
if app, found := f.apps[applicationID]; found {
return app, nil
}
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
}
var _ verificationApplications = (*fakeVerification)(nil)
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
func verifyChannelTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetChannel, ID: 7001,
Title: "Example News", Username: "examplenews", AccessHash: 42, Eligible: true,
}
}
func verifyBotTarget() domain.VerificationTarget {
return domain.VerificationTarget{
Type: domain.VerificationTargetBot, ID: 8002,
Title: "Example Bot", Username: "examplebot", Eligible: true,
}
}
func newVerifyBotTestService(t *testing.T, verification verificationApplications, opts ...Option) (*Service, *memory.UserStore, *memory.MessageStore) {
t.Helper()
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
all := append([]Option{WithVerification(verification)}, opts...)
return NewService(users, bots, messages, all...), users, messages
}
// verifyBotReplies returns every @verifybot message in the user's box, oldest
// first.
func verifyBotReplies(t *testing.T, messages *memory.MessageStore, userID int64) []domain.Message {
t.Helper()
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Limit: 200,
})
if err != nil {
t.Fatalf("list @verifybot history: %v", err)
}
out := make([]domain.Message, 0, len(list.Messages))
for _, msg := range list.Messages {
if msg.From.ID == domain.VerifyBotUserID {
out = append(out, msg)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func latestVerifyReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
replies := verifyBotReplies(t, messages, userID)
if len(replies) == 0 {
t.Fatal("no @verifybot reply")
}
latest := replies[len(replies)-1]
// Every keyboard the bot renders must be a valid, persistable markup: the send
// path validates before storing, so an invalid one would silently vanish.
if err := domain.ValidateReplyMarkup(latest.ReplyMarkup); err != nil {
t.Fatalf("reply markup invalid: %v (%+v)", err, latest.ReplyMarkup)
}
for _, row := range verifyInlineRows(latest) {
for _, button := range row {
if len(button.Data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data %q is %d bytes, limit is %d", button.Data, len(button.Data), domain.MaxCallbackDataLen)
}
}
}
return latest
}
func verifyInlineRows(msg domain.Message) [][]domain.MarkupButton {
if msg.ReplyMarkup == nil {
return nil
}
return msg.ReplyMarkup.Inline
}
// sendToVerifyBot drives the responder synchronously, bypassing the
// OnPrivateMessage goroutine dispatch for determinism (the same shortcut the
// BotFather and @Stickers tests take).
func sendToVerifyBot(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64, text string) domain.Message {
t.Helper()
svc.respondAsVerify(userID, domain.Message{
From: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.VerifyBotUserID},
Body: text,
})
return latestVerifyReply(t, messages, userID)
}
func verifyButtonData(msg domain.Message, label string) ([]byte, bool) {
for _, row := range verifyInlineRows(msg) {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && strings.Contains(button.Text, label) {
return append([]byte(nil), button.Data...), true
}
}
}
return nil, false
}
// pressVerifyCallbackData drives the internal callback path with raw data, the
// way rpc.Router does once it has validated the click.
func pressVerifyCallbackData(t *testing.T, svc *Service, userID int64, msg domain.Message, data []byte) domain.BotCallbackAnswer {
t.Helper()
if len(data) > domain.MaxCallbackDataLen {
t.Fatalf("callback data too long: %d bytes", len(data))
}
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
ID: 1,
BotUserID: domain.VerifyBotUserID,
UserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
MessageID: msg.ID,
Data: data,
})
if err != nil {
t.Fatalf("callback query: %v", err)
}
if !handled {
t.Fatal("callback query reported unhandled for @verifybot")
}
return answer
}
func pressVerifyButton(t *testing.T, svc *Service, userID int64, msg domain.Message, label string) domain.BotCallbackAnswer {
t.Helper()
data, found := verifyButtonData(msg, label)
if !found {
t.Fatalf("button %q is not in the keyboard of message %d: %+v", label, msg.ID, msg.ReplyMarkup)
}
return pressVerifyCallbackData(t, svc, userID, msg, data)
}
const (
verifyTestDescription = "Example News is the daily newsroom of the Example Foundation, publishing since 2015."
verifyTestWebsite = "https://news.example.com"
verifyTestPressLinks = "https://press.example.org/story-one\nhttps://media.example.net/story-two"
)
// runVerifyApplication walks the whole dialog up to (but not including) Submit and
// returns the summary message.
func runVerifyApplication(t *testing.T, svc *Service, messages *memory.MessageStore, userID int64) domain.Message {
t.Helper()
intro := sendToVerifyBot(t, svc, messages, userID, "/start")
pressVerifyButton(t, svc, userID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, picker, "@examplenews")
categories := latestVerifyReply(t, messages, userID)
pressVerifyButton(t, svc, userID, categories, "Media outlet")
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "describe the subject") {
t.Fatalf("after category, reply = %q", got.Body)
}
sendToVerifyBot(t, svc, messages, userID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, userID, verifyTestWebsite)
if !strings.Contains(social.Body, "social media") {
t.Fatalf("after website, reply = %q", social.Body)
}
pressVerifyButton(t, svc, userID, social, verifySkipButtonText)
if got := latestVerifyReply(t, messages, userID); !strings.Contains(got.Body, "press coverage") {
t.Fatalf("after skipping social links, reply = %q", got.Body)
}
note := sendToVerifyBot(t, svc, messages, userID, verifyTestPressLinks)
pressVerifyButton(t, svc, userID, note, verifySkipButtonText)
return latestVerifyReply(t, messages, userID)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestVerifyBotStartExplainsAndOffersApplyButton(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7100")
if !svc.HandlesBot(domain.VerifyBotUserID) {
t.Fatal("service should handle @verifybot")
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
for _, want := range []string{"official", "public @username", "/new", "/help"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/start reply missing %q: %q", want, reply.Body)
}
}
data, found := verifyButtonData(reply, verifyApplyButtonText)
if !found {
t.Fatalf("/start reply has no apply button: %+v", reply.ReplyMarkup)
}
if !strings.HasPrefix(string(data), verifyCallbackDataPrefix) {
t.Fatalf("callback data %q is not a @verifybot token", data)
}
if fake.starts != 0 {
t.Fatalf("StartDraft called %d times on /start, want 0", fake.starts)
}
}
func TestVerifyBotFullApplicationFlowFilesExactlyOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget(), verifyBotTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7101")
summary := runVerifyApplication(t, svc, messages, owner.ID)
for _, want := range []string{"Example News", "Media outlet", verifyTestWebsite, "press.example.org/story-one", verifySubmitButtonText} {
if !strings.Contains(summary.Body, want) {
t.Fatalf("summary missing %q: %q", want, summary.Body)
}
}
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
filed := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(filed.Body, "#101") || !strings.Contains(filed.Body, "/status") {
t.Fatalf("submitted reply = %q", filed.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d, want exactly one of each", fake.submits, len(fake.apps))
}
app := fake.apps[101]
if app.Status != domain.VerificationStatusSubmitted {
t.Fatalf("application status = %q", app.Status)
}
if app.Category != "media" || app.OfficialWebsite != verifyTestWebsite || len(app.PressLinks) != 2 {
t.Fatalf("stored application = %+v", app)
}
if app.TargetID != 7001 || app.TargetType != domain.VerificationTargetChannel {
t.Fatalf("stored target = %s/%d", app.TargetType, app.TargetID)
}
}
// The target buttons must not leak the peer they stand for: the whole point of the
// token table is that a click cannot name a peer at all.
func TestVerifyBotCallbackDataCarriesNoTargetIdentity(t *testing.T) {
target := verifyChannelTarget()
fake := newFakeVerification(target)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7102")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
buttons := 0
for _, row := range verifyInlineRows(picker) {
for _, button := range row {
buttons++
data := string(button.Data)
// Structural assertion rather than a substring hunt: the data is the
// prefix plus an opaque hex token and nothing else, so it is incapable of
// encoding a peer id, an access hash, a username or a peer type.
token, ok := strings.CutPrefix(data, verifyCallbackDataPrefix)
if !ok || len(token) != 2*verifyOptionTokenBytes {
t.Fatalf("callback data %q is not <prefix><token>", data)
}
for _, c := range token {
if !strings.ContainsRune("0123456789abcdef", c) {
t.Fatalf("callback data %q carries non-token bytes", data)
}
}
for _, forbidden := range []string{target.Username, string(target.Type)} {
if strings.Contains(data, forbidden) {
t.Fatalf("callback data %q leaks %q", data, forbidden)
}
}
}
}
if buttons == 0 {
t.Fatal("target picker has no buttons")
}
// The token is minted per render, so the same target never has a stable,
// guessable identifier on the wire.
firstData, _ := verifyButtonData(picker, "@"+target.Username)
sendToVerifyBot(t, svc, messages, owner.ID, "/new")
secondData, found := verifyButtonData(latestVerifyReply(t, messages, owner.ID), "@"+target.Username)
if !found {
t.Fatal("re-rendered picker has no target button")
}
if string(firstData) == string(secondData) {
t.Fatalf("token %q is stable across renders", firstData)
}
}
func TestVerifyBotRepeatedButtonPressIsIdempotent(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7103")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
first := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
second := latestVerifyReply(t, messages, owner.ID)
if first.Body != second.Body {
t.Fatalf("repeat target press changed the answer:\nfirst = %q\nsecond = %q", first.Body, second.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after pressing the same target twice, want 1", len(fake.apps))
}
}
// The same must hold for the terminal action: a double-tapped Submit files one
// application and repeats the same confirmation.
func TestVerifyBotRepeatedSubmitFilesOneApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7121")
summary := runVerifyApplication(t, svc, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
firstFiled := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
secondFiled := latestVerifyReply(t, messages, owner.ID)
if firstFiled.Body != secondFiled.Body {
t.Fatalf("repeat submit changed the answer:\nfirst = %q\nsecond = %q", firstFiled.Body, secondFiled.Body)
}
if fake.submits != 1 || len(fake.apps) != 1 {
t.Fatalf("submits=%d applications=%d after double submit, want 1/1", fake.submits, len(fake.apps))
}
}
func TestVerifyBotForgedCallbackTokenIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7104")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
before := len(verifyBotReplies(t, messages, owner.ID))
// A token that was never minted for this user, and a plausible-looking
// hand-written one: both resolve only through the user's own state, so both are
// refused without any side effect.
for _, data := range [][]byte{
[]byte(verifyCallbackDataPrefix + "deadbeefcafe"),
[]byte("tgt:channel:7001"),
[]byte(verifyCallbackDataPrefix),
} {
answer := pressVerifyCallbackData(t, svc, owner.ID, intro, data)
if !answer.Alert || !strings.Contains(answer.Message, "no longer active") {
t.Fatalf("forged data %q answered %+v, want an explaining alert", data, answer)
}
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("forged callbacks produced %d new messages", got-before)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("forged callbacks touched the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
// A token minted for one applicant must be meaningless for another: resolution
// goes through the clicking user's own chat state only.
func TestVerifyBotTokenFromAnotherUserIsRefused(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
victim := newOwner(t, users, "+7105")
attacker := newOwner(t, users, "+7106")
intro := sendToVerifyBot(t, svc, messages, victim.ID, "/start")
pressVerifyButton(t, svc, victim.ID, intro, verifyApplyButtonText)
picker := latestVerifyReply(t, messages, victim.ID)
stolen, found := verifyButtonData(picker, "@examplenews")
if !found {
t.Fatal("victim picker has no target button")
}
sendToVerifyBot(t, svc, messages, attacker.ID, "/start")
attackerIntro := latestVerifyReply(t, messages, attacker.ID)
answer := pressVerifyCallbackData(t, svc, attacker.ID, attackerIntro, stolen)
if !answer.Alert {
t.Fatalf("stolen token answered %+v, want an alert", answer)
}
for _, app := range fake.apps {
if app.ApplicantUserID == attacker.ID {
t.Fatalf("stolen token created an application for the attacker: %+v", app)
}
}
}
func TestVerifyBotPressLinkMinimumIsEnforced(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7107")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
social := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
pressVerifyButton(t, svc, owner.ID, social, verifySkipButtonText)
tooFew := sendToVerifyBot(t, svc, messages, owner.ID, "https://press.example.org/story-one")
if !strings.Contains(tooFew.Body, strconv.Itoa(domain.MinVerificationPressLinks)) {
t.Fatalf("single press link accepted or unexplained: %q", tooFew.Body)
}
if len(fake.apps[101].PressLinks) != 0 {
t.Fatalf("press links stored despite refusal: %+v", fake.apps[101].PressLinks)
}
accepted := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestPressLinks)
if !strings.Contains(accepted.Body, "reviewers should know") {
t.Fatalf("two press links did not advance the dialog: %q", accepted.Body)
}
if len(fake.apps[101].PressLinks) != 2 {
t.Fatalf("press links = %+v, want two stored", fake.apps[101].PressLinks)
}
}
func TestVerifyBotRejectsInvalidLinksWithAReason(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7108")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
// Not a URL, a non-web scheme, and an address the domain refuses as
// non-public (which is also what keeps a submitted link from becoming an SSRF
// probe).
for _, bad := range []string{"my site", "ftp://example.com", "http://127.0.0.1/admin", "https://localhost/x"} {
reply := sendToVerifyBot(t, svc, messages, owner.ID, bad)
if !strings.Contains(reply.Body, "http:// or https://") {
t.Fatalf("website %q answered %q, want the link rules", bad, reply.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("website %q was stored", bad)
}
}
// A short description is refused with the actual bar, not a generic error.
shortDesc := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestWebsite)
if !strings.Contains(shortDesc.Body, "social media") {
t.Fatalf("valid website not accepted: %q", shortDesc.Body)
}
}
func TestVerifyBotDescriptionMinimumIsExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7109")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
reply := sendToVerifyBot(t, svc, messages, owner.ID, "a newsroom")
if !strings.Contains(reply.Body, strconv.Itoa(domain.MinVerificationDescriptionLength)) {
t.Fatalf("short description answered %q, want the minimum length", reply.Body)
}
if fake.apps[101].Description != "" {
t.Fatalf("short description was stored: %q", fake.apps[101].Description)
}
}
func TestVerifyBotGlobalCommandsWorkMidStep(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7110")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
// /help in the middle of the description step answers help and keeps the step.
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
if help.Body != verifyBotHelpText {
t.Fatalf("/help mid-step = %q", help.Body)
}
status := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
if !strings.Contains(status.Body, "#101") {
t.Fatalf("/status mid-step = %q", status.Body)
}
resumed := sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
if !strings.Contains(resumed.Body, "official website") {
t.Fatalf("description not accepted after global commands: %q", resumed.Body)
}
if fake.apps[101].Description != verifyTestDescription {
t.Fatalf("description = %q, want the step to have survived", fake.apps[101].Description)
}
// An unknown command is never swallowed as a field value.
unknown := sendToVerifyBot(t, svc, messages, owner.ID, "/nope")
if !strings.Contains(unknown.Body, "do not know that command") {
t.Fatalf("unknown command = %q", unknown.Body)
}
if fake.apps[101].OfficialWebsite != "" {
t.Fatalf("unknown command stored as a website: %q", fake.apps[101].OfficialWebsite)
}
}
func TestVerifyBotStatusListsApplicationsWithoutInternalNotes(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7111")
if empty := sendToVerifyBot(t, svc, messages, owner.ID, "/status"); empty.Body != verifyNoApplicationsText {
t.Fatalf("/status without applications = %q", empty.Body)
}
fake.apps[500] = domain.VerificationApplication{
ID: 500, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
Status: domain.VerificationStatusRejected,
DecisionReason: "the linked coverage does not mention the channel",
InternalNote: "applicant argued with the reviewer",
ReviewedAt: time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC),
Version: 4,
}
fake.apps[501] = domain.VerificationApplication{
ID: 501, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetBot, TargetID: 8002, TargetUsername: "examplebot",
Status: domain.VerificationStatusSubmitted,
SubmittedAt: time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC),
Version: 2,
}
reply := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
for _, want := range []string{"#500", "#501", "@examplebot", "does not mention the channel", "2026-07-20"} {
if !strings.Contains(reply.Body, want) {
t.Fatalf("/status missing %q: %q", want, reply.Body)
}
}
if strings.Contains(reply.Body, "argued with the reviewer") {
t.Fatalf("/status leaked the internal note: %q", reply.Body)
}
}
func TestVerifyBotCancelWithdrawsTheOpenApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7112")
if nothing := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel"); nothing.Body != verifyNothingToCancelText {
t.Fatalf("/cancel with nothing open = %q", nothing.Body)
}
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
cancelled := sendToVerifyBot(t, svc, messages, owner.ID, "/cancel")
if !strings.Contains(cancelled.Body, "#101") || !strings.Contains(cancelled.Body, "withdrawn") {
t.Fatalf("/cancel = %q", cancelled.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after /cancel", fake.apps[101].Status)
}
// The dialog is gone with it, so a stale button cannot revive it.
idle := sendToVerifyBot(t, svc, messages, owner.ID, "still here?")
if idle.Body != verifyBotIdleText {
t.Fatalf("after /cancel, plain text = %q", idle.Body)
}
}
func TestVerifyBotCancelButtonWithdrawsFromInsideTheForm(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7113")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
categories := latestVerifyReply(t, messages, owner.ID)
pressVerifyButton(t, svc, owner.ID, categories, verifyCancelButtonText)
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "withdrawn") {
t.Fatalf("cancel button = %q", reply.Body)
}
if fake.apps[101].Status != domain.VerificationStatusCancelled {
t.Fatalf("application status = %q after the cancel button", fake.apps[101].Status)
}
}
func TestVerifyBotHelpAndIdleText(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7114")
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
for _, want := range []string{"/new", "/status", "/cancel", "/help"} {
if !strings.Contains(help.Body, want) {
t.Fatalf("/help missing %q: %q", want, help.Body)
}
}
assertReplyEntityText(t, help, domain.MessageEntityBotCommand, "/new")
// Nothing to verify: the requirement is stated instead of an empty picker.
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyNoTargetsText {
t.Fatalf("/new with no candidates = %q", reply.Body)
}
}
func TestVerifyBotShowsIneligibleTargetsWithTheirReason(t *testing.T) {
verified := verifyChannelTarget()
verified.Eligible = false
verified.Verified = true
verified.Reason = domain.ErrVerificationTargetAlreadyVerified.Error()
fake := newFakeVerification(verified)
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7115")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(picker.Body, "cannot be filed") && !strings.Contains(picker.Body, verifyNoEligibleText) {
t.Fatalf("picker with only ineligible candidates = %q", picker.Body)
}
answer := pressVerifyButton(t, svc, owner.ID, picker, "unavailable")
if !answer.Alert || !strings.Contains(answer.Message, "already verified") {
t.Fatalf("ineligible button answered %+v, want the reason", answer)
}
if fake.starts != 0 || len(fake.apps) != 0 {
t.Fatalf("ineligible button reached the service: starts=%d apps=%d", fake.starts, len(fake.apps))
}
}
func TestVerifyBotNewResumesTheOpenDraft(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7116")
intro := sendToVerifyBot(t, svc, messages, owner.ID, "/start")
pressVerifyButton(t, svc, owner.ID, intro, verifyApplyButtonText)
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "@examplenews")
pressVerifyButton(t, svc, owner.ID, latestVerifyReply(t, messages, owner.ID), "Media outlet")
sendToVerifyBot(t, svc, messages, owner.ID, verifyTestDescription)
resumed := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
if !strings.Contains(resumed.Body, "#101") || !strings.Contains(resumed.Body, "official website") {
t.Fatalf("/new mid-draft = %q, want a resume at the website step", resumed.Body)
}
if len(fake.apps) != 1 {
t.Fatalf("applications = %d after /new mid-draft, want 1", len(fake.apps))
}
}
func TestVerifyBotWithoutServiceReportsUnavailable(t *testing.T) {
svc, users, messages := newVerifyBotTestService(t, nil)
owner := newOwner(t, users, "+7117")
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("/new without a verification service = %q", reply.Body)
}
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText {
t.Fatalf("/help without a verification service = %q", reply.Body)
}
}
func TestVerifyBotCallbackForForeignBotIsNotClaimed(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, _, _ := newVerifyBotTestService(t, fake)
if _, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: 555111, UserID: 900, Data: []byte("vb:whatever"),
}); handled || err != nil {
t.Fatalf("foreign bot callback handled=%v err=%v, want (false, nil)", handled, err)
}
// A built-in bot with no keyboards is claimed but answered empty, so the click
// cannot hang for the whole callback timeout.
answer, handled, err := svc.OnCallbackQuery(context.Background(), domain.BotCallbackQuery{
BotUserID: domain.BotFatherUserID, UserID: 900, Data: []byte("x"),
})
if !handled || err != nil || answer.Message != "" {
t.Fatalf("BotFather callback = (%+v, %v, %v)", answer, handled, err)
}
}
func TestVerifyBotSendVerificationNoticeNeverLeaksInternalNote(t *testing.T) {
fake := newFakeVerification()
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7118")
ctx := context.Background()
app := domain.VerificationApplication{
ID: 4242, ApplicantUserID: owner.ID,
TargetType: domain.VerificationTargetChannel, TargetID: 7001,
TargetTitle: "Example News", TargetUsername: "examplenews",
DecisionReason: "the coverage you linked does not mention the channel",
InternalNote: "reviewer note: applicant is a repeat filer, escalate next time",
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindApproved); err != nil {
t.Fatalf("approved notice: %v", err)
}
approved := latestVerifyReply(t, messages, owner.ID)
for _, want := range []string{"#4242", "Example News", "@examplenews", "approved"} {
if !strings.Contains(approved.Body, want) {
t.Fatalf("approved notice missing %q: %q", want, approved.Body)
}
}
if strings.Contains(approved.Body, "repeat filer") {
t.Fatalf("approved notice leaked the internal note: %q", approved.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRejected); err != nil {
t.Fatalf("rejected notice: %v", err)
}
rejected := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(rejected.Body, "#4242") || !strings.Contains(rejected.Body, "does not mention the channel") {
t.Fatalf("rejected notice = %q", rejected.Body)
}
if strings.Contains(rejected.Body, "repeat filer") || strings.Contains(rejected.Body, "escalate") {
t.Fatalf("rejected notice leaked the internal note: %q", rejected.Body)
}
if err := svc.SendVerificationNotice(ctx, owner.ID, app, verificationapp.NoticeKindRevoked); err != nil {
t.Fatalf("revoked notice: %v", err)
}
revoked := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(revoked.Body, "revoked") || strings.Contains(revoked.Body, "repeat filer") {
t.Fatalf("revoked notice = %q", revoked.Body)
}
// An unknown kind is reported rather than delivered as an empty message: the
// outbox row must stay pending instead of being marked delivered.
before := len(verifyBotReplies(t, messages, owner.ID))
if err := svc.SendVerificationNotice(ctx, owner.ID, app, "teleported"); err == nil {
t.Fatal("unknown notice kind reported success")
}
if got := len(verifyBotReplies(t, messages, owner.ID)); got != before {
t.Fatalf("unknown notice kind sent %d messages", got-before)
}
if err := svc.SendVerificationNotice(ctx, 0, app, verificationapp.NoticeKindApproved); err == nil {
t.Fatal("empty recipient reported success")
}
}
func TestVerifyBotSubmitBouncesAnIncompleteApplication(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7119")
summary := runVerifyApplication(t, svc, messages, owner.ID)
// Simulate a payload that lost a required field between rendering the summary
// and the press: Submit must send the applicant back, not file a broken record.
app := fake.apps[101]
app.PressLinks = nil
app.Version++
fake.apps[101] = app
pressVerifyButton(t, svc, owner.ID, summary, verifySubmitButtonText)
bounced := latestVerifyReply(t, messages, owner.ID)
if !strings.Contains(bounced.Body, "press coverage") {
t.Fatalf("incomplete submit = %q, want the press step", bounced.Body)
}
if fake.submits != 0 {
t.Fatalf("submits = %d for an incomplete application", fake.submits)
}
}
func TestVerifyBotPolicyRefusalsAreExplained(t *testing.T) {
fake := newFakeVerification(verifyChannelTarget())
fake.startErr = domain.ErrVerificationRateLimited
svc, users, messages := newVerifyBotTestService(t, fake)
owner := newOwner(t, users, "+7120")
picker := sendToVerifyBot(t, svc, messages, owner.ID, "/new")
pressVerifyButton(t, svc, owner.ID, picker, "@examplenews")
if reply := latestVerifyReply(t, messages, owner.ID); !strings.Contains(reply.Body, "limit on open applications") {
t.Fatalf("rate-limited StartDraft = %q", reply.Body)
}
fake.targetsErr = verificationapp.ErrDisabled
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
t.Fatalf("disabled verification = %q", reply.Body)
}
if !errors.Is(fake.targetsErr, verificationapp.ErrDisabled) {
t.Fatal("test setup lost the sentinel")
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -55,9 +55,9 @@ const tdesktopClient = "tdesktop"
//
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
// so this compatibility key must always remain an array, even when it is empty.
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const defaultAppConfigHash = 24 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
const defaultAppConfigHash = 25 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//

View file

@ -45,39 +45,40 @@ func TestAppConfigPremiumKeys(t *testing.T) {
t.Fatalf("fragment_prefixes = %#v, want [\"888\"]", decoded["fragment_prefixes"])
}
wantNumbers := map[string]float64{
"reactions_user_max_default": 1,
"reactions_user_max_premium": 3,
"boosts_channel_level_max": 100,
"stargifts_pinned_to_top_limit": 6,
"about_length_limit_default": 70,
"about_length_limit_premium": 140,
"dialogs_pinned_limit_default": 5,
"dialogs_pinned_limit_premium": 10,
"dialogs_folder_pinned_limit_default": 100,
"dialogs_folder_pinned_limit_premium": 200,
"saved_dialogs_pinned_limit_default": 5,
"saved_dialogs_pinned_limit_premium": 100,
"caption_length_limit_default": 1024,
"caption_length_limit_premium": 4096,
"channels_limit_default": 500,
"channels_limit_premium": 1000,
"dialog_filters_limit_default": 10,
"dialog_filters_limit_premium": 20,
"chatlist_update_period": 3600,
"chatlist_invites_limit_default": 3,
"chatlist_invites_limit_premium": 20,
"chatlists_joined_limit_default": 2,
"chatlists_joined_limit_premium": 20,
"upload_max_fileparts_default": 4000,
"upload_max_fileparts_premium": 8000,
"aicompose_tone_examples_num": 3,
"aicompose_tone_title_length_max": 12,
"aicompose_tone_prompt_length_max": 1024,
"aicompose_tone_saved_limit_default": 5,
"aicompose_tone_saved_limit_premium": 20,
"stories_stealth_future_period": 1500,
"stories_stealth_past_period": 300,
"stories_stealth_cooldown_period": 10800,
"reactions_user_max_default": 1,
"reactions_user_max_premium": 3,
"boosts_channel_level_max": 100,
"stargifts_pinned_to_top_limit": 6,
"about_length_limit_default": 70,
"about_length_limit_premium": 140,
"bot_verification_description_length_limit": 70,
"dialogs_pinned_limit_default": 5,
"dialogs_pinned_limit_premium": 10,
"dialogs_folder_pinned_limit_default": 100,
"dialogs_folder_pinned_limit_premium": 200,
"saved_dialogs_pinned_limit_default": 5,
"saved_dialogs_pinned_limit_premium": 100,
"caption_length_limit_default": 1024,
"caption_length_limit_premium": 4096,
"channels_limit_default": 500,
"channels_limit_premium": 1000,
"dialog_filters_limit_default": 10,
"dialog_filters_limit_premium": 20,
"chatlist_update_period": 3600,
"chatlist_invites_limit_default": 3,
"chatlist_invites_limit_premium": 20,
"chatlists_joined_limit_default": 2,
"chatlists_joined_limit_premium": 20,
"upload_max_fileparts_default": 4000,
"upload_max_fileparts_premium": 8000,
"aicompose_tone_examples_num": 3,
"aicompose_tone_title_length_max": 12,
"aicompose_tone_prompt_length_max": 1024,
"aicompose_tone_saved_limit_default": 5,
"aicompose_tone_saved_limit_premium": 20,
"stories_stealth_future_period": 1500,
"stories_stealth_past_period": 300,
"stories_stealth_cooldown_period": 10800,
}
for key, want := range wantNumbers {
got, ok := decoded[key].(float64)

View file

@ -0,0 +1,453 @@
// Package rating implements the composite account rating use cases: reading the
// stored projection, recomputing it from the raw contribution signals, and
// applying operator adjustments through the contribution ledger.
//
// This is an admin-only local model, not Telegram's Stars Rating protocol
// surface. The service gathers signals, applies the configured weights and
// pending-delay policy, and persists the result under optimistic concurrency.
package rating
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
// defaultPendingDelay parks a rating increase for a day, matching the
// shipped TELESRV_RATING_PENDING_DELAY default.
defaultPendingDelay = 24 * time.Hour
// defaultStaleAfter is the recompute horizon used when none is configured.
defaultStaleAfter = 6 * time.Hour
// defaultListLimit / maxListLimit bound one leaderboard page.
defaultListLimit = 50
maxListLimit = 200
// defaultEventLimit / maxEventLimit bound one ledger page.
defaultEventLimit = 50
maxEventLimit = 200
// defaultRecomputeBatch / maxRecomputeBatch bound one worker cycle.
defaultRecomputeBatch = 500
maxRecomputeBatch = 10000
)
// ErrDisabled reports that the local composite rating feature is switched off.
// Reads degrade to an empty admin projection; writes are refused so an operator
// never believes an adjustment was recorded when it was not.
var ErrDisabled = errors.New("account rating is disabled")
// Service is the composite account rating use-case layer.
type Service struct {
store store.AccountRatingStore
weights domain.AccountRatingWeights
pendingDelay time.Duration
staleAfter time.Duration
enabled bool
now func() time.Time
log *zap.Logger
}
// Option adjusts optional service dependencies.
type Option func(*Service)
// WithStore injects the rating read model and ledger store.
func WithStore(st store.AccountRatingStore) Option {
return func(s *Service) { s.store = st }
}
// WithWeights installs the composite formula. An invalid set is rejected in
// favour of the shipped defaults, so a misconfigured deployment produces a
// conservative rating instead of an inconsistent one.
func WithWeights(weights domain.AccountRatingWeights) Option {
return func(s *Service) {
if err := weights.Validate(); err != nil {
return
}
s.weights = weights
}
}
// WithPendingDelay configures how long a rating increase stays parked as
// pending. Zero applies every change immediately.
func WithPendingDelay(delay time.Duration) Option {
return func(s *Service) {
if delay >= 0 {
s.pendingDelay = delay
}
}
}
// WithStaleAfter configures the projection age after which the background
// worker recomputes a user.
func WithStaleAfter(staleAfter time.Duration) Option {
return func(s *Service) {
if staleAfter > 0 {
s.staleAfter = staleAfter
}
}
}
// WithEnabled toggles the feature.
func WithEnabled(enabled bool) Option {
return func(s *Service) { s.enabled = enabled }
}
// WithClock injects the clock (tests).
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithLogger injects the service logger.
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// NewService creates the rating service. It is enabled by default so that the
// only switch is the configuration flag, and it stays safe without a store:
// reads answer empty and writes report a configuration error.
func NewService(opts ...Option) *Service {
s := &Service{
weights: domain.DefaultAccountRatingWeights(),
pendingDelay: defaultPendingDelay,
staleAfter: defaultStaleAfter,
enabled: true,
now: time.Now,
log: zap.NewNop(),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
if s.now == nil {
s.now = time.Now
}
if s.log == nil {
s.log = zap.NewNop()
}
if s.pendingDelay < 0 {
s.pendingDelay = 0
}
if s.staleAfter <= 0 {
s.staleAfter = defaultStaleAfter
}
if err := s.weights.Validate(); err != nil {
s.weights = domain.DefaultAccountRatingWeights()
}
return s
}
// Enabled reports whether the feature is switched on.
func (s *Service) Enabled() bool { return s != nil && s.enabled }
// Ready reports whether the feature is on and backed by a store.
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
// Weights returns the configured composite formula, so the admin panel can
// explain a level with the same numbers that produced it.
func (s *Service) Weights() domain.AccountRatingWeights {
if s == nil {
return domain.DefaultAccountRatingWeights()
}
return s.weights
}
func (s *Service) ratingStore() (store.AccountRatingStore, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
return s.store, nil
}
// Rating returns the stored projection.
//
// domain.ErrAccountRatingNotFound is propagated rather than flattened to a zero
// value so the admin API can distinguish "not computed" from a computed zero.
// A missing store reports a configuration error an operator can diagnose.
func (s *Service) Rating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled || userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, err
}
return st.AccountRating(ctx, userID)
}
// RatingBatch resolves several users in one round trip. Users without a stored
// projection are absent from the map, so a disabled feature and an unconfigured
// store both read as "nobody has a rating" -- the batch shape already encodes
// absence and needs no error to express it.
func (s *Service) RatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
if s == nil || !s.enabled || s.store == nil {
return map[int64]domain.AccountRating{}, nil
}
unique := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
unique = append(unique, userID)
}
if len(unique) == 0 {
return map[int64]domain.AccountRating{}, nil
}
batch, err := s.store.AccountRatingBatch(ctx, unique)
if err != nil {
return nil, err
}
if batch == nil {
return map[int64]domain.AccountRating{}, nil
}
return batch, nil
}
// Recompute gathers the contribution signals, applies the configured weights
// and the pending-delay policy relative to the stored value, and persists the
// result.
//
// The save is guarded by the stored version. A concurrent writer (another
// recompute, an adjustment, the worker) only invalidates the base the pending
// policy was resolved against, so exactly one retry against the freshly
// returned row is both sufficient and terminating.
func (s *Service) Recompute(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled {
return domain.AccountRating{}, ErrDisabled
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, err
}
if userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
}
// The service accounts are infrastructure, not participants. Refusing here as
// well as in the seeding query means an operator cannot create a rating for one
// by hand either -- the platform account is not flagged is_bot, so nothing else
// would stop it.
if !domain.RatableAccount(userID, false) {
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
}
signals, err := st.AccountRatingSignals(ctx, userID)
if err != nil {
return domain.AccountRating{}, err
}
signals.UserID = userID
prev, err := s.previous(ctx, st, userID)
if err != nil {
return domain.AccountRating{}, err
}
now := s.now().UTC()
computed := domain.ComputeAccountRating(signals, s.weights, now)
stored, changed, err := st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(prev, computed, s.pendingDelay, now))
if err != nil {
return domain.AccountRating{}, err
}
if changed {
return stored, nil
}
// One retry: `stored` is the row that won the race, so resolving the pending
// policy against it produces the correct next version.
stored, changed, err = st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(stored, computed, s.pendingDelay, now))
if err != nil {
return domain.AccountRating{}, err
}
if !changed {
return stored, fmt.Errorf("recompute account rating %d: concurrent version conflict", userID)
}
return stored, nil
}
// Adjust records an operator adjustment in the contribution ledger and
// immediately recomputes the projection, so the manual component is visible
// without waiting for the background worker. Replaying the same CommandKey
// records nothing and reports applied=false; the current rating is still
// returned so a retried admin command stays idempotent.
func (s *Service) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
if s == nil || !s.enabled {
return domain.AccountRating{}, false, ErrDisabled
}
st, err := s.ratingStore()
if err != nil {
return domain.AccountRating{}, false, err
}
if err := req.Validate(); err != nil {
return domain.AccountRating{}, false, err
}
_, applied, err := st.AdjustAccountRating(ctx, req)
if err != nil {
return domain.AccountRating{}, false, err
}
rating, err := s.Recompute(ctx, req.UserID)
if err != nil {
return domain.AccountRating{}, applied, err
}
return rating, applied, nil
}
// List is the admin leaderboard query with a bounded page size.
func (s *Service) List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || !s.enabled {
return nil, nil
}
st, err := s.ratingStore()
if err != nil {
return nil, err
}
if filter.MinLevel < 0 {
filter.MinLevel = 0
}
if filter.MinLevel > domain.MaxAccountRatingLevel {
filter.MinLevel = domain.MaxAccountRatingLevel
}
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
return st.ListAccountRatings(ctx, filter)
}
// Events returns one user's contribution ledger, newest first.
func (s *Service) Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || !s.enabled {
return nil, nil
}
st, err := s.ratingStore()
if err != nil {
return nil, err
}
if userID <= 0 {
return nil, domain.ErrAccountRatingAdjustmentInvalid
}
return st.AccountRatingEvents(ctx, userID, clampLimit(limit, defaultEventLimit, maxEventLimit))
}
// RunRecomputeCycle advances the read model by one bounded batch and returns how
// many users it wrote. A single user's failure is logged and skipped: one poisoned
// row must not stall the whole cycle.
//
// The cycle does two things, and the order matters. It first refreshes projections
// that have gone stale, because those are rows somebody is already looking at.
// Whatever batch budget is left it spends seeding accounts that have no projection
// at all -- without that pass the read model can never populate itself, since
// StaleAccountRatings walks account_rating and cannot return a user who is not in
// it. Staleness keeps existing ratings honest; seeding is what makes them exist at
// all, which is what makes the admin leaderboard populate without an operator
// opening every account first.
func (s *Service) RunRecomputeCycle(ctx context.Context, limit int) (int, error) {
if s == nil || !s.enabled {
return 0, nil
}
st, err := s.ratingStore()
if err != nil {
return 0, err
}
limit = clampLimit(limit, defaultRecomputeBatch, maxRecomputeBatch)
olderThan := s.now().UTC().Add(-s.staleAfter).Unix()
userIDs, err := st.StaleAccountRatings(ctx, olderThan, limit)
if err != nil {
return 0, err
}
processed, err := s.recomputeEach(ctx, userIDs, "recompute account rating failed")
if err != nil {
return processed, err
}
// The bound belongs to the cycle, not to each pass, so a backlog of stale rows
// can never turn one cycle into an unbounded amount of work.
remaining := limit - len(userIDs)
if remaining <= 0 {
return processed, nil
}
unrated, err := st.UnratedAccounts(ctx, remaining)
if err != nil {
// Seeding extends the cycle rather than being its purpose: a store that
// cannot enumerate accounts must not turn a successful stale pass into a
// failed cycle.
s.log.Warn("list unrated accounts failed", zap.Error(err))
return processed, nil
}
seeded, err := s.recomputeEach(ctx, unrated, "seed account rating failed")
return processed + seeded, err
}
// recomputeEach recomputes a list of users, skipping the ones that fail, and
// giving up early only when the context is done.
func (s *Service) recomputeEach(ctx context.Context, userIDs []int64, failureMessage string) (int, error) {
processed := 0
for _, userID := range userIDs {
if err := ctx.Err(); err != nil {
return processed, err
}
if userID <= 0 {
continue
}
if _, err := s.Recompute(ctx, userID); err != nil {
s.log.Warn(failureMessage,
zap.Int64("user_id", userID),
zap.Error(err))
continue
}
processed++
}
return processed, nil
}
// EnsureRating returns the stored local-admin projection, computing and storing
// it first when an administrative caller needs an immediate value.
//
// The background cycle reaches every account eventually; callers that require a
// local rating immediately use this bounded materialization path instead.
func (s *Service) EnsureRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || !s.enabled || userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
rating, err := s.Rating(ctx, userID)
if err == nil {
return rating, nil
}
if !errors.Is(err, domain.ErrAccountRatingNotFound) {
return domain.AccountRating{}, err
}
return s.Recompute(ctx, userID)
}
// previous reads the stored projection the pending policy is resolved against.
// A never-computed user yields the zero value, which domain.ResolveAccountRating
// Pending treats as "apply immediately" -- a first rating is never parked.
func (s *Service) previous(ctx context.Context, st store.AccountRatingStore, userID int64) (domain.AccountRating, error) {
prev, err := st.AccountRating(ctx, userID)
if err != nil {
if errors.Is(err, domain.ErrAccountRatingNotFound) {
return domain.AccountRating{}, nil
}
return domain.AccountRating{}, err
}
return prev, nil
}
func clampLimit(limit, fallback, maximum int) int {
if limit <= 0 {
return fallback
}
if limit > maximum {
return maximum
}
return limit
}

View file

@ -0,0 +1,719 @@
package rating
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
// concurrency contract as PostgreSQL: a save whose version does not follow the
// stored one reports changed=false and returns the row that won.
type fakeRatingStore struct {
signals map[int64]domain.AccountRatingSignals
ratings map[int64]domain.AccountRating
manual map[int64]int64
events map[int64][]domain.AccountRatingEvent
keys map[string]domain.AccountRatingEvent
stale []int64
staleOlderThan int64
staleLimit int
unrated []int64
unratedLimit int
unratedCalls int
unratedErr error
saves []domain.AccountRating
forceConflicts int
signalsErr error
}
func newFakeRatingStore() *fakeRatingStore {
return &fakeRatingStore{
signals: map[int64]domain.AccountRatingSignals{},
ratings: map[int64]domain.AccountRating{},
manual: map[int64]int64{},
events: map[int64][]domain.AccountRatingEvent{},
keys: map[string]domain.AccountRatingEvent{},
}
}
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
rating, ok := f.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
out := make(map[int64]domain.AccountRating, len(userIDs))
for _, userID := range userIDs {
if rating, ok := f.ratings[userID]; ok {
out[userID] = rating
}
}
return out, nil
}
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
f.saves = append(f.saves, rating)
current := f.ratings[rating.UserID]
if f.forceConflicts > 0 {
f.forceConflicts--
return current, false, nil
}
if rating.Version != current.Version+1 {
return current, false, nil
}
f.ratings[rating.UserID] = rating
return rating, true, nil
}
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
if f.signalsErr != nil {
return domain.AccountRatingSignals{}, f.signalsErr
}
signals := f.signals[userID]
signals.UserID = userID
signals.Manual = f.manual[userID]
return signals, nil
}
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if req.CommandKey != "" {
if event, ok := f.keys[req.CommandKey]; ok {
return event, false, nil
}
}
event := domain.AccountRatingEvent{
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
}
f.events[req.UserID] = append(f.events[req.UserID], event)
f.manual[req.UserID] += req.Amount
if req.CommandKey != "" {
f.keys[req.CommandKey] = event
}
return event, true, nil
}
func (f *fakeRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
out := make([]domain.AccountRating, 0, len(f.ratings))
for _, rating := range f.ratings {
if rating.Level >= filter.MinLevel {
out = append(out, rating)
}
if len(out) >= filter.Limit {
break
}
}
return out, nil
}
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
events := f.events[userID]
if len(events) > limit {
events = events[:limit]
}
return append([]domain.AccountRatingEvent(nil), events...), nil
}
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
f.staleOlderThan = olderThanUnix
f.staleLimit = limit
if len(f.stale) > limit {
return append([]int64(nil), f.stale[:limit]...), nil
}
return append([]int64(nil), f.stale...), nil
}
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
f.unratedCalls++
f.unratedLimit = limit
if f.unratedErr != nil {
return nil, f.unratedErr
}
out := make([]int64, 0, len(f.unrated))
for _, userID := range f.unrated {
if _, rated := f.ratings[userID]; rated {
continue
}
out = append(out, userID)
if len(out) == limit {
break
}
}
return out, nil
}
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
return NewService(append(base, opts...)...)
}
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
GiftsReceived: 2, ModerationCases: 1,
}
service := newTestService(st)
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
weights := domain.DefaultAccountRatingWeights()
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
GiftsReceived: 2, ModerationCases: 1,
}, weights, testNow)
if rating.Stars != want.Stars || rating.Level != want.Level ||
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
rating.PenaltyComponent != want.PenaltyComponent {
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
}
if rating.Version != 1 {
t.Fatalf("first stored version = %d, want 1", rating.Version)
}
if !rating.ComputedAt.Equal(testNow) {
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
}
}
func TestRecomputePendingPolicy(t *testing.T) {
t.Run("increase is parked", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 100 {
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
}
if rating.PendingStars != 400 {
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
}
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
}
if rating.Version != 5 {
t.Fatalf("version = %d, want 5", rating.Version)
}
})
t.Run("decrease applies immediately", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 0 || rating.PendingStars != 0 {
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
}
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
}
})
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{
UserID: 7, Stars: 100, Level: 1, Version: 2,
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(24*time.Hour))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
}
})
t.Run("zero delay never parks", func(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
service := newTestService(st, WithPendingDelay(0))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if rating.Stars != 500 || rating.PendingStars != 0 {
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
}
})
}
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
st.forceConflicts = 1
service := newTestService(st, WithPendingDelay(0))
rating, err := service.Recompute(context.Background(), 7)
if err != nil {
t.Fatalf("Recompute: %v", err)
}
if len(st.saves) != 2 {
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
}
if rating.Version != 4 || rating.Stars != 200 {
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
}
}
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
st.forceConflicts = 2
service := newTestService(st)
if _, err := service.Recompute(context.Background(), 7); err == nil {
t.Fatal("Recompute reported success while every save lost the version race")
}
if len(st.saves) != 2 {
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
}
}
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st, WithPendingDelay(0))
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
})
if err != nil || !applied {
t.Fatalf("Adjust = %v, %v", applied, err)
}
if rating.ManualComponent != 300 || rating.Stars != 400 {
t.Fatalf("rating = %#v, want the manual component folded in", rating)
}
if len(st.events[7]) != 1 {
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
}
}
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st, WithPendingDelay(0))
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
first, applied, err := service.Adjust(context.Background(), req)
if err != nil || !applied {
t.Fatalf("first Adjust = %v, %v", applied, err)
}
second, applied, err := service.Adjust(context.Background(), req)
if err != nil {
t.Fatalf("replayed Adjust: %v", err)
}
if applied {
t.Fatal("replayed Adjust reported applied=true")
}
if len(st.events[7]) != 1 || st.manual[7] != 300 {
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
}
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
}
}
func TestAdjustValidatesRequest(t *testing.T) {
st := newFakeRatingStore()
service := newTestService(st)
tests := []domain.AdjustAccountRatingRequest{
{UserID: 0, Amount: 10},
{UserID: 7, Amount: 0},
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
}
for _, req := range tests {
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
}
}
if len(st.events) != 0 || len(st.saves) != 0 {
t.Fatal("store was touched by an invalid adjustment")
}
}
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 2, 3}
for _, userID := range st.stale {
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
}
service := newTestService(st, WithStaleAfter(6*time.Hour))
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want 3", processed)
}
if st.staleLimit != 10 {
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
}
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
}
for _, userID := range st.stale {
if _, ok := st.ratings[userID]; !ok {
t.Fatalf("user %d was not recomputed", userID)
}
}
}
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 0, 2}
st.forceConflicts = 2 // both saves of the first user lose the race
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 0)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want the surviving user only", processed)
}
if st.staleLimit != defaultRecomputeBatch {
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
}
}
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
service := newTestService(st, WithEnabled(false))
if service.Enabled() || service.Ready() {
t.Fatal("disabled service reported enabled/ready")
}
// The userFull projection omits both TL flags on this error, which is exactly
// the pre-rating wire shape.
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
}
batch, err := service.RatingBatch(context.Background(), []int64{7})
if err != nil || len(batch) != 0 {
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
}
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
}
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
}
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil || processed != 0 {
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
}
}
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
service := NewService()
if service.Ready() {
t.Fatal("Ready = true without a store")
}
if _, err := service.Rating(context.Background(), 7); err == nil {
t.Fatal("Rating accepted a missing store")
}
if _, err := service.Recompute(context.Background(), 7); err == nil {
t.Fatal("Recompute accepted a missing store")
}
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
t.Fatal("Adjust accepted a missing store")
}
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
t.Fatal("RunRecomputeCycle accepted a missing store")
}
}
func TestNilServiceIsSafe(t *testing.T) {
var service *Service
if service.Enabled() || service.Ready() {
t.Fatal("nil service reported enabled/ready")
}
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
t.Fatalf("nil service weights = %#v, want the defaults", got)
}
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
}
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
}
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
}
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
}
}
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
st := newFakeRatingStore()
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
}
}
func TestListAndEventsBoundThePage(t *testing.T) {
st := newFakeRatingStore()
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
for i := range maxEventLimit + 10 {
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
}
service := newTestService(st)
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List = %d rows, want 1", len(list))
}
events, err := service.Events(context.Background(), 7, 100000)
if err != nil {
t.Fatalf("Events: %v", err)
}
if len(events) != maxEventLimit {
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
}
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Events accepted a zero user id")
}
}
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1}
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
service := newTestService(st)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
}()
// The first cycle runs before the ticker, so cancelling immediately still
// leaves exactly one recompute behind.
<-time.After(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("worker did not stop on context cancellation")
}
if _, ok := st.ratings[1]; !ok {
t.Fatal("worker did not recompute the stale user")
}
}
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
done := make(chan struct{})
go func() {
defer close(done)
worker.Run(context.Background())
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("disabled worker kept running")
}
if worker.batch != defaultRecomputeBatch {
t.Fatalf("batch = %d, want the default fallback", worker.batch)
}
}
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
// so it can only ever refresh rows that already exist; without a seeding pass the
// very first row for a user has to come from an operator recomputing that user by
// hand, and the read model stays permanently empty.
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
st := newFakeRatingStore()
st.unrated = []int64{11, 12, 13}
for _, userID := range st.unrated {
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
}
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want the three seeded accounts", processed)
}
for _, userID := range st.unrated {
if _, ok := st.ratings[userID]; !ok {
t.Fatalf("account %d was not seeded", userID)
}
}
// A second cycle has nothing left to seed, so seeding converges instead of
// rewriting the same rows every interval.
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
}
}
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
// must not let one cycle do an unbounded amount of work.
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1, 2}
st.unrated = []int64{11, 12, 13, 14}
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 3)
if err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if processed != 3 {
t.Fatalf("processed = %d, want the batch bound of 3", processed)
}
if st.unratedLimit != 1 {
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
}
// A cycle whose stale pass already fills the batch does not query for seeds at
// all: refreshing rows somebody is looking at comes first.
full := newFakeRatingStore()
full.stale = []int64{1, 2, 3}
full.unrated = []int64{11}
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
t.Fatalf("RunRecomputeCycle: %v", err)
}
if full.unratedCalls != 0 {
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
}
}
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
// accounts must not turn a successful stale pass into a failed cycle.
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
st := newFakeRatingStore()
st.stale = []int64{1}
st.unratedErr = errors.New("no users table")
service := newTestService(st)
processed, err := service.RunRecomputeCycle(context.Background(), 10)
if err != nil {
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want the one stale row", processed)
}
}
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
// when the worker has not reached an account yet, the first read materializes the
// local projection and the second read must not write again.
func TestEnsureRatingMaterializesOnce(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
service := newTestService(st)
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
}
rating, err := service.EnsureRating(context.Background(), 7)
if err != nil {
t.Fatalf("EnsureRating: %v", err)
}
if rating.UserID != 7 || rating.Stars == 0 {
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
}
writes := len(st.saves)
again, err := service.EnsureRating(context.Background(), 7)
if err != nil {
t.Fatalf("second EnsureRating: %v", err)
}
if again.Version != rating.Version {
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
}
if len(st.saves) != writes {
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
}
}
// A disabled feature materialises nothing. Telegram wire fields remain unset
// independently of this local feature flag.
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
st := newFakeRatingStore()
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
service := newTestService(st, WithEnabled(false))
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
}
if len(st.saves) != 0 {
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
}
}
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
// rating in the first place -- and an operator must not be able to create one by
// hand either.
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
for _, userID := range domain.SystemUserIDs() {
st := newFakeRatingStore()
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
st.unrated = []int64{userID}
service := newTestService(st)
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
}
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
}
if len(st.ratings) != 0 {
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
}
// A seeding pass that is somehow handed one skips it rather than failing the
// whole cycle.
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
}
}
// An ordinary account is unaffected.
st := newFakeRatingStore()
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
t.Fatalf("Recompute of an ordinary account: %v", err)
}
}

View file

@ -0,0 +1,91 @@
package rating
import (
"context"
"time"
"go.uber.org/zap"
)
const (
// defaultRecomputeInterval matches the shipped
// TELESRV_RATING_RECOMPUTE_INTERVAL default.
defaultRecomputeInterval = 15 * time.Minute
)
// RecomputeWorker keeps the rating read model fresh.
//
// The projection is derived from signals that change outside the rating write
// path (Stars flow, message activity, moderation decisions, account age), so no
// single writer can keep it current. This worker walks the stale projections in
// bounded batches; it never recomputes the whole table in one pass, and a
// cancelled context stops it between users rather than mid-write.
type RecomputeWorker struct {
service *Service
logger *zap.Logger
interval time.Duration
batch int
}
// NewRecomputeWorker creates the periodic recompute worker. Non-positive
// interval/batch fall back to the shipped defaults, matching the retention
// worker's contract.
func NewRecomputeWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *RecomputeWorker {
if logger == nil {
logger = zap.NewNop()
}
if interval <= 0 {
interval = defaultRecomputeInterval
}
if batch <= 0 {
batch = defaultRecomputeBatch
}
return &RecomputeWorker{service: service, logger: logger, interval: interval, batch: batch}
}
// Run recomputes one batch immediately and then on every tick until ctx is
// done. A disabled or store-less service exits immediately with one explicit
// log line instead of ticking forever over a no-op.
func (w *RecomputeWorker) Run(ctx context.Context) {
if w == nil {
return
}
if !w.service.Ready() {
w.logger.Info("account rating recompute worker disabled",
zap.Bool("enabled", w.service.Enabled()))
return
}
w.runOnce(ctx)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.runOnce(ctx)
}
}
}
func (w *RecomputeWorker) runOnce(ctx context.Context) {
if w == nil || w.service == nil {
return
}
processed, err := w.service.RunRecomputeCycle(ctx, w.batch)
if err != nil {
if ctx.Err() != nil {
return
}
w.logger.Warn("account rating recompute cycle failed",
zap.Int("processed", processed),
zap.Int("batch", w.batch),
zap.Error(err))
return
}
if processed > 0 {
w.logger.Info("account rating recompute cycle completed",
zap.Int("processed", processed),
zap.Int("batch", w.batch))
}
}

View file

@ -0,0 +1,552 @@
// Package usernames implements the collectible (Fragment-style) username
// registry use cases: reading a peer's username vector, toggling and reordering
// the collectible rows a client owns, and the operator lifecycle that mints,
// transfers, revokes and burns the assets behind those rows.
//
// The service owns normalisation and validation. Every entry point normalises
// the name through domain.NormalizeUsername and runs the domain Validate()
// checks before the store is touched, so an RPC handler, the admin API and a
// unit test all reject the same shapes with the same errors.
package usernames
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store"
)
const (
// defaultListLimit is the admin listing page size used when the caller does
// not bound the query itself.
defaultListLimit = 50
// maxListLimit bounds one listing page regardless of the requested limit.
maxListLimit = 200
// defaultTransferLimit / maxTransferLimit bound the provenance log page.
defaultTransferLimit = 50
maxTransferLimit = 200
// usernamePlaceholder is the substitution supported by the operator URL
// template, e.g. https://example.org/nft/{username}.
usernamePlaceholder = "{username}"
// defaultCollectibleURLPath is the public-link route used when no operator
// template is configured.
defaultCollectibleURLPath = "nft/username"
)
// ErrPeerInvalid rejects a registry mutation for a peer that cannot hold
// usernames. Only users and channels have a username registry; anything else is
// a caller bug rather than a client-visible protocol state.
var ErrPeerInvalid = errors.New("username peer invalid")
// PeerUsernameNotifier is the domain-only edge hook invoked after a username
// registry mutation. The RPC router implements it: it invalidates the cached
// peer projections and pushes the username change to online clients, exactly
// like the account.updateUsername path does for the editable slot. Keeping it an
// injected port means this package never depends on the protocol edge.
type PeerUsernameNotifier interface {
NotifyPeerUsernamesChanged(ctx context.Context, peer domain.Peer) error
}
// Service is the collectible username use-case layer.
type Service struct {
registry store.UsernameRegistryStore
collectibles store.CollectibleUsernameStore
notifier PeerUsernameNotifier
// urlTemplate is the operator-provided collectible landing URL template;
// publicBaseURL is the fallback root the default route is built from.
urlTemplate string
publicBaseURL string
now func() time.Time
log *zap.Logger
}
// Option adjusts optional service dependencies.
type Option func(*Service)
// WithRegistryStore injects the peer username registry reader/writer.
func WithRegistryStore(registry store.UsernameRegistryStore) Option {
return func(s *Service) { s.registry = registry }
}
// WithCollectibleStore injects the collectible asset lifecycle store.
func WithCollectibleStore(collectibles store.CollectibleUsernameStore) Option {
return func(s *Service) { s.collectibles = collectibles }
}
// WithNotifier injects the edge invalidation/update hook.
func WithNotifier(notifier PeerUsernameNotifier) Option {
return func(s *Service) { s.notifier = notifier }
}
// WithURLTemplate configures the collectible asset landing URL template. An
// empty template keeps the public-link default route.
func WithURLTemplate(template string) Option {
return func(s *Service) { s.urlTemplate = strings.TrimSpace(template) }
}
// WithPublicBaseURL configures the public-link root the default collectible URL
// route is derived from.
func WithPublicBaseURL(baseURL string) Option {
return func(s *Service) { s.publicBaseURL = strings.TrimSpace(baseURL) }
}
// WithClock injects the clock (tests).
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithLogger injects the service logger.
func WithLogger(log *zap.Logger) Option {
return func(s *Service) {
if log != nil {
s.log = log
}
}
}
// NewService creates the collectible username service. Every dependency is
// optional: a service without stores answers with a configuration error instead
// of panicking, which keeps partial deployments diagnosable.
func NewService(opts ...Option) *Service {
s := &Service{now: time.Now, log: zap.NewNop()}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
if s.now == nil {
s.now = time.Now
}
if s.log == nil {
s.log = zap.NewNop()
}
return s
}
// SetPeerUsernameNotifier injects the edge hook after construction. The RPC
// router is built after the app services, so the notification port is bound
// here rather than through NewService.
func (s *Service) SetPeerUsernameNotifier(notifier PeerUsernameNotifier) {
if s == nil {
return
}
s.notifier = notifier
}
// Configured reports whether both registries are installed.
func (s *Service) Configured() bool {
return s != nil && s.registry != nil && s.collectibles != nil
}
func (s *Service) registryStore() (store.UsernameRegistryStore, error) {
if s == nil || s.registry == nil {
return nil, fmt.Errorf("username registry store is not configured")
}
return s.registry, nil
}
func (s *Service) collectibleStore() (store.CollectibleUsernameStore, error) {
if s == nil || s.collectibles == nil {
return nil, fmt.Errorf("collectible username store is not configured")
}
return s.collectibles, nil
}
// PeerUsernames returns the peer's username vector in projection order.
func (s *Service) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
registry, err := s.registryStore()
if err != nil {
return nil, err
}
if !validPeer(peer) {
return nil, nil
}
list, err := registry.PeerUsernames(ctx, peer)
if err != nil {
return nil, err
}
return domain.SortUsernames(list), nil
}
// UsernamesBatch resolves several peers in one round trip. Peers holding no
// usernames are absent from the result.
func (s *Service) UsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
registry, err := s.registryStore()
if err != nil {
return nil, err
}
unique := make([]domain.Peer, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if !validPeer(peer) {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
unique = append(unique, peer)
}
if len(unique) == 0 {
return map[domain.Peer][]domain.Username{}, nil
}
batch, err := registry.PeerUsernamesBatch(ctx, unique)
if err != nil {
return nil, err
}
out := make(map[domain.Peer][]domain.Username, len(batch))
for peer, list := range batch {
if len(list) == 0 {
continue
}
out[peer] = domain.SortUsernames(list)
}
return out, nil
}
// ToggleUsername activates or deactivates one collectible row. The editable
// slot is never touched: it is owned by account/channels.updateUsername.
func (s *Service) ToggleUsername(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
registry, err := s.registryStore()
if err != nil {
return false, err
}
if !validPeer(peer) {
return false, ErrPeerInvalid
}
username = domain.NormalizeUsername(username)
if username == "" {
return false, domain.ErrUsernameInvalid
}
current, err := registry.PeerUsernames(ctx, peer)
if err != nil {
return false, err
}
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
changed, err := registry.SetUsernameActive(ctx, peer, username, active)
if err != nil {
return false, err
}
if changed {
s.notifyPeers(ctx, peer)
}
return changed, nil
}
// ReorderUsernames rewrites the collectible order. order must be a permutation
// of the peer's collectible usernames; the editable slot always projects first.
func (s *Service) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
registry, err := s.registryStore()
if err != nil {
return false, err
}
if !validPeer(peer) {
return false, ErrPeerInvalid
}
normalized := make([]string, 0, len(order))
for _, name := range order {
normalized = append(normalized, domain.NormalizeUsername(name))
}
current, err := registry.PeerUsernames(ctx, peer)
if err != nil {
return false, err
}
if err := domain.ValidateUsernameReorder(current, normalized); err != nil {
return false, err
}
changed, err := registry.ReorderUsernames(ctx, peer, normalized)
if err != nil {
return false, err
}
if changed {
s.notifyPeers(ctx, peer)
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row.
func (s *Service) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
registry, err := s.registryStore()
if err != nil {
return false, err
}
if !validPeer(peer) {
return false, ErrPeerInvalid
}
changed, err := registry.DeactivateAllUsernames(ctx, peer)
if err != nil {
return false, err
}
if changed {
s.notifyPeers(ctx, peer)
}
return changed, nil
}
// CollectibleInfo returns the fragment.collectibleInfo projection for a name.
func (s *Service) CollectibleInfo(ctx context.Context, username string) (domain.CollectibleInfo, error) {
asset, err := s.Collectible(ctx, username)
if err != nil {
return domain.CollectibleInfo{}, err
}
return asset.Info(), nil
}
// Collectible looks up the asset behind a collectible username.
func (s *Service) Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, err
}
username = domain.NormalizeUsername(username)
if !domain.ValidCollectibleUsername(username) {
return domain.CollectibleUsername{}, domain.ErrUsernameInvalid
}
return collectibles.CollectibleUsername(ctx, username)
}
// Mint creates a collectible asset, optionally assigning it in the same
// command. An empty URL is rendered from the configured template and an unset
// purchase date is stamped with the service clock, so the stored provenance is
// always complete and reproducible.
func (s *Service) Mint(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if strings.TrimSpace(req.URL) == "" {
req.URL = s.CollectibleURL(req.Username)
}
if req.PurchaseDate.IsZero() {
req.PurchaseDate = s.now().UTC()
}
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
asset, created, err := collectibles.MintCollectibleUsername(ctx, req)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if created {
s.notifyPeers(ctx, req.Owner, asset.Owner)
}
return asset, created, nil
}
// Transfer moves the asset to req.To, either out of the vault or from the
// current holder. Both the previous and the new holder are invalidated.
func (s *Service) Transfer(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
asset, changed, err := collectibles.TransferCollectibleUsername(ctx, req)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if changed {
s.notifyPeers(ctx, previousOwner, req.To, asset.Owner)
}
return asset, changed, nil
}
// Revoke returns the asset to the vault, or burns it when req.Burn is set.
func (s *Service) Revoke(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return domain.CollectibleUsername{}, false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
asset, changed, err := collectibles.RevokeCollectibleUsername(ctx, req)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if changed {
s.notifyPeers(ctx, previousOwner, asset.Owner)
}
return asset, changed, nil
}
// Delete removes an asset outright, releasing its name and discarding its
// provenance. Revoke with Burn retires an asset but keeps the history; this is
// the operator's escape hatch for an asset issued by mistake.
//
// The previous owner is notified exactly like a revoke: the peer's projection
// still carries the username until it is invalidated.
func (s *Service) Delete(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return false, err
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
previousOwner := s.currentOwner(ctx, collectibles, req.Username)
deleted, err := collectibles.DeleteCollectibleUsername(ctx, req)
if err != nil {
return false, err
}
if deleted {
s.notifyPeers(ctx, previousOwner, domain.Peer{})
}
return deleted, nil
}
// List is the admin listing query. The limit is always bounded, so an
// unfiltered operator request can never ask the store for an unbounded scan.
func (s *Service) List(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return nil, err
}
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
if filter.Owner.Type != "" && !validPeer(filter.Owner) {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
filter.Query = domain.NormalizeUsername(filter.Query)
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
return collectibles.ListCollectibleUsernames(ctx, filter)
}
// Transfers returns the provenance log of one asset, newest first.
func (s *Service) Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
collectibles, err := s.collectibleStore()
if err != nil {
return nil, err
}
if collectibleID <= 0 {
return nil, domain.ErrCollectibleUsernameNotFound
}
return collectibles.CollectibleUsernameTransfers(ctx, collectibleID, clampLimit(limit, defaultTransferLimit, maxTransferLimit))
}
// CollectibleURL renders the asset landing URL for a name. The operator
// template wins; {username} is substituted when present and appended as a path
// segment when it is not. Without a template the public-link default route is
// used, and without any configured root the URL stays empty rather than
// pointing at an unrelated host.
func (s *Service) CollectibleURL(username string) string {
if s == nil {
return ""
}
username = domain.NormalizeUsername(username)
if username == "" {
return ""
}
template := strings.TrimSpace(s.urlTemplate)
if template != "" {
if strings.Contains(template, usernamePlaceholder) {
return strings.ReplaceAll(template, usernamePlaceholder, username)
}
return strings.TrimRight(template, "/") + "/" + username
}
if strings.TrimSpace(s.publicBaseURL) == "" {
return ""
}
return links.Build(s.publicBaseURL, defaultCollectibleURLPath+"/"+username, nil)
}
// currentOwner reads the holder before a lifecycle mutation so the previous
// peer's projection is invalidated too. It is best effort: a missing or
// unreadable asset only means there is no extra peer to notify, and the
// mutation itself remains the authority.
func (s *Service) currentOwner(ctx context.Context, collectibles store.CollectibleUsernameStore, username string) domain.Peer {
asset, err := collectibles.CollectibleUsername(ctx, username)
if err != nil {
if !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
s.log.Debug("read collectible username owner before mutation",
zap.String("username", username),
zap.Error(err))
}
return domain.Peer{}
}
if !asset.Owned() {
return domain.Peer{}
}
return asset.Owner
}
// notifyPeers invalidates projections and pushes updates for every distinct
// affected peer. Notification is best effort: the registry mutation already
// committed, and a failed push converges through the client's next
// authoritative peer read.
func (s *Service) notifyPeers(ctx context.Context, peers ...domain.Peer) {
if s == nil || s.notifier == nil {
return
}
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if !validPeer(peer) {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
if err := s.notifier.NotifyPeerUsernamesChanged(ctx, peer); err != nil {
s.log.Warn("notify collectible username change failed",
zap.String("peer_type", string(peer.Type)),
zap.Int64("peer_id", peer.ID),
zap.Error(err))
}
}
}
func validPeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}
func clampLimit(limit, fallback, maximum int) int {
if limit <= 0 {
return fallback
}
if limit > maximum {
return maximum
}
return limit
}

View file

@ -0,0 +1,734 @@
package usernames
import (
"context"
"errors"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
var (
testUser = domain.Peer{Type: domain.PeerTypeUser, ID: 42}
testChannel = domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
testClock = time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
)
type toggleCall struct {
peer domain.Peer
username string
active bool
}
// fakeRegistry is an in-memory domain.Username registry recording exactly what
// the service asked it to do, so the tests can assert normalisation reached the
// store and validation did not.
type fakeRegistry struct {
lists map[domain.Peer][]domain.Username
toggles []toggleCall
orders [][]string
clears []domain.Peer
changed bool
batchErr error
}
func newFakeRegistry() *fakeRegistry {
return &fakeRegistry{lists: map[domain.Peer][]domain.Username{}, changed: true}
}
func (f *fakeRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
if f.batchErr != nil {
return nil, f.batchErr
}
return append([]domain.Username(nil), f.lists[peer]...), nil
}
func (f *fakeRegistry) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
if f.batchErr != nil {
return nil, f.batchErr
}
out := make(map[domain.Peer][]domain.Username, len(peers))
for _, peer := range peers {
if list, ok := f.lists[peer]; ok {
out[peer] = append([]domain.Username(nil), list...)
}
}
return out, nil
}
func (f *fakeRegistry) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
f.toggles = append(f.toggles, toggleCall{peer: peer, username: username, active: active})
return f.changed, nil
}
func (f *fakeRegistry) ReorderUsernames(_ context.Context, _ domain.Peer, order []string) (bool, error) {
f.orders = append(f.orders, append([]string(nil), order...))
return f.changed, nil
}
func (f *fakeRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
f.clears = append(f.clears, peer)
return f.changed, nil
}
// fakeCollectibles records the lifecycle commands and serves stored assets.
type fakeCollectibles struct {
assets map[string]domain.CollectibleUsername
mints []domain.MintCollectibleUsernameRequest
transfers []domain.TransferCollectibleUsernameRequest
revokes []domain.RevokeCollectibleUsernameRequest
deletes []domain.DeleteCollectibleUsernameRequest
filters []domain.CollectibleUsernameFilter
logLimits []int
created bool
changed bool
}
func newFakeCollectibles() *fakeCollectibles {
return &fakeCollectibles{assets: map[string]domain.CollectibleUsername{}, created: true, changed: true}
}
func (f *fakeCollectibles) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.mints = append(f.mints, req)
asset := domain.CollectibleUsername{
ID: int64(len(f.mints)), Username: req.Username, Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: req.PurchaseDate, Currency: req.Currency, Amount: req.Amount, URL: req.URL,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
asset.OriginalOwner = req.Owner
}
f.assets[strings.ToLower(req.Username)] = asset
return asset, f.created, nil
}
func (f *fakeCollectibles) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.transfers = append(f.transfers, req)
asset := f.assets[strings.ToLower(req.Username)]
asset.Username = req.Username
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
asset.TransferCount++
f.assets[strings.ToLower(req.Username)] = asset
return asset, f.changed, nil
}
func (f *fakeCollectibles) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
f.revokes = append(f.revokes, req)
asset := f.assets[strings.ToLower(req.Username)]
asset.Username = req.Username
asset.Owner = domain.Peer{}
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
asset.Status = domain.CollectibleUsernameStatusBurned
}
f.assets[strings.ToLower(req.Username)] = asset
return asset, f.changed, nil
}
func (f *fakeCollectibles) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
f.deletes = append(f.deletes, req)
key := strings.ToLower(req.Username)
if _, ok := f.assets[key]; !ok {
return false, nil
}
delete(f.assets, key)
return true, nil
}
func (f *fakeCollectibles) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
asset, ok := f.assets[strings.ToLower(username)]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
func (f *fakeCollectibles) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
for _, asset := range f.assets {
if asset.ID == id {
return asset, nil
}
}
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
func (f *fakeCollectibles) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
f.filters = append(f.filters, filter)
return nil, nil
}
func (f *fakeCollectibles) CollectibleUsernameTransfers(_ context.Context, _ int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
f.logLimits = append(f.logLimits, limit)
return nil, nil
}
// recordingNotifier captures the peers whose projections were invalidated.
type recordingNotifier struct {
peers []domain.Peer
err error
}
func (n *recordingNotifier) NotifyPeerUsernamesChanged(_ context.Context, peer domain.Peer) error {
n.peers = append(n.peers, peer)
return n.err
}
func newTestService(t *testing.T, registry *fakeRegistry, collectibles *fakeCollectibles, opts ...Option) (*Service, *recordingNotifier) {
t.Helper()
notifier := &recordingNotifier{}
base := []Option{
WithRegistryStore(registry),
WithCollectibleStore(collectibles),
WithNotifier(notifier),
WithClock(func() time.Time { return testClock }),
}
return NewService(append(base, opts...)...), notifier
}
func TestPeerUsernamesProjectsStoredOrder(t *testing.T) {
// Legacy numbering: the editable slot and the first collectible both carry
// sort_order 0, and the editable slot wins that tie, so a peer that never
// reordered anything projects its own username first.
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "editable", Active: true, Editable: true, SortOrder: 0},
}
service, _ := newTestService(t, registry, newFakeCollectibles())
if got := projectedNames(t, service); got != "editable,alpha,zeta" {
t.Fatalf("projection order = %v, want editable,alpha,zeta", got)
}
// After a reorder that made a collectible primary, stored order decides and
// the editable slot is no longer first: clients show usernames[0] as primary.
registry.lists[testUser] = []domain.Username{
{Username: "zeta", Active: true, SortOrder: 2, CollectibleID: 2},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "editable", Active: true, Editable: true, SortOrder: 1},
}
if got := projectedNames(t, service); got != "alpha,editable,zeta" {
t.Fatalf("reordered projection = %v, want alpha,editable,zeta", got)
}
}
func projectedNames(t *testing.T, service *Service) string {
t.Helper()
list, err := service.PeerUsernames(context.Background(), testUser)
if err != nil {
t.Fatalf("PeerUsernames: %v", err)
}
got := make([]string, 0, len(list))
for _, item := range list {
got = append(got, item.Username)
}
return strings.Join(got, ",")
}
func TestUsernamesBatchSkipsInvalidAndEmptyPeers(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}}
registry.lists[testChannel] = nil
service, _ := newTestService(t, registry, newFakeCollectibles())
batch, err := service.UsernamesBatch(context.Background(), []domain.Peer{testUser, testUser, testChannel, {}, {Type: domain.PeerTypeUser}})
if err != nil {
t.Fatalf("UsernamesBatch: %v", err)
}
if len(batch) != 1 || len(batch[testUser]) != 1 {
t.Fatalf("batch = %#v, want only the peer holding usernames", batch)
}
}
func TestToggleUsernameNormalizesBeforeStore(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "editable", Active: true, Editable: true},
{Username: "Nft_One", Active: false, CollectibleID: 1},
}
service, notifier := newTestService(t, registry, newFakeCollectibles())
changed, err := service.ToggleUsername(context.Background(), testUser, " @Nft_One ", true)
if err != nil || !changed {
t.Fatalf("ToggleUsername = %v, %v", changed, err)
}
if len(registry.toggles) != 1 || registry.toggles[0].username != "Nft_One" || !registry.toggles[0].active {
t.Fatalf("store toggles = %#v, want normalized Nft_One", registry.toggles)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
t.Fatalf("notified peers = %#v, want the toggled peer", notifier.peers)
}
}
func TestToggleUsernameValidatesBeforeStore(t *testing.T) {
tests := []struct {
name string
list []domain.Username
username string
active bool
wantErr error
}{
{
name: "editable slot is not collectible",
list: []domain.Username{{Username: "editable", Active: true, Editable: true}},
username: "editable",
wantErr: domain.ErrUsernameNotCollectible,
},
{
name: "unknown username",
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
username: "missing",
wantErr: domain.ErrUsernameNotOccupied,
},
{
name: "empty username",
list: []domain.Username{{Username: "alpha", Active: true, CollectibleID: 1}},
username: "@",
wantErr: domain.ErrUsernameInvalid,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = test.list
service, notifier := newTestService(t, registry, newFakeCollectibles())
_, err := service.ToggleUsername(context.Background(), testUser, test.username, test.active)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ToggleUsername error = %v, want %v", err, test.wantErr)
}
if len(registry.toggles) != 0 {
t.Fatalf("store was called with invalid input: %#v", registry.toggles)
}
if len(notifier.peers) != 0 {
t.Fatalf("notifier ran for a rejected toggle: %#v", notifier.peers)
}
})
}
}
func TestReorderUsernamesNormalizesPermutation(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "editable", Active: true, Editable: true},
{Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1},
{Username: "zeta", Active: true, SortOrder: 1, CollectibleID: 2},
}
service, notifier := newTestService(t, registry, newFakeCollectibles())
changed, err := service.ReorderUsernames(context.Background(), testUser, []string{"@zeta", " alpha ", "editable"})
if err != nil || !changed {
t.Fatalf("ReorderUsernames = %v, %v", changed, err)
}
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "zeta,alpha,editable" {
t.Fatalf("store order = %#v, want normalized zeta,alpha,editable", registry.orders)
}
if len(notifier.peers) != 1 {
t.Fatalf("notified peers = %#v, want one", notifier.peers)
}
}
func TestReorderUsernamesRejectsIncompletePermutation(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "alpha", Active: true, CollectibleID: 1},
{Username: "zeta", Active: true, CollectibleID: 2},
}
service, _ := newTestService(t, registry, newFakeCollectibles())
if _, err := service.ReorderUsernames(context.Background(), testUser, []string{"alpha"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("ReorderUsernames error = %v, want ErrUsernameOrderInvalid", err)
}
if len(registry.orders) != 0 {
t.Fatalf("store was called with a non-permutation: %#v", registry.orders)
}
}
// TestReorderUsernamesAcceptsTheEditableSlot is the report "channels.reorderUsernames
// answers USERNAME_INVALID": Telegram Desktop sends the whole visible list, and
// core.telegram.org/api/fragment requires exactly that ("all currently active
// usernames must be specified"), so the editable slot is a legitimate member of
// the order -- including as its first entry, and including when it is the only
// username the peer has.
func TestReorderUsernamesAcceptsTheEditableSlot(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testChannel] = []domain.Username{
{Username: "chan_slot", Active: true, Editable: true},
}
service, _ := newTestService(t, registry, newFakeCollectibles())
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
t.Fatalf("editable-only reorder: %v", err)
}
if len(registry.orders) != 1 || strings.Join(registry.orders[0], ",") != "chan_slot" {
t.Fatalf("store order = %#v, want chan_slot", registry.orders)
}
// An inactive collectible does not have to be listed, and listing an unknown
// name is still rejected.
registry.lists[testChannel] = []domain.Username{
{Username: "chan_slot", Active: true, Editable: true},
{Username: "hidden", Active: false, CollectibleID: 7},
}
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot"}); err != nil {
t.Fatalf("reorder omitting an inactive collectible: %v", err)
}
if _, err := service.ReorderUsernames(context.Background(), testChannel, []string{"chan_slot", "nothere"}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
t.Fatalf("reorder with an unknown name = %v, want ErrUsernameOrderInvalid", err)
}
}
func TestDeactivateAllUsernamesNotifiesPeer(t *testing.T) {
registry := newFakeRegistry()
service, notifier := newTestService(t, registry, newFakeCollectibles())
changed, err := service.DeactivateAllUsernames(context.Background(), testChannel)
if err != nil || !changed {
t.Fatalf("DeactivateAllUsernames = %v, %v", changed, err)
}
if len(registry.clears) != 1 || registry.clears[0] != testChannel {
t.Fatalf("store clears = %#v", registry.clears)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testChannel {
t.Fatalf("notified peers = %#v", notifier.peers)
}
}
func TestMintRendersCollectibleURL(t *testing.T) {
tests := []struct {
name string
opts []Option
url string
wantURL string
username string
}{
{
name: "public-link default route",
opts: []Option{WithPublicBaseURL("https://example.test")},
username: "alpha",
wantURL: "https://example.test/nft/username/alpha",
},
{
name: "template placeholder",
opts: []Option{WithURLTemplate("https://frag.example/u/{username}?ref=1"), WithPublicBaseURL("https://example.test")},
username: "alpha",
wantURL: "https://frag.example/u/alpha?ref=1",
},
{
name: "template without placeholder appends the name",
opts: []Option{WithURLTemplate("https://frag.example/u/")},
username: "alpha",
wantURL: "https://frag.example/u/alpha",
},
{
name: "explicit request URL wins",
opts: []Option{WithURLTemplate("https://frag.example/u/{username}")},
username: "alpha",
url: "https://operator.example/custom",
wantURL: "https://operator.example/custom",
},
{
name: "no template and no base URL keeps the URL empty",
username: "alpha",
wantURL: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
collectibles := newFakeCollectibles()
service, _ := newTestService(t, newFakeRegistry(), collectibles, test.opts...)
asset, created, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
Username: "@" + test.username, Currency: domain.CollectibleCurrencyStars, Amount: 1000, URL: test.url,
})
if err != nil || !created {
t.Fatalf("Mint = %v, %v", created, err)
}
if asset.URL != test.wantURL {
t.Fatalf("asset URL = %q, want %q", asset.URL, test.wantURL)
}
if len(collectibles.mints) != 1 {
t.Fatalf("mints = %d, want 1", len(collectibles.mints))
}
if got := collectibles.mints[0].Username; got != test.username {
t.Fatalf("stored username = %q, want normalized %q", got, test.username)
}
if !collectibles.mints[0].PurchaseDate.Equal(testClock) {
t.Fatalf("purchase date = %v, want the service clock %v", collectibles.mints[0].PurchaseDate, testClock)
}
})
}
}
func TestMintValidatesBeforeStore(t *testing.T) {
tests := []struct {
name string
req domain.MintCollectibleUsernameRequest
wantErr error
}{
{
name: "username too short",
req: domain.MintCollectibleUsernameRequest{Username: "ab", Currency: domain.CollectibleCurrencyStars},
wantErr: domain.ErrUsernameInvalid,
},
{
name: "unsupported currency",
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: "EUR"},
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
{
name: "crypto amount without currency",
req: domain.MintCollectibleUsernameRequest{Username: "alpha", Currency: domain.CollectibleCurrencyStars, CryptoAmount: 5},
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
{
name: "owner peer is not a username holder",
req: domain.MintCollectibleUsernameRequest{
Username: "alpha", Currency: domain.CollectibleCurrencyStars,
Owner: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
},
wantErr: domain.ErrCollectibleUsernameStateInvalid,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
collectibles := newFakeCollectibles()
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
if _, _, err := service.Mint(context.Background(), test.req); !errors.Is(err, test.wantErr) {
t.Fatalf("Mint error = %v, want %v", err, test.wantErr)
}
if len(collectibles.mints) != 0 {
t.Fatalf("store was called with invalid input: %#v", collectibles.mints)
}
if len(notifier.peers) != 0 {
t.Fatalf("notifier ran for a rejected mint: %#v", notifier.peers)
}
})
}
}
func TestMintNotifiesOwnerOnly(t *testing.T) {
collectibles := newFakeCollectibles()
service, notifier := newTestService(t, newFakeRegistry(), collectibles, WithPublicBaseURL("https://example.test"))
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
Username: "vaulted", Currency: domain.CollectibleCurrencyStars,
}); err != nil {
t.Fatalf("Mint vault: %v", err)
}
if len(notifier.peers) != 0 {
t.Fatalf("vault mint notified %#v, want nothing", notifier.peers)
}
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{
Username: "assigned", Currency: domain.CollectibleCurrencyStars, Owner: testUser,
}); err != nil {
t.Fatalf("Mint assigned: %v", err)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
t.Fatalf("notified peers = %#v, want the assigned owner", notifier.peers)
}
}
func TestTransferNotifiesPreviousAndNewOwner(t *testing.T) {
collectibles := newFakeCollectibles()
collectibles.assets["alpha"] = domain.CollectibleUsername{
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
}
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
_, changed, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{
Username: "@Alpha", To: testChannel, Actor: "admin", CommandKey: "cmd-1",
})
if err != nil || !changed {
t.Fatalf("Transfer = %v, %v", changed, err)
}
if len(collectibles.transfers) != 1 || collectibles.transfers[0].Username != "Alpha" {
t.Fatalf("stored transfer = %#v, want normalized username", collectibles.transfers)
}
if len(notifier.peers) != 2 {
t.Fatalf("notified peers = %#v, want previous and new owner", notifier.peers)
}
seen := map[domain.Peer]bool{notifier.peers[0]: true, notifier.peers[1]: true}
if !seen[testUser] || !seen[testChannel] {
t.Fatalf("notified peers = %#v, want %v and %v", notifier.peers, testUser, testChannel)
}
}
func TestRevokeNotifiesPreviousOwner(t *testing.T) {
collectibles := newFakeCollectibles()
collectibles.assets["alpha"] = domain.CollectibleUsername{
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
}
service, notifier := newTestService(t, newFakeRegistry(), collectibles)
asset, changed, err := service.Revoke(context.Background(), domain.RevokeCollectibleUsernameRequest{
Username: "alpha", Burn: true, Actor: "admin",
})
if err != nil || !changed {
t.Fatalf("Revoke = %v, %v", changed, err)
}
if asset.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("asset status = %q, want burned", asset.Status)
}
if len(notifier.peers) != 1 || notifier.peers[0] != testUser {
t.Fatalf("notified peers = %#v, want the previous owner", notifier.peers)
}
}
func TestCollectibleInfoProjectsPurchaseRecord(t *testing.T) {
collectibles := newFakeCollectibles()
collectibles.assets["alpha"] = domain.CollectibleUsername{
ID: 1, Username: "alpha", Status: domain.CollectibleUsernameStatusOwned, Owner: testUser,
PurchaseDate: testClock, Currency: domain.CollectibleCurrencyStars, Amount: 2500,
URL: "https://example.test/nft/username/alpha",
}
service, _ := newTestService(t, newFakeRegistry(), collectibles)
info, err := service.CollectibleInfo(context.Background(), "@ALPHA")
if err != nil {
t.Fatalf("CollectibleInfo: %v", err)
}
if info.PurchaseDate != int(testClock.Unix()) || info.Amount != 2500 || info.Currency != domain.CollectibleCurrencyStars {
t.Fatalf("collectible info = %#v", info)
}
if _, err := service.CollectibleInfo(context.Background(), "ab"); !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("CollectibleInfo short name error = %v, want ErrUsernameInvalid", err)
}
}
func TestListAndTransfersBoundThePage(t *testing.T) {
collectibles := newFakeCollectibles()
service, _ := newTestService(t, newFakeRegistry(), collectibles)
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Query: " @Alpha ", Limit: 0}); err != nil {
t.Fatalf("List: %v", err)
}
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Limit: 100000}); err != nil {
t.Fatalf("List: %v", err)
}
if len(collectibles.filters) != 2 ||
collectibles.filters[0].Limit != defaultListLimit || collectibles.filters[0].Query != "Alpha" ||
collectibles.filters[1].Limit != maxListLimit {
t.Fatalf("filters = %#v", collectibles.filters)
}
if _, err := service.List(context.Background(), domain.CollectibleUsernameFilter{Status: "sold"}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
t.Fatalf("List accepted an unmodelled status")
}
if _, err := service.Transfers(context.Background(), 7, 0); err != nil {
t.Fatalf("Transfers: %v", err)
}
if len(collectibles.logLimits) != 1 || collectibles.logLimits[0] != defaultTransferLimit {
t.Fatalf("transfer log limits = %#v", collectibles.logLimits)
}
if _, err := service.Transfers(context.Background(), 0, 10); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("Transfers accepted a zero collectible id")
}
}
func TestServiceWithoutStoresReportsConfiguration(t *testing.T) {
service := NewService()
if service.Configured() {
t.Fatal("Configured = true without stores")
}
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
t.Fatal("PeerUsernames accepted a missing registry store")
}
if _, err := service.ToggleUsername(context.Background(), testUser, "alpha", true); err == nil {
t.Fatal("ToggleUsername accepted a missing registry store")
}
if _, _, err := service.Mint(context.Background(), domain.MintCollectibleUsernameRequest{Username: "alpha"}); err == nil {
t.Fatal("Mint accepted a missing collectible store")
}
}
func TestNilServiceIsSafe(t *testing.T) {
var service *Service
service.SetPeerUsernameNotifier(&recordingNotifier{})
if service.Configured() {
t.Fatal("nil service reported configured")
}
if url := service.CollectibleURL("alpha"); url != "" {
t.Fatalf("nil service URL = %q", url)
}
if _, err := service.PeerUsernames(context.Background(), testUser); err == nil {
t.Fatal("nil service PeerUsernames returned no error")
}
if _, _, err := service.Transfer(context.Background(), domain.TransferCollectibleUsernameRequest{Username: "alpha", To: testUser}); err == nil {
t.Fatal("nil service Transfer returned no error")
}
}
func TestNotifierFailureDoesNotFailTheMutation(t *testing.T) {
registry := newFakeRegistry()
registry.lists[testUser] = []domain.Username{
{Username: "editable", Active: true, Editable: true},
{Username: "alpha", Active: true, CollectibleID: 1},
}
notifier := &recordingNotifier{err: errors.New("push failed")}
service := NewService(
WithRegistryStore(registry),
WithCollectibleStore(newFakeCollectibles()),
WithNotifier(notifier),
)
changed, err := service.ToggleUsername(context.Background(), testUser, "alpha", false)
if err != nil || !changed {
t.Fatalf("ToggleUsername = %v, %v; committed mutation must survive a failed push", changed, err)
}
}
// TestServiceDeleteNotifiesPreviousOwner covers the hard delete: the request is
// normalised and validated before the store is touched, and the peer that held
// the asset is invalidated so its projection stops advertising the username.
func TestServiceDeleteNotifiesPreviousOwner(t *testing.T) {
ctx := context.Background()
registry := newFakeRegistry()
collectibles := newFakeCollectibles()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 501}
collectibles.assets["gone"] = domain.CollectibleUsername{
ID: 9, Username: "Gone", Status: domain.CollectibleUsernameStatusOwned, Owner: holder,
}
svc, notifier := newTestService(t, registry, collectibles)
deleted, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
Username: " @Gone ", Actor: "admin", Reason: "issued by mistake",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if len(collectibles.deletes) != 1 || collectibles.deletes[0].Username != "Gone" {
t.Fatalf("store received %+v, want the normalised name", collectibles.deletes)
}
if len(notifier.peers) != 1 || notifier.peers[0] != holder {
t.Fatalf("notified peers = %#v, want the previous owner %+v", notifier.peers, holder)
}
// An invalid name never reaches the store.
before := len(collectibles.deletes)
if _, err := svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{Username: "no"}); err == nil {
t.Fatalf("delete of a too-short name = nil error, want rejection")
}
if len(collectibles.deletes) != before {
t.Fatalf("store was called with an invalid request: %+v", collectibles.deletes)
}
// Nothing live left is not an error, and nothing is notified.
notifier.peers = nil
deleted, err = svc.Delete(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "absentname", Actor: "admin", Reason: "again",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
if len(notifier.peers) != 0 {
t.Fatalf("no-op delete notified %+v", notifier.peers)
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
package verification
import (
"context"
"time"
"go.uber.org/zap"
)
// defaultNotifyInterval matches the shipped
// TELESRV_VERIFICATION_NOTIFY_INTERVAL default.
const defaultNotifyInterval = 15 * time.Second
// NotificationWorker drains the applicant-notification outbox.
//
// A decision is committed together with its outbox row, never with a message
// send: @verifybot may be blocked, the applicant may be deleted, and the panel
// must not wait on either. Delivery is therefore a separate, retrying cycle over
// durable rows, and this worker is only its cadence.
type NotificationWorker struct {
service *Service
logger *zap.Logger
interval time.Duration
batch int
}
// NewNotificationWorker creates the periodic delivery worker. Non-positive
// interval/batch fall back to the shipped defaults, matching the rating
// recompute worker's contract.
func NewNotificationWorker(service *Service, logger *zap.Logger, interval time.Duration, batch int) *NotificationWorker {
if logger == nil {
logger = zap.NewNop()
}
if interval <= 0 {
interval = defaultNotifyInterval
}
if batch <= 0 {
batch = defaultNotifyBatch
}
return &NotificationWorker{service: service, logger: logger, interval: interval, batch: batch}
}
// Run delivers one batch immediately and then on every tick until ctx is done. A
// disabled or store-less service exits immediately with one explicit log line
// instead of ticking forever over a no-op.
func (w *NotificationWorker) Run(ctx context.Context) {
if w == nil {
return
}
if !w.service.Ready() {
w.logger.Info("verification notification worker disabled",
zap.Bool("enabled", w.service.Enabled()))
return
}
w.runOnce(ctx)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.runOnce(ctx)
}
}
}
func (w *NotificationWorker) runOnce(ctx context.Context) {
if w == nil || w.service == nil {
return
}
delivered, err := w.service.RunNotificationCycle(ctx, w.batch)
if err != nil {
if ctx.Err() != nil {
return
}
w.logger.Warn("verification notification cycle failed",
zap.Int("delivered", delivered),
zap.Int("batch", w.batch),
zap.Error(err))
return
}
if delivered > 0 {
w.logger.Info("verification notification cycle completed",
zap.Int("delivered", delivered),
zap.Int("batch", w.batch))
}
}

View file

@ -13,6 +13,7 @@ import (
"golang.org/x/text/language"
"telesrv/internal/domain"
"telesrv/internal/links"
)
@ -130,6 +131,19 @@ type Config struct {
AdminUIPassword string
AdminUIToken string
AdminSessionKey string
// AdminUIPermissions is the permission set granted to a panel session that
// authenticated with TELESRV_ADMIN_UI_PASSWORD / _TOKEN. The single entry "*"
// means "every permission" and is the shipped default, so enabling RBAC never
// silently locks an operator out of a panel that worked before.
AdminUIPermissions []string
// AdminScopedTokens are additional adminapi bearer tokens with a bounded
// permission set each. They exist so an integration can be given exactly the
// rights it needs instead of the unrestricted TELESRV_ADMIN_API_TOKEN. Parsed
// from
// "name:token:perm1,perm2" entries separated by ';'; a malformed entry, a
// duplicate name or a duplicate token fails startup rather than silently
// granting or dropping rights.
AdminScopedTokens []AdminScopedToken
// PostgresDSN 是业务数据auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。
// 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。
@ -374,6 +388,106 @@ type Config struct {
StarGiftCraftDelay time.Duration
StarGiftCraftChancePermille int
// RatingEnabled controls the local admin-only composite account rating.
// Disabled keeps every local projection empty and refuses rating writes; no
// client-facing Telegram field changes in either mode.
RatingEnabled bool
// RatingPendingDelay is how long a rating increase stays parked as a pending
// local score before it becomes the visible admin level. A decrease is
// always applied immediately: a penalty must not sit behind a delay.
// 0 applies every change immediately.
RatingPendingDelay time.Duration
// RatingRecomputeInterval / RatingRecomputeBatch drive the background
// recompute worker. The rating derives from signals owned by other
// subsystems, so freshness is a worker property, not a write-path one.
RatingRecomputeInterval time.Duration
RatingRecomputeBatch int
// RatingStaleAfter is the projection age after which the worker recomputes a
// user.
RatingStaleAfter time.Duration
// Rating weights are the integer composite formula. Defaults mirror
// domain.DefaultAccountRatingWeights() exactly, so the shipped behaviour is
// identical whether or not these keys are set. Every weight is a magnitude:
// the penalties are subtracted by the domain formula, so all values are
// non-negative and a negative value fails startup.
RatingWeightStarsReceivedPermille int64
RatingWeightStarsSpentPermille int64
RatingWeightMessageSent int64
RatingWeightAccountAgeDay int64
RatingWeightGiftReceived int64
RatingWeightModerationCase int64
RatingWeightScamPenalty int64
RatingWeightFakePenalty int64
// RatingActivityCap bounds the activity component so activity alone cannot
// outweigh Stars and moderation; 0 leaves it uncapped.
RatingActivityCap int64
// VerificationEnabled controls official platform verification: the @verifybot
// application flow and the panel's review queue. Disabled refuses every
// verification use case explicitly; already-verified peers keep their badge,
// because the flag lives on the peer record and is not derived from this
// feature being on.
VerificationEnabled bool
// VerificationAllowUserTargets opts plain user accounts in as verification
// subjects. Off by default: the official process verifies a public presence
// (bot, public channel, public supergroup), and a private account has nothing
// to check.
VerificationAllowUserTargets bool
// VerificationRejectCooldown is how long an applicant must wait before filing
// the same target again after a rejection. Measured from the decision, so a
// slow review never shortens it; 0 disables the cooldown.
VerificationRejectCooldown time.Duration
// VerificationApplyRateLimit / VerificationApplyRateWindow bound how many
// applications one applicant may create per window. 0 for either disables the
// budget.
VerificationApplyRateLimit int
VerificationApplyRateWindow time.Duration
// VerificationBotRateLimit / VerificationBotRateWindow bound the @verifybot
// dialog itself (per-applicant command rate), independently of how many
// applications are actually created.
VerificationBotRateLimit int
VerificationBotRateWindow time.Duration
// VerificationNotifyInterval / VerificationNotifyBatch drive the applicant
// notification worker. A decision commits with its outbox row, never with a
// message send, so delivery cadence is a worker property.
VerificationNotifyInterval time.Duration
VerificationNotifyBatch int
// VerificationMaxActivePerUser bounds how many applications one applicant may
// keep open at once; 0 disables the cap.
VerificationMaxActivePerUser int
// BotVerificationEnabled controls THIRD-PARTY bot verification
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with
// its own icon and description, projected onto
// user/channel.bot_verification_icon and botInfo.verifier_settings. It is a
// different mechanism from VerificationEnabled above -- that one is the
// operator-granted platform checkmark, and the two never read each other's
// state.
//
// Disabled refuses every third-party mutation (grants, revocations,
// applications, catalogue edits) while the marks already granted keep
// projecting: blanking one verifier's badges is what its per-verifier kill
// switch is for.
BotVerificationEnabled bool
// BotVerificationMaxPerVerifier bounds how many peers one verifier bot may
// mark. Verifier status is granted per deployment rather than earned per peer,
// so an unbounded verifier would be an unbounded badge printer. 0 disables the
// service-level bound and leaves only the storage bound
// (domain.MaxCustomVerificationsPerVerifier), which is also the maximum this
// key accepts.
BotVerificationMaxPerVerifier int
// BotVerificationRequestRateLimit / BotVerificationRequestRateWindow bound how
// many verification applications one applicant may file per window, across all
// verifier bots. 0 for either disables the budget.
BotVerificationRequestRateLimit int
BotVerificationRequestRateWindow time.Duration
// CollectibleUsernameURLTemplate is the landing URL recorded on a minted
// collectible username when the mint request carries no explicit URL.
// Empty derives <TELESRV_PUBLIC_BASE_URL>/nft/username/<username>; a template
// may carry the {username} placeholder, and without it the name is appended
// as the last path segment. No external marketplace is contacted.
CollectibleUsernameURLTemplate string
// GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态
// 4s 一跳M1 起 SFU liveness reporter 同样刷新该水位)。
GroupCallCheckTTL time.Duration
@ -423,6 +537,15 @@ type Config struct {
SFUAdvertiseIP string
}
// AdminScopedToken is one adminapi bearer token restricted to a permission set.
// Name is the audit identity written next to actions performed with the token;
// Permissions is the closed list of rights it carries ("*" means all).
type AdminScopedToken struct {
Name string
Token string
Permissions []string
}
type AIProviderConfig struct {
Name string
Kind string
@ -473,6 +596,13 @@ func Load() (Config, error) {
if err != nil {
return Config{}, fmt.Errorf("TELESRV_DEFAULT_COUNTRY_CODE: %w", err)
}
// The composite rating weight defaults are the domain formula's own defaults;
// see RatingWeight* below.
defaultRatingWeights := domain.DefaultAccountRatingWeights()
adminScopedTokens, err := parseAdminScopedTokens(envAllowEmptyOr("TELESRV_ADMIN_SCOPED_TOKENS", ""))
if err != nil {
return Config{}, err
}
cfg := Config{
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
@ -537,6 +667,8 @@ func Load() (Config, error) {
TelegramLoginRetention: envDurationOr("TELESRV_TELEGRAM_LOGIN_RETENTION", 7*24*time.Hour),
TelegramLoginSweepInterval: envDurationOr("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", 5*time.Minute),
TelegramLoginSweepBatch: envIntOr("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", 500),
AdminUIPermissions: envListOr("TELESRV_ADMIN_UI_PERMISSIONS", []string{adminPermissionAll}),
AdminScopedTokens: adminScopedTokens,
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
@ -664,6 +796,47 @@ func Load() (Config, error) {
StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0),
StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250),
RatingEnabled: envBoolOr("TELESRV_RATING_ENABLED", true),
RatingPendingDelay: envDurationOr("TELESRV_RATING_PENDING_DELAY", 24*time.Hour),
RatingRecomputeInterval: envDurationOr("TELESRV_RATING_RECOMPUTE_INTERVAL", 15*time.Minute),
RatingRecomputeBatch: envIntOr("TELESRV_RATING_RECOMPUTE_BATCH", 500),
RatingStaleAfter: envDurationOr("TELESRV_RATING_STALE_AFTER", 6*time.Hour),
// Weight defaults are read from the domain formula itself so the shipped
// behaviour cannot drift from domain.DefaultAccountRatingWeights().
RatingWeightStarsReceivedPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", defaultRatingWeights.StarsReceivedPermille),
RatingWeightStarsSpentPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE", defaultRatingWeights.StarsSpentPermille),
RatingWeightMessageSent: envInt64Or("TELESRV_RATING_WEIGHT_MESSAGE_SENT", defaultRatingWeights.PerMessageSent),
RatingWeightAccountAgeDay: envInt64Or("TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY", defaultRatingWeights.PerAccountAgeDay),
RatingWeightGiftReceived: envInt64Or("TELESRV_RATING_WEIGHT_GIFT_RECEIVED", defaultRatingWeights.PerGiftReceived),
RatingWeightModerationCase: envInt64Or("TELESRV_RATING_WEIGHT_MODERATION_CASE", defaultRatingWeights.PerModerationCase),
RatingWeightScamPenalty: envInt64Or("TELESRV_RATING_WEIGHT_SCAM_PENALTY", defaultRatingWeights.ScamPenalty),
RatingWeightFakePenalty: envInt64Or("TELESRV_RATING_WEIGHT_FAKE_PENALTY", defaultRatingWeights.FakePenalty),
RatingActivityCap: envInt64Or("TELESRV_RATING_ACTIVITY_CAP", defaultRatingWeights.ActivityCap),
CollectibleUsernameURLTemplate: strings.TrimSpace(envAllowEmptyOr("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", "")),
// Official verification defaults ship the feature on with the official bar
// in place: user accounts are not accepted, a rejection costs a month, and
// an applicant can neither flood the queue nor keep an unbounded number of
// applications open.
VerificationEnabled: envBoolOr("TELESRV_VERIFICATION_ENABLED", true),
VerificationAllowUserTargets: envBoolOr("TELESRV_VERIFICATION_ALLOW_USER_TARGETS", false),
VerificationRejectCooldown: envDurationOr("TELESRV_VERIFICATION_REJECT_COOLDOWN", 720*time.Hour),
VerificationApplyRateLimit: envIntOr("TELESRV_VERIFICATION_APPLY_RATE_LIMIT", 3),
VerificationApplyRateWindow: envDurationOr("TELESRV_VERIFICATION_APPLY_RATE_WINDOW", 24*time.Hour),
VerificationBotRateLimit: envIntOr("TELESRV_VERIFICATION_BOT_RATE_LIMIT", 30),
VerificationBotRateWindow: envDurationOr("TELESRV_VERIFICATION_BOT_RATE_WINDOW", time.Minute),
VerificationNotifyInterval: envDurationOr("TELESRV_VERIFICATION_NOTIFY_INTERVAL", 15*time.Second),
VerificationNotifyBatch: envIntOr("TELESRV_VERIFICATION_NOTIFY_BATCH", 50),
VerificationMaxActivePerUser: envIntOr("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", 3),
BotVerificationEnabled: envBoolOr("TELESRV_BOT_VERIFICATION_ENABLED", true),
BotVerificationMaxPerVerifier: envIntOr("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", domain.MaxCustomVerificationsPerVerifier),
// The applicant budget is deliberately looser than the official one
// (TELESRV_VERIFICATION_APPLY_RATE_LIMIT=3): a deployment can run many
// verifier bots, and filing with a second company is not a retry of the first.
BotVerificationRequestRateLimit: envIntOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", 5),
BotVerificationRequestRateWindow: envDurationOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", 24*time.Hour),
GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),
GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second),
GroupCallMaxParticipants: envIntOr("TELESRV_GROUPCALL_MAX_PARTICIPANTS", 32),
@ -697,6 +870,18 @@ func Load() (Config, error) {
if err := validateStarGiftConfig(cfg); err != nil {
return Config{}, err
}
if err := validateAccountRatingConfig(cfg); err != nil {
return Config{}, err
}
if err := validateCollectibleUsernameConfig(cfg); err != nil {
return Config{}, err
}
if err := validateVerificationConfig(cfg); err != nil {
return Config{}, err
}
if err := validateAdminRBACConfig(cfg); err != nil {
return Config{}, err
}
if err := validateTelegramLoginConfig(cfg); err != nil {
return Config{}, err
}
@ -785,6 +970,247 @@ func validateStarGiftConfig(cfg Config) error {
return nil
}
// AccountRatingWeights renders the configured composite rating formula. It is
// the single conversion point between env keys and the domain formula, so the
// app service and the admin explanation always use the same numbers.
func (c Config) AccountRatingWeights() domain.AccountRatingWeights {
return domain.AccountRatingWeights{
StarsReceivedPermille: c.RatingWeightStarsReceivedPermille,
StarsSpentPermille: c.RatingWeightStarsSpentPermille,
PerMessageSent: c.RatingWeightMessageSent,
PerAccountAgeDay: c.RatingWeightAccountAgeDay,
PerGiftReceived: c.RatingWeightGiftReceived,
PerModerationCase: c.RatingWeightModerationCase,
ScamPenalty: c.RatingWeightScamPenalty,
FakePenalty: c.RatingWeightFakePenalty,
ActivityCap: c.RatingActivityCap,
}
}
// validateAccountRatingConfig rejects a formula or worker cadence that cannot
// produce a reproducible rating. Weights are validated even when the feature is
// disabled: enabling it later must not be the moment a typo is discovered.
func validateAccountRatingConfig(cfg Config) error {
if err := cfg.AccountRatingWeights().Validate(); err != nil {
return fmt.Errorf("TELESRV_RATING_WEIGHT_* and TELESRV_RATING_ACTIVITY_CAP must be non-negative: %w", err)
}
if cfg.RatingPendingDelay < 0 {
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must be non-negative")
}
const maxRatingPendingDelay = 30 * 24 * time.Hour
if cfg.RatingPendingDelay > maxRatingPendingDelay {
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must not exceed 720h")
}
if cfg.RatingRecomputeInterval <= 0 {
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_INTERVAL must be positive")
}
if cfg.RatingStaleAfter <= 0 {
return fmt.Errorf("TELESRV_RATING_STALE_AFTER must be positive")
}
if cfg.RatingRecomputeBatch <= 0 || cfg.RatingRecomputeBatch > 10000 {
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_BATCH must be 1..10000")
}
return nil
}
// adminPermissionAll is the wildcard permission: a session or token carrying it
// may perform every admin action.
const adminPermissionAll = "*"
// validateVerificationConfig rejects a verification policy that cannot be
// enforced, for both mechanisms: the operator-granted platform badge and the
// third-party bot verification marks. It runs even when either feature is
// disabled, so enabling it later is not the moment a typo is discovered.
func validateVerificationConfig(cfg Config) error {
if cfg.VerificationRejectCooldown < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_REJECT_COOLDOWN must be non-negative")
}
const maxVerificationRejectCooldown = 365 * 24 * time.Hour
if cfg.VerificationRejectCooldown > maxVerificationRejectCooldown {
return fmt.Errorf("TELESRV_VERIFICATION_REJECT_COOLDOWN must not exceed 8760h")
}
if cfg.VerificationApplyRateLimit < 0 || cfg.VerificationBotRateLimit < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_LIMIT and TELESRV_VERIFICATION_BOT_RATE_LIMIT must be non-negative")
}
if cfg.VerificationApplyRateWindow < 0 || cfg.VerificationBotRateWindow < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_WINDOW and TELESRV_VERIFICATION_BOT_RATE_WINDOW must be non-negative")
}
// A positive limit with a zero window is not "unlimited", it is a limiter that
// can never refill: reject it instead of shipping a permanent lockout.
if cfg.VerificationApplyRateLimit > 0 && cfg.VerificationApplyRateWindow <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_WINDOW must be positive when TELESRV_VERIFICATION_APPLY_RATE_LIMIT is set")
}
if cfg.VerificationBotRateLimit > 0 && cfg.VerificationBotRateWindow <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_BOT_RATE_WINDOW must be positive when TELESRV_VERIFICATION_BOT_RATE_LIMIT is set")
}
if cfg.VerificationNotifyInterval <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_NOTIFY_INTERVAL must be positive")
}
if cfg.VerificationNotifyBatch <= 0 || cfg.VerificationNotifyBatch > 500 {
return fmt.Errorf("TELESRV_VERIFICATION_NOTIFY_BATCH must be 1..500")
}
if cfg.VerificationMaxActivePerUser < 0 || cfg.VerificationMaxActivePerUser > 50 {
return fmt.Errorf("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER must be 0..50")
}
// Third-party bot verification. The ceiling is the storage bound: a value above
// it would be silently unreachable, and a configuration key that cannot do what
// it says is worse than no key.
if cfg.BotVerificationMaxPerVerifier < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER must be non-negative")
}
if cfg.BotVerificationMaxPerVerifier > domain.MaxCustomVerificationsPerVerifier {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER must not exceed %d", domain.MaxCustomVerificationsPerVerifier)
}
if cfg.BotVerificationRequestRateLimit < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT must be non-negative")
}
if cfg.BotVerificationRequestRateWindow < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW must be non-negative")
}
// Same trap as the official budget above: a positive limit with a zero window is
// not "unlimited", it is a limiter that can never refill.
if cfg.BotVerificationRequestRateLimit > 0 && cfg.BotVerificationRequestRateWindow <= 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW must be positive when TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT is set")
}
return nil
}
// validateAdminRBACConfig checks the panel/adminapi permission configuration.
// An unparsable permission name is refused rather than ignored: a silently
// dropped permission is either a lockout or an unintended grant.
func validateAdminRBACConfig(cfg Config) error {
if len(cfg.AdminUIPermissions) == 0 {
return fmt.Errorf("TELESRV_ADMIN_UI_PERMISSIONS must not be empty; use * to grant every permission")
}
for _, permission := range cfg.AdminUIPermissions {
if !validAdminPermission(permission) {
return fmt.Errorf("TELESRV_ADMIN_UI_PERMISSIONS contains invalid permission %q", permission)
}
}
names := make(map[string]struct{}, len(cfg.AdminScopedTokens))
tokens := make(map[string]struct{}, len(cfg.AdminScopedTokens))
for _, scoped := range cfg.AdminScopedTokens {
if _, dup := names[strings.ToLower(scoped.Name)]; dup {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS has duplicate name %q", scoped.Name)
}
names[strings.ToLower(scoped.Name)] = struct{}{}
if _, dup := tokens[scoped.Token]; dup {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS reuses one token for several names")
}
tokens[scoped.Token] = struct{}{}
if scoped.Token == cfg.AdminAPIToken && strings.TrimSpace(cfg.AdminAPIToken) != "" {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q reuses TELESRV_ADMIN_API_TOKEN, which would silently widen it to every permission", scoped.Name)
}
if len(scoped.Permissions) == 0 {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has no permissions", scoped.Name)
}
for _, permission := range scoped.Permissions {
if !validAdminPermission(permission) {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has invalid permission %q", scoped.Name, permission)
}
}
}
return nil
}
// parseAdminScopedTokens reads "name:token:perm1,perm2" entries separated by ';'.
//
// The shape is strict on purpose: the value carries credentials, and a
// half-understood entry must fail startup rather than produce a token whose
// rights nobody can predict. The permission list is the last field, so a token
// itself may not contain ':' -- which is also why it is validated here rather
// than being re-split later by a consumer.
func parseAdminScopedTokens(raw string) ([]AdminScopedToken, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
out := make([]AdminScopedToken, 0, 4)
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must be name:token:perm1,perm2", entry)
}
name := strings.TrimSpace(parts[0])
token := strings.TrimSpace(parts[1])
if name == "" || token == "" {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must carry a non-empty name and token", entry)
}
if strings.ContainsAny(token, " \t\r\n") {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has whitespace inside its token", name)
}
permissions := make([]string, 0, 4)
for _, permission := range strings.Split(parts[2], ",") {
permission = strings.TrimSpace(permission)
if permission == "" {
continue
}
permissions = append(permissions, permission)
}
if len(permissions) == 0 {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must list at least one permission", name)
}
out = append(out, AdminScopedToken{Name: name, Token: token, Permissions: permissions})
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// validAdminPermission accepts the wildcard and dotted/namespaced permission
// names such as "users.read" or "verification:decide".
func validAdminPermission(permission string) bool {
if permission == adminPermissionAll {
return true
}
if permission == "" || len(permission) > 64 {
return false
}
for i := 0; i < len(permission); i++ {
c := permission[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
case c == '_' || c == '-':
case (c == '.' || c == ':') && i != 0 && i != len(permission)-1:
case c == '*' && i == len(permission)-1 && i > 0 && (permission[i-1] == '.' || permission[i-1] == ':'):
// A trailing "namespace.*" grants a whole namespace.
default:
return false
}
}
return true
}
// validateCollectibleUsernameConfig checks the optional mint URL template. An
// empty template is the documented default (the public-link route is derived
// from TELESRV_PUBLIC_BASE_URL); a configured one must be a client-openable
// absolute http(s) URL that still fits the registry's url column.
func validateCollectibleUsernameConfig(cfg Config) error {
template := strings.TrimSpace(cfg.CollectibleUsernameURLTemplate)
if template == "" {
return nil
}
if len(template)+domain.MaxCollectibleUsernameLength > domain.MaxCollectibleUsernameURLLength {
return fmt.Errorf("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE is too long to hold a rendered username")
}
// Render the placeholder before parsing so a templated path segment is
// validated in its final shape.
rendered := strings.ReplaceAll(template, "{username}", "username")
parsed, err := url.Parse(rendered)
if err != nil || parsed.Host == "" || parsed.User != nil ||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE must be an absolute http(s) URL without userinfo")
}
return nil
}
const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
func validateRPCResultCacheConfig(cfg Config) error {

View file

@ -5,6 +5,8 @@ import (
"path/filepath"
"testing"
"time"
"telesrv/internal/domain"
)
func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
@ -720,6 +722,359 @@ func TestValidateStarGiftConfigRejectsNegativeInternalTONGrant(t *testing.T) {
}
}
func TestLoadAccountRatingDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.RatingEnabled {
t.Fatal("RatingEnabled = false, want the feature on by default")
}
if cfg.RatingPendingDelay != 24*time.Hour || cfg.RatingRecomputeInterval != 15*time.Minute ||
cfg.RatingRecomputeBatch != 500 || cfg.RatingStaleAfter != 6*time.Hour {
t.Fatalf("rating worker defaults = %v/%v/%d/%v, want 24h/15m/500/6h",
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
}
if got, want := cfg.AccountRatingWeights(), domain.DefaultAccountRatingWeights(); got != want {
t.Fatalf("rating weights = %#v, want the domain defaults %#v", got, want)
}
if cfg.CollectibleUsernameURLTemplate != "" {
t.Fatalf("CollectibleUsernameURLTemplate = %q, want empty (derived from the public base URL)",
cfg.CollectibleUsernameURLTemplate)
}
}
func TestLoadAccountRatingOverrides(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_RATING_ENABLED", "false")
t.Setenv("TELESRV_RATING_PENDING_DELAY", "1h")
t.Setenv("TELESRV_RATING_RECOMPUTE_INTERVAL", "90s")
t.Setenv("TELESRV_RATING_RECOMPUTE_BATCH", "42")
t.Setenv("TELESRV_RATING_STALE_AFTER", "30m")
t.Setenv("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", "500")
t.Setenv("TELESRV_RATING_WEIGHT_MESSAGE_SENT", "0")
t.Setenv("TELESRV_RATING_ACTIVITY_CAP", "0")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.RatingEnabled {
t.Fatal("RatingEnabled = true, want the explicit override")
}
weights := cfg.AccountRatingWeights()
if weights.StarsReceivedPermille != 500 || weights.PerMessageSent != 0 || weights.ActivityCap != 0 {
t.Fatalf("weights = %#v, want the overridden values", weights)
}
if weights.StarsSpentPermille != domain.DefaultAccountRatingWeights().StarsSpentPermille {
t.Fatalf("unset weight = %d, want the domain default", weights.StarsSpentPermille)
}
if cfg.RatingPendingDelay != time.Hour || cfg.RatingRecomputeInterval != 90*time.Second ||
cfg.RatingRecomputeBatch != 42 || cfg.RatingStaleAfter != 30*time.Minute {
t.Fatalf("rating worker overrides = %v/%v/%d/%v",
cfg.RatingPendingDelay, cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch, cfg.RatingStaleAfter)
}
}
func TestLoadRejectsInvalidAccountRatingConfig(t *testing.T) {
tests := []struct {
name string
key string
value string
}{
{name: "negative stars weight", key: "TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", value: "-1"},
{name: "negative moderation weight", key: "TELESRV_RATING_WEIGHT_MODERATION_CASE", value: "-150"},
{name: "negative scam penalty", key: "TELESRV_RATING_WEIGHT_SCAM_PENALTY", value: "-1"},
{name: "negative activity cap", key: "TELESRV_RATING_ACTIVITY_CAP", value: "-5000"},
{name: "negative pending delay", key: "TELESRV_RATING_PENDING_DELAY", value: "-1h"},
{name: "zero recompute interval", key: "TELESRV_RATING_RECOMPUTE_INTERVAL", value: "0s"},
{name: "zero stale horizon", key: "TELESRV_RATING_STALE_AFTER", value: "0s"},
{name: "zero recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "0"},
{name: "oversized recompute batch", key: "TELESRV_RATING_RECOMPUTE_BATCH", value: "20000"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
func TestLoadCollectibleUsernameURLTemplate(t *testing.T) {
t.Run("absolute template accepted", func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", " https://frag.example/u/{username} ")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.CollectibleUsernameURLTemplate != "https://frag.example/u/{username}" {
t.Fatalf("template = %q, want the trimmed value", cfg.CollectibleUsernameURLTemplate)
}
})
for _, invalid := range []string{"/nft/{username}", "ftp://frag.example/{username}", "https://user:pass@frag.example/{username}"} {
t.Run("rejects "+invalid, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", invalid)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted template %q", invalid)
}
})
}
}
func TestLoadVerificationDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.VerificationEnabled {
t.Fatal("VerificationEnabled = false, want the feature shipped on")
}
if cfg.VerificationAllowUserTargets {
t.Fatal("VerificationAllowUserTargets = true, want user targets opt-in")
}
if cfg.VerificationRejectCooldown != 720*time.Hour {
t.Fatalf("VerificationRejectCooldown = %v, want 720h", cfg.VerificationRejectCooldown)
}
if cfg.VerificationApplyRateLimit != 3 || cfg.VerificationApplyRateWindow != 24*time.Hour {
t.Fatalf("apply rate = %d/%v, want 3/24h", cfg.VerificationApplyRateLimit, cfg.VerificationApplyRateWindow)
}
if cfg.VerificationBotRateLimit != 30 || cfg.VerificationBotRateWindow != time.Minute {
t.Fatalf("bot rate = %d/%v, want 30/1m", cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow)
}
if cfg.VerificationNotifyInterval != 15*time.Second || cfg.VerificationNotifyBatch != 50 {
t.Fatalf("notify = %v/%d, want 15s/50", cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch)
}
if cfg.VerificationMaxActivePerUser != 3 {
t.Fatalf("VerificationMaxActivePerUser = %d, want 3", cfg.VerificationMaxActivePerUser)
}
}
func TestLoadVerificationOverrides(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_VERIFICATION_ENABLED", "false")
t.Setenv("TELESRV_VERIFICATION_ALLOW_USER_TARGETS", "true")
t.Setenv("TELESRV_VERIFICATION_REJECT_COOLDOWN", "48h")
t.Setenv("TELESRV_VERIFICATION_APPLY_RATE_LIMIT", "7")
t.Setenv("TELESRV_VERIFICATION_APPLY_RATE_WINDOW", "12h")
t.Setenv("TELESRV_VERIFICATION_BOT_RATE_LIMIT", "0")
t.Setenv("TELESRV_VERIFICATION_BOT_RATE_WINDOW", "0s")
t.Setenv("TELESRV_VERIFICATION_NOTIFY_INTERVAL", "5s")
t.Setenv("TELESRV_VERIFICATION_NOTIFY_BATCH", "200")
t.Setenv("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", "0")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VerificationEnabled || !cfg.VerificationAllowUserTargets {
t.Fatalf("enabled=%v allowUserTargets=%v", cfg.VerificationEnabled, cfg.VerificationAllowUserTargets)
}
if cfg.VerificationRejectCooldown != 48*time.Hour {
t.Fatalf("cooldown = %v", cfg.VerificationRejectCooldown)
}
if cfg.VerificationApplyRateLimit != 7 || cfg.VerificationApplyRateWindow != 12*time.Hour {
t.Fatalf("apply rate = %d/%v", cfg.VerificationApplyRateLimit, cfg.VerificationApplyRateWindow)
}
// A zero limit disables the budget, so a zero window is accepted with it.
if cfg.VerificationBotRateLimit != 0 || cfg.VerificationBotRateWindow != 0 {
t.Fatalf("bot rate = %d/%v", cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow)
}
if cfg.VerificationNotifyInterval != 5*time.Second || cfg.VerificationNotifyBatch != 200 {
t.Fatalf("notify = %v/%d", cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch)
}
if cfg.VerificationMaxActivePerUser != 0 {
t.Fatalf("maxActive = %d, want the cap disabled", cfg.VerificationMaxActivePerUser)
}
}
func TestLoadRejectsInvalidVerificationConfig(t *testing.T) {
for _, test := range []struct {
key string
value string
}{
{"TELESRV_VERIFICATION_REJECT_COOLDOWN", "-1h"},
{"TELESRV_VERIFICATION_REJECT_COOLDOWN", "9000h"},
{"TELESRV_VERIFICATION_APPLY_RATE_LIMIT", "-1"},
{"TELESRV_VERIFICATION_APPLY_RATE_WINDOW", "0s"},
{"TELESRV_VERIFICATION_APPLY_RATE_WINDOW", "-5m"},
{"TELESRV_VERIFICATION_BOT_RATE_LIMIT", "-2"},
{"TELESRV_VERIFICATION_BOT_RATE_WINDOW", "0s"},
{"TELESRV_VERIFICATION_NOTIFY_INTERVAL", "0s"},
{"TELESRV_VERIFICATION_NOTIFY_BATCH", "0"},
{"TELESRV_VERIFICATION_NOTIFY_BATCH", "501"},
{"TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", "-1"},
{"TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", "51"},
} {
t.Run(test.key+"="+test.value, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
func TestLoadBotVerificationDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.BotVerificationEnabled {
t.Fatal("BotVerificationEnabled = false, want the feature shipped on")
}
// The default is the storage bound itself, so the shipped behaviour is the same
// whether or not the key is set.
if cfg.BotVerificationMaxPerVerifier != domain.MaxCustomVerificationsPerVerifier {
t.Fatalf("BotVerificationMaxPerVerifier = %d, want %d", cfg.BotVerificationMaxPerVerifier, domain.MaxCustomVerificationsPerVerifier)
}
if cfg.BotVerificationRequestRateLimit != 5 || cfg.BotVerificationRequestRateWindow != 24*time.Hour {
t.Fatalf("request rate = %d/%v, want 5/24h", cfg.BotVerificationRequestRateLimit, cfg.BotVerificationRequestRateWindow)
}
}
func TestLoadBotVerificationOverrides(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_BOT_VERIFICATION_ENABLED", "false")
t.Setenv("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "0")
t.Setenv("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", "0")
t.Setenv("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", "0s")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.BotVerificationEnabled {
t.Fatal("BotVerificationEnabled = true, want the override honoured")
}
if cfg.BotVerificationMaxPerVerifier != 0 {
t.Fatalf("BotVerificationMaxPerVerifier = %d, want the service bound disabled", cfg.BotVerificationMaxPerVerifier)
}
// A zero limit disables the budget, so a zero window is accepted with it.
if cfg.BotVerificationRequestRateLimit != 0 || cfg.BotVerificationRequestRateWindow != 0 {
t.Fatalf("request rate = %d/%v, want the budget disabled", cfg.BotVerificationRequestRateLimit, cfg.BotVerificationRequestRateWindow)
}
}
func TestLoadRejectsInvalidBotVerificationConfig(t *testing.T) {
for _, test := range []struct {
key string
value string
}{
{"TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "-1"},
{"TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "10001"},
{"TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", "-1"},
{"TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", "-5m"},
// A positive limit with no window is a limiter that never refills.
{"TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", "0s"},
} {
t.Run(test.key+"="+test.value, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
// TestLoadValidatesBotVerificationWhileDisabled pins that the policy is checked
// even with the feature off, so switching it on later is not the moment a typo is
// discovered.
func TestLoadValidatesBotVerificationWhileDisabled(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_BOT_VERIFICATION_ENABLED", "false")
t.Setenv("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", "-3")
if _, err := Load(); err == nil {
t.Fatal("Load accepted a negative per-verifier bound while the feature was disabled")
}
}
func TestLoadAdminRBACDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.AdminUIPermissions) != 1 || cfg.AdminUIPermissions[0] != "*" {
t.Fatalf("AdminUIPermissions = %v, want the wildcard default", cfg.AdminUIPermissions)
}
if len(cfg.AdminScopedTokens) != 0 {
t.Fatalf("AdminScopedTokens = %+v, want none by default", cfg.AdminScopedTokens)
}
}
func TestLoadAdminScopedTokens(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADMIN_UI_PERMISSIONS", "users.read, verification:decide")
t.Setenv("TELESRV_ADMIN_SCOPED_TOKENS", "ops:tok-ops-1:users.read,users.write; audit:tok-audit-2:verification.*")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.AdminUIPermissions) != 2 ||
cfg.AdminUIPermissions[0] != "users.read" || cfg.AdminUIPermissions[1] != "verification:decide" {
t.Fatalf("AdminUIPermissions = %v", cfg.AdminUIPermissions)
}
if len(cfg.AdminScopedTokens) != 2 {
t.Fatalf("AdminScopedTokens = %+v, want 2 entries", cfg.AdminScopedTokens)
}
first := cfg.AdminScopedTokens[0]
if first.Name != "ops" || first.Token != "tok-ops-1" ||
len(first.Permissions) != 2 || first.Permissions[0] != "users.read" || first.Permissions[1] != "users.write" {
t.Fatalf("first scoped token = %+v", first)
}
second := cfg.AdminScopedTokens[1]
if second.Name != "audit" || second.Token != "tok-audit-2" ||
len(second.Permissions) != 1 || second.Permissions[0] != "verification.*" {
t.Fatalf("second scoped token = %+v", second)
}
}
func TestLoadRejectsInvalidAdminRBACConfig(t *testing.T) {
for name, env := range map[string]map[string]string{
"missing permissions field": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-ops-1"},
"too many fields": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok:extra:users.read"},
"empty name": {"TELESRV_ADMIN_SCOPED_TOKENS": ":tok-ops-1:users.read"},
"empty token": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops::users.read"},
"no permissions listed": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-ops-1:"},
"invalid permission": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-ops-1:users read"},
"duplicate name": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-a:users.read;OPS:tok-b:users.read"},
"duplicate token": {"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-a:users.read;audit:tok-a:users.read"},
"reuses the admin api token": {
"TELESRV_ADMIN_API_TOKEN": "tok-a",
"TELESRV_ADMIN_SCOPED_TOKENS": "ops:tok-a:users.read",
},
"invalid ui permission": {"TELESRV_ADMIN_UI_PERMISSIONS": "users/read"},
} {
t.Run(name, func(t *testing.T) {
disableDefaultConfigFile(t)
for key, value := range env {
t.Setenv(key, value)
}
if _, err := Load(); err == nil {
t.Fatalf("Load accepted %v", env)
}
})
}
}
func writeConfigFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {

View file

@ -0,0 +1,398 @@
package domain
import (
"errors"
"time"
)
// Composite account rating.
//
// This is a server-local moderation/operations score for the admin panel. It is
// deliberately not Telegram's Stars Rating: the official field describes Stars
// transaction volume, whereas this model combines Stars, account activity and
// moderation penalties. Projecting it into userFull.stars_rating would give
// official clients a materially false meaning, so the RPC edge keeps those
// fields unset.
const (
// MaxAccountRatingLevel bounds the local admin level.
MaxAccountRatingLevel = 50
// accountRatingLevelUnit is the score required for level 1. Thresholds grow
// quadratically from it: level n needs accountRatingLevelUnit * n^2.
accountRatingLevelUnit = 100
// MaxAccountRatingReasonLength matches the event ledger CHECK on reason.
MaxAccountRatingReasonLength = 512
// MaxAccountRatingActorLength matches the event ledger CHECK on actor.
MaxAccountRatingActorLength = 128
// MaxAccountRatingCommandKeyLength matches the idempotency CHECK.
MaxAccountRatingCommandKeyLength = 128
)
var (
// ErrAccountRatingNotFound reports a user with no rating row yet.
ErrAccountRatingNotFound = errors.New("account rating not found")
// ErrAccountRatingWeightsInvalid rejects a non-sensical weight set.
ErrAccountRatingWeightsInvalid = errors.New("account rating weights invalid")
// ErrAccountRatingAdjustmentInvalid rejects a malformed manual adjustment.
ErrAccountRatingAdjustmentInvalid = errors.New("account rating adjustment invalid")
)
// AccountRatingEventKind is the contribution source of a ledger row. Only
// 'manual' rows survive a full recompute; the rest are audit trail.
type AccountRatingEventKind string
const (
AccountRatingEventStars AccountRatingEventKind = "stars"
AccountRatingEventActivity AccountRatingEventKind = "activity"
AccountRatingEventModeration AccountRatingEventKind = "moderation"
AccountRatingEventManual AccountRatingEventKind = "manual"
AccountRatingEventRecompute AccountRatingEventKind = "recompute"
)
// Valid reports whether the kind is modelled.
func (k AccountRatingEventKind) Valid() bool {
switch k {
case AccountRatingEventStars, AccountRatingEventActivity, AccountRatingEventModeration,
AccountRatingEventManual, AccountRatingEventRecompute:
return true
default:
return false
}
}
// AccountRating is the stored read model for one user.
type AccountRating struct {
UserID int64
Level int
Stars int64
CurrentLevelStars int64
// NextLevelStars is meaningful only when HasNextLevel is true.
NextLevelStars int64
HasNextLevel bool
// Components are the explainable breakdown. PenaltyComponent is a
// non-negative magnitude that is subtracted.
StarsComponent int64
ActivityComponent int64
PenaltyComponent int64
ManualComponent int64
// PendingStars is a score delta not yet applied to the visible level, with
// PendingDate reporting when it becomes effective.
PendingStars int64
PendingDate time.Time
ComputedAt time.Time
UpdatedAt time.Time
Version int64
}
// AccountRatingLevel is the local admin-facing level snapshot.
type AccountRatingLevel struct {
Level int
CurrentLevelStars int64
Stars int64
NextLevelStars int64
HasNextLevelStars bool
}
// RatableAccount reports whether an account may carry a composite rating.
//
// The rating measures what an account did with Stars -- gifts bought, paid
// messages sent, activity, moderation history. Two kinds of account have no
// meaningful answer there and are excluded everywhere the rating is computed,
// seeded or projected:
//
// - Bots. A bot does not buy gifts or send paid messages on its own behalf, so
// its score would only ever be the flat account-age term.
// - The built-in service accounts (the platform account, BotFather, @Stickers,
// @ChatBot, the verification bots). They are infrastructure rather than
// participants: a leaderboard entry for the platform account is noise, and a
// level badge on it would claim something about transaction volume that means
// nothing.
//
// Note that the platform account is not flagged is_bot, so the bot check alone
// does not cover it -- which is exactly how it ended up in the seeding pass.
func RatableAccount(userID int64, bot bool) bool {
return userID > 0 && !bot && !IsSystemUserID(userID)
}
// LevelSnapshot returns the current local admin-facing level.
func (r AccountRating) LevelSnapshot() AccountRatingLevel {
return AccountRatingLevel{
Level: r.Level,
CurrentLevelStars: r.CurrentLevelStars,
Stars: r.Stars,
NextLevelStars: r.NextLevelStars,
HasNextLevelStars: r.HasNextLevel,
}
}
// PendingLevel returns the local level after the pending score is applied and
// reports whether a pending record exists at all.
func (r AccountRating) PendingLevel() (AccountRatingLevel, bool) {
if r.PendingStars == 0 || r.PendingDate.IsZero() {
return AccountRatingLevel{}, false
}
total := r.Stars + r.PendingStars
if total < 0 {
total = 0
}
level, current, next, hasNext := AccountRatingLevelForStars(total)
return AccountRatingLevel{
Level: level,
CurrentLevelStars: current,
Stars: total,
NextLevelStars: next,
HasNextLevelStars: hasNext,
}, true
}
// AccountRatingWeights is the composite formula. All weights are integers so the
// score is exactly reproducible across a recompute and across store backends.
type AccountRatingWeights struct {
// StarsReceivedPermille weighs Stars credited to the account (gifts,
// reactions, paid messages received), in permille of the raw amount.
StarsReceivedPermille int64
// StarsSpentPermille weighs Stars the account spent. Spending is a weaker
// signal than receiving, so the default is lower.
StarsSpentPermille int64
// PerMessageSent rewards sustained use.
PerMessageSent int64
// PerAccountAgeDay rewards account longevity.
PerAccountAgeDay int64
// PerGiftReceived rewards collectible gifts held.
PerGiftReceived int64
// PerModerationCase is the penalty for each upheld moderation case.
PerModerationCase int64
// ScamPenalty and FakePenalty are flat penalties for the peer flags.
ScamPenalty int64
FakePenalty int64
// ActivityCap bounds the activity component so activity alone cannot
// outweigh everything else. Zero means uncapped.
ActivityCap int64
}
// DefaultAccountRatingWeights returns the shipped local policy. Stars dominate,
// activity contributes a bounded floor, and moderation subtracts.
func DefaultAccountRatingWeights() AccountRatingWeights {
return AccountRatingWeights{
StarsReceivedPermille: 1000,
StarsSpentPermille: 250,
PerMessageSent: 1,
PerAccountAgeDay: 2,
PerGiftReceived: 25,
PerModerationCase: 150,
ScamPenalty: 5000,
FakePenalty: 5000,
ActivityCap: 5000,
}
}
// Validate rejects negative weights and an impossible cap.
func (w AccountRatingWeights) Validate() error {
values := []int64{
w.StarsReceivedPermille, w.StarsSpentPermille, w.PerMessageSent,
w.PerAccountAgeDay, w.PerGiftReceived, w.PerModerationCase,
w.ScamPenalty, w.FakePenalty, w.ActivityCap,
}
for _, v := range values {
if v < 0 {
return ErrAccountRatingWeightsInvalid
}
}
return nil
}
// AccountRatingSignals is the raw snapshot gathered from the contributing
// sources for one user. It is deliberately a plain value: the same snapshot must
// produce the same score in a unit test and in production.
type AccountRatingSignals struct {
UserID int64
StarsReceived int64
StarsSpent int64
MessagesSent int64
AccountAgeDays int64
GiftsReceived int64
ModerationCases int64
Scam bool
Fake bool
// Manual is the sum of admin adjustments, carried across recomputes.
Manual int64
}
// ComputeAccountRating turns a signal snapshot into the read model. The score is
// clamped at zero: penalties can erase this local score but never invert it.
func ComputeAccountRating(signals AccountRatingSignals, weights AccountRatingWeights, now time.Time) AccountRating {
if err := weights.Validate(); err != nil {
weights = DefaultAccountRatingWeights()
}
starsComponent := permille(max64(signals.StarsReceived, 0), weights.StarsReceivedPermille) +
permille(max64(signals.StarsSpent, 0), weights.StarsSpentPermille)
activityComponent := max64(signals.MessagesSent, 0)*weights.PerMessageSent +
max64(signals.AccountAgeDays, 0)*weights.PerAccountAgeDay +
max64(signals.GiftsReceived, 0)*weights.PerGiftReceived
if weights.ActivityCap > 0 && activityComponent > weights.ActivityCap {
activityComponent = weights.ActivityCap
}
penalty := max64(signals.ModerationCases, 0) * weights.PerModerationCase
if signals.Scam {
penalty += weights.ScamPenalty
}
if signals.Fake {
penalty += weights.FakePenalty
}
total := starsComponent + activityComponent + signals.Manual - penalty
if total < 0 {
total = 0
}
level, current, next, hasNext := AccountRatingLevelForStars(total)
return AccountRating{
UserID: signals.UserID,
Level: level,
Stars: total,
CurrentLevelStars: current,
NextLevelStars: next,
HasNextLevel: hasNext,
StarsComponent: starsComponent,
ActivityComponent: activityComponent,
PenaltyComponent: penalty,
ManualComponent: signals.Manual,
ComputedAt: now,
UpdatedAt: now,
Version: 1,
}
}
// AccountRatingLevelThreshold returns the score needed to reach the given level.
// Level 0 needs nothing; growth is quadratic so early levels arrive quickly and
// later ones stay meaningful.
func AccountRatingLevelThreshold(level int) int64 {
if level <= 0 {
return 0
}
if level > MaxAccountRatingLevel {
level = MaxAccountRatingLevel
}
n := int64(level)
return accountRatingLevelUnit * n * n
}
// AccountRatingLevelForStars maps a score onto the level and the surrounding
// thresholds. hasNext is false at MaxAccountRatingLevel.
func AccountRatingLevelForStars(stars int64) (level int, currentLevelStars int64, nextLevelStars int64, hasNext bool) {
if stars < 0 {
stars = 0
}
level = 0
for candidate := 1; candidate <= MaxAccountRatingLevel; candidate++ {
if stars < AccountRatingLevelThreshold(candidate) {
break
}
level = candidate
}
currentLevelStars = AccountRatingLevelThreshold(level)
if level >= MaxAccountRatingLevel {
return level, currentLevelStars, 0, false
}
return level, currentLevelStars, AccountRatingLevelThreshold(level + 1), true
}
// ResolveAccountRatingPending decides whether a freshly computed score becomes
// visible immediately or is parked as pending.
//
// A score that dropped is applied at once -- a penalty must not sit behind a
// delay. A score that grew is parked until delay has elapsed; once the parked
// window has passed the pending delta is folded into the visible admin rating.
func ResolveAccountRatingPending(prev, computed AccountRating, delay time.Duration, now time.Time) AccountRating {
out := computed
out.Version = prev.Version + 1
if out.Version <= 0 {
out.Version = 1
}
if delay <= 0 || prev.UserID == 0 {
return out
}
if computed.Stars <= prev.Stars {
return out
}
// A previously parked delta whose date has arrived is applied now.
if prev.PendingStars != 0 && !prev.PendingDate.IsZero() && !now.Before(prev.PendingDate) {
return out
}
pendingSince := prev.PendingDate
if prev.PendingStars == 0 || pendingSince.IsZero() {
pendingSince = now.Add(delay)
}
visible := prev
visible.StarsComponent = computed.StarsComponent
visible.ActivityComponent = computed.ActivityComponent
visible.PenaltyComponent = computed.PenaltyComponent
visible.ManualComponent = computed.ManualComponent
visible.PendingStars = computed.Stars - prev.Stars
visible.PendingDate = pendingSince
visible.ComputedAt = now
visible.UpdatedAt = now
visible.Version = out.Version
return visible
}
// AccountRatingEvent is one contribution ledger row.
type AccountRatingEvent struct {
ID int64
UserID int64
Kind AccountRatingEventKind
Amount int64
Reason string
Actor string
CommandKey string
CreatedAt time.Time
}
// AdjustAccountRatingRequest is an operator adjustment to the manual component.
type AdjustAccountRatingRequest struct {
UserID int64
Amount int64
Reason string
Actor string
CommandKey string
}
// Validate rejects a no-op or oversized adjustment.
func (r AdjustAccountRatingRequest) Validate() error {
if r.UserID <= 0 || r.Amount == 0 {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.Reason) > MaxAccountRatingReasonLength {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.Actor) > MaxAccountRatingActorLength {
return ErrAccountRatingAdjustmentInvalid
}
if len(r.CommandKey) > MaxAccountRatingCommandKeyLength {
return ErrAccountRatingAdjustmentInvalid
}
return nil
}
// AccountRatingFilter bounds an admin listing query.
type AccountRatingFilter struct {
MinLevel int
UserID int64
BeforeID int64
Limit int
}
func permille(value, weight int64) int64 {
if value <= 0 || weight <= 0 {
return 0
}
return value * weight / 1000
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}

Some files were not shown because too many files have changed in this diff Show more