From d1ba6664957eb1ad8e87bf3430450bfe27c48e60 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 21:55:59 +0200 Subject: [PATCH 1/2] feat(admin): manual account blocking (suspensions) Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint. Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB). --- PRERELEASE.md | 1 + backend/internal/account/suspension.go | 268 ++++++++++++++++++ .../adminconsole/templates/layout.gohtml | 1 + .../templates/pages/reasons.gohtml | 27 ++ .../templates/pages/user_detail.gohtml | 29 ++ backend/internal/adminconsole/views.go | 36 +++ backend/internal/game/service.go | 62 ++++ backend/internal/game/store.go | 20 ++ backend/internal/inttest/forfeit_test.go | 123 ++++++++ .../inttest/suspension_console_test.go | 121 ++++++++ .../internal/inttest/suspension_gate_test.go | 151 ++++++++++ backend/internal/inttest/suspension_test.go | 209 ++++++++++++++ .../jet/backend/model/account_suspensions.go | 24 ++ .../jet/backend/model/suspension_reasons.go | 21 ++ .../jet/backend/table/account_suspensions.go | 99 +++++++ .../jet/backend/table/suspension_reasons.go | 90 ++++++ .../jet/backend/table/table_use_schema.go | 2 + .../migrations/00003_account_suspensions.sql | 45 +++ backend/internal/server/handlers.go | 3 + backend/internal/server/handlers_account.go | 42 +++ .../internal/server/handlers_admin_console.go | 171 +++++++++++ backend/internal/server/middleware.go | 37 +++ backend/internal/server/server.go | 3 + docs/ARCHITECTURE.md | 20 ++ docs/FUNCTIONAL.md | 12 + docs/FUNCTIONAL_ru.md | 13 + gateway/internal/backendclient/api.go | 19 ++ gateway/internal/transcode/encode.go | 14 + gateway/internal/transcode/transcode.go | 12 + .../transcode/transcode_block_test.go | 64 +++++ pkg/fbs/scrabble.fbs | 12 + pkg/fbs/scrabblefb/BlockStatus.go | 101 +++++++ ui/e2e/blocked.spec.ts | 42 +++ ui/src/App.svelte | 3 + ui/src/gen/fbs/scrabblefb.ts | 1 + ui/src/gen/fbs/scrabblefb/block-status.ts | 80 ++++++ ui/src/lib/app.svelte.ts | 45 ++- ui/src/lib/blocked.test.ts | 27 ++ ui/src/lib/blocked.ts | 27 ++ ui/src/lib/client.ts | 4 + ui/src/lib/codec.test.ts | 33 +++ ui/src/lib/codec.ts | 11 + ui/src/lib/i18n/en.ts | 5 + ui/src/lib/i18n/ru.ts | 5 + ui/src/lib/mock/client.ts | 5 + ui/src/lib/model.ts | 12 + ui/src/lib/transport.ts | 3 + ui/src/screens/Blocked.svelte | 53 ++++ 48 files changed, 2206 insertions(+), 2 deletions(-) create mode 100644 backend/internal/account/suspension.go create mode 100644 backend/internal/adminconsole/templates/pages/reasons.gohtml create mode 100644 backend/internal/inttest/forfeit_test.go create mode 100644 backend/internal/inttest/suspension_console_test.go create mode 100644 backend/internal/inttest/suspension_gate_test.go create mode 100644 backend/internal/inttest/suspension_test.go create mode 100644 backend/internal/postgres/jet/backend/model/account_suspensions.go create mode 100644 backend/internal/postgres/jet/backend/model/suspension_reasons.go create mode 100644 backend/internal/postgres/jet/backend/table/account_suspensions.go create mode 100644 backend/internal/postgres/jet/backend/table/suspension_reasons.go create mode 100644 backend/internal/postgres/migrations/00003_account_suspensions.sql create mode 100644 gateway/internal/transcode/transcode_block_test.go create mode 100644 pkg/fbs/scrabblefb/BlockStatus.go create mode 100644 ui/e2e/blocked.spec.ts create mode 100644 ui/src/gen/fbs/scrabblefb/block-status.ts create mode 100644 ui/src/lib/blocked.test.ts create mode 100644 ui/src/lib/blocked.ts create mode 100644 ui/src/screens/Blocked.svelte diff --git a/PRERELEASE.md b/PRERELEASE.md index 08ac29f..78b1345 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -30,6 +30,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** | | OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** | | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | +| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/internal/account/suspension.go b/backend/internal/account/suspension.go new file mode 100644 index 0000000..e1e2039 --- /dev/null +++ b/backend/internal/account/suspension.go @@ -0,0 +1,268 @@ +package account + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Suspension is an account's currently-in-force manual block, the operator's hard counterpart +// to the soft, reversible FlaggedHighRateAt marker. It is named "suspension" to stay distinct +// from the peer-to-peer blocks in internal/social (one player muting another); the vocabulary +// the player sees is "blocked". BlockedUntil is nil for a permanent block and the expiry +// instant for a temporary one. ReasonEn and ReasonRu are the reason-text snapshot taken at +// block time (both empty when no reason was cited), so editing or deleting the picklist entry +// never changes what an already-blocked player is shown. +type Suspension struct { + AccountID uuid.UUID + BlockedAt time.Time + BlockedUntil *time.Time + ReasonEn string + ReasonRu string +} + +// Permanent reports whether the suspension has no expiry. +func (s Suspension) Permanent() bool { return s.BlockedUntil == nil } + +// LocalizedReason returns the reason text in the given language ("ru" selects Russian, anything +// else English), or empty when no reason was cited. The two snapshots are always both set or +// both empty, since a reason is chosen from the en+ru picklist. +func (s Suspension) LocalizedReason(language string) string { + if language == "ru" { + return s.ReasonRu + } + return s.ReasonEn +} + +// Reason is one entry of the operator-editable suspension-reason picklist, carrying the English +// and Russian text shown to a blocked player in their language. +type Reason struct { + ID uuid.UUID + TextEn string + TextRu string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Suspend records a new manual block on the account: permanent when until is nil, otherwise in +// force until that instant. reasonEn and reasonRu are the optional reason-text snapshot (both +// empty for no reason) and reasonID is the loose picklist link (nil when none, nulled later if +// that entry is deleted). It returns the persisted Suspension. Suspending an already-blocked +// account simply appends another block; CurrentSuspension always reflects the strongest. +func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Time, reasonEn, reasonRu string, reasonID *uuid.UUID) (Suspension, error) { + suspensionID, err := uuid.NewV7() + if err != nil { + return Suspension{}, fmt.Errorf("account: new suspension id: %w", err) + } + stmt := table.AccountSuspensions.INSERT( + table.AccountSuspensions.SuspensionID, + table.AccountSuspensions.AccountID, + table.AccountSuspensions.BlockedUntil, + table.AccountSuspensions.ReasonEn, + table.AccountSuspensions.ReasonRu, + table.AccountSuspensions.ReasonID, + ).VALUES( + suspensionID, + accountID, + nullableTimestamp(until), + nullableString(reasonEn), + nullableString(reasonRu), + nullableUUID(reasonID), + ).RETURNING(table.AccountSuspensions.AllColumns) + + var row model.AccountSuspensions + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err) + } + return modelToSuspension(row), nil +} + +// LiftSuspension lifts every in-force block on the account (the operator's manual unblock), +// stamping lifted_at on each. It is a no-op when the account is not currently blocked. Lifting +// does not un-resign games already forfeited at block time — those stay lost. +func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error { + now := time.Now().UTC() + stmt := table.AccountSuspensions. + UPDATE(table.AccountSuspensions.LiftedAt). + SET(postgres.TimestampzT(now)). + WHERE( + table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)). + AND(activeSuspensionPredicate(now)), + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: lift suspension %s: %w", accountID, err) + } + return nil +} + +// CurrentSuspension returns the account's in-force block and true, or false when the account is +// not blocked. When several blocks overlap it returns the strongest — a permanent one first, +// otherwise the latest-expiring — which is what the player's blocked screen shows. The gate +// middleware calls it on every authenticated request, served by account_suspensions_account_idx. +func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Suspension, bool, error) { + // A store with no database (the zero-value store used by the routing unit tests) has no + // suspensions; report not-blocked rather than dereferencing a nil pool. A real pool that + // errors still surfaces the error to the gate, which fails closed. + if s.db == nil { + return Suspension{}, false, nil + } + now := time.Now().UTC() + stmt := postgres.SELECT(table.AccountSuspensions.AllColumns). + FROM(table.AccountSuspensions). + WHERE( + table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)). + AND(activeSuspensionPredicate(now)), + ). + ORDER_BY(table.AccountSuspensions.BlockedUntil.DESC().NULLS_FIRST()). + LIMIT(1) + + var row model.AccountSuspensions + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Suspension{}, false, nil + } + return Suspension{}, false, fmt.Errorf("account: current suspension %s: %w", accountID, err) + } + return modelToSuspension(row), true, nil +} + +// activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and +// either permanent or not yet expired. +func activeSuspensionPredicate(now time.Time) postgres.BoolExpression { + return table.AccountSuspensions.LiftedAt.IS_NULL(). + AND( + table.AccountSuspensions.BlockedUntil.IS_NULL(). + OR(table.AccountSuspensions.BlockedUntil.GT(postgres.TimestampzT(now))), + ) +} + +// ListReasons returns the suspension-reason picklist, oldest first. +func (s *Store) ListReasons(ctx context.Context) ([]Reason, error) { + stmt := postgres.SELECT(table.SuspensionReasons.AllColumns). + FROM(table.SuspensionReasons). + ORDER_BY(table.SuspensionReasons.CreatedAt.ASC()) + var rows []model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("account: list reasons: %w", err) + } + out := make([]Reason, 0, len(rows)) + for _, r := range rows { + out = append(out, modelToReason(r)) + } + return out, nil +} + +// GetReason loads one picklist entry, or ErrNotFound when it is absent. +func (s *Store) GetReason(ctx context.Context, id uuid.UUID) (Reason, error) { + stmt := postgres.SELECT(table.SuspensionReasons.AllColumns). + FROM(table.SuspensionReasons). + WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Reason{}, ErrNotFound + } + return Reason{}, fmt.Errorf("account: get reason %s: %w", id, err) + } + return modelToReason(row), nil +} + +// CreateReason inserts a new picklist entry with the given English and Russian text. +func (s *Store) CreateReason(ctx context.Context, textEn, textRu string) (Reason, error) { + id, err := uuid.NewV7() + if err != nil { + return Reason{}, fmt.Errorf("account: new reason id: %w", err) + } + stmt := table.SuspensionReasons.INSERT( + table.SuspensionReasons.ReasonID, + table.SuspensionReasons.TextEn, + table.SuspensionReasons.TextRu, + ).VALUES(id, textEn, textRu). + RETURNING(table.SuspensionReasons.AllColumns) + var row model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Reason{}, fmt.Errorf("account: create reason: %w", err) + } + return modelToReason(row), nil +} + +// UpdateReason rewrites a picklist entry's English and Russian text, returning ErrNotFound when +// it is absent. Existing suspensions keep their text snapshot, so the change only affects future +// blocks. +func (s *Store) UpdateReason(ctx context.Context, id uuid.UUID, textEn, textRu string) (Reason, error) { + stmt := table.SuspensionReasons. + UPDATE(table.SuspensionReasons.TextEn, table.SuspensionReasons.TextRu, table.SuspensionReasons.UpdatedAt). + SET(postgres.String(textEn), postgres.String(textRu), postgres.TimestampzT(time.Now().UTC())). + WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))). + RETURNING(table.SuspensionReasons.AllColumns) + var row model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Reason{}, ErrNotFound + } + return Reason{}, fmt.Errorf("account: update reason %s: %w", id, err) + } + return modelToReason(row), nil +} + +// DeleteReason removes a picklist entry. It is a hard delete: the reason_id link on past +// suspensions is nulled by the foreign key, but their text snapshot is untouched. +func (s *Store) DeleteReason(ctx context.Context, id uuid.UUID) error { + stmt := table.SuspensionReasons. + DELETE(). + WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: delete reason %s: %w", id, err) + } + return nil +} + +// modelToSuspension projects a generated row into the public Suspension struct. +func modelToSuspension(row model.AccountSuspensions) Suspension { + s := Suspension{AccountID: row.AccountID, BlockedAt: row.BlockedAt, BlockedUntil: row.BlockedUntil} + if row.ReasonEn != nil { + s.ReasonEn = *row.ReasonEn + } + if row.ReasonRu != nil { + s.ReasonRu = *row.ReasonRu + } + return s +} + +// modelToReason projects a generated row into the public Reason struct. +func modelToReason(row model.SuspensionReasons) Reason { + return Reason{ID: row.ReasonID, TextEn: row.TextEn, TextRu: row.TextRu, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} +} + +// nullableString renders an empty string as SQL NULL, otherwise the string literal. +func nullableString(v string) postgres.Expression { + if v == "" { + return postgres.NULL + } + return postgres.String(v) +} + +// nullableTimestamp renders a nil time as SQL NULL, otherwise the UTC timestamp literal. +func nullableTimestamp(v *time.Time) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.TimestampzT(v.UTC()) +} + +// nullableUUID renders a nil id as SQL NULL, otherwise the uuid literal. +func nullableUUID(v *uuid.UUID) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.UUID(*v) +} diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 14e09d9..770ac90 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -18,6 +18,7 @@ Complaints Messages Throttled + Reasons Dictionary Broadcast Grafana ↗ diff --git a/backend/internal/adminconsole/templates/pages/reasons.gohtml b/backend/internal/adminconsole/templates/pages/reasons.gohtml new file mode 100644 index 0000000..844dabb --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/reasons.gohtml @@ -0,0 +1,27 @@ +{{define "content" -}} +

