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:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
99
.env.example
99
.env.example
|
|
@ -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
|
||||
|
|
|
|||
569
cmd/telesrv-admin/botverification.go
Normal file
569
cmd/telesrv-admin/botverification.go
Normal 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)
|
||||
}
|
||||
559
cmd/telesrv-admin/botverification_test.go
Normal file
559
cmd/telesrv-admin/botverification_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
146
cmd/telesrv-admin/readstore_accounts_integration_test.go
Normal file
146
cmd/telesrv-admin/readstore_accounts_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
571
cmd/telesrv-admin/readstore_botverification_integration_test.go
Normal file
571
cmd/telesrv-admin/readstore_botverification_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
354
cmd/telesrv-admin/readstore_verification_integration_test.go
Normal file
354
cmd/telesrv-admin/readstore_verification_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
210
cmd/telesrv-admin/security.go
Normal file
210
cmd/telesrv-admin/security.go
Normal 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{}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
262
cmd/telesrv-admin/verification.go
Normal file
262
cmd/telesrv-admin/verification.go
Normal 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)
|
||||
}
|
||||
690
cmd/telesrv-admin/verification_test.go
Normal file
690
cmd/telesrv-admin/verification_test.go
Normal 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
9
cmd/telesrv-admin/web/dist/assets/index-D_BLAfeq.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-D_BLAfeq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-KZOn7Xwd.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-KZOn7Xwd.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
14
cmd/telesrv-admin/web/package-lock.json
generated
14
cmd/telesrv-admin/web/package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}`),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,]+/)
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
261
cmd/telesrv-admin/web/src/pages/AccountRatingDetailPage.tsx
Normal file
261
cmd/telesrv-admin/web/src/pages/AccountRatingDetailPage.tsx
Normal 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>;
|
||||
}
|
||||
167
cmd/telesrv-admin/web/src/pages/AccountRatingsPage.tsx
Normal file
167
cmd/telesrv-admin/web/src/pages/AccountRatingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
965
cmd/telesrv-admin/web/src/pages/BotVerificationPage.tsx
Normal file
965
cmd/telesrv-admin/web/src/pages/BotVerificationPage.tsx
Normal 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}`;
|
||||
}
|
||||
360
cmd/telesrv-admin/web/src/pages/BotVerificationRequestPage.tsx
Normal file
360
cmd/telesrv-admin/web/src/pages/BotVerificationRequestPage.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
309
cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
Normal file
309
cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
412
cmd/telesrv-admin/web/src/pages/VerificationDetailPage.tsx
Normal file
412
cmd/telesrv-admin/web/src/pages/VerificationDetailPage.tsx
Normal 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>;
|
||||
}
|
||||
232
cmd/telesrv-admin/web/src/pages/VerificationPage.tsx
Normal file
232
cmd/telesrv-admin/web/src/pages/VerificationPage.tsx
Normal 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}`;
|
||||
}
|
||||
75
cmd/telesrv-admin/web/src/permissions.tsx
Normal file
75
cmd/telesrv-admin/web/src/permissions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
45
deploy/migrations/0151_collectible_usernames.down.sql
Normal file
45
deploy/migrations/0151_collectible_usernames.down.sql
Normal 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;
|
||||
194
deploy/migrations/0151_collectible_usernames.up.sql
Normal file
194
deploy/migrations/0151_collectible_usernames.up.sql
Normal 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;
|
||||
$$;
|
||||
3
deploy/migrations/0152_account_rating.down.sql
Normal file
3
deploy/migrations/0152_account_rating.down.sql
Normal 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;
|
||||
73
deploy/migrations/0152_account_rating.up.sql
Normal file
73
deploy/migrations/0152_account_rating.up.sql
Normal 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');
|
||||
9
deploy/migrations/0153_verify_service_bot.down.sql
Normal file
9
deploy/migrations/0153_verify_service_bot.down.sql
Normal 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;
|
||||
89
deploy/migrations/0153_verify_service_bot.up.sql
Normal file
89
deploy/migrations/0153_verify_service_bot.up.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
138
deploy/migrations/0154_verification_applications.up.sql
Normal file
138
deploy/migrations/0154_verification_applications.up.sql
Normal 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;
|
||||
4
deploy/migrations/0155_bot_verification.down.sql
Normal file
4
deploy/migrations/0155_bot_verification.down.sql
Normal 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;
|
||||
140
deploy/migrations/0155_bot_verification.up.sql
Normal file
140
deploy/migrations/0155_bot_verification.up.sql
Normal 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);
|
||||
12
deploy/migrations/0156_verifier_service_bot.down.sql
Normal file
12
deploy/migrations/0156_verifier_service_bot.down.sql
Normal 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;
|
||||
103
deploy/migrations/0156_verifier_service_bot.up.sql
Normal file
103
deploy/migrations/0156_verifier_service_bot.up.sql
Normal 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;
|
||||
23
deploy/migrations/0157_custom_emoji_reactions.down.sql
Normal file
23
deploy/migrations/0157_custom_emoji_reactions.down.sql
Normal 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);
|
||||
28
deploy/migrations/0157_custom_emoji_reactions.up.sql
Normal file
28
deploy/migrations/0157_custom_emoji_reactions.up.sql
Normal 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]));
|
||||
|
|
@ -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;
|
||||
20
deploy/migrations/0158_drop_service_account_ratings.up.sql
Normal file
20
deploy/migrations/0158_drop_service_account_ratings.up.sql
Normal 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
359
docs/bot_verification.md
Normal 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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 nanotons,XTR 无子单位。后台 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 cap,storage 硬上限仍为 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
291
docs/verification.md
Normal 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.
|
||||
994
internal/admin/botverification.go
Normal file
994
internal/admin/botverification.go
Normal 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))
|
||||
}
|
||||
1045
internal/admin/botverification_test.go
Normal file
1045
internal/admin/botverification_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
529
internal/admin/verification.go
Normal file
529
internal/admin/verification.go
Normal 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))
|
||||
}
|
||||
629
internal/admin/verification_test.go
Normal file
629
internal/admin/verification_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
561
internal/adminapi/botverification.go
Normal file
561
internal/adminapi/botverification.go
Normal 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)
|
||||
}
|
||||
826
internal/adminapi/botverification_test.go
Normal file
826
internal/adminapi/botverification_test.go
Normal 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
196
internal/adminapi/rbac.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
361
internal/adminapi/verification.go
Normal file
361
internal/adminapi/verification.go
Normal 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)
|
||||
}
|
||||
546
internal/adminapi/verification_test.go
Normal file
546
internal/adminapi/verification_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 报告该收件人是否为内置应答 bot(messages.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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 层实现):
|
||||
// - RevokeBotSessions:token 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{
|
||||
|
|
|
|||
1626
internal/app/bots/verifierbot.go
Normal file
1626
internal/app/bots/verifierbot.go
Normal file
File diff suppressed because it is too large
Load diff
1012
internal/app/bots/verifierbot_test.go
Normal file
1012
internal/app/bots/verifierbot_test.go
Normal file
File diff suppressed because it is too large
Load diff
1493
internal/app/bots/verifybot.go
Normal file
1493
internal/app/bots/verifybot.go
Normal file
File diff suppressed because it is too large
Load diff
984
internal/app/bots/verifybot_test.go
Normal file
984
internal/app/bots/verifybot_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
1527
internal/app/botverification/service.go
Normal file
1527
internal/app/botverification/service.go
Normal file
File diff suppressed because it is too large
Load diff
1539
internal/app/botverification/service_test.go
Normal file
1539
internal/app/botverification/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
453
internal/app/rating/service.go
Normal file
453
internal/app/rating/service.go
Normal 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
|
||||
}
|
||||
719
internal/app/rating/service_test.go
Normal file
719
internal/app/rating/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
91
internal/app/rating/worker.go
Normal file
91
internal/app/rating/worker.go
Normal 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))
|
||||
}
|
||||
}
|
||||
552
internal/app/usernames/service.go
Normal file
552
internal/app/usernames/service.go
Normal 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
|
||||
}
|
||||
734
internal/app/usernames/service_test.go
Normal file
734
internal/app/usernames/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1422
internal/app/verification/service.go
Normal file
1422
internal/app/verification/service.go
Normal file
File diff suppressed because it is too large
Load diff
1416
internal/app/verification/service_test.go
Normal file
1416
internal/app/verification/service_test.go
Normal file
File diff suppressed because it is too large
Load diff
88
internal/app/verification/worker.go
Normal file
88
internal/app/verification/worker.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
398
internal/domain/account_rating.go
Normal file
398
internal/domain/account_rating.go
Normal 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
Loading…
Add table
Add a link
Reference in a new issue