tools: add createuser command for reserving a custom user id

auth.signUp never lets a caller pick a user id (users_id_seq always
assigns it), but users.id is GENERATED BY DEFAULT rather than ALWAYS, so
an explicit id in the INSERT is honored - the same mechanism
ensureOfficialSystemUserWithDB already relies on to seed the built-in
system accounts at fixed ids.

createuser -id N [-phone ...|-email ...] inserts a user row at that id
for local/dev use, refusing (unless -force) a reserved system-account id
or one at/above UserIDSequenceBase where a future organic signup could
collide with it. -email reproduces the real email-signup path exactly:
a synthetic 888-prefixed display phone (domain.NewEmailSignupDisplayPhone,
re-rolled on collision) plus the real address in signup_email, rather
than storing the address in users.phone directly.
This commit is contained in:
Astra 2026-09-13 17:59:39 +01:00
parent eb23afdd39
commit caa4d955e5

153
cmd/createuser/main.go Normal file
View file

@ -0,0 +1,153 @@
// Command createuser inserts a users row with an operator-chosen id, bypassing
// the normal users_id_seq auto-assignment. This works because users.id is
// GENERATED BY DEFAULT AS IDENTITY (not GENERATED ALWAYS) -- an explicit id in
// the INSERT is honored, the same mechanism ensureOfficialSystemUserWithDB
// (internal/store/postgres/message_send.go) already relies on to seed the
// built-in system accounts (ChatBot, BotFather, ...) at their fixed ids.
//
// Normal signup (auth.signUp) never lets a caller pick an id, so this exists
// purely for local/dev tooling -- reserving a specific low id (below
// OfficialSystemUserID=777000, say) for a test account.
//
// Usage:
//
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -phone "15550001234"
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -email "test@example.com"
//
// -phone and -email are mutually exclusive: an email-signup account never
// stores the address in users.phone directly (see internal/domain/emailphone.go)
// -- it gets a synthetic "888"-prefixed display phone instead (the same one
// assignEmailSignupDisplayPhone hands a real email-signup account), with the
// real address recorded separately in signup_email.
//
// Reads TELESRV_POSTGRES_DSN the same way the server does (internal/config).
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"flag"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/config"
"telesrv/internal/domain"
)
// maxEmailSignupPhoneAttempts bounds the display-phone collision-retry loop,
// mirroring internal/app/auth/service.go's own constant of the same name.
const maxEmailSignupPhoneAttempts = 20
func randomInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("rand: %w", err)
}
return int64(binary.LittleEndian.Uint64(b[:])), nil
}
func main() {
id := flag.Int64("id", 0, "user id to create (required)")
firstName := flag.String("first-name", "Test", "first name")
lastName := flag.String("last-name", "", "last name")
username := flag.String("username", "", "username, without @ (optional)")
phone := flag.String("phone", "", "phone number (optional; mutually exclusive with -email)")
email := flag.String("email", "", "email address for an email-signup account (optional; mutually exclusive with -phone)")
force := flag.Bool("force", false, "skip the reserved-id / sequence-collision safety checks")
flag.Parse()
if *id <= 0 {
fmt.Fprintln(os.Stderr, "createuser: -id is required and must be positive")
os.Exit(2)
}
if *phone != "" && *email != "" {
fmt.Fprintln(os.Stderr, "createuser: -phone and -email are mutually exclusive")
os.Exit(2)
}
if !*force {
if domain.IsSystemUserID(*id) {
fmt.Fprintf(os.Stderr, "createuser: %d is a reserved built-in system account id (see internal/domain/system.go) - refusing, pass -force to override\n", *id)
os.Exit(2)
}
if *id >= domain.UserIDSequenceBase {
fmt.Fprintf(os.Stderr, "createuser: %d is >= UserIDSequenceBase (%d) - a future organic signup could eventually collide with it; pass -force to proceed anyway (then consider bumping users_id_seq yourself)\n", *id, domain.UserIDSequenceBase)
os.Exit(2)
}
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: load config: %v\n", err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, cfg.PostgresDSN)
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: connect: %v\n", err)
os.Exit(1)
}
defer pool.Close()
accessHash, err := randomInt64()
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
os.Exit(1)
}
displayPhone := *phone
signupEmail := ""
if *email != "" {
signupEmail = domain.NormalizeEmailForPhone(*email)
displayPhone, err = assignEmailSignupDisplayPhone(ctx, pool)
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
os.Exit(1)
}
}
// phone/username/signup_email all sit under partial unique indexes that
// exclude '', so leaving any of them blank never collides with another
// blank-valued account.
row := pool.QueryRow(ctx, `
INSERT INTO users (id, access_hash, phone, signup_email, first_name, last_name, username, country_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, '')
ON CONFLICT (id) DO NOTHING
RETURNING id`,
*id, accessHash, displayPhone, signupEmail, *firstName, *lastName, *username)
var createdID int64
if err := row.Scan(&createdID); err != nil {
fmt.Fprintf(os.Stderr, "createuser: id %d already exists (or insert failed): %v\n", *id, err)
os.Exit(1)
}
fmt.Printf("created user id=%d access_hash=%d first_name=%q last_name=%q username=%q phone=%q signup_email=%q\n",
createdID, accessHash, *firstName, *lastName, *username, displayPhone, signupEmail)
}
// assignEmailSignupDisplayPhone mirrors internal/app/auth/service.go's method
// of the same name: pick a random "888"-prefixed display phone and re-roll on
// the astronomically unlikely collision with an existing account's phone.
func assignEmailSignupDisplayPhone(ctx context.Context, pool *pgxpool.Pool) (string, error) {
for range maxEmailSignupPhoneAttempts {
candidate, err := domain.NewEmailSignupDisplayPhone(domain.EmailPhonePrefix)
if err != nil {
return "", err
}
var exists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM users WHERE phone = $1)`, candidate).Scan(&exists); err != nil {
return "", fmt.Errorf("check display phone collision: %w", err)
}
if !exists {
return candidate, nil
}
}
return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts)
}