Suspension reasons

+{{with .Data}} +

Reasons offered when blocking a user; each is shown to the blocked player in their own language. Editing or deleting a reason does not change a reason already shown to a currently-blocked player (blocks snapshot the text).

+

Add reason

+
+ + +
+
+
+

Existing reasons

+{{range .Items}} +
+
+ + + +
+
+ +
+
+{{else}}

no reasons yet

{{end}} +
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index f4396d1..89a324e 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -34,6 +34,35 @@ {{else}}

no statistics

{{end}} +

Account block

+{{$uid := .ID}} +{{with .Suspension}}{{if .Blocked}} + +
+ +
+{{else}}

not blocked

{{end}}{{end}} +
+ + + +
+
+
{{if .MoveChart}}

Move timing

Think time per move number across all games — min · mean · max.

diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index e59d46c..036a6d5 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -135,6 +135,29 @@ type UserDetailView struct { // MoveChart is the pre-rendered inline SVG of the account's per-move-number think // time (min/mean/max), empty when the account has no timed move. MoveChart template.HTML + // Suspension is the account's current manual-block state, shown with the block/unblock + // form; Reasons is the operator-editable reason picklist offered in the block form. + Suspension SuspensionView + Reasons []ReasonOption +} + +// SuspensionView is an account's current manual-block state shown on the user card: whether it +// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent), +// when it was applied, and the reason snapshot in both languages (empty when none was cited). +type SuspensionView struct { + Blocked bool + Permanent bool + Until string + BlockedAt string + ReasonEn string + ReasonRu string +} + +// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown. +type ReasonOption struct { + ID string + TextEn string + TextRu string } // StatsRow is an account's lifetime statistics. @@ -317,6 +340,19 @@ type FlaggedAccountRow struct { FlaggedAt string } +// ReasonsView is the suspension-reason picklist management page: every editable reason entry. +type ReasonsView struct { + Items []ReasonRow +} + +// ReasonRow is one editable suspension-reason entry, with its English and Russian text. +type ReasonRow struct { + ID string + TextEn string + TextRu string + CreatedAt string +} + // MessageView is the result page shown after a POST action. type MessageView struct { Heading string diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 026cc5b..e69757e 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -378,6 +378,68 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo return MoveResult{Move: rec, Game: post}, nil } +// ForfeitAllForAccount resigns every game the account is currently playing and cancels every game +// it has opened but no opponent has joined, returning how many it acted on. It is the game-side +// effect of an admin block: the blocked player instantly loses each live game (the opponent +// winning, exactly as a manual resignation) and leaves matchmaking so nobody joins a doomed game. +// It reuses the per-game resignation path (its lock, commit and live events), so it is safe to +// call while play continues and is idempotent — an already-resigned or finished game is skipped. +// A per-game failure is logged and skipped so one bad game does not strand the rest; the +// turn-timeout sweeper remains the backstop for anything missed in a race. +func (svc *Service) ForfeitAllForAccount(ctx context.Context, accountID uuid.UUID) (int, error) { + games, err := svc.store.ListGamesForAccount(ctx, accountID) + if err != nil { + return 0, err + } + count := 0 + for _, g := range games { + if g.Status != StatusActive && g.Status != StatusOpen { + continue // a finished game holds no turn to forfeit + } + acted, err := svc.forfeitOne(ctx, g.ID, accountID) + if err != nil { + svc.log.Warn("forfeit on block", zap.String("game", g.ID.String()), zap.Error(err)) + continue + } + if acted { + count++ + } + } + return count, nil +} + +// forfeitOne resigns one game on behalf of accountID, or cancels it when it is an open auto-match +// game still awaiting an opponent. It reports whether it changed the game. A game that has already +// finished, or in which the account no longer holds a seat, is a benign no-op. +func (svc *Service) forfeitOne(ctx context.Context, gameID, accountID uuid.UUID) (bool, error) { + _, err := svc.Resign(ctx, gameID, accountID) + switch { + case err == nil: + return true, nil + case errors.Is(err, ErrNoOpponentYet): + // An open game with no opponent cannot be resigned (there is no one to award the win), so + // delete it to clear it from the matchmaking pool. If it filled in the race window it is + // now active, so resign the seat instead. + deleted, derr := svc.store.DeleteOpenGame(ctx, gameID) + if derr != nil { + return false, derr + } + if deleted { + svc.cache.remove(gameID) + return true, nil + } + if _, err := svc.Resign(ctx, gameID, accountID); err != nil && + !errors.Is(err, ErrFinished) && !errors.Is(err, ErrNoOpponentYet) { + return false, err + } + return true, nil + case errors.Is(err, ErrFinished), errors.Is(err, ErrNotAPlayer), errors.Is(err, ErrNotFound): + return false, nil + default: + return false, err + } +} + // GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet // indices to concrete letters before delegating to the letter-based play, exchange and // word-check methods, keeping a single domain path shared with the robot. diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 4db9f3c..d5ab8e2 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -354,6 +354,26 @@ func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, err return len(rows) > 0, nil } +// DeleteOpenGame removes a game only while it is still open (an auto-match game awaiting an +// opponent), reporting whether a row was deleted. The starter's lone seat and any draft cascade +// away with it. It no-ops (false) once the game has filled and become active, so a caller can +// fall back to resigning the now-active seat. Used to clear a blocked player from matchmaking. +func (s *Store) DeleteOpenGame(ctx context.Context, gameID uuid.UUID) (bool, error) { + stmt := table.Games.DELETE().WHERE( + table.Games.GameID.EQ(postgres.UUID(gameID)). + AND(table.Games.Status.EQ(postgres.String(StatusOpen))), + ) + res, err := stmt.ExecContext(ctx, s.db) + if err != nil { + return false, fmt.Errorf("game: delete open game %s: %w", gameID, err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("game: delete open game rows %s: %w", gameID, err) + } + return n > 0, nil +} + // ListGamesForAccount loads every game the account is seated in (active and // finished), newest first, each joined with its ordered seats. It backs the lobby's // "my games" lists. diff --git a/backend/internal/inttest/forfeit_test.go b/backend/internal/inttest/forfeit_test.go new file mode 100644 index 0000000..e5720f6 --- /dev/null +++ b/backend/internal/inttest/forfeit_test.go @@ -0,0 +1,123 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/game" +) + +// TestForfeitAllResignsActiveGame checks the game-side effect of a block on a two-player game: +// ForfeitAllForAccount resigns the blocked player, finishing the game with the opponent winning. +func TestForfeitAllResignsActiveGame(t *testing.T) { + ctx := context.Background() + svc := newGameService() + gid, seats := newGameWithSeats(t, 2) // seats[0] = blocked, seats[1] = opponent + + n, err := svc.ForfeitAllForAccount(ctx, seats[0]) + if err != nil { + t.Fatalf("forfeit all: %v", err) + } + if n < 1 { + t.Fatalf("forfeit count = %d, want at least 1", n) + } + + g, err := svc.GameByID(ctx, gid) + if err != nil { + t.Fatalf("get game: %v", err) + } + if g.Status != game.StatusFinished { + t.Errorf("status = %q, want finished", g.Status) + } + if w := seatWinner(g, seats[0]); w { + t.Error("the blocked player must not be the winner") + } + if w := seatWinner(g, seats[1]); !w { + t.Error("the opponent must win when the blocked player forfeits") + } +} + +// TestForfeitAllCancelsOpenGame checks that a block deletes the blocked player's open auto-match +// game (no opponent to resign against), clearing it from the matchmaking pool. +func TestForfeitAllCancelsOpenGame(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + starter := provisionAccount(t) + g := openGame(t, svc, starter, evenOpeningSeed(t)) + + n, err := svc.ForfeitAllForAccount(ctx, starter) + if err != nil { + t.Fatalf("forfeit all: %v", err) + } + if n < 1 { + t.Fatalf("forfeit count = %d, want at least 1", n) + } + if c := gameRowCount(t, g.ID); c != 0 { + t.Errorf("open game rows after forfeit = %d, want 0 (deleted)", c) + } +} + +// TestForfeitAllResignsSeatInMultiplayerGame checks that in a 3-player game the blocked player's +// seat is resigned while the game continues for the remaining players. +func TestForfeitAllResignsSeatInMultiplayerGame(t *testing.T) { + ctx := context.Background() + svc := newGameService() + gid, seats := newGameWithSeats(t, 3) + + n, err := svc.ForfeitAllForAccount(ctx, seats[0]) + if err != nil { + t.Fatalf("forfeit all: %v", err) + } + if n < 1 { + t.Fatalf("forfeit count = %d, want at least 1", n) + } + + g, err := svc.GameByID(ctx, gid) + if err != nil { + t.Fatalf("get game: %v", err) + } + if g.Status != game.StatusActive { + t.Errorf("status = %q, want still active (two seats remain)", g.Status) + } + if c := resignMoveCount(t, gid, 0); c != 1 { + t.Errorf("resign moves for seat 0 = %d, want 1", c) + } +} + +// seatWinner reports the is_winner flag for the given account's seat in g. +func seatWinner(g game.Game, accountID uuid.UUID) bool { + for _, s := range g.Seats { + if s.AccountID == accountID { + return s.IsWinner + } + } + return false +} + +// gameRowCount counts the rows in the games table for gameID (0 once it is deleted). +func gameRowCount(t *testing.T, gameID uuid.UUID) int { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.games WHERE game_id = $1`, gameID).Scan(&n); err != nil { + t.Fatalf("count game rows: %v", err) + } + return n +} + +// resignMoveCount counts the resign moves journalled for a seat in a game. +func resignMoveCount(t *testing.T, gameID uuid.UUID, seat int) int { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.game_moves WHERE game_id = $1 AND action = 'resign' AND seat = $2`, + gameID, seat).Scan(&n); err != nil { + t.Fatalf("count resign moves: %v", err) + } + return n +} diff --git a/backend/internal/inttest/suspension_console_test.go b/backend/internal/inttest/suspension_console_test.go new file mode 100644 index 0000000..dea8948 --- /dev/null +++ b/backend/internal/inttest/suspension_console_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "go.uber.org/zap/zaptest" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/game" + "scrabble/backend/internal/server" +) + +// TestConsoleBlockAndUnblock drives the admin console block flow through HTTP: blocking a user +// records the suspension and forfeits their active game, the user card renders both branches, +// and unblocking lifts it. +func TestConsoleBlockAndUnblock(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + games := newGameService() + srv := consoleServer(t, accounts, games) + + gid, seats := newGameWithSeats(t, 2) + blocked := seats[0] + + // The card renders the not-blocked branch before any block. + if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK { + t.Fatalf("user card before block = %d, want 200", rec.Code) + } + + // Block permanently via the console. + if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/block", url.Values{"duration": {"permanent"}}); rec.Code != http.StatusOK { + t.Fatalf("console block = %d, want 200", rec.Code) + } + if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || !ok { + t.Fatalf("after console block: blocked=%v err=%v, want blocked", ok, err) + } + if g, err := games.GameByID(ctx, gid); err != nil || g.Status != game.StatusFinished { + t.Fatalf("game after block: status=%q err=%v, want finished", g.Status, err) + } + + // The card renders the blocked branch. + if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK { + t.Fatalf("user card after block = %d, want 200", rec.Code) + } + + // Unblock via the console. + if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/unblock", url.Values{}); rec.Code != http.StatusOK { + t.Fatalf("console unblock = %d, want 200", rec.Code) + } + if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || ok { + t.Fatalf("after console unblock: blocked=%v err=%v, want not blocked", ok, err) + } +} + +// TestConsoleReasonsCRUD drives reason creation through the console and checks the page renders. +func TestConsoleReasonsCRUD(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + srv := consoleServer(t, accounts, newGameService()) + + if rec := consoleGet(t, srv, "/_gm/reasons"); rec.Code != http.StatusOK { + t.Fatalf("reasons page = %d, want 200", rec.Code) + } + form := url.Values{"text_en": {"Console reason"}, "text_ru": {"Причина из консоли"}} + if rec := consolePost(t, srv, "/_gm/reasons", form); rec.Code != http.StatusOK { + t.Fatalf("console create reason = %d, want 200", rec.Code) + } + reasons, err := accounts.ListReasons(ctx) + if err != nil { + t.Fatalf("list reasons: %v", err) + } + found := false + for _, r := range reasons { + if r.TextEn == "Console reason" && r.TextRu == "Причина из консоли" { + found = true + } + } + if !found { + t.Error("reason created via the console was not found") + } +} + +// consoleServer assembles a server with the admin console mounted (it needs accounts, games and a +// registry). +func consoleServer(t *testing.T, accounts *account.Store, games *game.Service) *server.Server { + t.Helper() + return server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: accounts, + Games: games, + Registry: testRegistry, + DictDir: dictDir(), + }) +} + +// consoleGet issues a console GET (a safe method, so the same-origin guard passes). +func consoleGet(t *testing.T, srv *server.Server, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil)) + return rec +} + +// consolePost issues a same-origin form POST to the console. +func consolePost(t *testing.T, srv *server.Server, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "http://example.com") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} diff --git a/backend/internal/inttest/suspension_gate_test.go b/backend/internal/inttest/suspension_gate_test.go new file mode 100644 index 0000000..27555f8 --- /dev/null +++ b/backend/internal/inttest/suspension_gate_test.go @@ -0,0 +1,151 @@ +//go:build integration + +package inttest + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "go.uber.org/zap/zaptest" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/server" +) + +// blockStatusBody mirrors the backend's /block-status JSON for the test. +type blockStatusBody struct { + Blocked bool `json:"blocked"` + Permanent bool `json:"permanent"` + Until string `json:"until"` + Reason string `json:"reason"` +} + +// TestSuspensionGate drives the gate end-to-end through the assembled HTTP server: a blocked +// account is refused on a normal user route with 403 + account_blocked, the block-status probe +// stays reachable and resolves the reason to the account's language, and an unblock restores +// access. +func TestSuspensionGate(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + srv := server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: accounts, + }) + + acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") + if err != nil { + t.Fatalf("provision: %v", err) + } + id := acc.ID + + // Not blocked: a normal route passes and block-status reports false. + if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK { + t.Fatalf("profile before block = %d, want 200", rec.Code) + } + if bs := blockStatus(t, srv, id); bs.Blocked { + t.Fatal("fresh account should not be blocked") + } + + // Block permanently with a reason. + if _, err := accounts.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil { + t.Fatalf("suspend: %v", err) + } + + // A normal route is now refused with the stable code. + rec := userGet(t, srv, "/api/v1/user/profile", id) + if rec.Code != http.StatusForbidden { + t.Fatalf("profile while blocked = %d, want 403", rec.Code) + } + if code := errorCode(t, rec); code != "account_blocked" { + t.Fatalf("error code = %q, want account_blocked", code) + } + + // The block-status probe stays reachable and resolves the reason to the account's language. + bs := blockStatus(t, srv, id) + if !bs.Blocked || !bs.Permanent { + t.Fatalf("block-status = %+v, want blocked+permanent", bs) + } + if bs.Reason != "Спам" { + t.Errorf("reason = %q, want the Russian snapshot Спам", bs.Reason) + } + if bs.Until != "" { + t.Errorf("until = %q, want empty for a permanent block", bs.Until) + } + + // Unblock restores access. + if err := accounts.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift: %v", err) + } + if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK { + t.Fatalf("profile after unblock = %d, want 200", rec.Code) + } +} + +// TestSuspensionGateTemporaryUntil checks a temporary block reports its expiry through +// block-status as a future RFC3339 instant and not permanent. +func TestSuspensionGateTemporaryUntil(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) + id := provisionAccount(t) + + until := time.Now().Add(24 * time.Hour) + if _, err := accounts.Suspend(ctx, id, &until, "", "", nil); err != nil { + t.Fatalf("suspend: %v", err) + } + bs := blockStatus(t, srv, id) + if !bs.Blocked || bs.Permanent { + t.Fatalf("block-status = %+v, want blocked, not permanent", bs) + } + parsed, err := time.Parse(time.RFC3339, bs.Until) + if err != nil { + t.Fatalf("until %q is not RFC3339: %v", bs.Until, err) + } + if !parsed.After(time.Now()) { + t.Errorf("until = %v, want a future instant", parsed) + } +} + +// userGet issues an authenticated GET (X-User-ID) against the assembled server. +func userGet(t *testing.T, srv *server.Server, path string, id uuid.UUID) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-User-ID", id.String()) + srv.Handler().ServeHTTP(rec, req) + return rec +} + +// blockStatus fetches and decodes the /block-status payload, asserting a 200. +func blockStatus(t *testing.T, srv *server.Server, id uuid.UUID) blockStatusBody { + t.Helper() + rec := userGet(t, srv, "/api/v1/user/block-status", id) + if rec.Code != http.StatusOK { + t.Fatalf("block-status status = %d, want 200", rec.Code) + } + var b blockStatusBody + if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { + t.Fatalf("decode block-status: %v", err) + } + return b +} + +// errorCode extracts the stable code from the backend error envelope. +func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var e struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { + t.Fatalf("decode error envelope: %v", err) + } + return e.Error.Code +} diff --git a/backend/internal/inttest/suspension_test.go b/backend/internal/inttest/suspension_test.go new file mode 100644 index 0000000..32546f4 --- /dev/null +++ b/backend/internal/inttest/suspension_test.go @@ -0,0 +1,209 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// TestSuspensionRoundTrip covers a permanent block: a fresh account is not blocked, Suspend with +// a reason makes CurrentSuspension report it (permanent, with the en/ru snapshot resolved per +// language), and LiftSuspension reverses it. +func TestSuspensionRoundTrip(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + id := provisionAccount(t) + + if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { + t.Fatalf("fresh CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err) + } + + if _, err := store.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil { + t.Fatalf("suspend: %v", err) + } + + susp, ok, err := store.CurrentSuspension(ctx, id) + if err != nil || !ok { + t.Fatalf("CurrentSuspension after suspend = (ok %v, err %v), want (true, nil)", ok, err) + } + if !susp.Permanent() { + t.Errorf("Permanent() = false, want true for a nil-until block") + } + if got := susp.LocalizedReason("en"); got != "Spam" { + t.Errorf("LocalizedReason(en) = %q, want Spam", got) + } + if got := susp.LocalizedReason("ru"); got != "Спам" { + t.Errorf("LocalizedReason(ru) = %q, want Спам", got) + } + if got := susp.LocalizedReason("fr"); got != "Spam" { + t.Errorf("LocalizedReason(fr) = %q, want the English fallback Spam", got) + } + + if err := store.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift: %v", err) + } + if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { + t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err) + } +} + +// TestTemporarySuspensionExpiry checks the until boundary: a future expiry is in force (and not +// permanent), while a past expiry is already not in force. +func TestTemporarySuspensionExpiry(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + future := provisionAccount(t) + until := time.Now().Add(time.Hour) + if _, err := store.Suspend(ctx, future, &until, "", "", nil); err != nil { + t.Fatalf("suspend future: %v", err) + } + susp, ok, err := store.CurrentSuspension(ctx, future) + if err != nil || !ok { + t.Fatalf("future CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) + } + if susp.Permanent() { + t.Error("Permanent() = true, want false for a dated block") + } + if susp.BlockedUntil == nil || !susp.BlockedUntil.After(time.Now()) { + t.Errorf("BlockedUntil = %v, want a future instant", susp.BlockedUntil) + } + + past := provisionAccount(t) + expired := time.Now().Add(-time.Hour) + if _, err := store.Suspend(ctx, past, &expired, "", "", nil); err != nil { + t.Fatalf("suspend past: %v", err) + } + if _, ok, err := store.CurrentSuspension(ctx, past); err != nil || ok { + t.Fatalf("expired CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err) + } +} + +// TestSuspensionStrongestWins checks that with overlapping blocks CurrentSuspension returns the +// strongest — a permanent block outranks any dated one — and that LiftSuspension clears them all. +func TestSuspensionStrongestWins(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + id := provisionAccount(t) + + soon := time.Now().Add(time.Hour) + if _, err := store.Suspend(ctx, id, &soon, "", "", nil); err != nil { + t.Fatalf("suspend dated: %v", err) + } + if _, err := store.Suspend(ctx, id, nil, "", "", nil); err != nil { + t.Fatalf("suspend permanent: %v", err) + } + later := time.Now().Add(2 * time.Hour) + if _, err := store.Suspend(ctx, id, &later, "", "", nil); err != nil { + t.Fatalf("suspend dated-later: %v", err) + } + + susp, ok, err := store.CurrentSuspension(ctx, id) + if err != nil || !ok { + t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) + } + if !susp.Permanent() { + t.Error("strongest block should be the permanent one") + } + + if err := store.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift: %v", err) + } + if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { + t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err) + } +} + +// TestReasonsCRUD covers the operator-editable picklist: create, list, get, update, delete, and +// the ErrNotFound paths. +func TestReasonsCRUD(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + created, err := store.CreateReason(ctx, "Cheating", "Читерство") + if err != nil { + t.Fatalf("create reason: %v", err) + } + if created.ID == uuid.Nil || created.TextEn != "Cheating" || created.TextRu != "Читерство" { + t.Fatalf("created reason = %+v, want populated en/ru and id", created) + } + + got, err := store.GetReason(ctx, created.ID) + if err != nil { + t.Fatalf("get reason: %v", err) + } + if got.TextEn != "Cheating" { + t.Errorf("get TextEn = %q, want Cheating", got.TextEn) + } + + list, err := store.ListReasons(ctx) + if err != nil { + t.Fatalf("list reasons: %v", err) + } + if !containsReason(list, created.ID) { + t.Error("created reason missing from ListReasons") + } + + updated, err := store.UpdateReason(ctx, created.ID, "Abuse", "Оскорбления") + if err != nil { + t.Fatalf("update reason: %v", err) + } + if updated.TextEn != "Abuse" || updated.TextRu != "Оскорбления" { + t.Errorf("updated reason = %+v, want Abuse/Оскорбления", updated) + } + + if err := store.DeleteReason(ctx, created.ID); err != nil { + t.Fatalf("delete reason: %v", err) + } + if _, err := store.GetReason(ctx, created.ID); !errors.Is(err, account.ErrNotFound) { + t.Errorf("get deleted reason = %v, want ErrNotFound", err) + } + if _, err := store.UpdateReason(ctx, uuid.New(), "x", "y"); !errors.Is(err, account.ErrNotFound) { + t.Errorf("update missing reason = %v, want ErrNotFound", err) + } +} + +// TestSuspensionReasonSnapshotSurvivesDelete checks that a block keeps the reason text it was +// stamped with even after the picklist entry is deleted (the FK nulls reason_id, the snapshot +// stays). +func TestSuspensionReasonSnapshotSurvivesDelete(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + id := provisionAccount(t) + + reason, err := store.CreateReason(ctx, "Toxicity", "Токсичность") + if err != nil { + t.Fatalf("create reason: %v", err) + } + if _, err := store.Suspend(ctx, id, nil, reason.TextEn, reason.TextRu, &reason.ID); err != nil { + t.Fatalf("suspend with reason: %v", err) + } + if err := store.DeleteReason(ctx, reason.ID); err != nil { + t.Fatalf("delete reason: %v", err) + } + + susp, ok, err := store.CurrentSuspension(ctx, id) + if err != nil || !ok { + t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) + } + if susp.LocalizedReason("en") != "Toxicity" || susp.LocalizedReason("ru") != "Токсичность" { + t.Errorf("reason snapshot lost after delete: en=%q ru=%q", susp.LocalizedReason("en"), susp.LocalizedReason("ru")) + } +} + +// containsReason reports whether list holds a reason with the given id. +func containsReason(list []account.Reason, id uuid.UUID) bool { + for _, r := range list { + if r.ID == id { + return true + } + } + return false +} diff --git a/backend/internal/postgres/jet/backend/model/account_suspensions.go b/backend/internal/postgres/jet/backend/model/account_suspensions.go new file mode 100644 index 0000000..5163231 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/account_suspensions.go @@ -0,0 +1,24 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type AccountSuspensions struct { + SuspensionID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + BlockedAt time.Time + BlockedUntil *time.Time + ReasonEn *string + ReasonRu *string + ReasonID *uuid.UUID + LiftedAt *time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/suspension_reasons.go b/backend/internal/postgres/jet/backend/model/suspension_reasons.go new file mode 100644 index 0000000..176dc17 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/suspension_reasons.go @@ -0,0 +1,21 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type SuspensionReasons struct { + ReasonID uuid.UUID `sql:"primary_key"` + TextEn string + TextRu string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/account_suspensions.go b/backend/internal/postgres/jet/backend/table/account_suspensions.go new file mode 100644 index 0000000..4c66947 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/account_suspensions.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AccountSuspensions = newAccountSuspensionsTable("backend", "account_suspensions", "") + +type accountSuspensionsTable struct { + postgres.Table + + // Columns + SuspensionID postgres.ColumnString + AccountID postgres.ColumnString + BlockedAt postgres.ColumnTimestampz + BlockedUntil postgres.ColumnTimestampz + ReasonEn postgres.ColumnString + ReasonRu postgres.ColumnString + ReasonID postgres.ColumnString + LiftedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AccountSuspensionsTable struct { + accountSuspensionsTable + + EXCLUDED accountSuspensionsTable +} + +// AS creates new AccountSuspensionsTable with assigned alias +func (a AccountSuspensionsTable) AS(alias string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AccountSuspensionsTable with assigned schema name +func (a AccountSuspensionsTable) FromSchema(schemaName string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AccountSuspensionsTable with assigned table prefix +func (a AccountSuspensionsTable) WithPrefix(prefix string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AccountSuspensionsTable with assigned table suffix +func (a AccountSuspensionsTable) WithSuffix(suffix string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAccountSuspensionsTable(schemaName, tableName, alias string) *AccountSuspensionsTable { + return &AccountSuspensionsTable{ + accountSuspensionsTable: newAccountSuspensionsTableImpl(schemaName, tableName, alias), + EXCLUDED: newAccountSuspensionsTableImpl("", "excluded", ""), + } +} + +func newAccountSuspensionsTableImpl(schemaName, tableName, alias string) accountSuspensionsTable { + var ( + SuspensionIDColumn = postgres.StringColumn("suspension_id") + AccountIDColumn = postgres.StringColumn("account_id") + BlockedAtColumn = postgres.TimestampzColumn("blocked_at") + BlockedUntilColumn = postgres.TimestampzColumn("blocked_until") + ReasonEnColumn = postgres.StringColumn("reason_en") + ReasonRuColumn = postgres.StringColumn("reason_ru") + ReasonIDColumn = postgres.StringColumn("reason_id") + LiftedAtColumn = postgres.TimestampzColumn("lifted_at") + allColumns = postgres.ColumnList{SuspensionIDColumn, AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn} + defaultColumns = postgres.ColumnList{BlockedAtColumn} + ) + + return accountSuspensionsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + SuspensionID: SuspensionIDColumn, + AccountID: AccountIDColumn, + BlockedAt: BlockedAtColumn, + BlockedUntil: BlockedUntilColumn, + ReasonEn: ReasonEnColumn, + ReasonRu: ReasonRuColumn, + ReasonID: ReasonIDColumn, + LiftedAt: LiftedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/suspension_reasons.go b/backend/internal/postgres/jet/backend/table/suspension_reasons.go new file mode 100644 index 0000000..1ca7f2e --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/suspension_reasons.go @@ -0,0 +1,90 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var SuspensionReasons = newSuspensionReasonsTable("backend", "suspension_reasons", "") + +type suspensionReasonsTable struct { + postgres.Table + + // Columns + ReasonID postgres.ColumnString + TextEn postgres.ColumnString + TextRu postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type SuspensionReasonsTable struct { + suspensionReasonsTable + + EXCLUDED suspensionReasonsTable +} + +// AS creates new SuspensionReasonsTable with assigned alias +func (a SuspensionReasonsTable) AS(alias string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new SuspensionReasonsTable with assigned schema name +func (a SuspensionReasonsTable) FromSchema(schemaName string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new SuspensionReasonsTable with assigned table prefix +func (a SuspensionReasonsTable) WithPrefix(prefix string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new SuspensionReasonsTable with assigned table suffix +func (a SuspensionReasonsTable) WithSuffix(suffix string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newSuspensionReasonsTable(schemaName, tableName, alias string) *SuspensionReasonsTable { + return &SuspensionReasonsTable{ + suspensionReasonsTable: newSuspensionReasonsTableImpl(schemaName, tableName, alias), + EXCLUDED: newSuspensionReasonsTableImpl("", "excluded", ""), + } +} + +func newSuspensionReasonsTableImpl(schemaName, tableName, alias string) suspensionReasonsTable { + var ( + ReasonIDColumn = postgres.StringColumn("reason_id") + TextEnColumn = postgres.StringColumn("text_en") + TextRuColumn = postgres.StringColumn("text_ru") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ReasonIDColumn, TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return suspensionReasonsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ReasonID: ReasonIDColumn, + TextEn: TextEnColumn, + TextRu: TextRuColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 8f79b5d..60f5d15 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -11,6 +11,7 @@ package table // this method only once at the beginning of the program. func UseSchema(schema string) { AccountStats = AccountStats.FromSchema(schema) + AccountSuspensions = AccountSuspensions.FromSchema(schema) Accounts = Accounts.FromSchema(schema) Blocks = Blocks.FromSchema(schema) ChatMessages = ChatMessages.FromSchema(schema) @@ -28,4 +29,5 @@ func UseSchema(schema string) { Games = Games.FromSchema(schema) Identities = Identities.FromSchema(schema) Sessions = Sessions.FromSchema(schema) + SuspensionReasons = SuspensionReasons.FromSchema(schema) } diff --git a/backend/internal/postgres/migrations/00003_account_suspensions.sql b/backend/internal/postgres/migrations/00003_account_suspensions.sql new file mode 100644 index 0000000..26e6639 --- /dev/null +++ b/backend/internal/postgres/migrations/00003_account_suspensions.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- Manual account blocking ("suspension"), the operator's hard counterpart to the soft, +-- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema +-- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another); +-- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to +-- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until +-- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12. +SET search_path = backend, pg_catalog; + +-- The operator-editable reason picklist, one row per reason with its English and Russian text. +-- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or +-- deleting a reason here never changes or breaks a reason already shown to a blocked player. +CREATE TABLE suspension_reasons ( + reason_id uuid PRIMARY KEY, + text_en text NOT NULL, + text_ru text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- The block history: one row per block. blocked_until is NULL for a permanent block and the +-- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early. +-- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was +-- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled +-- if that entry is deleted (the snapshot remains the source of truth shown to the player). An +-- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS +-- NULL OR blocked_until > now()). +CREATE TABLE account_suspensions ( + suspension_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + blocked_at timestamptz NOT NULL DEFAULT now(), + blocked_until timestamptz, + reason_en text, + reason_ru text, + reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL, + lifted_at timestamptz +); +-- The enforcement gate looks up the newest suspension for an account on every authenticated +-- request; this index serves that "latest by account" probe. +CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE IF EXISTS account_suspensions; +DROP TABLE IF EXISTS suspension_reasons; diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 57ac6e3..7f49515 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -46,6 +46,9 @@ func (s *Server) registerRoutes() { u.GET("/profile", s.handleProfile) u.PUT("/profile", s.handleUpdateProfile) u.GET("/stats", s.handleStats) + // Exempt from the suspension gate (see requireNotSuspended): the one endpoint a blocked + // client may still reach, to fetch the block's expiry and reason for the blocked screen. + u.GET("/block-status", s.handleBlockStatus) } if s.links != nil { // Account linking & merge. The request step always mails a code; diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index 27a44ef..1bd974a 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -86,6 +86,48 @@ func (s *Server) handleUpdateProfile(c *gin.Context) { c.JSON(http.StatusOK, profileResponseFor(acc)) } +// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC +// instant for a temporary block and empty for a permanent one (or when not blocked); Reason is +// the operator-set reason resolved to the account's language, empty when none was cited. It is +// the one payload a blocked client can still fetch, behind the suspension gate's exemption. +type blockStatusResponse struct { + Blocked bool `json:"blocked"` + Permanent bool `json:"permanent"` + Until string `json:"until,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// handleBlockStatus reports whether the caller is blocked and, if so, the block's expiry and the +// reason resolved to the account's language. The suspension gate exempts this route so a blocked +// client can render the terminal blocked screen. +func (s *Server) handleBlockStatus(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + susp, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + resp := blockStatusResponse{Blocked: blocked} + if blocked { + resp.Permanent = susp.Permanent() + if susp.BlockedUntil != nil { + resp.Until = susp.BlockedUntil.UTC().Format(time.RFC3339) + } + // Resolve the reason snapshot to the account's interface language; fall back to English + // if the account row cannot be read for some reason. + lang := "en" + if acc, err := s.accounts.GetByID(c.Request.Context(), uid); err == nil { + lang = acc.PreferredLanguage + } + resp.Reason = susp.LocalizedReason(lang) + } + c.JSON(http.StatusOK, resp) +} + // handleStats returns the caller's lifetime statistics. func (s *Server) handleStats(c *gin.Context) { uid, ok := userID(c) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 6a765ea..53dbe96 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -51,6 +51,12 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/users/:id", s.consoleUserDetail) gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) + gm.POST("/users/:id/block", s.consoleBlockUser) + gm.POST("/users/:id/unblock", s.consoleUnblockUser) + gm.GET("/reasons", s.consoleReasons) + gm.POST("/reasons", s.consoleCreateReason) + gm.POST("/reasons/:id/update", s.consoleUpdateReason) + gm.POST("/reasons/:id/delete", s.consoleDeleteReason) gm.GET("/throttled", s.consoleThrottled) gm.GET("/games", s.consoleGames) gm.GET("/games/:id", s.consoleGameDetail) @@ -291,6 +297,20 @@ func (s *Server) consoleUserDetail(c *gin.Context) { } view.MoveChart = adminconsole.MoveDurationChart(cps) } + if susp, blocked, err := s.accounts.CurrentSuspension(ctx, id); err == nil && blocked { + view.Suspension = adminconsole.SuspensionView{ + Blocked: true, Permanent: susp.Permanent(), BlockedAt: fmtTime(susp.BlockedAt), + ReasonEn: susp.ReasonEn, ReasonRu: susp.ReasonRu, + } + if susp.BlockedUntil != nil { + view.Suspension.Until = fmtTime(*susp.BlockedUntil) + } + } + if reasons, err := s.accounts.ListReasons(ctx); err == nil { + for _, r := range reasons { + view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu}) + } + } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } @@ -754,6 +774,157 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) { s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) } +// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a +// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active +// games, removing them from matchmaking. The block takes effect on the player's next request. +func (s *Server) consoleBlockUser(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + until, ok := parseSuspendUntil(trimForm(c, "duration"), trimForm(c, "until")) + if !ok { + s.renderConsoleMessage(c, "Invalid duration", "pick a preset or a future custom date/time (UTC)", back) + return + } + var reasonEn, reasonRu string + var reasonID *uuid.UUID + if rid := trimForm(c, "reason"); rid != "" { + parsed, err := uuid.Parse(rid) + if err != nil { + s.renderConsoleMessage(c, "Invalid reason", "the selected reason is not valid", back) + return + } + reason, err := s.accounts.GetReason(ctx, parsed) + if err != nil { + s.consoleError(c, err) + return + } + reasonEn, reasonRu, reasonID = reason.TextEn, reason.TextRu, &reason.ID + } + if _, err := s.accounts.Suspend(ctx, id, until, reasonEn, reasonRu, reasonID); err != nil { + s.consoleError(c, err) + return + } + forfeited, err := s.games.ForfeitAllForAccount(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back) +} + +// consoleUnblockUser lifts an account's block (temporary or permanent). Games already lost at +// block time are not restored. +func (s *Server) consoleUnblockUser(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + if err := s.accounts.LiftSuspension(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String()) +} + +// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for +// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the +// future. It reports false for an unrecognised choice or an invalid/past custom date. +func parseSuspendUntil(choice, custom string) (*time.Time, bool) { + now := time.Now().UTC() + switch choice { + case "permanent": + return nil, true + case "1d": + t := now.AddDate(0, 0, 1) + return &t, true + case "3d": + t := now.AddDate(0, 0, 3) + return &t, true + case "1w": + t := now.AddDate(0, 0, 7) + return &t, true + case "1m": + t := now.AddDate(0, 1, 0) + return &t, true + case "custom": + for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} { + if t, err := time.Parse(layout, custom); err == nil { + if !t.After(now) { + return nil, false + } + return &t, true + } + } + return nil, false + default: + return nil, false + } +} + +// consoleReasons renders the operator-editable suspension-reason picklist. +func (s *Server) consoleReasons(c *gin.Context) { + reasons, err := s.accounts.ListReasons(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + var view adminconsole.ReasonsView + for _, r := range reasons { + view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)}) + } + s.renderConsole(c, "reasons", "reasons", "Reasons", view) +} + +// consoleCreateReason adds a suspension-reason picklist entry; both languages are required. +func (s *Server) consoleCreateReason(c *gin.Context) { + en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru") + if en == "" || ru == "" { + s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons") + return + } + if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons") +} + +// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their +// snapshot, so the change only affects future blocks. +func (s *Server) consoleUpdateReason(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/reasons") + if !ok { + return + } + en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru") + if en == "" || ru == "" { + s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons") + return + } + if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons") +} + +// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot. +func (s *Server) consoleDeleteReason(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/reasons") + if !ok { + return + } + if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons") +} + // variantVersions builds the per-variant resident-version summary from the registry. func (s *Server) variantVersions() []adminconsole.VariantVersions { out := make([]adminconsole.VariantVersions, 0, len(engine.Variants())) diff --git a/backend/internal/server/middleware.go b/backend/internal/server/middleware.go index f1a1e84..56a5e41 100644 --- a/backend/internal/server/middleware.go +++ b/backend/internal/server/middleware.go @@ -77,3 +77,40 @@ func sameOrigin(r *http.Request) bool { } return false } + +// codeAccountBlocked is the stable error code the suspension gate returns for a blocked account. +// It threads through the gateway unchanged as the Execute result_code, so the UI can detect the +// block from any call and switch to the terminal blocked screen. +const codeAccountBlocked = "account_blocked" + +// blockStatusPath is the one /api/v1/user route exempt from the suspension gate: a blocked client +// must still reach it to fetch the block's expiry and reason for the blocked screen. +const blockStatusPath = "/api/v1/user/block-status" + +// requireNotSuspended returns middleware that rejects a blocked account's requests with 403 and +// code "account_blocked", so the UI can detect an active block from any call. The block-status +// probe is exempt. It is a no-op when the account store is not wired. It runs after RequireUserID +// (which has already placed the account id in the context). +func (s *Server) requireNotSuspended() gin.HandlerFunc { + return func(c *gin.Context) { + if s.accounts == nil || c.FullPath() == blockStatusPath { + c.Next() + return + } + id, ok := userID(c) + if !ok { + c.Next() // RequireUserID runs first and has already rejected a missing id + return + } + _, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), id) + if err != nil { + s.abortErr(c, err) + return + } + if blocked { + c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: codeAccountBlocked, Message: "account is blocked"}}) + return + } + c.Next() + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 640a9dc..5c681be 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -185,6 +185,9 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) { s.public = v1.Group("/public") s.user = v1.Group("/user") s.user.Use(RequireUserID()) + // The suspension gate runs after identity is established: a blocked account is refused on + // every user route (except the block-status probe) so the UI can show the blocked screen. + s.user.Use(s.requireNotSuspended()) s.internal = v1.Group("/internal") } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a4e26f3..ed0d524 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -700,6 +700,7 @@ promotions) is future work and would deliver short markdown messages (text + lin | Session minting; email-code / guest validation | gateway (with backend) | | Session → `user_id` resolution, `X-User-ID` injection | gateway | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | +| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) | | Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked | | backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) | @@ -707,6 +708,25 @@ This is an explicit, accepted MVP risk: compromise of the gateway↔backend network segment defeats backend authentication. Mitigated by network isolation; mutual auth is a future hardening step. +**Manual account block (suspension).** Beyond the soft, reversible high-rate flag (§11, never a +gate), an operator can hard-block an account from the admin console — permanently or until a +date, with an optional reason chosen from an editable en+ru picklist. A block is a row in +`account_suspensions` (the chosen reason's text is **snapshotted**, so editing or deleting a +picklist entry never changes what an already-blocked player is shown); it is named *suspension* +to stay distinct from the peer-to-peer `blocks` table. Enforcement is a backend middleware gate +after `X-User-ID`: every `/api/v1/user/*` route except the block-status probe refuses a blocked +account with **HTTP 403 + code `account_blocked`**, which threads through the gateway unchanged as +the Execute `result_code`, so the UI detects the block from *any* call and replaces every screen +with a terminal "blocked" screen, stopping all push/poll. The one exempt route, +`GET /api/v1/user/block-status`, returns the expiry and the reason resolved to the account's +language so the blocked client can render the message. Sessions are **not** revoked on block (a +revoked token would fail session resolution at the gateway *before* the gate, sending the UI to +login instead of the blocked screen). A block instantly **forfeits** every active game the player +is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels +their open matchmaking games; a temporary block lapses automatically once its expiry passes (no +sweeper — the gate recomputes against `now`). No operator identity is recorded (shared +Basic-Auth). + **Short numeric codes** (email confirm-codes and friend codes) are stored only as SHA-256 hashes and are short-lived and single-use. The unauthenticated email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem** diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1637271..cd9475c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -208,3 +208,15 @@ account sustaining rejections past a tunable threshold is flagged automatically the marker is reversible, shown as a badge in the user list and on the user card, and **never blocks play**; the operator reviews and clears it from the user card. There is no automatic ban. + +The console also lets an operator **manually block** an account — the hard counterpart to the +soft high-rate flag. From the user card the operator blocks the account **permanently** or +**until a date** (presets: 1 day / 3 days / 1 week / 1 month, or a custom date in UTC), optionally +citing a **reason** chosen from an editable **en+ru picklist** managed on a **Reasons** page; the +blocked player sees the reason in their own language. Blocking instantly **forfeits** the player's +active games (each opponent wins, as if the player resigned) and removes them from matchmaking. A +blocked player — on opening the app or on any action — is shown a terminal screen: *"Your account +is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary, in their timezone), +plus the reason when one was given, and the app stops all background traffic with the server. A +temporary block lifts itself when it expires; the operator can also **unblock** from the user card +at any time (games already lost stay lost). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 3d547d1..1ecdbad 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -213,3 +213,16 @@ Telegram-identity) или **отправить пост в игровой кан автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с карточки пользователя. Автоматического бана нет. + +Консоль также позволяет оператору **вручную заблокировать** аккаунт — жёсткий аналог мягкого +high-rate флага. С карточки пользователя оператор блокирует аккаунт **навсегда** или **до даты** +(пресеты: 1 день / 3 дня / 1 неделя / 1 месяц либо произвольная дата в UTC), при желании указав +**причину** из редактируемого **списка en+ru**, который ведётся на странице **Reasons**; +заблокированный игрок видит причину на своём языке. Блокировка мгновенно засчитывает **поражение** +во всех активных партиях игрока (каждый соперник побеждает, как при сдаче) и убирает его из +матчмейкинга. Заблокированный игрок — при открытии приложения или любом действии — видит +терминальный экран: *«Ваша учётная запись заблокирована»* (постоянная) или *«…заблокирована до +<дата, время>»* (временная, в его часовом поясе), плюс причину, если она была указана, и +приложение прекращает любое фоновое общение с сервером. Временная блокировка снимается сама по +истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент +(уже проигранные партии не возвращаются). diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 91e421e..b6eeb25 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -224,6 +224,25 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error return out, err } +// BlockStatusResp is the caller's current manual-block state. Until is an RFC3339 UTC instant for +// a temporary block, empty for a permanent one or when not blocked; Reason is resolved to the +// account's language, empty when none was cited. +type BlockStatusResp struct { + Blocked bool `json:"blocked"` + Permanent bool `json:"permanent"` + Until string `json:"until"` + Reason string `json:"reason"` +} + +// BlockStatus fetches the caller's current block. It is the one user endpoint a blocked account +// can still reach (the backend's suspension gate exempts it), so the UI can render the blocked +// screen. +func (c *Client) BlockStatus(ctx context.Context, userID string) (BlockStatusResp, error) { + var out BlockStatusResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/block-status", userID, "", nil, &out) + return out, err +} + // SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet // index. func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) { diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index ae99486..95f5de2 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -81,6 +81,20 @@ func encodeProfile(p backendclient.ProfileResp) []byte { return b.FinishedBytes() } +// encodeBlockStatus builds a BlockStatus payload. +func encodeBlockStatus(s backendclient.BlockStatusResp) []byte { + b := flatbuffers.NewBuilder(128) + until := b.CreateString(s.Until) + reason := b.CreateString(s.Reason) + fb.BlockStatusStart(b) + fb.BlockStatusAddBlocked(b, s.Blocked) + fb.BlockStatusAddPermanent(b, s.Permanent) + fb.BlockStatusAddUntil(b, until) + fb.BlockStatusAddReason(b, reason) + b.Finish(fb.BlockStatusEnd(b)) + return b.FinishedBytes() +} + // encodeLinkResult builds a LinkResult payload. A switched-session token // (a guest initiator whose durable counterpart won) is carried as a nested Session // for the client to adopt; it is omitted otherwise. supportedLangs is the variant diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 9cfd7bc..035cccc 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -22,6 +22,7 @@ const ( MsgAuthEmailReq = "auth.email.request" MsgAuthEmailLogin = "auth.email.login" MsgProfileGet = "profile.get" + MsgBlockStatus = "account.block_status" MsgGameSubmitPlay = "game.submit_play" MsgGameState = "game.state" MsgLobbyEnqueue = "lobby.enqueue" @@ -95,6 +96,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true} r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), Email: true} r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true} + r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true} @@ -199,6 +201,16 @@ func profileHandler(backend *backendclient.Client) Handler { } } +func blockStatusHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + bs, err := backend.BlockStatus(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeBlockStatus(bs), nil + } +} + func submitPlayHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0) diff --git a/gateway/internal/transcode/transcode_block_test.go b/gateway/internal/transcode/transcode_block_test.go new file mode 100644 index 0000000..8b7bd70 --- /dev/null +++ b/gateway/internal/transcode/transcode_block_test.go @@ -0,0 +1,64 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestBlockStatusRoundTrip checks the account.block_status op forwards to the backend block-status +// endpoint and encodes its JSON into the BlockStatus FlatBuffer. +func TestBlockStatusRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/block-status" { + t.Errorf("path = %q, want /api/v1/user/block-status", r.URL.Path) + } + _, _ = w.Write([]byte(`{"blocked":true,"permanent":false,"until":"2026-07-01T12:00:00Z","reason":"Спам"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgBlockStatus) + if !ok { + t.Fatal("account.block_status not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + bs := fb.GetRootAsBlockStatus(payload, 0) + if !bs.Blocked() || bs.Permanent() { + t.Fatalf("blocked=%v permanent=%v, want true/false", bs.Blocked(), bs.Permanent()) + } + if string(bs.Until()) != "2026-07-01T12:00:00Z" { + t.Errorf("until = %q, want the forwarded instant", bs.Until()) + } + if string(bs.Reason()) != "Спам" { + t.Errorf("reason = %q, want Спам", bs.Reason()) + } +} + +// TestBlockedBackendSurfacesDomainCode checks that a backend 403 with code account_blocked (the +// suspension gate) surfaces as a domain code, which the Execute layer turns into the envelope +// result_code the UI keys off. +func TestBlockedBackendSurfacesDomainCode(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":"account_blocked","message":"account is blocked"}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) // any gated op hits the same gate + _, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err == nil { + t.Fatal("expected an error from a blocked backend response") + } + code, ok := transcode.DomainCode(err) + if !ok || code != "account_blocked" { + t.Fatalf("DomainCode = (%q, %v), want (account_blocked, true)", code, ok) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index e786445..b45f2c5 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -150,6 +150,18 @@ table Profile { notifications_in_app_only:bool = true; } +// BlockStatus reports the caller's current manual block. The UI fetches it after any operation +// returns the "account_blocked" result code, then replaces every screen with the terminal blocked +// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339 +// UTC instant for a temporary block and empty for a permanent one; reason is the operator-set text +// resolved to the account's language, empty when none was cited. +table BlockStatus { + blocked:bool; + permanent:bool; + until:string; + reason:string; +} + // --- game (authenticated) --- // SubmitPlayRequest places tiles on the player's turn; the backend infers the play's diff --git a/pkg/fbs/scrabblefb/BlockStatus.go b/pkg/fbs/scrabblefb/BlockStatus.go new file mode 100644 index 0000000..48ea4bf --- /dev/null +++ b/pkg/fbs/scrabblefb/BlockStatus.go @@ -0,0 +1,101 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BlockStatus struct { + _tab flatbuffers.Table +} + +func GetRootAsBlockStatus(buf []byte, offset flatbuffers.UOffsetT) *BlockStatus { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BlockStatus{} + x.Init(buf, n+offset) + return x +} + +func FinishBlockStatusBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBlockStatus(buf []byte, offset flatbuffers.UOffsetT) *BlockStatus { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BlockStatus{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBlockStatusBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BlockStatus) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BlockStatus) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BlockStatus) Blocked() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *BlockStatus) MutateBlocked(n bool) bool { + return rcv._tab.MutateBoolSlot(4, n) +} + +func (rcv *BlockStatus) Permanent() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *BlockStatus) MutatePermanent(n bool) bool { + return rcv._tab.MutateBoolSlot(6, n) +} + +func (rcv *BlockStatus) Until() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BlockStatus) Reason() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func BlockStatusStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func BlockStatusAddBlocked(builder *flatbuffers.Builder, blocked bool) { + builder.PrependBoolSlot(0, blocked, false) +} +func BlockStatusAddPermanent(builder *flatbuffers.Builder, permanent bool) { + builder.PrependBoolSlot(1, permanent, false) +} +func BlockStatusAddUntil(builder *flatbuffers.Builder, until flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(until), 0) +} +func BlockStatusAddReason(builder *flatbuffers.Builder, reason flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(reason), 0) +} +func BlockStatusEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/e2e/blocked.spec.ts b/ui/e2e/blocked.spec.ts new file mode 100644 index 0000000..37836d4 --- /dev/null +++ b/ui/e2e/blocked.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from './fixtures'; + +// The terminal blocked screen replaces every screen. The mock never blocks of its own accord, so +// the e2e drives it through the mock-mode __block seam (lib/app.svelte.ts), mirroring what +// enterBlocked does when any call returns the account_blocked code. + +test('a temporary block shows the dated message and reason, replacing the lobby', async ({ + page, +}) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Your turn')).toBeVisible(); // reached the lobby + + await page.evaluate(() => + (window as unknown as { __block: (b: unknown) => void }).__block({ + permanent: false, + until: '2026-07-01T12:00:00Z', + reason: 'Спам', + }), + ); + + const blocked = page.locator('.blocked'); + await expect(blocked).toBeVisible(); + await expect(blocked).toContainText('2026'); // the expiry rendered in the user's locale/timezone + await expect(blocked).toContainText('Спам'); // the operator reason + // The lobby is gone: the blocked screen overlays every screen, stopping its push/poll. + await expect(page.getByText('Your turn')).toHaveCount(0); +}); + +test('a permanent block shows the permanent message with no date', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Your turn')).toBeVisible(); + + await page.evaluate(() => + (window as unknown as { __block: (b: unknown) => void }).__block({ permanent: true }), + ); + + const blocked = page.locator('.blocked'); + await expect(blocked).toBeVisible(); + await expect(blocked).not.toContainText('2026'); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 9e42fb6..7c6348e 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -13,6 +13,7 @@ import Stats from './screens/Stats.svelte'; import Game from './game/Game.svelte'; import CommsHub from './game/CommsHub.svelte'; + import Blocked from './screens/Blocked.svelte'; onMount(() => { void bootstrap(); @@ -68,6 +69,8 @@ {#if !app.ready}
{t('common.loading')}
+{:else if app.blocked} + {:else}
{#key routeKey} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 198e0d0..8bca894 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -4,6 +4,7 @@ export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; export { BlockList } from './scrabblefb/block-list.js'; +export { BlockStatus } from './scrabblefb/block-status.js'; export { ChatList } from './scrabblefb/chat-list.js'; export { ChatMessage } from './scrabblefb/chat-message.js'; export { ChatPostRequest } from './scrabblefb/chat-post-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/block-status.ts b/ui/src/gen/fbs/scrabblefb/block-status.ts new file mode 100644 index 0000000..d955cde --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/block-status.ts @@ -0,0 +1,80 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class BlockStatus { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BlockStatus { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus { + return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +blocked():boolean { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +permanent():boolean { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +until():string|null +until(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +until(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +reason():string|null +reason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +reason(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startBlockStatus(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addBlocked(builder:flatbuffers.Builder, blocked:boolean) { + builder.addFieldInt8(0, +blocked, +false); +} + +static addPermanent(builder:flatbuffers.Builder, permanent:boolean) { + builder.addFieldInt8(1, +permanent, +false); +} + +static addUntil(builder:flatbuffers.Builder, untilOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, untilOffset, 0); +} + +static addReason(builder:flatbuffers.Builder, reasonOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, reasonOffset, 0); +} + +static endBlockStatus(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBlockStatus(builder:flatbuffers.Builder, blocked:boolean, permanent:boolean, untilOffset:flatbuffers.Offset, reasonOffset:flatbuffers.Offset):flatbuffers.Offset { + BlockStatus.startBlockStatus(builder); + BlockStatus.addBlocked(builder, blocked); + BlockStatus.addPermanent(builder, permanent); + BlockStatus.addUntil(builder, untilOffset); + BlockStatus.addReason(builder, reasonOffset); + return BlockStatus.endBlockStatus(builder); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 4994b71..a53ad61 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -3,7 +3,7 @@ // gateway calls funnel through here so errors map to one user-facing toast and an // expired session logs out. -import type { LinkResult, Profile, PushEvent, Session } from './model'; +import type { BlockStatus, LinkResult, Profile, PushEvent, Session } from './model'; import { gateway } from './gateway'; import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; @@ -43,6 +43,9 @@ export const app = $state<{ streamAlive: boolean; session: Session | null; profile: Profile | null; + /** The caller's active manual block, or null. When set, App.svelte replaces every screen with + * the terminal blocked screen and all push/poll is stopped. */ + blocked: BlockStatus | null; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; @@ -65,6 +68,7 @@ export const app = $state<{ streamAlive: false, session: null, profile: null, + blocked: null, toast: null, lastEvent: null, theme: 'auto', @@ -140,11 +144,39 @@ export function handleError(err: unknown): void { void logout(); return; } + if (code === 'account_blocked') { + void enterBlocked(); + return; + } if (isConnectionCode(code) || !connection.online) return; telegramHaptic('error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } +/** + * enterBlocked switches the app to the terminal blocked screen and stops all push/poll. It is + * triggered whenever any call reports the "account_blocked" code. It flips the screen + * synchronously (a generic placeholder), then fetches the block's expiry and reason — the one + * endpoint a blocked client may still reach — to render the precise message. If that fetch reports + * the block was already lifted (a race), it recovers: clears the blocked screen and reopens the + * stream. + */ +export async function enterBlocked(): Promise { + closeStream(); // stop the live stream; the blocked screen makes no further calls + app.blocked ??= { blocked: true, permanent: true, until: '', reason: '' }; + try { + const s = await gateway.blockStatus(); + if (s.blocked) { + app.blocked = s; + } else { + app.blocked = null; + if (app.session) openStream(); + } + } catch { + // Keep the placeholder blocked screen even if the details cannot be fetched. + } +} + /** * viewingGame reports whether the game board for the given game id is the current route. That screen * maintains its own cache from live events, so the global stream handler must not also advance it (a @@ -284,6 +316,8 @@ async function adoptSession(s: Session): Promise { } catch (err) { handleError(err); } + // A blocked account stays on the blocked screen: no live stream, no notification poll. + if (app.blocked) return; openStream(); void refreshNotifications(); } @@ -392,7 +426,8 @@ export async function bootstrap(): Promise { telegramDisableVerticalSwipes(); try { await adoptSession(await gateway.authTelegram(launch.initData)); - await routeStartParam(launch.startParam); + // A blocked account skips deep-link routing — the blocked screen overlays every route. + if (!app.blocked) await routeStartParam(launch.startParam); } catch (err) { handleError(err); navigate('/login'); @@ -568,4 +603,10 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') { drop: closeStream, restore: openStream, }; + // Drive the terminal blocked screen from the e2e (the mock never blocks of its own accord). It + // mirrors enterBlocked: flip the screen and stop the live stream. + (window as unknown as { __block?: (b?: Partial) => void }).__block = (b) => { + closeStream(); + app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b }; + }; } diff --git a/ui/src/lib/blocked.test.ts b/ui/src/lib/blocked.test.ts new file mode 100644 index 0000000..dd51004 --- /dev/null +++ b/ui/src/lib/blocked.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { formatBlockUntil } from './blocked'; + +describe('formatBlockUntil', () => { + const instant = '2026-07-01T12:00:00Z'; + + it('formats a valid instant in the given timezone', () => { + const out = formatBlockUntil(instant, 'en', 'UTC'); + expect(out).not.toBe(instant); // it was localized, not echoed + expect(out).toContain('2026'); + }); + + it('shifts the rendered time by timezone', () => { + const utc = formatBlockUntil(instant, 'en', 'UTC'); + const ny = formatBlockUntil(instant, 'en', 'America/New_York'); + expect(ny).not.toBe(utc); // 12:00 UTC renders as a different wall-clock in New York + }); + + it('falls back to the raw string for an unparseable instant', () => { + expect(formatBlockUntil('not-a-date', 'en', 'UTC')).toBe('not-a-date'); + }); + + it('does not throw on an invalid timezone, still rendering the date', () => { + const out = formatBlockUntil(instant, 'en', 'Not/AZone'); + expect(out).toContain('2026'); + }); +}); diff --git a/ui/src/lib/blocked.ts b/ui/src/lib/blocked.ts new file mode 100644 index 0000000..a26e2de --- /dev/null +++ b/ui/src/lib/blocked.ts @@ -0,0 +1,27 @@ +// Pure helpers for the terminal blocked screen, kept out of the .svelte component so they are +// unit-testable in the node vitest env. + +import type { Locale } from './i18n/index.svelte'; + +/** + * formatBlockUntil renders a temporary block's RFC3339 UTC expiry in the user's locale and + * timezone, e.g. "1 Jul 2026, 15:00". It falls back to UTC formatting when the timezone is + * unknown/invalid, and to the raw string when the instant cannot be parsed at all. + */ +export function formatBlockUntil(until: string, locale: Locale, timeZone: string): string { + const d = new Date(until); + if (Number.isNaN(d.getTime())) return until; + try { + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: timeZone || undefined, + }).format(d); + } catch { + try { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d); + } catch { + return until; + } + } +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index efe63fb..c73a775 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -6,6 +6,7 @@ import type { AccountRef, + BlockStatus, ChatMessage, EvalResult, FriendCode, @@ -60,6 +61,9 @@ export interface GatewayClient { // --- profile / lists --- profileGet(): Promise; + /** The caller's current manual block. Exempt from the backend's suspension gate, so a blocked + * client can still fetch it to render the blocked screen. */ + blockStatus(): Promise; gamesList(): Promise; // --- lobby --- diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 121df13..5717c63 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import * as fb from '../gen/fbs/scrabblefb'; import { BLANK_INDEX, setAlphabet } from './alphabet'; import { + decodeBlockStatus, decodeDraftView, decodeEvent, decodeFriendList, @@ -37,6 +38,38 @@ describe('codec', () => { expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}'); }); + it('decodes a temporary BlockStatus with reason', () => { + const b = new Builder(64); + const until = b.createString('2026-07-01T12:00:00Z'); + const reason = b.createString('Спам'); + fb.BlockStatus.startBlockStatus(b); + fb.BlockStatus.addBlocked(b, true); + fb.BlockStatus.addPermanent(b, false); + fb.BlockStatus.addUntil(b, until); + fb.BlockStatus.addReason(b, reason); + b.finish(fb.BlockStatus.endBlockStatus(b)); + expect(decodeBlockStatus(b.asUint8Array())).toEqual({ + blocked: true, + permanent: false, + until: '2026-07-01T12:00:00Z', + reason: 'Спам', + }); + }); + + it('decodes a permanent BlockStatus with empty until/reason', () => { + const b = new Builder(32); + fb.BlockStatus.startBlockStatus(b); + fb.BlockStatus.addBlocked(b, true); + fb.BlockStatus.addPermanent(b, true); + b.finish(fb.BlockStatus.endBlockStatus(b)); + expect(decodeBlockStatus(b.asUint8Array())).toEqual({ + blocked: true, + permanent: true, + until: '', + reason: '', + }); + }); + it('encodes a SubmitPlayRequest with alphabet indices', () => { setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 7882a74..5d7e29d 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -9,6 +9,7 @@ import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } f import type { PlacedTile } from './client'; import type { AccountRef, + BlockStatus, ChatMessage, EvalResult, FriendCode, @@ -314,6 +315,16 @@ export function decodeProfile(buf: Uint8Array): Profile { }; } +export function decodeBlockStatus(buf: Uint8Array): BlockStatus { + const b = fb.BlockStatus.getRootAsBlockStatus(new ByteBuffer(buf)); + return { + blocked: b.blocked(), + permanent: b.permanent(), + until: s(b.until()), + reason: s(b.reason()), + }; +} + // decodeStateViewTable projects a StateView table (a root or one nested in an event) to the // model. It caches the alphabet when present (a per-variant cache miss) and decodes the index // rack to display letters with it. diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index f834b3c..3c86897 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -6,6 +6,11 @@ export const en = { 'app.title': 'Scrabble', 'connection.connecting': 'Connecting…', + 'blocked.title': 'Account blocked', + 'blocked.permanent': 'Your account is blocked.', + 'blocked.temporary': 'Your account is blocked until {until}.', + 'blocked.reason': 'Reason:', + 'common.back': 'Back', 'common.cancel': 'Cancel', 'common.ok': 'OK', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 4ade79d..925fd80 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -7,6 +7,11 @@ export const ru: Record = { 'app.title': 'Scrabble', 'connection.connecting': 'Подключение…', + 'blocked.title': 'Учётная запись заблокирована', + 'blocked.permanent': 'Ваша учётная запись заблокирована.', + 'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.', + 'blocked.reason': 'Причина:', + 'common.back': 'Назад', 'common.cancel': 'Отмена', 'common.ok': 'ОК', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 5c40716..3f86e52 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -13,6 +13,7 @@ import type { import { GatewayError } from '../client'; import type { AccountRef, + BlockStatus, ChatMessage, EvalResult, FriendCode, @@ -139,6 +140,10 @@ export class MockGateway implements GatewayClient { async profileGet(): Promise { return { ...this.profile }; } + async blockStatus(): Promise { + // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. + return { blocked: false, permanent: false, until: '', reason: '' }; + } async gamesList(): Promise { return { games: [...this.games.values()].map((g) => structuredClone(g.view)) }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 72fd8f2..806a334 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -121,6 +121,18 @@ export interface Profile { notificationsInAppOnly: boolean; } +/** BlockStatus is the caller's current manual block, fetched after any call reports + * the "account_blocked" code. When blocked the app shows the terminal blocked screen + * and stops all push/poll. */ +export interface BlockStatus { + blocked: boolean; + permanent: boolean; + /** RFC3339 UTC instant for a temporary block; "" for a permanent one or when not blocked. */ + until: string; + /** Operator reason resolved to the account's language; "" when none was cited. */ + reason: string; +} + /** The full editable profile sent to profileUpdate (overwrites every field). */ export interface ProfileUpdate { displayName: string; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index a976a21..94df8af 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -77,6 +77,9 @@ export function createTransport(baseUrl: string): GatewayClient { async profileGet() { return codec.decodeProfile(await exec('profile.get', codec.empty())); }, + async blockStatus() { + return codec.decodeBlockStatus(await exec('account.block_status', codec.empty())); + }, async gamesList() { return codec.decodeGameList(await exec('games.list', codec.empty())); }, diff --git a/ui/src/screens/Blocked.svelte b/ui/src/screens/Blocked.svelte new file mode 100644 index 0000000..e6b9431 --- /dev/null +++ b/ui/src/screens/Blocked.svelte @@ -0,0 +1,53 @@ + + +
+
+

{t('blocked.title')}

+

{message}

+ {#if app.blocked?.reason} +

{t('blocked.reason')} {app.blocked.reason}

+ {/if} +
+
+ + From 290874720f93f59f0cf12992cb03d3415fe30950 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 22:24:57 +0200 Subject: [PATCH 2/2] perf(admin): cache the suspension gate lookup The suspension gate runs CurrentSuspension on every authenticated request. Add a write-through in-memory cache on account.Store keyed by account id, invalidated on Suspend/LiftSuspension, with the cached entry re-evaluated against the wall clock so a temporary block lapses without an explicit invalidation. Single-instance, matching the deployment (one shared Store). Keeps the gate off the database on the hot path while a block still takes effect on the next request. --- backend/internal/account/account.go | 5 +- backend/internal/account/suspension.go | 82 +++++++++++++++++++ .../internal/account/suspension_cache_test.go | 52 ++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 backend/internal/account/suspension_cache_test.go diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index a436da6..16d7d22 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -99,12 +99,15 @@ type Identity struct { type Store struct { db *sql.DB metrics *accountMetrics + // suspensions caches each account's current manual block, read by the suspension gate on + // every authenticated request and invalidated on Suspend/LiftSuspension. See suspension.go. + suspensions *suspensionCache } // NewStore constructs a Store wrapping db. Metrics default to a no-op meter until // SetMetrics installs the real one during startup wiring. func NewStore(db *sql.DB) *Store { - return &Store{db: db, metrics: defaultAccountMetrics()} + return &Store{db: db, metrics: defaultAccountMetrics(), suspensions: newSuspensionCache()} } // ProvisionByIdentity returns the account bound to (kind, externalID), creating diff --git a/backend/internal/account/suspension.go b/backend/internal/account/suspension.go index e1e2039..155f289 100644 --- a/backend/internal/account/suspension.go +++ b/backend/internal/account/suspension.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "time" "github.com/go-jet/jet/v2/postgres" @@ -82,6 +83,7 @@ func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Ti if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err) } + s.invalidateSuspension(accountID) return modelToSuspension(row), nil } @@ -100,6 +102,7 @@ func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error { if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("account: lift suspension %s: %w", accountID, err) } + s.invalidateSuspension(accountID) return nil } @@ -115,6 +118,30 @@ func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Sus return Suspension{}, false, nil } now := time.Now().UTC() + if s.suspensions != nil { + if e, ok := s.suspensions.get(accountID); ok { + if !e.found { + return Suspension{}, false, nil // cached: not blocked + } + if suspensionActiveAt(e.susp, now) { + return e.susp, true, nil // cached block still in force + } + // The cached block has lapsed since it was cached; refresh from the database below. + } + } + susp, found, err := s.queryCurrentSuspension(ctx, accountID, now) + if err != nil { + return Suspension{}, false, err + } + if s.suspensions != nil { + s.suspensions.put(accountID, suspensionCacheEntry{susp: susp, found: found}) + } + return susp, found, nil +} + +// queryCurrentSuspension reads the account's strongest in-force block straight from the database +// (no cache), as of now. It backs CurrentSuspension on a cache miss or a lapsed entry. +func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, now time.Time) (Suspension, bool, error) { stmt := postgres.SELECT(table.AccountSuspensions.AllColumns). FROM(table.AccountSuspensions). WHERE( @@ -134,6 +161,61 @@ func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Sus return modelToSuspension(row), true, nil } +// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it. +// Called after Suspend and LiftSuspension. +func (s *Store) invalidateSuspension(accountID uuid.UUID) { + if s.suspensions != nil { + s.suspensions.invalidate(accountID) + } +} + +// suspensionActiveAt reports whether a suspension is in force at now: permanent, or not yet +// expired. The lifted check is implicit — only non-lifted blocks are ever cached or returned. +func suspensionActiveAt(susp Suspension, now time.Time) bool { + return susp.BlockedUntil == nil || susp.BlockedUntil.After(now) +} + +// suspensionCache is the gate's write-through cache of each account's current block, keyed by +// account id. The suspension gate reads it on every authenticated request; Suspend and +// LiftSuspension invalidate the account's entry. An entry holds the strongest active block at +// query time (or a not-blocked marker), re-evaluated against the wall clock on read, so a +// temporary block lapses without an explicit invalidation. It is single-instance, matching the +// deployment (one shared Store); a multi-instance deployment would need a shared cache. +type suspensionCache struct { + mu sync.RWMutex + m map[uuid.UUID]suspensionCacheEntry +} + +// suspensionCacheEntry is a cached lookup: the strongest active block when found, else a +// not-blocked marker (found=false). +type suspensionCacheEntry struct { + susp Suspension + found bool +} + +func newSuspensionCache() *suspensionCache { + return &suspensionCache{m: make(map[uuid.UUID]suspensionCacheEntry)} +} + +func (c *suspensionCache) get(id uuid.UUID) (suspensionCacheEntry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.m[id] + return e, ok +} + +func (c *suspensionCache) put(id uuid.UUID, e suspensionCacheEntry) { + c.mu.Lock() + defer c.mu.Unlock() + c.m[id] = e +} + +func (c *suspensionCache) invalidate(id uuid.UUID) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.m, id) +} + // activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and // either permanent or not yet expired. func activeSuspensionPredicate(now time.Time) postgres.BoolExpression { diff --git a/backend/internal/account/suspension_cache_test.go b/backend/internal/account/suspension_cache_test.go new file mode 100644 index 0000000..9ec24b3 --- /dev/null +++ b/backend/internal/account/suspension_cache_test.go @@ -0,0 +1,52 @@ +package account + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +// TestSuspensionActiveAt covers the wall-clock re-evaluation the cache relies on: a permanent +// block is always active, a future-dated one is active, a past-dated one is not. +func TestSuspensionActiveAt(t *testing.T) { + now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) + future := now.Add(time.Hour) + past := now.Add(-time.Hour) + + if !suspensionActiveAt(Suspension{BlockedUntil: nil}, now) { + t.Error("a permanent block must be active") + } + if !suspensionActiveAt(Suspension{BlockedUntil: &future}, now) { + t.Error("a future-dated block must be active") + } + if suspensionActiveAt(Suspension{BlockedUntil: &past}, now) { + t.Error("a past-dated block must be inactive") + } +} + +// TestSuspensionCache covers the cache primitives: a miss on an empty cache, a hit after put for +// both the not-blocked and blocked markers, and a miss after invalidate. +func TestSuspensionCache(t *testing.T) { + c := newSuspensionCache() + id := uuid.New() + + if _, ok := c.get(id); ok { + t.Fatal("empty cache must miss") + } + + c.put(id, suspensionCacheEntry{found: false}) + if e, ok := c.get(id); !ok || e.found { + t.Fatalf("cached not-blocked entry = (%+v, ok %v), want hit with found=false", e, ok) + } + + c.put(id, suspensionCacheEntry{susp: Suspension{AccountID: id}, found: true}) + if e, ok := c.get(id); !ok || !e.found { + t.Fatalf("cached blocked entry = (%+v, ok %v), want hit with found=true", e, ok) + } + + c.invalidate(id) + if _, ok := c.get(id); ok { + t.Fatal("invalidated entry must miss") + } +}