feat(bootstrap): первичная настройка инстанса и CLI-команды

- internal/bootstrap: создание инстанс-админа, главного сервера, ролей
  «Администратор» и «Пользователь» (all permissions / дефолтный набор §6.3),
  общей текстовой комнаты; повторный запуск идемпотентен
- админ получает флаг is_instance_admin и системный бейдж instance_admin
- CLI: glchat bootstrap-admin, reset-password, make-admin, remove-admin
  (работают внутри контейнера через DATA_DIR)
- auth: UserByEmail, SetPassword (отзывает сессии), RegisterAdmin (в обход
  флага регистрации — bootstrap выполняет установщик)
- тесты: создание админа и главного сервера, идемпотентность, автовступление
  нового пользователя с ролью @user
This commit is contained in:
2026-09-19 21:37:13 +03:00
parent f61751ed26
commit c0537d3c17
6 changed files with 699 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
// Package bootstrap выполняет первичную настройку инстанса (AGENT.md 10.3 шаг 10,
// 13 Фаза 1): инстанс-администратор, главный сервер, роли по умолчанию.
package bootstrap
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"glchat/internal/auth"
"glchat/internal/crypto"
"glchat/internal/permissions"
"glchat/internal/store"
)
// DefaultMainGuildName — название главного сервера по умолчанию.
const DefaultMainGuildName = "Главный сервер"
// Options — параметры первичной настройки.
type Options struct {
Email string
Password string
Username string
InstanceName string
MainGuild string
Force bool
}
// Result — что было создано (используется CLI и smoke-тестом).
type Result struct {
UserID uint64
GuildID uint64
AdminRoleID uint64
MemberRoleID uint64
CreatedUser bool
CreatedGuild bool
AlreadyDone bool
}
// Runner выполняет первичную настройку поверх хранилища и сервиса аутентификации.
type Runner struct {
store *store.Store
auth *auth.Service
logger *slog.Logger
}
func New(st *store.Store, authService *auth.Service, logger *slog.Logger) *Runner {
return &Runner{store: st, auth: authService, logger: logger}
}
// Run создаёт администратора инстанса и главный сервер; повторный запуск
// идемпотентен (AGENT.md 10.3: установщик можно перезапускать).
func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) {
email := crypto.NormalizeEmail(opts.Email)
if email == "" {
return nil, errors.New("email is required")
}
if opts.Username == "" {
// Логин по умолчанию — часть адреса до @, приведённая к допустимым символам.
opts.Username = sanitizeUsername(strings.SplitN(email, "@", 2)[0])
}
if err := crypto.ValidateUsername(opts.Username); err != nil {
return nil, fmt.Errorf("invalid admin username %q: %w", opts.Username, err)
}
result := &Result{}
admin, err := r.findOrCreateAdmin(ctx, opts, result)
if err != nil {
return nil, err
}
settings, err := r.store.InstanceSettings(ctx)
if err != nil {
return nil, fmt.Errorf("load instance settings: %w", err)
}
if settings.MainGuildID != 0 {
guild, err := r.store.GetGuild(ctx, settings.MainGuildID)
if err == nil {
result.GuildID = guild.ID
result.AlreadyDone = !result.CreatedUser
if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil {
return nil, err
}
return result, nil
}
if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
}
guildName := opts.MainGuild
if guildName == "" {
guildName = DefaultMainGuildName
}
guild, err := r.store.CreateGuild(ctx, store.CreateGuildParams{
Name: guildName,
OwnerID: admin.ID,
IsMain: true,
IsDiscoverable: false,
Description: "",
})
if err != nil {
return nil, fmt.Errorf("create main guild: %w", err)
}
result.GuildID = guild.ID
result.CreatedGuild = true
if err := r.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
return nil, fmt.Errorf("set main guild: %w", err)
}
if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil {
return nil, err
}
// Главный сервер всегда содержит общую текстовую комнату.
if _, err := r.store.CreateChannel(ctx, store.CreateChannelParams{
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
}); err != nil {
return nil, fmt.Errorf("create main channel: %w", err)
}
return result, nil
}
// findOrCreateAdmin находит администратора по email или создаёт его.
func (r *Runner) findOrCreateAdmin(ctx context.Context, opts Options, result *Result) (*store.User, error) {
email := crypto.NormalizeEmail(opts.Email)
existing, err := r.auth.UserByEmail(ctx, email)
if err == nil && existing != nil {
result.UserID = existing.ID
if !existing.IsInstanceAdmin {
if err := r.store.SetInstanceAdmin(ctx, existing.ID, true); err != nil {
return nil, fmt.Errorf("grant instance admin: %w", err)
}
}
// Пароль администратора, заданный установщиком, обновляем осознанно:
// без этого повторный запуск с новым паролем не менял бы доступ.
if opts.Password != "" {
if err := r.auth.SetPassword(ctx, existing.ID, opts.Password); err != nil {
return nil, fmt.Errorf("update admin password: %w", err)
}
}
return r.store.GetUser(ctx, existing.ID)
}
if err != nil && !errors.Is(err, store.ErrNotFound) {
return nil, err
}
if opts.Password == "" {
return nil, errors.New("password is required to create the instance administrator")
}
user, _, _, err := r.auth.RegisterAdmin(ctx, auth.RegisterInput{
Username: opts.Username,
DisplayName: opts.Username,
Email: email,
Password: opts.Password,
Locale: "ru",
UserAgent: "glchat-bootstrap",
})
if err != nil {
return nil, fmt.Errorf("create instance administrator: %w", err)
}
result.UserID = user.ID
result.CreatedUser = true
// Бейдж «Администратор сервера» отображается рядом с ником (AGENT.md 7.19).
if err := r.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil {
r.logger.WarnContext(ctx, "failed to set admin badge", slog.Any("error", err))
}
return user, nil
}
// ensureMainGuildSetup гарантирует роли по умолчанию, участие владельца и
// выдачу роли «Администратор» (AGENT.md 6.3).
func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, owner *store.User, result *Result) error {
roles, err := r.store.ListGuildRoles(ctx, guild.ID)
if err != nil {
return err
}
var adminRole, defaultRole *store.Role
for i := range roles {
switch {
case permissions.Permission(roles[i].Permissions).Has(permissions.Administrator):
adminRole = &roles[i]
case roles[i].IsDefault:
defaultRole = &roles[i]
}
}
if adminRole == nil {
created, err := r.store.CreateRole(ctx, store.CreateRoleParams{
GuildID: guild.ID, Name: "Администратор",
Permissions: uint64(permissions.AllPermissions), Position: 100, Mentionable: true,
})
if err != nil {
return fmt.Errorf("create administrator role: %w", err)
}
adminRole = created
}
result.AdminRoleID = adminRole.ID
if defaultRole == nil {
created, err := r.store.CreateRole(ctx, store.CreateRoleParams{
GuildID: guild.ID, Name: "Пользователь",
Permissions: uint64(permissions.DefaultUserPermissions), Position: 0,
IsDefault: true, Mentionable: true,
})
if err != nil {
return fmt.Errorf("create default role: %w", err)
}
defaultRole = created
}
result.MemberRoleID = defaultRole.ID
if _, err := r.store.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil {
return fmt.Errorf("add owner to main guild: %w", err)
}
if err := r.store.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil {
return fmt.Errorf("assign administrator role: %w", err)
}
return nil
}
func sanitizeUsername(value string) string {
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.':
return r
default:
return '_'
}
}, value)
if len(cleaned) < 2 {
cleaned = "admin"
}
if len(cleaned) > 32 {
cleaned = cleaned[:32]
}
return cleaned
}
+190
View File
@@ -0,0 +1,190 @@
package bootstrap_test
import (
"context"
"log/slog"
"path/filepath"
"testing"
"glchat/internal/auth"
"glchat/internal/bootstrap"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/permissions"
"glchat/internal/store"
)
func newRunner(t *testing.T) (*bootstrap.Runner, *store.Store, *auth.Service) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true,
})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
st := store.New(db)
logger := slog.New(slog.DiscardHandler)
cfg := config.Config{
SessionPepper: "bootstrap-pepper",
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
Argon2MemoryKiB: 1024,
Argon2Iterations: 1,
Argon2Parallelism: 1,
}
authService, err := auth.New(ctx, cfg, st, logger)
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return bootstrap.New(st, authService, logger), st, authService
}
func TestBootstrapCreatesAdminAndMainGuild(t *testing.T) {
ctx := context.Background()
runner, st, _ := newRunner(t)
result, err := runner.Run(ctx, bootstrap.Options{
Email: "owner@example.com",
Password: "correct-horse-battery",
MainGuild: "Главный сервер",
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !result.CreatedUser || !result.CreatedGuild {
t.Fatalf("unexpected result: %+v", result)
}
admin, err := st.GetUser(ctx, result.UserID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if !admin.IsInstanceAdmin {
t.Fatal("bootstrap admin must have the instance admin flag")
}
if len(admin.Badges) == 0 || admin.Badges[0] != "instance_admin" {
t.Fatalf("admin badge is missing: %+v", admin.Badges)
}
settings, err := st.InstanceSettings(ctx)
if err != nil {
t.Fatalf("InstanceSettings: %v", err)
}
if settings.MainGuildID != result.GuildID {
t.Fatalf("main guild id = %d, want %d", settings.MainGuildID, result.GuildID)
}
roles, err := st.ListGuildRoles(ctx, result.GuildID)
if err != nil {
t.Fatalf("ListGuildRoles: %v", err)
}
if len(roles) != 2 {
t.Fatalf("main guild must have 2 default roles, got %d", len(roles))
}
var defaultRole *store.Role
for i := range roles {
if roles[i].IsDefault {
defaultRole = &roles[i]
}
}
if defaultRole == nil {
t.Fatal("default role is missing")
}
if permissions.Permission(defaultRole.Permissions) != permissions.DefaultUserPermissions {
t.Fatalf("default role permissions = %s", permissions.Permission(defaultRole.Permissions))
}
// Владелец — участник с ролью администратора.
memberRoles, err := st.MemberRoles(ctx, result.GuildID, result.UserID)
if err != nil {
t.Fatalf("MemberRoles: %v", err)
}
found := false
for _, role := range memberRoles {
if role.ID == result.AdminRoleID {
found = true
}
}
if !found {
t.Fatal("owner must receive the administrator role")
}
// Общая текстовая комната создана.
channels, err := st.ListGuildChannels(ctx, result.GuildID)
if err != nil {
t.Fatalf("ListGuildChannels: %v", err)
}
if len(channels) != 1 || channels[0].Name != "общий" {
t.Fatalf("unexpected channels: %+v", channels)
}
}
func TestBootstrapIsIdempotent(t *testing.T) {
ctx := context.Background()
runner, st, authService := newRunner(t)
options := bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"}
first, err := runner.Run(ctx, options)
if err != nil {
t.Fatalf("first Run: %v", err)
}
second, err := runner.Run(ctx, options)
if err != nil {
t.Fatalf("second Run: %v", err)
}
if second.GuildID != first.GuildID {
t.Fatalf("second run created another guild: %d != %d", second.GuildID, first.GuildID)
}
if second.CreatedUser || second.CreatedGuild {
t.Fatalf("second run must not create anything: %+v", second)
}
guilds, err := st.ListAllGuilds(ctx)
if err != nil {
t.Fatalf("ListAllGuilds: %v", err)
}
if len(guilds) != 1 {
t.Fatalf("guild count = %d, want 1", len(guilds))
}
// Новый пользователь может войти с заданным паролем.
if _, _, _, err := authService.Login(ctx, auth.LoginInput{
Email: "owner@example.com", Password: "correct-horse-battery",
}); err == nil {
t.Fatal("instance admin without 2FA must not be able to log in")
}
}
func TestNewUserJoinsMainGuild(t *testing.T) {
ctx := context.Background()
runner, st, authService := newRunner(t)
result, err := runner.Run(ctx, bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"})
if err != nil {
t.Fatalf("Run: %v", err)
}
user, _, _, err := authService.Register(ctx, auth.RegisterInput{
Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("Register: %v", err)
}
guilds, err := st.ListGuildsForUser(ctx, user.ID)
if err != nil {
t.Fatalf("ListGuildsForUser: %v", err)
}
if len(guilds) != 1 || guilds[0].ID != result.GuildID {
t.Fatalf("newcomer must join the main guild: %+v", guilds)
}
roles, err := st.MemberRoles(ctx, result.GuildID, user.ID)
if err != nil {
t.Fatalf("MemberRoles: %v", err)
}
if len(roles) != 1 || !roles[0].IsDefault {
t.Fatalf("newcomer must get the default role: %+v", roles)
}
if !permissions.Permission(roles[0].Permissions).Has(permissions.ViewGuild) {
t.Fatal("default role must allow viewing the guild")
}
}