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

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

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

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

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

View file

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

View file

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