feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+236
View File
@@ -0,0 +1,236 @@
// Package admin owns the platform's administrator records inside the
// `backend.admin_accounts` table together with the Basic Auth verifier
// consumed by `backend/internal/server/middleware/basicauth`.
//
// The package introduces the package on top of the The implementation user surface.
// The previous placeholder verifier
// (`basicauth.StaticVerifier`) is retired from production wiring; the
// admin-account CRUD endpoints under `/api/v1/admin/admin-accounts/*`
// flip from 501 placeholders to real implementations backed by
// `*admin.Service`.
//
// The package is intentionally narrow: it owns its own table, exposes
// a Verifier-shaped surface, and ships an idempotent env-driven
// bootstrap so a fresh deploy can authenticate the first operator
// without manual SQL. Cross-domain admin handlers (users, games,
// runtime, mail, notification, geo) live in their respective module
// packages; this package only owns the credential gate.
package admin
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
// bootstrapBcryptCost is the cost factor used for every admin password
// hash. It matches `ARCHITECTURE.md` §14 and `backend/README.md` §12.
//
// The Stage-5.1 auth code uses `bcrypt.DefaultCost` (10) for one-time
// login codes; admin passwords stay separate at cost 12 so the
// stronger hashing covers reused secrets.
const bootstrapBcryptCost = 12
// pgErrCodeUniqueViolation is the SQLSTATE value emitted by Postgres
// when a UNIQUE constraint is violated. The pgx driver surfaces the
// value on `*pgconn.PgError`. The constant is duplicated from
// `internal/user/user.go` so the two packages stay decoupled.
const pgErrCodeUniqueViolation = "23505"
// Admin is the read-side aggregate served to handlers and the
// in-memory cache. It mirrors the OpenAPI `AdminAccount` schema; the
// password hash is intentionally absent so handlers cannot accidentally
// surface it.
type Admin struct {
Username string
CreatedAt time.Time
LastUsedAt *time.Time
DisabledAt *time.Time
}
// Deps aggregates every collaborator the Service depends on.
// Constructing the Service through Deps (rather than positional args)
// keeps wiring patches small when new dependencies are added.
type Deps struct {
// Store must be non-nil. It owns every Postgres query against
// `backend.admin_accounts`.
Store *Store
// Cache must be non-nil. The Verifier consults it on the request
// path; mutation methods write through after a successful commit.
Cache *Cache
// Logger is named under "admin" by NewService. Nil falls back to
// zap.NewNop.
Logger *zap.Logger
// Now overrides time.Now for deterministic tests. A nil Now defaults
// to time.Now in NewService.
Now func() time.Time
}
// Service is the admin-domain entry point. Concurrency safety is
// delegated to Postgres for persisted state and to the embedded Cache
// for the in-memory projection.
type Service struct {
deps Deps
}
// NewService constructs a Service from deps. A nil Now defaults to
// time.Now; a nil Logger defaults to zap.NewNop. Store and Cache must
// be non-nil — calling Service methods with nil values will panic at
// first use, matching how main.go signals missing wiring.
func NewService(deps Deps) *Service {
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("admin")
return &Service{deps: deps}
}
// CreateInput is the parameter struct for Service.Create.
type CreateInput struct {
Username string
Password string
}
// Validate normalises the request and rejects empty fields.
func (in *CreateInput) Validate() error {
in.Username = strings.TrimSpace(in.Username)
if in.Username == "" {
return fmt.Errorf("%w: username must not be empty", ErrInvalidInput)
}
if in.Password == "" {
return fmt.Errorf("%w: password must not be empty", ErrInvalidInput)
}
return nil
}
// List returns every admin row ordered by username ASC.
func (s *Service) List(ctx context.Context) ([]Admin, error) {
rows, _, err := s.deps.Store.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("admin list: %w", err)
}
return rows, nil
}
// Get returns the admin aggregate for username. Returns ErrNotFound
// when no row matches.
func (s *Service) Get(ctx context.Context, username string) (Admin, error) {
username = strings.TrimSpace(username)
if username == "" {
return Admin{}, ErrNotFound
}
admin, _, err := s.deps.Store.Lookup(ctx, username)
if err != nil {
return Admin{}, err
}
return admin, nil
}
// Create persists a fresh admin row with the bcrypt-hashed password,
// refreshes the in-memory cache, and returns the persisted aggregate.
// Returns ErrUsernameTaken when the username already exists.
func (s *Service) Create(ctx context.Context, in CreateInput) (Admin, error) {
if err := (&in).Validate(); err != nil {
return Admin{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bootstrapBcryptCost)
if err != nil {
return Admin{}, fmt.Errorf("admin create: hash password: %w", err)
}
admin, err := s.deps.Store.Insert(ctx, in.Username, hash)
if err != nil {
if errors.Is(err, ErrUsernameTaken) {
return Admin{}, err
}
return Admin{}, fmt.Errorf("admin create: %w", err)
}
s.deps.Cache.Put(admin, hash)
return admin, nil
}
// Disable sets `disabled_at = now()` when the account is currently
// enabled. The operation is idempotent: when the account is already
// disabled the existing row is returned unchanged. Returns ErrNotFound
// when no row matches.
func (s *Service) Disable(ctx context.Context, username string) (Admin, error) {
username = strings.TrimSpace(username)
if username == "" {
return Admin{}, ErrNotFound
}
now := s.deps.Now().UTC()
admin, hash, err := s.deps.Store.SetDisabledAt(ctx, username, &now)
if err != nil {
return Admin{}, fmt.Errorf("admin disable: %w", err)
}
s.deps.Cache.Put(admin, hash)
return admin, nil
}
// Enable clears `disabled_at` when the account is currently disabled.
// The operation is idempotent: when the account is already enabled the
// existing row is returned unchanged. Returns ErrNotFound when no row
// matches.
func (s *Service) Enable(ctx context.Context, username string) (Admin, error) {
username = strings.TrimSpace(username)
if username == "" {
return Admin{}, ErrNotFound
}
admin, hash, err := s.deps.Store.SetDisabledAt(ctx, username, nil)
if err != nil {
return Admin{}, fmt.Errorf("admin enable: %w", err)
}
s.deps.Cache.Put(admin, hash)
return admin, nil
}
// ResetPassword bcrypt-hashes newPassword and replaces the stored
// password_hash. The new password itself is not returned per the
// OpenAPI contract ("delivered out-of-band").
func (s *Service) ResetPassword(ctx context.Context, username, newPassword string) (Admin, error) {
username = strings.TrimSpace(username)
if username == "" {
return Admin{}, ErrNotFound
}
if newPassword == "" {
return Admin{}, fmt.Errorf("%w: password must not be empty", ErrInvalidInput)
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bootstrapBcryptCost)
if err != nil {
return Admin{}, fmt.Errorf("admin reset password: hash: %w", err)
}
admin, err := s.deps.Store.UpdatePasswordHash(ctx, username, hash)
if err != nil {
return Admin{}, fmt.Errorf("admin reset password: %w", err)
}
s.deps.Cache.Put(admin, hash)
return admin, nil
}
// isUniqueViolation reports whether err is a Postgres UNIQUE
// constraint violation. constraintName may be empty to match any
// UNIQUE violation.
func isUniqueViolation(err error, constraintName string) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
if pgErr.Code != pgErrCodeUniqueViolation {
return false
}
if constraintName == "" {
return true
}
return pgErr.ConstraintName == constraintName
}