From d2631d4bfb3b29cfbf46134a1e164ac58614e1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Oester?= Date: Tue, 20 Oct 2026 18:14:47 +0200 Subject: [PATCH] feat(antibot): an unban pardons the caller until its evidence is gone An unban lifted the ban and forgot its offence, but the watchdogs kept what they had read. A rule that still read the caller guilty banned it again five minutes later (reflagInterval), as a first offence. UnbanPlayer now writes a pardon: a row of antibot.pardons or antibot.account_pardons, with when, the ban it lifted (flags, offence, end) and when the pardon ends. Every pardon is kept. While one runs: - the jury flags nothing on that key, however guilty it reads; - a pardoned caller convicts nobody, not even the accounts it rotates with; - an operator's BanPlayer still bans it. A pardon lasts antiBot.evidence.retention (72h). No evidence outlives retention, so when the pardon ends every reading rests on play after the unban. The evidence is not touched. The bans Record carries the latest pardon, read in the same query and written in the same transaction and lock as the ban. UnbanPlayer and InspectPlayer answer pardoned_until, and the backoffice shows it. Its unban hint no longer says the player is banned again. Co-Authored-By: Claude Opus 5.5 --- apps/backend/CLAUDE.md | 35 ++++-- .../generated/proto/planet/v1/admin.pb.go | 106 +++++++++++------- apps/backend/internal/antibot/antibot.go | 23 +++- apps/backend/internal/antibot/antibot_test.go | 45 ++++---- .../internal/antibot/internal/bans/banner.go | 38 ++++++- .../antibot/internal/bans/banner_test.go | 80 ++++++++++++- .../internal/antibot/internal/bans/book.go | 32 +++++- .../antibot/internal/bans/book_test.go | 53 ++++++++- .../antibot/internal/bans/helpers_test.go | 21 ++++ .../internal/antibot/internal/bans/store.go | 7 ++ .../internal/bans/store_contract_testing.go | 41 +++++++ .../antibot/internal/bans/store_test.go | 12 +- .../antibot/internal/detect/detect.go | 2 + .../antibot/internal/evidence/evidence.go | 4 +- .../internal/antibot/internal/jury/jury.go | 6 + .../antibot/internal/jury/jury_test.go | 49 +++++++- .../antibot/internal/jury/state_test.go | 2 +- .../antibot/internal/jury/sweep_test.go | 2 + .../20261020120000_create_pardons.down.sql | 2 + .../20261020120000_create_pardons.up.sql | 19 ++++ .../internal/postgres_ban_store/store.go | 76 +++++++++++-- .../internal/postgres_ban_store/store_test.go | 30 +++++ .../unban_player_usecase/audit_unban/audit.go | 2 +- .../audit_unban/audit_test.go | 5 +- .../usecases/unban_player_usecase/usecase.go | 24 ++-- .../unban_player_usecase/usecase_test.go | 17 ++- .../inspect_player_handler/handler.go | 1 + .../inspect_player_handler/handler_test.go | 4 + .../unban_player_handler/handler.go | 9 +- .../unban_player_handler/handler_test.go | 3 +- apps/backoffice/CLAUDE.md | 4 +- apps/backoffice/src/fakeAdmin.ts | 2 +- .../src/gen/grpc/planet/v1/admin_pb.ts | 12 ++ apps/backoffice/src/players/InspectPanel.tsx | 1 + apps/backoffice/src/players/Moderation.tsx | 8 +- .../src/players/PlayersPage.test.tsx | 18 ++- apps/backoffice/src/players/actions.test.ts | 2 +- apps/backoffice/src/players/actions.ts | 4 +- .../src/gen/grpc/planet/v1/admin_pb.ts | 12 ++ proto/planet/v1/admin.proto | 2 + 40 files changed, 681 insertions(+), 134 deletions(-) create mode 100644 apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.down.sql create mode 100644 apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.up.sql diff --git a/apps/backend/CLAUDE.md b/apps/backend/CLAUDE.md index 73049636f..1a6519649 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -110,7 +110,7 @@ root package**: | `player` | `Config`, `NewModule` | | `seasons` | `Config`, `NewModule` | | `support` | `Config`, `NewModule` | -| `antibot` | `Config`, `Observer`, `Guard`, `New`, `Description`, `Click`, `Report`, `Sentence`, `Examination`, `Reading` | +| `antibot` | `Config`, `Observer`, `Guard`, `New`, `Description`, `Click`, `Report`, `Sentence`, `Pardon`, `Examination`, `Reading` | | `clips` | `Config`, `ReplayConfig`, `Fetch`, `Choose`, `Schedule`, `Pick`, `Platform`, `PlatformsOf`, `Report`, `PlatformOutcome` | That holds for `cmd/api` too: the composition root lists modules and cannot @@ -413,7 +413,7 @@ because it serves every concept over one Connect service. It only maps. | `ledger/usecases/find_players_usecase` | who is painting a flag, and where | `Ledger`, `Owners`, `Borders`, `Bans` | | `ledger/usecases/top_players_usecase` | who takes the most tiles, every flag | `Ledger`, `Owners`, `Bans` | | `ledger/usecases/ban_player_usecase` | the operator's ban | `Banner` | -| `ledger/usecases/unban_player_usecase` | lifts a running ban and forgets its offence | `Unbanner` | +| `ledger/usecases/unban_player_usecase` | lifts a running ban, forgets its offence and pardons the caller | `Unbanner` | | `ledger/usecases/inspect_player_usecase` | what the antibot holds on one caller | `Examiner` | | `ledger/usecases/revert_player_usecase` | gives back what one caller still holds | `Ledger`, `Map` | | `ledger/usecases/anonymize_takes_usecase` | flushes the ledger, then takes a deleted account off every take postgres keeps | `Ledger`, `Takes` | @@ -2337,13 +2337,13 @@ What is left after sessions. A player who solves Turnstile in a real browser and then runs a userscript holds a genuine session, and no address- or token-based check can tell them from a player. The signal that survives is **behavioural**. -**The whole of its API is eleven names**, and `internal/antibot/antibot.go` is all +**The whole of its API is twelve names**, and `internal/antibot/antibot.go` is all of it: `Config`, `Observer`, `Guard`, `New` and `Description` to wire it, plus -`Click`, `Point`, `Report`, `Sentence`, `Examination` and `Reading` — the types a caller writes down, because it builds two +`Click`, `Point`, `Report`, `Sentence`, `Pardon`, `Examination` and `Reading` — the types a caller writes down, because it builds two and is handed the others. A caller hands over the block and the two hooks it wants findings reported through, and gets back a `Guard` — one that refuses and bans nothing when the block is off, so the DI sequence wires it the same way either way — that answers `Attempted`, `Inspect`, `Committed`, `Caught`, `Missed`, `Fetched`, `Listened`, `Flagged`, `EnforcedSentence`, `LoadState`, `Run` and `Enabled`, plus `Ban`, -`Unban`, `Sentence`, `Enforcing` and `Examine` for the operator tools (see [Operator tools](#operator-tools-adminservice)). It is +`Unban`, `Sentence`, `Pardon`, `Enforcing` and `Examine` for the operator tools (see [Operator tools](#operator-tools-adminservice)). It is **one** `Run` whatever the file turned on: how many sweepers there are is this package's business, which is why `planet` registers one runner rather than one per sweeper. @@ -2534,6 +2534,11 @@ in memory no window of 10m (`suspicionWindow`), 15m (`trackWindow`) or 30m tables, each with its own ladder. An offence is forgotten only when an operator lifts its ban (`UnbanPlayer`): the row is written with one offence fewer, and with the ban ended when it was still running. +- **Each unban is a pardon, and every pardon is kept**: a row of `antibot.pardons` or + `antibot.account_pardons`, with when, the ban it lifted (`flags`, `offence`, + `banned_until`) and when it ends (`expires_at`). The `Record` carries the latest one, + read in the same query and written in the same transaction as the ban, under the same + lock. See [Manual bans](#manual-bans-findplayers-topplayers-banplayer-unbanplayer-revertplayer-inspectplayer). - **Evidence is the tile map's pattern.** `evidence.Persistence` is the port, `postgres_evidence_store` the adapter, `MemoryPersistence` (behind the `testing` tag) the fake. It lives in memory and is flushed every `saveInterval` (1m), with a @@ -3427,11 +3432,19 @@ For the patterns no watchdog catches but a person sees on the map. A player is a answers, so a restart keeps the caller unbanned: no restart and no SQL. It lifts the key it is given, so **a guest the jury banned needs two calls**, its account and its scope. A caller with no offence left is `NotFound` (`ErrNotBanned`), which a typo or the wrong half shows loudly; with `antiBot.enabled` false it is `FailedPrecondition`, as `BanPlayer` is. - - **It does not touch the evidence.** The watchdogs kept what they read before the ban, so a rule that still - reads `certain` bans the caller again on its first click after the unban, as a first offence. **Fix the rule first**, then unban. `InspectPlayer` says which rule it was. Forgetting the - evidence instead would only put the ban off until the same play reads the same way again, and it would take - a forget in each of the ten watchdogs, keyed by scope, by account or by wider prefix. `TestAnUnbanLeavesTheEvidenceSoTheJuryBansAgain` pins it. -- **`InspectPlayer(scope | account_id)`** answers how close the antibot is to a caller, which the `antibot ban` log line cannot: it is only written when a ban fires, so on 2026-09-14 a day of bots and no bans left nothing to read. It is `Guard.Examine`. **An account is read on the scope of its latest take** in the ledger, because the watchdogs judge scopes, with the bans on both; an account with no take inside the retention answers its bans alone and an empty `scope`. It changes nothing — no caller record is created, no watchdog is asked again, no ban is passed. It answers any running ban (`banned`, `bannedUntil`, `offence`, `flags`); per watchdog its `level` and `evidence`, aged the way the jury ages them (past `suspicionWindow` a verdict reads `clear` but keeps its evidence); `suspects` against `minSuspects` and `guilty`, what the jury would decide on a click now (the ban itself would still wait for `reflagInterval`); and the click summary the ban line carries. `tracked` false is a scope the jury has not seen inside its `trackWindow`. Parsed with `ledger.ParseCaller` and refused with `FailedPrecondition` when `antiBot.enabled` is false, as `BanPlayer` is. **A watchdog that reads `clear` has no evidence**: watchdogs only word the rule that tripped, so it says how close a caller is only once some rule has. + - **It pardons the key it lifts, for `antiBot.evidence.retention` (72h)** (`bans.Pardon`, in the same row + change). Until then, the jury flags nothing on that key, however guilty it reads. The watchdogs kept what they + read before the ban, and until 2026-10-20 a rule that still read `certain` banned the caller again five + minutes after the unban (`reflagInterval`). No evidence outlives retention, so when the pardon ends every + reading rests on play after the unban. + - **It does not touch the evidence.** Forgetting it would take a forget in each of the ten watchdogs, keyed by + scope, by account or by wider prefix. The cost: a rule that still reads the same play the same way bans again + once the pardon ends, as a first offence. **Fix the rule** if it is the rule. `InspectPlayer` says which one. + - **A pardoned caller convicts nobody** (`Book.Pardoned`, every key the jury would flag): not even the accounts + it rotates with. An accomplice that is pardoned is skipped and the rest of the ring still falls. + - **An operator's `BanPlayer` still bans a pardoned key**: the pardon only holds off the jury. + - The answer carries `pardonedUntil`. `TestAnUnbanPardonsTheCallerUntilTheEvidenceThatBannedItIsGone` pins it. +- **`InspectPlayer(scope | account_id)`** answers how close the antibot is to a caller, which the `antibot ban` log line cannot: it is only written when a ban fires, so on 2026-09-14 a day of bots and no bans left nothing to read. It is `Guard.Examine`. **An account is read on the scope of its latest take** in the ledger, because the watchdogs judge scopes, with the bans on both; an account with no take inside the retention answers its bans alone and an empty `scope`. It changes nothing — no caller record is created, no watchdog is asked again, no ban is passed. It answers any running ban (`banned`, `bannedUntil`, `offence`, `flags`) and pardon (`pardonedUntil`); per watchdog its `level` and `evidence`, aged the way the jury ages them (past `suspicionWindow` a verdict reads `clear` but keeps its evidence); `suspects` against `minSuspects` and `guilty`, what the jury would decide on a click now (the ban itself would still wait for `reflagInterval`); and the click summary the ban line carries. `tracked` false is a scope the jury has not seen inside its `trackWindow`. Parsed with `ledger.ParseCaller` and refused with `FailedPrecondition` when `antiBot.enabled` is false, as `BanPlayer` is. **A watchdog that reads `clear` has no evidence**: watchdogs only word the rule that tripped, so it says how close a caller is only once some rule has. - `audit_ban`, `audit_unban` and `audit_revert` log every call at Warn, as `audit_reassign` does. `FindPlayers`, `TopPlayers` and `InspectPlayer` are reads and log nothing. ### The clips job (`internal/clips/`, `cmd/clips`) @@ -3916,7 +3929,7 @@ There is no struct-tag validation and therefore no validator dependency — a ho - `antiBot.bans.reflagInterval` — how soon a banned caller can be judged again - `antiBot.jury.minSuspects` — how many watchdogs at `suspect` make a ban; one at `certain` bans alone; `stamina` and `defence` together count as one - `antiBot.jury.suspicionWindow`, `trackWindow`, `sweepInterval` — how long a verdict stands while another watchdog catches up, and how long a silent caller is remembered -- `antiBot.evidence.saveInterval`, `retention` — how often every watchdog's evidence and the jury's record are written to `antibot.evidence` (1m, and on shutdown), and the oldest kept (72h) on load and in memory. See [What survives a restart](#what-survives-a-restart) +- `antiBot.evidence.saveInterval`, `retention` — how often every watchdog's evidence and the jury's record are written to `antibot.evidence` (1m, and on shutdown), and the oldest kept (72h) on load and in memory. Also how long `UnbanPlayer` holds the jury off. See [What survives a restart](#what-survives-a-restart) - `antiBot.retaker.enabled`, `detector.reactionWindow`, `minReactions`, `maxSpread`, `maxMedian` — what counts as a reaction, how many are needed, and the band that reads `suspect` then `certain` - `antiBot.sequencer.enabled`, `detector.minSteps`, `minShare`, `certainSteps`, `certainShare` — how long a run of constant-stride clicks must be, and how much of it must sit at that stride - `antiBot.metronome.enabled`, `detector.maxGap`, `maxSpread`, `minClicks`, `certainFor`, `certainClicks` — what ends a run, how tight its gaps must be, and how long it must hold; `detector.shape.maxGap`, `clicks`, `maxSkew`, `certainClicks`, `certainSkew` — the longest gap sampled, and the skew of the last gaps that reads each level (unset, it only measures); `detector.stamina.slice`, `clicks`, `window`, `minBusy`, `certainBusy` — what makes a slice busy, and the busy time inside the window that reads each level (unset, it only measures) diff --git a/apps/backend/generated/proto/planet/v1/admin.pb.go b/apps/backend/generated/proto/planet/v1/admin.pb.go index ded484b32..c492a96d2 100644 --- a/apps/backend/generated/proto/planet/v1/admin.pb.go +++ b/apps/backend/generated/proto/planet/v1/admin.pb.go @@ -547,6 +547,7 @@ type InspectPlayerResponse struct { BannedUntil *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=banned_until,json=bannedUntil,proto3" json:"banned_until,omitempty"` Offence uint32 `protobuf:"varint,5,opt,name=offence,proto3" json:"offence,omitempty"` Flags uint32 `protobuf:"varint,6,opt,name=flags,proto3" json:"flags,omitempty"` + PardonedUntil *timestamppb.Timestamp `protobuf:"bytes,18,opt,name=pardoned_until,json=pardonedUntil,proto3" json:"pardoned_until,omitempty"` Readings []*WatchdogReading `protobuf:"bytes,7,rep,name=readings,proto3" json:"readings,omitempty"` Suspects uint32 `protobuf:"varint,8,opt,name=suspects,proto3" json:"suspects,omitempty"` MinSuspects uint32 `protobuf:"varint,9,opt,name=min_suspects,json=minSuspects,proto3" json:"min_suspects,omitempty"` @@ -640,6 +641,13 @@ func (x *InspectPlayerResponse) GetFlags() uint32 { return 0 } +func (x *InspectPlayerResponse) GetPardonedUntil() *timestamppb.Timestamp { + if x != nil { + return x.PardonedUntil + } + return nil +} + func (x *InspectPlayerResponse) GetReadings() []*WatchdogReading { if x != nil { return x.Readings @@ -1321,6 +1329,7 @@ type UnbanPlayerResponse struct { AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` Offence uint32 `protobuf:"varint,3,opt,name=offence,proto3" json:"offence,omitempty"` BannedUntil *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=banned_until,json=bannedUntil,proto3" json:"banned_until,omitempty"` + PardonedUntil *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=pardoned_until,json=pardonedUntil,proto3" json:"pardoned_until,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1383,6 +1392,13 @@ func (x *UnbanPlayerResponse) GetBannedUntil() *timestamppb.Timestamp { return nil } +func (x *UnbanPlayerResponse) GetPardonedUntil() *timestamppb.Timestamp { + if x != nil { + return x.PardonedUntil + } + return nil +} + type RevertPlayerRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Scope string `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` @@ -1700,7 +1716,7 @@ const file_planet_v1_admin_proto_rawDesc = "" + "\x14InspectPlayerRequest\x12\x14\n" + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x1d\n" + "\n" + - "account_id\x18\x02 \x01(\tR\taccountId\"\xca\x05\n" + + "account_id\x18\x02 \x01(\tR\taccountId\"\x8d\x06\n" + "\x15InspectPlayerResponse\x12\x14\n" + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x1d\n" + "\n" + @@ -1709,7 +1725,8 @@ const file_planet_v1_admin_proto_rawDesc = "" + "\x06banned\x18\x03 \x01(\bH\x01R\x06banned\x88\x01\x01\x12=\n" + "\fbanned_until\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vbannedUntil\x12\x18\n" + "\aoffence\x18\x05 \x01(\rR\aoffence\x12\x14\n" + - "\x05flags\x18\x06 \x01(\rR\x05flags\x126\n" + + "\x05flags\x18\x06 \x01(\rR\x05flags\x12A\n" + + "\x0epardoned_until\x18\x12 \x01(\v2\x1a.google.protobuf.TimestampR\rpardonedUntil\x126\n" + "\breadings\x18\a \x03(\v2\x1a.planet.v1.WatchdogReadingR\breadings\x12\x1a\n" + "\bsuspects\x18\b \x01(\rR\bsuspects\x12!\n" + "\fmin_suspects\x18\t \x01(\rR\vminSuspects\x12\x1b\n" + @@ -1778,13 +1795,14 @@ const file_planet_v1_admin_proto_rawDesc = "" + "\x12UnbanPlayerRequest\x12\x14\n" + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x1d\n" + "\n" + - "account_id\x18\x02 \x01(\tR\taccountId\"\xa3\x01\n" + + "account_id\x18\x02 \x01(\tR\taccountId\"\xe6\x01\n" + "\x13UnbanPlayerResponse\x12\x14\n" + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x1d\n" + "\n" + "account_id\x18\x02 \x01(\tR\taccountId\x12\x18\n" + "\aoffence\x18\x03 \x01(\rR\aoffence\x12=\n" + - "\fbanned_until\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vbannedUntil\"c\n" + + "\fbanned_until\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vbannedUntil\x12A\n" + + "\x0epardoned_until\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\rpardonedUntil\"c\n" + "\x13RevertPlayerRequest\x12\x14\n" + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x1d\n" + "\n" + @@ -1880,45 +1898,47 @@ var file_planet_v1_admin_proto_depIdxs = []int32{ 26, // 8: planet.v1.GrantChargesResponse.before:type_name -> planet.v1.ChargesHeld 26, // 9: planet.v1.GrantChargesResponse.after:type_name -> planet.v1.ChargesHeld 23, // 10: planet.v1.InspectPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp - 9, // 11: planet.v1.InspectPlayerResponse.readings:type_name -> planet.v1.WatchdogReading - 27, // 12: planet.v1.InspectPlayerResponse.active_for:type_name -> google.protobuf.Duration - 27, // 13: planet.v1.InspectPlayerResponse.longest_gap:type_name -> google.protobuf.Duration - 23, // 14: planet.v1.InspectPlayerResponse.last_click_at:type_name -> google.protobuf.Timestamp - 23, // 15: planet.v1.WatchdogReading.at:type_name -> google.protobuf.Timestamp - 14, // 16: planet.v1.FindPlayersResponse.players:type_name -> planet.v1.Player - 14, // 17: planet.v1.TopPlayersResponse.players:type_name -> planet.v1.Player - 23, // 18: planet.v1.Player.first_at:type_name -> google.protobuf.Timestamp - 23, // 19: planet.v1.Player.last_at:type_name -> google.protobuf.Timestamp - 23, // 20: planet.v1.Player.banned_until:type_name -> google.protobuf.Timestamp - 27, // 21: planet.v1.Player.active_for:type_name -> google.protobuf.Duration - 27, // 22: planet.v1.BanPlayerRequest.duration:type_name -> google.protobuf.Duration - 23, // 23: planet.v1.BanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp - 23, // 24: planet.v1.UnbanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp - 21, // 25: planet.v1.AdminService.ReassignCountry:input_type -> planet.v1.ReassignCountryRequest - 10, // 26: planet.v1.AdminService.FindPlayers:input_type -> planet.v1.FindPlayersRequest - 12, // 27: planet.v1.AdminService.TopPlayers:input_type -> planet.v1.TopPlayersRequest - 15, // 28: planet.v1.AdminService.BanPlayer:input_type -> planet.v1.BanPlayerRequest - 17, // 29: planet.v1.AdminService.UnbanPlayer:input_type -> planet.v1.UnbanPlayerRequest - 19, // 30: planet.v1.AdminService.RevertPlayer:input_type -> planet.v1.RevertPlayerRequest - 5, // 31: planet.v1.AdminService.PaintRandomTiles:input_type -> planet.v1.PaintRandomTilesRequest - 7, // 32: planet.v1.AdminService.InspectPlayer:input_type -> planet.v1.InspectPlayerRequest - 3, // 33: planet.v1.AdminService.GrantCharges:input_type -> planet.v1.GrantChargesRequest - 0, // 34: planet.v1.AdminService.GetReplay:input_type -> planet.v1.GetReplayRequest - 22, // 35: planet.v1.AdminService.ReassignCountry:output_type -> planet.v1.ReassignCountryResponse - 11, // 36: planet.v1.AdminService.FindPlayers:output_type -> planet.v1.FindPlayersResponse - 13, // 37: planet.v1.AdminService.TopPlayers:output_type -> planet.v1.TopPlayersResponse - 16, // 38: planet.v1.AdminService.BanPlayer:output_type -> planet.v1.BanPlayerResponse - 18, // 39: planet.v1.AdminService.UnbanPlayer:output_type -> planet.v1.UnbanPlayerResponse - 20, // 40: planet.v1.AdminService.RevertPlayer:output_type -> planet.v1.RevertPlayerResponse - 6, // 41: planet.v1.AdminService.PaintRandomTiles:output_type -> planet.v1.PaintRandomTilesResponse - 8, // 42: planet.v1.AdminService.InspectPlayer:output_type -> planet.v1.InspectPlayerResponse - 4, // 43: planet.v1.AdminService.GrantCharges:output_type -> planet.v1.GrantChargesResponse - 1, // 44: planet.v1.AdminService.GetReplay:output_type -> planet.v1.GetReplayResponse - 35, // [35:45] is the sub-list for method output_type - 25, // [25:35] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 23, // 11: planet.v1.InspectPlayerResponse.pardoned_until:type_name -> google.protobuf.Timestamp + 9, // 12: planet.v1.InspectPlayerResponse.readings:type_name -> planet.v1.WatchdogReading + 27, // 13: planet.v1.InspectPlayerResponse.active_for:type_name -> google.protobuf.Duration + 27, // 14: planet.v1.InspectPlayerResponse.longest_gap:type_name -> google.protobuf.Duration + 23, // 15: planet.v1.InspectPlayerResponse.last_click_at:type_name -> google.protobuf.Timestamp + 23, // 16: planet.v1.WatchdogReading.at:type_name -> google.protobuf.Timestamp + 14, // 17: planet.v1.FindPlayersResponse.players:type_name -> planet.v1.Player + 14, // 18: planet.v1.TopPlayersResponse.players:type_name -> planet.v1.Player + 23, // 19: planet.v1.Player.first_at:type_name -> google.protobuf.Timestamp + 23, // 20: planet.v1.Player.last_at:type_name -> google.protobuf.Timestamp + 23, // 21: planet.v1.Player.banned_until:type_name -> google.protobuf.Timestamp + 27, // 22: planet.v1.Player.active_for:type_name -> google.protobuf.Duration + 27, // 23: planet.v1.BanPlayerRequest.duration:type_name -> google.protobuf.Duration + 23, // 24: planet.v1.BanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp + 23, // 25: planet.v1.UnbanPlayerResponse.banned_until:type_name -> google.protobuf.Timestamp + 23, // 26: planet.v1.UnbanPlayerResponse.pardoned_until:type_name -> google.protobuf.Timestamp + 21, // 27: planet.v1.AdminService.ReassignCountry:input_type -> planet.v1.ReassignCountryRequest + 10, // 28: planet.v1.AdminService.FindPlayers:input_type -> planet.v1.FindPlayersRequest + 12, // 29: planet.v1.AdminService.TopPlayers:input_type -> planet.v1.TopPlayersRequest + 15, // 30: planet.v1.AdminService.BanPlayer:input_type -> planet.v1.BanPlayerRequest + 17, // 31: planet.v1.AdminService.UnbanPlayer:input_type -> planet.v1.UnbanPlayerRequest + 19, // 32: planet.v1.AdminService.RevertPlayer:input_type -> planet.v1.RevertPlayerRequest + 5, // 33: planet.v1.AdminService.PaintRandomTiles:input_type -> planet.v1.PaintRandomTilesRequest + 7, // 34: planet.v1.AdminService.InspectPlayer:input_type -> planet.v1.InspectPlayerRequest + 3, // 35: planet.v1.AdminService.GrantCharges:input_type -> planet.v1.GrantChargesRequest + 0, // 36: planet.v1.AdminService.GetReplay:input_type -> planet.v1.GetReplayRequest + 22, // 37: planet.v1.AdminService.ReassignCountry:output_type -> planet.v1.ReassignCountryResponse + 11, // 38: planet.v1.AdminService.FindPlayers:output_type -> planet.v1.FindPlayersResponse + 13, // 39: planet.v1.AdminService.TopPlayers:output_type -> planet.v1.TopPlayersResponse + 16, // 40: planet.v1.AdminService.BanPlayer:output_type -> planet.v1.BanPlayerResponse + 18, // 41: planet.v1.AdminService.UnbanPlayer:output_type -> planet.v1.UnbanPlayerResponse + 20, // 42: planet.v1.AdminService.RevertPlayer:output_type -> planet.v1.RevertPlayerResponse + 6, // 43: planet.v1.AdminService.PaintRandomTiles:output_type -> planet.v1.PaintRandomTilesResponse + 8, // 44: planet.v1.AdminService.InspectPlayer:output_type -> planet.v1.InspectPlayerResponse + 4, // 45: planet.v1.AdminService.GrantCharges:output_type -> planet.v1.GrantChargesResponse + 1, // 46: planet.v1.AdminService.GetReplay:output_type -> planet.v1.GetReplayResponse + 37, // [37:47] is the sub-list for method output_type + 27, // [27:37] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_planet_v1_admin_proto_init() } diff --git a/apps/backend/internal/antibot/antibot.go b/apps/backend/internal/antibot/antibot.go index 42fd8fb79..ee7d1bdb9 100644 --- a/apps/backend/internal/antibot/antibot.go +++ b/apps/backend/internal/antibot/antibot.go @@ -32,6 +32,7 @@ type ( Point = detect.Point Report = detect.Report Sentence = bans.Sentence + Pardon = bans.Pardon Examination = detect.Examination Reading = detect.Reading @@ -349,7 +350,7 @@ func build( juryConfig := config.Jury.WithDefaults() - banner := bans.NewBook(config.Bans, clock, scopeBans, accountBans) + banner := bans.NewBook(config.Bans, config.Evidence.WithDefaults().Retention, clock, scopeBans, accountBans) g.banner = banner g.jury = jury.New(juryConfig, banner, clock, juryHooks(observer), watchdogs...) @@ -521,6 +522,14 @@ func (g *Guard) LastSentence(ctx context.Context, scope, account string) (Senten return g.banner.LastSentence(ctx, bans.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. } +func (g *Guard) Pardon(ctx context.Context, scope, account string) (Pardon, bool, error) { + if !g.Enabled() { + return Pardon{}, false, nil + } + + return g.banner.Pardon(ctx, bans.Caller{Scope: scope, Account: account}) //nolint:wrapcheck // the banner already named what failed. +} + func (g *Guard) Examine(ctx context.Context, scope, account string) (Examination, error) { if !g.Enabled() { return Examination{Scope: scope, Account: account}, nil @@ -529,7 +538,9 @@ func (g *Guard) Examine(ctx context.Context, scope, account string) (Examination examination := g.jury.Examine(scope) examination.Account = account - sentence, banned, err := g.banner.Sentence(ctx, bans.Caller{Scope: scope, Account: account}) + caller := bans.Caller{Scope: scope, Account: account} + + sentence, banned, err := g.banner.Sentence(ctx, caller) if err != nil { return Examination{}, err //nolint:wrapcheck // the banner already named what failed. } @@ -540,6 +551,14 @@ func (g *Guard) Examine(ctx context.Context, scope, account string) (Examination examination.BannedUntil = sentence.Until } + pardon, pardoned, err := g.banner.Pardon(ctx, caller) + if err != nil { + return Examination{}, err //nolint:wrapcheck // the banner already named what failed. + } + if pardoned { + examination.PardonedUntil = pardon.Until + } + return examination, nil } diff --git a/apps/backend/internal/antibot/antibot_test.go b/apps/backend/internal/antibot/antibot_test.go index fbe32adc3..e58713b3c 100644 --- a/apps/backend/internal/antibot/antibot_test.go +++ b/apps/backend/internal/antibot/antibot_test.go @@ -979,7 +979,7 @@ func staminaBansAlone(config *antibot.Config) { config.Metronome.Detector.Stamina.CertainBusy = 5*time.Hour + 30*time.Minute } -func TestAnUnbanLeavesTheEvidenceSoTheJuryBansAgain(t *testing.T) { +func TestAnUnbanPardonsTheCallerUntilTheEvidenceThatBannedItIsGone(t *testing.T) { s := newStack(t, staminaBansAlone) //nolint:gosec // seeded test PRNG @@ -990,32 +990,39 @@ func TestAnUnbanLeavesTheEvidenceSoTheJuryBansAgain(t *testing.T) { s.clock.Advance(4500*time.Millisecond + time.Duration(random.Int64N(int64(time.Second)))) return s.clickAs(scope, "night", 180000+uint32(random.IntN(60000)), "DZ") } - - start := s.clock.Now() - var caught bool - for !caught && s.clock.Now().Sub(start) < 8*time.Hour { - for i := 0; i < 300 && !caught; i++ { - caught = click() - } - if !caught { + playUntilCaught := func() bool { + start := s.clock.Now() + for s.clock.Now().Sub(start) < 8*time.Hour { + for range 300 { + if click() { + return true + } + } s.clock.Advance(5 * time.Minute) } + return false } - require.True(t, caught) - unbannedAt := s.clock.Now() + require.True(t, playUntilCaught()) + reports := len(s.reports) + s.unban(scope, "night") + unbannedAt := s.clock.Now() - var bannedAgainAfter time.Duration - for bannedAgainAfter == 0 && s.clock.Now().Sub(unbannedAt) < time.Hour { - if click() { - bannedAgainAfter = s.clock.Now().Sub(unbannedAt) - } + for s.clock.Now().Sub(unbannedAt) < time.Hour { + require.False(t, click(), "the evidence that banned it still reads, and it is pardoned") } + assert.Len(t, s.reports, reports) + + examination := s.examine(scope, "night") + assert.True(t, examination.Guilty) + assert.False(t, examination.Banned) + assert.Equal(t, unbannedAt.Add(72*time.Hour), examination.PardonedUntil) + + s.clock.Advance(72 * time.Hour) + assert.True(t, s.examine(scope, "night").PardonedUntil.IsZero()) - require.NotZero(t, bannedAgainAfter, "the watchdogs still read the evidence that banned it") - assert.GreaterOrEqual(t, bannedAgainAfter, 5*time.Minute, "not before the reflag interval") - assert.Less(t, bannedAgainAfter, 6*time.Minute) + require.True(t, playUntilCaught(), "the same play read again on new evidence bans again") assert.Equal(t, 1, s.reports[len(s.reports)-1].Offence, "the lifted ban is no offence") } diff --git a/apps/backend/internal/antibot/internal/bans/banner.go b/apps/backend/internal/antibot/internal/bans/banner.go index e25e1fe02..d3f7808d6 100644 --- a/apps/backend/internal/antibot/internal/bans/banner.go +++ b/apps/backend/internal/antibot/internal/bans/banner.go @@ -34,12 +34,13 @@ type Sentence struct { Until time.Time } -func New(config Config, clock cptime.Clock, store Store) *Banner { - return &Banner{config: config.withDefaults(), clock: clock, store: store} +func New(config Config, pardon time.Duration, clock cptime.Clock, store Store) *Banner { + return &Banner{config: config.withDefaults(), pardon: pardon, clock: clock, store: store} } type Banner struct { config Config + pardon time.Duration clock cptime.Clock store Store } @@ -52,6 +53,10 @@ func (r Record) liftable(now time.Time) bool { return r.running(now) || r.Offences > 0 } +func (p Pardon) running(now time.Time) bool { + return now.Before(p.Until) +} + func (r Record) sentence() Sentence { return Sentence{Flags: r.Flags, Offence: r.Offences, Until: r.ExpiresAt} } @@ -63,6 +68,10 @@ func (r Record) extendedTo(until time.Time) Record { return r } +func (b *Banner) flaggable(record Record, now time.Time) bool { + return !record.Pardon.running(now) && !now.Before(record.LastFlaggedAt.Add(b.config.ReflagInterval)) +} + func (b *Banner) flagged(record Record, now time.Time) Record { if !record.running(now) { record.Offences++ @@ -84,7 +93,8 @@ func (b *Banner) banned(record Record, now time.Time, duration time.Duration) Re return record.extendedTo(now.Add(duration)) } -func lifted(record Record, now time.Time) Record { +func (b *Banner) lifted(record Record, now time.Time) Record { + record.Pardon = Pardon{At: now, Lifted: record.sentence(), Until: now.Add(b.pardon)} record.Offences = max(record.Offences-1, 0) if record.running(now) { record.ExpiresAt = now @@ -104,8 +114,8 @@ func (b *Banner) Flag(ctx context.Context, scope string) (Sentence, bool, error) accepted bool ) - if err := b.store.Change(ctx, scope, func(record Record, found bool) (Record, bool) { - if found && now.Before(record.LastFlaggedAt.Add(b.config.ReflagInterval)) { + if err := b.store.Change(ctx, scope, func(record Record, _ bool) (Record, bool) { + if !b.flaggable(record, now) { sentence = record.sentence() return record, false } @@ -150,7 +160,7 @@ func (b *Banner) Unban(ctx context.Context, scope string) error { if !found || !record.liftable(now) { return record, false } - return lifted(record, now), true + return b.lifted(record, now), true }); err != nil { return fmt.Errorf("failed to unban %q: %w", scope, err) } @@ -186,6 +196,22 @@ func (b *Banner) sentenceOf( return record.sentence(), true, nil } +func (b *Banner) Pardon(ctx context.Context, key string) (Pardon, bool, error) { + if key == "" { + return Pardon{}, false, nil + } + + record, found, err := b.store.Record(ctx, key) + if err != nil { + return Pardon{}, false, fmt.Errorf("failed to read the pardon of %q: %w", key, err) + } + if !found || !record.Pardon.running(b.clock.Now()) { + return Pardon{}, false, nil + } + + return record.Pardon, true, nil +} + func (b *Banner) duration(offence int) time.Duration { ladder := b.config.BanDurations if offence > len(ladder) { diff --git a/apps/backend/internal/antibot/internal/bans/banner_test.go b/apps/backend/internal/antibot/internal/bans/banner_test.go index b7c727561..9158b82dd 100644 --- a/apps/backend/internal/antibot/internal/bans/banner_test.go +++ b/apps/backend/internal/antibot/internal/bans/banner_test.go @@ -15,7 +15,10 @@ func newClock() *cptime.FixedClock { return cptime.NewFixedClock(time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)) } -const threeYears = 3 * 365 * 24 * time.Hour +const ( + threeYears = 3 * 365 * 24 * time.Hour + pardon = 72 * time.Hour +) func newBanner(t *testing.T, config bans.Config, clock cptime.Clock) banner { t.Helper() @@ -24,7 +27,7 @@ func newBanner(t *testing.T, config bans.Config, clock cptime.Clock) banner { func bannerOver(t *testing.T, config bans.Config, clock cptime.Clock, store bans.Store) banner { t.Helper() - return banner{t: t, inner: bans.New(config, clock, store)} + return banner{t: t, inner: bans.New(config, pardon, clock, store)} } func config() bans.Config { @@ -232,12 +235,84 @@ func TestAnUnbanEndsTheBanNowAndForgetsItsOffence(t *testing.T) { assert.False(t, running) assert.Equal(t, 0, banner.Flagged()) + clock.Advance(pardon) sentence, accepted := banner.Flag("player") require.True(t, accepted) assert.Equal(t, 1, sentence.Offence, "the next ban starts the ladder where the lifted one did") assert.Equal(t, clock.Now().Add(time.Hour), sentence.Until) } +func TestAnUnbanPardonsTheKeySoNoFlagBansItUntilThePardonEnds(t *testing.T) { + clock := newClock() + banner := newBanner(t, config(), clock) + + banned, _ := banner.Flag("player") + clock.Advance(10 * time.Minute) + banner.Unban("player") + unbannedAt := clock.Now() + + pardoned, found := banner.Pardon("player") + require.True(t, found) + assert.Equal(t, bans.Pardon{At: unbannedAt, Lifted: banned, Until: unbannedAt.Add(pardon)}, pardoned) + + clock.Advance(pardon - time.Second) + _, accepted := banner.Flag("player") + assert.False(t, accepted) + assert.False(t, banner.Banned("player")) + + clock.Advance(time.Second) + _, found = banner.Pardon("player") + assert.False(t, found, "a pardon that ended is no pardon") + _, accepted = banner.Flag("player") + assert.True(t, accepted) + assert.True(t, banner.Banned("player")) +} + +func TestAnOperatorStillBansAPardonedKey(t *testing.T) { + clock := newClock() + banner := newBanner(t, config(), clock) + + banner.Flag("player") + banner.Unban("player") + + sentence := banner.Ban("player", time.Hour) + assert.Equal(t, 1, sentence.Offence) + assert.True(t, banner.Banned("player")) +} + +func TestAPardonIsOnlyTheKeyItNames(t *testing.T) { + clock := newClock() + banner := newBanner(t, config(), clock) + + banner.Flag("player") + banner.Unban("player") + + _, found := banner.Pardon("") + assert.False(t, found) + _, found = banner.Pardon("nobody") + assert.False(t, found) + _, accepted := banner.Flag("neighbour") + assert.True(t, accepted) +} + +func TestEachUnbanIsANewPardon(t *testing.T) { + clock := newClock() + banner := newBanner(t, config(), clock) + + banner.Ban("player", 0) + clock.Advance(2 * time.Hour) + banner.Ban("player", 0) + banner.Unban("player") + clock.Advance(time.Hour) + banner.Unban("player") + + pardoned, found := banner.Pardon("player") + require.True(t, found) + assert.Equal(t, clock.Now(), pardoned.At) + assert.Equal(t, clock.Now().Add(pardon), pardoned.Until) + assert.Equal(t, 1, pardoned.Lifted.Offence, "the second unban lifts the first offence") +} + func TestAnUnbanForgetsOnlyTheOffenceItLifts(t *testing.T) { clock := newClock() banner := newBanner(t, config(), clock) @@ -264,6 +339,7 @@ func TestAnUnbanOfAnEndedBanForgetsItsOffenceAndKeepsItsEnd(t *testing.T) { assert.Equal(t, 0, store.Stored()["player"].Offences) assert.Equal(t, banned.Until, store.Stored()["player"].ExpiresAt, "a ban that ended is not moved") + clock.Advance(pardon) sentence, accepted := banner.Flag("player") require.True(t, accepted) assert.Equal(t, 1, sentence.Offence, "the next ban starts the ladder where the lifted one did") diff --git a/apps/backend/internal/antibot/internal/bans/book.go b/apps/backend/internal/antibot/internal/bans/book.go index a50f339d7..59c6d43b5 100644 --- a/apps/backend/internal/antibot/internal/bans/book.go +++ b/apps/backend/internal/antibot/internal/bans/book.go @@ -14,10 +14,10 @@ type Caller struct { SignedIn bool } -func NewBook(config Config, clock cptime.Clock, scopes, accounts Store) *Book { +func NewBook(config Config, pardon time.Duration, clock cptime.Clock, scopes, accounts Store) *Book { return &Book{ - scopes: New(config, clock, scopes), - accounts: New(config, clock, accounts), + scopes: New(config, pardon, clock, scopes), + accounts: New(config, pardon, clock, accounts), } } @@ -94,6 +94,32 @@ func (b *Book) sentenceOf( return latest(scope, account), onScope || onAccount, nil } +func (b *Book) Pardoned(ctx context.Context, caller Caller) (bool, error) { + for _, target := range b.targets(caller) { + _, pardoned, err := target.banner.Pardon(ctx, target.key) + if err != nil || !pardoned { + return false, err + } + } + return true, nil +} + +func (b *Book) Pardon(ctx context.Context, caller Caller) (Pardon, bool, error) { + scope, onScope, err := b.scopes.Pardon(ctx, caller.Scope) + if err != nil { + return Pardon{}, false, err + } + account, onAccount, err := b.accounts.Pardon(ctx, caller.Account) + if err != nil { + return Pardon{}, false, err + } + + if onAccount && account.Until.After(scope.Until) { + return account, true, nil + } + return scope, onScope, nil +} + func (b *Book) Flagged(ctx context.Context) (int, error) { scopes, err := b.scopes.Flagged(ctx) if err != nil { diff --git a/apps/backend/internal/antibot/internal/bans/book_test.go b/apps/backend/internal/antibot/internal/bans/book_test.go index 03ff7952b..3bb4fd26d 100644 --- a/apps/backend/internal/antibot/internal/bans/book_test.go +++ b/apps/backend/internal/antibot/internal/bans/book_test.go @@ -15,7 +15,7 @@ func newBook(t *testing.T, clock cptime.Clock) (book, *bans.MemoryStore, *bans.M t.Helper() scopes, accounts := bans.NewMemoryStore(), bans.NewMemoryStore() - return book{t: t, inner: bans.NewBook(config(), clock, scopes, accounts)}, scopes, accounts + return book{t: t, inner: bans.NewBook(config(), pardon, clock, scopes, accounts)}, scopes, accounts } func TestAFlaggedGuestIsBannedOnItsAccountAndItsScope(t *testing.T) { @@ -90,7 +90,7 @@ func TestAccountBansAreKeptApart(t *testing.T) { assert.NotContains(t, scopes.Stored(), "guest") assert.Contains(t, accounts.Stored(), "guest") - after := book{t: t, inner: bans.NewBook(config(), clock, scopes, accounts)} + after := book{t: t, inner: bans.NewBook(config(), pardon, clock, scopes, accounts)} assert.True(t, after.Banned(bans.Caller{Scope: "5.6.7.8", Account: "guest"})) } @@ -129,3 +129,52 @@ func TestTheLastSentenceOfAnAccountIsReadAfterItsBanEnded(t *testing.T) { assert.False(t, found) assert.Equal(t, 0, accounts.Stored()["player"].Offences) } + +func TestAGuestIsPardonedOnceItsAccountAndItsScopeAreUnbanned(t *testing.T) { + book, _, _ := newBook(t, newClock()) + guest := bans.Caller{Scope: "1.2.3.4", Account: "guest"} + + book.Flag(guest) + + book.Unban(bans.Caller{Account: "guest"}) + assert.False(t, book.Pardoned(guest), "its scope is not pardoned") + + book.Unban(bans.Caller{Scope: "1.2.3.4"}) + assert.True(t, book.Pardoned(guest)) + _, accepted := book.Flag(guest) + assert.False(t, accepted) + assert.False(t, book.Banned(guest)) +} + +func TestASignedInAccountIsPardonedOnItsAccountAlone(t *testing.T) { + book, _, _ := newBook(t, newClock()) + player := bans.Caller{Scope: "campus", Account: "player", SignedIn: true} + + book.Flag(player) + book.Unban(bans.Caller{Account: "player"}) + + assert.True(t, book.Pardoned(player)) + assert.False(t, book.Pardoned(bans.Caller{Scope: "campus", Account: "classmate", SignedIn: true})) +} + +func TestThePardonIsTheOneThatEndsLast(t *testing.T) { + clock := newClock() + book, _, _ := newBook(t, clock) + + book.Ban(bans.Caller{Scope: "1.2.3.4"}, time.Hour) + book.Ban(bans.Caller{Account: "guest"}, time.Hour) + book.Unban(bans.Caller{Scope: "1.2.3.4"}) + clock.Advance(time.Minute) + book.Unban(bans.Caller{Account: "guest"}) + + pardoned, found := book.Pardon(bans.Caller{Scope: "1.2.3.4", Account: "guest"}) + require.True(t, found) + assert.Equal(t, clock.Now().Add(pardon), pardoned.Until) + + pardoned, found = book.Pardon(bans.Caller{Scope: "1.2.3.4", Account: "someone-else"}) + require.True(t, found) + assert.Equal(t, clock.Now().Add(pardon-time.Minute), pardoned.Until) + + _, found = book.Pardon(bans.Caller{Scope: "5.6.7.8", Account: "someone-else"}) + assert.False(t, found) +} diff --git a/apps/backend/internal/antibot/internal/bans/helpers_test.go b/apps/backend/internal/antibot/internal/bans/helpers_test.go index bb10355c5..2e8815984 100644 --- a/apps/backend/internal/antibot/internal/bans/helpers_test.go +++ b/apps/backend/internal/antibot/internal/bans/helpers_test.go @@ -47,6 +47,13 @@ func (b banner) LastSentence(scope string) (bans.Sentence, bool) { return sentence, found } +func (b banner) Pardon(scope string) (bans.Pardon, bool) { + b.t.Helper() + pardon, found, err := b.inner.Pardon(b.t.Context(), scope) + require.NoError(b.t, err) + return pardon, found +} + func (b banner) Banned(scope string) bool { b.t.Helper() _, banned, err := b.inner.EnforcedSentence(b.t.Context(), scope) @@ -101,6 +108,20 @@ func (b book) LastSentence(caller bans.Caller) (bans.Sentence, bool) { return sentence, found } +func (b book) Pardoned(caller bans.Caller) bool { + b.t.Helper() + pardoned, err := b.inner.Pardoned(b.t.Context(), caller) + require.NoError(b.t, err) + return pardoned +} + +func (b book) Pardon(caller bans.Caller) (bans.Pardon, bool) { + b.t.Helper() + pardon, found, err := b.inner.Pardon(b.t.Context(), caller) + require.NoError(b.t, err) + return pardon, found +} + func (b book) Banned(caller bans.Caller) bool { b.t.Helper() _, banned, err := b.inner.EnforcedSentence(b.t.Context(), caller) diff --git a/apps/backend/internal/antibot/internal/bans/store.go b/apps/backend/internal/antibot/internal/bans/store.go index 9b0608568..0c49b2df2 100644 --- a/apps/backend/internal/antibot/internal/bans/store.go +++ b/apps/backend/internal/antibot/internal/bans/store.go @@ -11,6 +11,13 @@ type Record struct { Offences int ExpiresAt time.Time LastFlaggedAt time.Time + Pardon Pardon +} + +type Pardon struct { + At time.Time + Lifted Sentence + Until time.Time } type Store interface { diff --git a/apps/backend/internal/antibot/internal/bans/store_contract_testing.go b/apps/backend/internal/antibot/internal/bans/store_contract_testing.go index cdc302491..1e8aec0e2 100644 --- a/apps/backend/internal/antibot/internal/bans/store_contract_testing.go +++ b/apps/backend/internal/antibot/internal/bans/store_contract_testing.go @@ -72,6 +72,47 @@ func (s *StoreContractSuite) TestAChangeSeesWhatWasKeptAndReplacesIt() { s.Equal(Record{Key: s.Key(1), Flags: 2, ExpiresAt: contractAt.Add(-time.Minute)}, record) } +func (s *StoreContractSuite) TestAPardonIsKeptAndReadBack() { + pardoned := Record{ + Key: s.Key(1), Flags: 2, Offences: 0, ExpiresAt: contractAt, LastFlaggedAt: contractAt.Add(-time.Hour), + Pardon: Pardon{ + At: contractAt, + Lifted: Sentence{Flags: 2, Offence: 1, Until: contractAt.Add(24 * time.Hour)}, + Until: contractAt.Add(72 * time.Hour), + }, + } + s.keep(pardoned) + + record, found := s.record(s.Key(1)) + s.Require().True(found) + s.Equal(pardoned, record) +} + +func (s *StoreContractSuite) TestTheLatestPardonIsRead() { + first := Pardon{At: contractAt, Lifted: Sentence{Flags: 1, Offence: 2, Until: contractAt.Add(time.Hour)}, Until: contractAt.Add(time.Hour)} + second := Pardon{At: contractAt.Add(time.Minute), Lifted: Sentence{Flags: 1, Offence: 1, Until: contractAt}, Until: contractAt.Add(2 * time.Hour)} + s.keep(Record{Key: s.Key(1), Flags: 1, Offences: 1, ExpiresAt: contractAt, Pardon: first}) + s.keep(Record{Key: s.Key(1), Flags: 1, ExpiresAt: contractAt, Pardon: second}) + + record, _ := s.record(s.Key(1)) + s.Equal(second, record.Pardon) +} + +func (s *StoreContractSuite) TestAChangeKeepsThePardonItSaw() { + pardon := Pardon{At: contractAt, Lifted: Sentence{Flags: 1, Offence: 1, Until: contractAt}, Until: contractAt.Add(time.Hour)} + s.keep(Record{Key: s.Key(1), Flags: 1, ExpiresAt: contractAt, Pardon: pardon}) + + s.Require().NoError(s.store.Change(s.T().Context(), s.Key(1), func(record Record, _ bool) (Record, bool) { + s.Equal(pardon, record.Pardon) + record.Flags++ + return record, true + })) + + record, _ := s.record(s.Key(1)) + s.Equal(2, record.Flags) + s.Equal(pardon, record.Pardon) +} + func (s *StoreContractSuite) TestAChangeOfAKeyNeverKeptStartsFromNothing() { s.Require().NoError(s.store.Change(s.T().Context(), s.Key(1), func(record Record, found bool) (Record, bool) { s.False(found) diff --git a/apps/backend/internal/antibot/internal/bans/store_test.go b/apps/backend/internal/antibot/internal/bans/store_test.go index 95e7baf8e..7001f1340 100644 --- a/apps/backend/internal/antibot/internal/bans/store_test.go +++ b/apps/backend/internal/antibot/internal/bans/store_test.go @@ -67,7 +67,7 @@ func TestAFailingStoreIsAnError(t *testing.T) { store := bans.NewMemoryStore() cause := errors.New("postgres is down") store.FailWith(cause) - banner := bans.New(config(), newClock(), store) + banner := bans.New(config(), pardon, newClock(), store) ctx := t.Context() _, _, err := banner.Flag(ctx, "bot") @@ -89,7 +89,7 @@ func TestWithEnforceOffBannedReadsNothing(t *testing.T) { c := config() c.Enforce = false - _, banned, err := bans.New(c, newClock(), store).EnforcedSentence(t.Context(), "bot") + _, banned, err := bans.New(c, pardon, newClock(), store).EnforcedSentence(t.Context(), "bot") require.NoError(t, err) assert.False(t, banned) @@ -107,11 +107,19 @@ func TestAnUnbanIsKeptInTheStore(t *testing.T) { assert.Equal(t, bans.Record{ Key: "player", Flags: 1, ExpiresAt: clock.Now(), LastFlaggedAt: flaggedAt, + Pardon: bans.Pardon{ + At: clock.Now(), + Lifted: bans.Sentence{Flags: 1, Offence: 1, Until: flaggedAt.Add(time.Hour)}, + Until: clock.Now().Add(pardon), + }, }, store.Stored()["player"]) after := bannerOver(t, config(), clock, store) assert.False(t, after.Banned("player")) + _, accepted := after.Flag("player") + assert.False(t, accepted, "the pardon is in the store, not in the banner") + clock.Advance(pardon) sentence, accepted := after.Flag("player") require.True(t, accepted) assert.Equal(t, 1, sentence.Offence) diff --git a/apps/backend/internal/antibot/internal/detect/detect.go b/apps/backend/internal/antibot/internal/detect/detect.go index 3c867d315..64a2f81b4 100644 --- a/apps/backend/internal/antibot/internal/detect/detect.go +++ b/apps/backend/internal/antibot/internal/detect/detect.go @@ -141,6 +141,8 @@ type Examination struct { Offence int BannedUntil time.Time + PardonedUntil time.Time + Readings []Reading Suspects int diff --git a/apps/backend/internal/antibot/internal/evidence/evidence.go b/apps/backend/internal/antibot/internal/evidence/evidence.go index acbd51f1b..68e8b9805 100644 --- a/apps/backend/internal/antibot/internal/evidence/evidence.go +++ b/apps/backend/internal/antibot/internal/evidence/evidence.go @@ -23,7 +23,7 @@ const ( defaultRetention = 72 * time.Hour ) -func (c Config) withDefaults() Config { +func (c Config) WithDefaults() Config { if c.SaveInterval <= 0 { c.SaveInterval = defaultSaveInterval } @@ -59,7 +59,7 @@ const flushTimeout = 10 * time.Second func New(config Config, clock cptime.Clock, persistence Persistence, onStateError func(error), sections ...Section) *Store { return &Store{ - config: config.withDefaults(), + config: config.WithDefaults(), clock: clock, persistence: persistence, onStateError: onStateError, diff --git a/apps/backend/internal/antibot/internal/jury/jury.go b/apps/backend/internal/antibot/internal/jury/jury.go index b018f6533..cde8a9ef0 100644 --- a/apps/backend/internal/antibot/internal/jury/jury.go +++ b/apps/backend/internal/antibot/internal/jury/jury.go @@ -51,6 +51,7 @@ func (c Config) WithDefaults() Config { type Banner interface { Flag(ctx context.Context, caller bans.Caller) (bans.Sentence, bool, error) + Pardoned(ctx context.Context, caller bans.Caller) (bool, error) Flagged(ctx context.Context) (int, error) } @@ -133,6 +134,11 @@ func (j *Jury) Inspect(ctx context.Context, click detect.Click) error { } func (j *Jury) flag(ctx context.Context, click detect.Click, report detect.Report) error { + pardoned, err := j.banner.Pardoned(ctx, callerOf(click)) + if err != nil || pardoned { + return err //nolint:wrapcheck // the banner already named what failed. + } + var errs []error for _, convict := range convictsOf(click, report) { diff --git a/apps/backend/internal/antibot/internal/jury/jury_test.go b/apps/backend/internal/antibot/internal/jury/jury_test.go index e4de7f735..6dc09a17e 100644 --- a/apps/backend/internal/antibot/internal/jury/jury_test.go +++ b/apps/backend/internal/antibot/internal/jury/jury_test.go @@ -57,7 +57,7 @@ func newHarness(t *testing.T, config jury.Config, ban bans.Config, watchdogs ... t.Helper() h := &harness{t: t, clock: cptime.NewFixedClock(time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC))} - h.banner = bans.NewBook(ban, h.clock, bans.NewMemoryStore(), bans.NewMemoryStore()) + h.banner = bans.NewBook(ban, pardon, h.clock, bans.NewMemoryStore(), bans.NewMemoryStore()) h.jury = jury.New(config, h.banner, h.clock, jury.Hooks{ OnFlag: func(report detect.Report) { h.reports = append(h.reports, report) @@ -108,6 +108,8 @@ func juryConfig() jury.Config { } } +const pardon = 72 * time.Hour + func banConfig() bans.Config { return bans.Config{ Enforce: true, @@ -542,6 +544,49 @@ func TestAGuiltyVerdictPassesToTheAccomplicesAWatchdogNames(t *testing.T) { "signed-in accomplices leave their scope alone") } +func (h *harness) pardon(caller bans.Caller) { + h.t.Helper() + _, err := h.banner.Ban(h.t.Context(), caller, time.Hour) + require.NoError(h.t, err) + require.NoError(h.t, h.banner.Unban(h.t.Context(), caller)) +} + +func TestAPardonedCallerBansNobodyNotEvenItsRing(t *testing.T) { + h := newHarness(t, juryConfig(), banConfig(), + &stubWatchdog{name: "ring", verdict: detect.Suspect, accomplices: []detect.Accomplice{{Account: "second", SignedIn: true}}}, + &stubWatchdog{name: "other", verdict: detect.Suspect}, + ) + h.pardon(bans.Caller{Account: "first"}) + + for range 10 { + require.False(t, h.inspect(detect.Click{Scope: "campus", Account: "first", SignedIn: true, Tile: 1, Country: "FR", At: h.clock.Now()})) + h.clock.Advance(time.Minute) + } + assert.Empty(t, h.reports, "its ring falls with its guilt, and its guilt is pardoned") + _, ringBanned, err := h.banner.EnforcedSentence(t.Context(), bans.Caller{Account: "second", SignedIn: true}) + require.NoError(t, err) + assert.False(t, ringBanned) + + h.clock.Advance(pardon) + require.True(t, h.inspect(detect.Click{Scope: "campus", Account: "first", SignedIn: true, Tile: 1, Country: "FR", At: h.clock.Now()})) + assert.Equal(t, []banned{{account: "first"}, {account: "second", alongside: "first"}}, h.banned()) +} + +func TestAPardonedAccomplicePassesNoBanOnToItsRing(t *testing.T) { + h := newHarness(t, juryConfig(), banConfig(), + &stubWatchdog{name: "ring", verdict: detect.Suspect, accomplices: []detect.Accomplice{ + {Account: "second", SignedIn: true}, + {Account: "third", SignedIn: true}, + }}, + &stubWatchdog{name: "other", verdict: detect.Suspect}, + ) + h.pardon(bans.Caller{Account: "second"}) + + require.True(t, h.inspect(detect.Click{Scope: "campus", Account: "first", SignedIn: true, Tile: 1, Country: "FR", At: h.clock.Now()})) + + assert.Equal(t, []banned{{account: "first"}, {account: "third", alongside: "first"}}, h.banned()) +} + func TestAReadingOfTurnsStillNamesTheRingOfAGuiltyCaller(t *testing.T) { h := newHarness(t, juryConfig(), banConfig(), &stubWatchdog{name: "turns", verdict: detect.Suspect, turns: true, accomplices: []detect.Accomplice{{Account: "second", SignedIn: true}}}, @@ -606,7 +651,7 @@ func TestAFlagThatCannotBeKeptIsReported(t *testing.T) { cause := errors.New("postgres is down") store.FailWith(cause) - j := jury.New(juryConfig(), bans.NewBook(banConfig(), clock, store, store), clock, jury.Hooks{}, + j := jury.New(juryConfig(), bans.NewBook(banConfig(), pardon, clock, store, store), clock, jury.Hooks{}, &stubWatchdog{name: "sure", verdict: detect.Certain}) err := j.Inspect(t.Context(), detect.Click{Scope: "caller", Tile: 1, Country: "FR", At: clock.Now()}) diff --git a/apps/backend/internal/antibot/internal/jury/state_test.go b/apps/backend/internal/antibot/internal/jury/state_test.go index 37b9d313f..3c1486998 100644 --- a/apps/backend/internal/antibot/internal/jury/state_test.go +++ b/apps/backend/internal/antibot/internal/jury/state_test.go @@ -23,7 +23,7 @@ func (s *saying) Committed(detect.Click) {} func (s *saying) Watch(detect.Click) (detect.Verdict, detect.Evidence) { return s.verdict, s.evidence } func newJury(clock cptime.Clock, hooks Hooks, watchdog detect.Watchdog) *Jury { - banner := bans.NewBook(bans.Config{Enforce: true}, clock, bans.NewMemoryStore(), bans.NewMemoryStore()) + banner := bans.NewBook(bans.Config{Enforce: true}, 72*time.Hour, clock, bans.NewMemoryStore(), bans.NewMemoryStore()) return New(Config{TrackWindow: time.Hour}, banner, clock, hooks, watchdog) } diff --git a/apps/backend/internal/antibot/internal/jury/sweep_test.go b/apps/backend/internal/antibot/internal/jury/sweep_test.go index d579d9f4a..67057c73b 100644 --- a/apps/backend/internal/antibot/internal/jury/sweep_test.go +++ b/apps/backend/internal/antibot/internal/jury/sweep_test.go @@ -34,6 +34,8 @@ func (stubBanner) Flag(context.Context, bans.Caller) (bans.Sentence, bool, error return bans.Sentence{Flags: 1}, true, nil } +func (stubBanner) Pardoned(context.Context, bans.Caller) (bool, error) { return false, nil } + func (stubBanner) Flagged(context.Context) (int, error) { return 0, nil } func inspect(t *testing.T, j *Jury, click detect.Click) { diff --git a/apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.down.sql b/apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.down.sql new file mode 100644 index 000000000..bf0d60f4c --- /dev/null +++ b/apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.down.sql @@ -0,0 +1,2 @@ +DROP TABLE account_pardons; +DROP TABLE pardons; diff --git a/apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.up.sql b/apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.up.sql new file mode 100644 index 000000000..0cfc57a66 --- /dev/null +++ b/apps/backend/internal/antibot/internal/migrations/20261020120000_create_pardons.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE pardons ( + scope text NOT NULL REFERENCES bans (scope), + pardoned_at timestamptz NOT NULL, + flags integer NOT NULL CHECK (flags >= 0), + offence integer NOT NULL CHECK (offence >= 0), + banned_until timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY (scope, pardoned_at) +); + +CREATE TABLE account_pardons ( + account uuid NOT NULL REFERENCES account_bans (account), + pardoned_at timestamptz NOT NULL, + flags integer NOT NULL CHECK (flags >= 0), + offence integer NOT NULL CHECK (offence >= 0), + banned_until timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY (account, pardoned_at) +); diff --git a/apps/backend/internal/antibot/internal/postgres_ban_store/store.go b/apps/backend/internal/antibot/internal/postgres_ban_store/store.go index 512e84dd3..45ad44c0f 100644 --- a/apps/backend/internal/antibot/internal/postgres_ban_store/store.go +++ b/apps/backend/internal/antibot/internal/postgres_ban_store/store.go @@ -20,7 +20,16 @@ const ( func NewScopes(db cppg.QuerierBeginner) *Store { return &Store{db: db, lock: scopesLock, queries: queries{ - record: `SELECT flags, offences, expires_at, last_flagged_at FROM bans WHERE scope = $1`, + record: ` + SELECT b.flags, b.offences, b.expires_at, b.last_flagged_at, + p.pardoned_at, p.flags, p.offence, p.banned_until, p.expires_at + FROM bans b + LEFT JOIN LATERAL ( + SELECT pardoned_at, flags, offence, banned_until, expires_at FROM pardons + WHERE pardons.scope = b.scope ORDER BY pardoned_at DESC LIMIT 1 + ) p ON true + WHERE b.scope = $1 + `, running: `SELECT count(*) FROM bans WHERE expires_at > $1`, save: ` INSERT INTO bans (scope, flags, offences, expires_at, last_flagged_at) VALUES ($1, $2, $3, $4, $5) @@ -30,12 +39,29 @@ func NewScopes(db cppg.QuerierBeginner) *Store { expires_at = excluded.expires_at, last_flagged_at = excluded.last_flagged_at `, + savePardon: ` + INSERT INTO pardons (scope, pardoned_at, flags, offence, banned_until, expires_at) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (scope, pardoned_at) DO UPDATE SET + flags = excluded.flags, + offence = excluded.offence, + banned_until = excluded.banned_until, + expires_at = excluded.expires_at + `, }} } func NewAccounts(db cppg.QuerierBeginner) *Store { return &Store{db: db, lock: accountsLock, queries: queries{ - record: `SELECT flags, offences, expires_at, last_flagged_at FROM account_bans WHERE account = $1`, + record: ` + SELECT b.flags, b.offences, b.expires_at, b.last_flagged_at, + p.pardoned_at, p.flags, p.offence, p.banned_until, p.expires_at + FROM account_bans b + LEFT JOIN LATERAL ( + SELECT pardoned_at, flags, offence, banned_until, expires_at FROM account_pardons + WHERE account_pardons.account = b.account ORDER BY pardoned_at DESC LIMIT 1 + ) p ON true + WHERE b.account = $1 + `, running: `SELECT count(*) FROM account_bans WHERE expires_at > $1`, save: ` INSERT INTO account_bans (account, flags, offences, expires_at, last_flagged_at) VALUES ($1, $2, $3, $4, $5) @@ -45,6 +71,15 @@ func NewAccounts(db cppg.QuerierBeginner) *Store { expires_at = excluded.expires_at, last_flagged_at = excluded.last_flagged_at `, + savePardon: ` + INSERT INTO account_pardons (account, pardoned_at, flags, offence, banned_until, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (account, pardoned_at) DO UPDATE SET + flags = excluded.flags, + offence = excluded.offence, + banned_until = excluded.banned_until, + expires_at = excluded.expires_at + `, }} } @@ -55,9 +90,10 @@ type Store struct { } type queries struct { - record string - running string - save string + record string + running string + save string + savePardon string } func (s *Store) Record(ctx context.Context, key string) (bans.Record, bool, error) { @@ -67,8 +103,15 @@ func (s *Store) Record(ctx context.Context, key string) (bans.Record, bool, erro func (s *Store) recordOf(ctx context.Context, db cppg.Querier, key string) (bans.Record, bool, error) { record := bans.Record{Key: key} - var flagged sql.NullTime - err := db.QueryRowContext(ctx, s.queries.record, key).Scan(&record.Flags, &record.Offences, &record.ExpiresAt, &flagged) + var ( + flagged sql.NullTime + pardonedAt, liftedUntil, pardonUntil sql.NullTime + liftedFlags, liftedOffence sql.NullInt64 + ) + err := db.QueryRowContext(ctx, s.queries.record, key).Scan( + &record.Flags, &record.Offences, &record.ExpiresAt, &flagged, + &pardonedAt, &liftedFlags, &liftedOffence, &liftedUntil, &pardonUntil, + ) if errors.Is(err, sql.ErrNoRows) { return record, false, nil } @@ -80,6 +123,17 @@ func (s *Store) recordOf(ctx context.Context, db cppg.Querier, key string) (bans if flagged.Valid { record.LastFlaggedAt = flagged.Time.UTC() } + if pardonedAt.Valid { + record.Pardon = bans.Pardon{ + At: pardonedAt.Time.UTC(), + Lifted: bans.Sentence{ + Flags: int(liftedFlags.Int64), + Offence: int(liftedOffence.Int64), + Until: liftedUntil.Time.UTC(), + }, + Until: pardonUntil.Time.UTC(), + } + } return record, true, nil } @@ -126,6 +180,14 @@ func (s *Store) Change( return fmt.Errorf("failed to save the ban: %w", err) } + if !next.Pardon.At.IsZero() { + pardon := next.Pardon + if _, err := tx.ExecContext(ctx, s.queries.savePardon, key, pardon.At, + pardon.Lifted.Flags, pardon.Lifted.Offence, pardon.Lifted.Until, pardon.Until); err != nil { + return fmt.Errorf("failed to save the pardon: %w", err) + } + } + if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit the ban: %w", err) } diff --git a/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go b/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go index 943a294af..4990cd529 100644 --- a/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go +++ b/apps/backend/internal/antibot/internal/postgres_ban_store/store_test.go @@ -78,3 +78,33 @@ func (s *testSuite) TestAKeyThatIsNotAnAccountIsAnError() { s.Require().NoError(err) s.Zero(running) } + +func (s *testSuite) TestEveryPardonIsKept() { + s.purge() + ctx := s.T().Context() + const ( + scope = "2001:db8:1::/64" + account = "0b7e5b6c-8f3a-4d2e-9c1a-2f6d8e4b7a10" + ) + keys := map[string]*postgres_ban_store.Store{ + scope: postgres_ban_store.NewScopes(s.db), + account: postgres_ban_store.NewAccounts(s.db), + } + + for key, store := range keys { + for i := range 2 { + at := until.Add(time.Duration(i) * time.Hour) + s.Require().NoError(store.Change(ctx, key, func(record bans.Record, _ bool) (bans.Record, bool) { + record.ExpiresAt = at + record.Pardon = bans.Pardon{At: at, Lifted: bans.Sentence{Offence: 1, Until: at}, Until: at.Add(time.Hour)} + return record, true + })) + } + } + + var pardons, accountPardons int + s.Require().NoError(s.db.QueryRowContext(ctx, `SELECT count(*) FROM pardons WHERE scope = $1`, scope).Scan(&pardons)) + s.Require().NoError(s.db.QueryRowContext(ctx, `SELECT count(*) FROM account_pardons WHERE account = $1`, account).Scan(&accountPardons)) + s.Equal(2, pardons) + s.Equal(2, accountPardons) +} diff --git a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit.go b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit.go index 0c093e168..a64037326 100644 --- a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit.go +++ b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit.go @@ -26,7 +26,7 @@ func (a *Audited) Execute(ctx context.Context, in unban_player_usecase.In) (unba attrs := []any{ slog.String("asked", in.Scope), slog.String("askedAccount", in.Account), slog.String("scope", out.Scope), slog.String("account", out.Account), slog.Int("offence", out.Offence), - slog.Time("bannedUntil", out.Until), + slog.Time("bannedUntil", out.Until), slog.Time("pardonedUntil", out.PardonedUntil), } if err != nil { diff --git a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit_test.go b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit_test.go index 1511cc168..2301d06a8 100644 --- a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit_test.go +++ b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/audit_unban/audit_test.go @@ -25,7 +25,8 @@ func (s stubUseCase) Execute(context.Context, unban_player_usecase.In) (unban_pl func TestAnUnbanIsLoggedWithTheBanItLifted(t *testing.T) { var logs bytes.Buffer - want := unban_player_usecase.Out{Scope: "2001:db8::/64", Offence: 1, Until: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)} + until := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + want := unban_player_usecase.Out{Scope: "2001:db8::/64", Offence: 1, Until: until, PardonedUntil: until.Add(72 * time.Hour)} useCase := audit_unban.New(stubUseCase{out: want}, slog.New(slog.NewTextHandler(&logs, nil))) out, err := useCase.Execute(t.Context(), unban_player_usecase.In{Scope: "2001:db8::1"}) @@ -33,7 +34,7 @@ func TestAnUnbanIsLoggedWithTheBanItLifted(t *testing.T) { assert.Equal(t, want, out) assert.Contains(t, logs.String(), - `level=WARN msg="admin unban" asked=2001:db8::1 askedAccount="" scope=2001:db8::/64 account="" offence=1 bannedUntil=2026-10-09T12:00:00.000Z`) + `level=WARN msg="admin unban" asked=2001:db8::1 askedAccount="" scope=2001:db8::/64 account="" offence=1 bannedUntil=2026-10-09T12:00:00.000Z pardonedUntil=2026-10-12T12:00:00.000Z`) } func TestARefusedUnbanIsLoggedToo(t *testing.T) { diff --git a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase.go b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase.go index 2ebe5c4d6..238d34a1e 100644 --- a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase.go +++ b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase.go @@ -18,6 +18,7 @@ var ( type Unbanner interface { LastSentence(ctx context.Context, scope, account string) (antibot.Sentence, bool, error) Unban(ctx context.Context, scope, account string) error + Pardon(ctx context.Context, scope, account string) (antibot.Pardon, bool, error) Enabled() bool } @@ -27,10 +28,11 @@ type In struct { } type Out struct { - Scope string - Account string - Offence int - Until time.Time + Scope string + Account string + Offence int + Until time.Time + PardonedUntil time.Time } func New(unbanner Unbanner) *UseCase { @@ -63,10 +65,16 @@ func (u *UseCase) Execute(ctx context.Context, in In) (Out, error) { return Out{}, fmt.Errorf("failed to unban: %w", err) } + pardon, _, err := u.unbanner.Pardon(ctx, caller.Scope, caller.Account) + if err != nil { + return Out{}, fmt.Errorf("failed to read the pardon: %w", err) + } + return Out{ - Scope: caller.Scope, - Account: caller.Account, - Offence: sentence.Offence, - Until: sentence.Until, + Scope: caller.Scope, + Account: caller.Account, + Offence: sentence.Offence, + Until: sentence.Until, + PardonedUntil: pardon.Until, }, nil } diff --git a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase_test.go b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase_test.go index d9a5705e5..6c0237220 100644 --- a/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase_test.go +++ b/apps/backend/internal/planet/internal/ledger/usecases/unban_player_usecase/usecase_test.go @@ -14,10 +14,14 @@ import ( "github.com/raphoester/clickplanet.lol-backend/internal/planet/internal/ledger/usecases/unban_player_usecase" ) -var until = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) +var ( + until = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + pardonedUntil = until.Add(72 * time.Hour) +) type unbanner struct { banned map[string]antibot.Sentence + pardoned map[string]antibot.Pardon scopes []string accounts []string off bool @@ -25,7 +29,7 @@ type unbanner struct { } func bannedOn(keys ...string) *unbanner { - u := &unbanner{banned: map[string]antibot.Sentence{}} + u := &unbanner{banned: map[string]antibot.Sentence{}, pardoned: map[string]antibot.Pardon{}} for _, key := range keys { u.banned[key] = antibot.Sentence{Flags: 2, Offence: 1, Until: until} } @@ -40,9 +44,15 @@ func (u *unbanner) LastSentence(_ context.Context, scope, account string) (antib func (u *unbanner) Unban(_ context.Context, scope, account string) error { u.scopes = append(u.scopes, scope) u.accounts = append(u.accounts, account) + u.pardoned[scope+account] = antibot.Pardon{Lifted: u.banned[scope+account], Until: pardonedUntil} return nil } +func (u *unbanner) Pardon(_ context.Context, scope, account string) (antibot.Pardon, bool, error) { + pardon, ok := u.pardoned[scope+account] + return pardon, ok, nil +} + func (u *unbanner) Enabled() bool { return !u.off } func TestAnAddressIsUnbannedAsItsScope(t *testing.T) { @@ -51,7 +61,7 @@ func TestAnAddressIsUnbannedAsItsScope(t *testing.T) { out, err := unban_player_usecase.New(u).Execute(t.Context(), unban_player_usecase.In{Scope: "2001:db8:1:2::9"}) require.NoError(t, err) - assert.Equal(t, unban_player_usecase.Out{Scope: "2001:db8:1:2::/64", Offence: 1, Until: until}, out) + assert.Equal(t, unban_player_usecase.Out{Scope: "2001:db8:1:2::/64", Offence: 1, Until: until, PardonedUntil: pardonedUntil}, out) assert.Equal(t, []string{"2001:db8:1:2::/64"}, u.scopes) assert.Equal(t, []string{""}, u.accounts) } @@ -65,6 +75,7 @@ func TestAnAccountIsUnbannedAlone(t *testing.T) { assert.Equal(t, "0b7e5b6c-8f3a-4d2e-9c1a-2f6d8e4b7a10", out.Account) assert.Empty(t, out.Scope) + assert.Equal(t, pardonedUntil, out.PardonedUntil) assert.Equal(t, []string{""}, u.scopes) assert.Equal(t, []string{"0b7e5b6c-8f3a-4d2e-9c1a-2f6d8e4b7a10"}, u.accounts) } diff --git a/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler.go index a398f9d07..7e7d05e3a 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler.go +++ b/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler.go @@ -64,6 +64,7 @@ func encode(out antibot.Examination) *planetv1.InspectPlayerResponse { BannedUntil: timestampOrNil(out.BannedUntil), Offence: uint32(out.Offence), //nolint:gosec // an offence count, never negative. Flags: uint32(out.Flags), //nolint:gosec // a flag count, never negative. + PardonedUntil: timestampOrNil(out.PardonedUntil), Readings: readings, Suspects: uint32(out.Suspects), //nolint:gosec // bounded by the watchdogs. MinSuspects: uint32(out.MinSuspects), //nolint:gosec // defaulted to at least one. diff --git a/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler_test.go b/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler_test.go index c81c5f0dd..ae5dae6e9 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler_test.go +++ b/apps/backend/internal/planet/internal/planetv1controller/inspect_player_handler/handler_test.go @@ -50,6 +50,8 @@ func TestTheExaminationComesBackWhole(t *testing.T) { Flags: 3, Offence: 2, BannedUntil: at.Add(24 * time.Hour), + + PardonedUntil: at.Add(72 * time.Hour), Readings: []antibot.Reading{ {Watchdog: "metronome", Level: "suspect", Evidence: "cadence clicks=120 spread=40ms", At: at}, {Watchdog: "sequencer", Level: "clear"}, @@ -75,6 +77,7 @@ func TestTheExaminationComesBackWhole(t *testing.T) { assert.Equal(t, uint32(3), res.GetFlags()) assert.Equal(t, uint32(2), res.GetOffence()) assert.Equal(t, at.Add(24*time.Hour), res.GetBannedUntil().AsTime()) + assert.Equal(t, at.Add(72*time.Hour), res.GetPardonedUntil().AsTime()) require.Len(t, res.GetReadings(), 2) assert.Equal(t, "metronome", res.GetReadings()[0].GetWatchdog()) @@ -107,6 +110,7 @@ func TestAnUntrackedScopeStillSaysSo(t *testing.T) { require.NotNil(t, res.Banned) assert.False(t, res.GetBanned()) assert.Nil(t, res.GetBannedUntil()) + assert.Nil(t, res.GetPardonedUntil()) assert.Nil(t, res.GetActiveFor()) assert.Nil(t, res.GetLastClickAt()) } diff --git a/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler.go b/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler.go index 081bec355..97853489d 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler.go +++ b/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler.go @@ -33,10 +33,11 @@ func (h UnbanPlayerHandler) UnbanPlayer( switch { case err == nil: return connect.NewResponse(&planetv1.UnbanPlayerResponse{ - Scope: out.Scope, - AccountId: out.Account, - Offence: uint32(out.Offence), //nolint:gosec // an offence count, never negative. - BannedUntil: timestamppb.New(out.Until), + Scope: out.Scope, + AccountId: out.Account, + Offence: uint32(out.Offence), //nolint:gosec // an offence count, never negative. + BannedUntil: timestamppb.New(out.Until), + PardonedUntil: timestamppb.New(out.PardonedUntil), }), nil case errors.Is(err, ledger.ErrInvalidScope), errors.Is(err, ledger.ErrInvalidAccount), errors.Is(err, ledger.ErrNoCaller): return nil, connect.NewError(connect.CodeInvalidArgument, err) diff --git a/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler_test.go b/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler_test.go index 8ebf955ad..6e6b528ee 100644 --- a/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler_test.go +++ b/apps/backend/internal/planet/internal/planetv1controller/unban_player_handler/handler_test.go @@ -41,7 +41,7 @@ func unban(t *testing.T, useCase *stubUseCase, req *planetv1.UnbanPlayerRequest) func TestTheRequestReachesTheUseCaseAndTheLiftedBanComesBack(t *testing.T) { until := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) - useCase := &stubUseCase{out: unban_player_usecase.Out{Scope: "9.9.9.9", Offence: 2, Until: until}} + useCase := &stubUseCase{out: unban_player_usecase.Out{Scope: "9.9.9.9", Offence: 2, Until: until, PardonedUntil: until.Add(time.Hour)}} res, err := unban(t, useCase, &planetv1.UnbanPlayerRequest{Scope: "9.9.9.9"}) require.NoError(t, err) @@ -51,6 +51,7 @@ func TestTheRequestReachesTheUseCaseAndTheLiftedBanComesBack(t *testing.T) { assert.Empty(t, res.GetAccountId()) assert.Equal(t, uint32(2), res.GetOffence()) assert.Equal(t, until, res.GetBannedUntil().AsTime()) + assert.Equal(t, until.Add(time.Hour), res.GetPardonedUntil().AsTime()) } func TestAnAccountReachesTheUseCaseAndComesBack(t *testing.T) { diff --git a/apps/backoffice/CLAUDE.md b/apps/backoffice/CLAUDE.md index 1f7ba94e7..7a7a4cea5 100644 --- a/apps/backoffice/CLAUDE.md +++ b/apps/backoffice/CLAUDE.md @@ -218,10 +218,10 @@ address over IPv4, the /64 over IPv6) and checks. heading is its flag and its name: the name the profile answers, which is the name it holds now, over the one the page that opened it knew; the flag of its row, or else the country it took the most tiles for. `not_found` is an account the game keeps nothing about. An address has no profile, and is not asked. -- **The page inspects on open** (`InspectPlayer`): the antibot's readings, its ban and its clicks. The antibot off +- **The page inspects on open** (`InspectPlayer`): the antibot's readings, its ban, its pardon and its clicks. The antibot off reads as such, not as a failure. A ban or an unban reads it again. - **The tools**: ban (`BanPlayer`, the ladder's next step by default), unban (`UnbanPlayer`, which forgets the - latest offence; `not_found` is no ban left), revert (`RevertPlayer`, dry run first), and for an account alone + latest offence and pardons the key, and says until when; `not_found` is no ban left), revert (`RevertPlayer`, dry run first), and for an account alone mute (`Mute`) and charges (`GrantCharges`: a refill, a bomb, enclosures, spread clicks, shields; the answer says what the account held before and after, since the caps hold). diff --git a/apps/backoffice/src/fakeAdmin.ts b/apps/backoffice/src/fakeAdmin.ts index 97876f4bf..acca6b7a0 100644 --- a/apps/backoffice/src/fakeAdmin.ts +++ b/apps/backoffice/src/fakeAdmin.ts @@ -171,7 +171,7 @@ export class FakeAdmin implements ChatAdmin, PlanetAdmin, PlayerAdmin, SupportAd unbanPlayer(request: PartialMessage): Promise { return this.answer("unbanPlayer", request, - new UnbanPlayerResponse({offence: 2, bannedUntil: Timestamp.fromDate(AT)})) + new UnbanPlayerResponse({offence: 2, bannedUntil: Timestamp.fromDate(AT), pardonedUntil: Timestamp.fromDate(AT)})) } revertPlayer(request: PartialMessage): Promise { diff --git a/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts b/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts index 714230dd9..76e6c06f8 100644 --- a/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts +++ b/apps/backoffice/src/gen/grpc/planet/v1/admin_pb.ts @@ -468,6 +468,11 @@ export class InspectPlayerResponse extends Message { */ flags = 0; + /** + * @generated from field: google.protobuf.Timestamp pardoned_until = 18; + */ + pardonedUntil?: Timestamp; + /** * @generated from field: repeated planet.v1.WatchdogReading readings = 7; */ @@ -533,6 +538,7 @@ export class InspectPlayerResponse extends Message { { no: 4, name: "banned_until", kind: "message", T: Timestamp }, { no: 5, name: "offence", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 6, name: "flags", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 18, name: "pardoned_until", kind: "message", T: Timestamp }, { no: 7, name: "readings", kind: "message", T: WatchdogReading, repeated: true }, { no: 8, name: "suspects", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 9, name: "min_suspects", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, @@ -1077,6 +1083,11 @@ export class UnbanPlayerResponse extends Message { */ bannedUntil?: Timestamp; + /** + * @generated from field: google.protobuf.Timestamp pardoned_until = 5; + */ + pardonedUntil?: Timestamp; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -1089,6 +1100,7 @@ export class UnbanPlayerResponse extends Message { { no: 2, name: "account_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 3, name: "offence", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 4, name: "banned_until", kind: "message", T: Timestamp }, + { no: 5, name: "pardoned_until", kind: "message", T: Timestamp }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): UnbanPlayerResponse { diff --git a/apps/backoffice/src/players/InspectPanel.tsx b/apps/backoffice/src/players/InspectPanel.tsx index 7a8208562..98c00ecfa 100644 --- a/apps/backoffice/src/players/InspectPanel.tsx +++ b/apps/backoffice/src/players/InspectPanel.tsx @@ -58,6 +58,7 @@ export function Inspection({value, caller, onOpenScope}: InspectionProps) { ? <>yes, offence {value.offence}, until : "no"} + {value.pardonedUntil !== undefined && until } {value.tracked === true ? `yes, ${value.flags} flags` : "no"} {`${value.suspects} of ${value.minSuspects}`}{value.guilty === true && ", guilty"} diff --git a/apps/backoffice/src/players/Moderation.tsx b/apps/backoffice/src/players/Moderation.tsx index 7a282bd60..7d03e8d0f 100644 --- a/apps/backoffice/src/players/Moderation.tsx +++ b/apps/backoffice/src/players/Moderation.tsx @@ -50,10 +50,7 @@ export function BanAction({planet, caller, who, onDone}: BanProps) { export function UnbanAction({planet, caller, who, onDone}: BanProps) { return (
-

- For a ban that was a mistake. It forgets the latest offence, ended or running. A rule that still reads - the player certain bans it again on its next click: fix the rule first. -

+

For a ban that was a mistake. It forgets the latest offence, ended or running.

void} diff --git a/apps/backoffice/src/players/PlayersPage.test.tsx b/apps/backoffice/src/players/PlayersPage.test.tsx index b722a54ee..6346014a5 100644 --- a/apps/backoffice/src/players/PlayersPage.test.tsx +++ b/apps/backoffice/src/players/PlayersPage.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment jsdom +import {Timestamp} from "@bufbuild/protobuf" import {Code} from "@connectrpc/connect" import {cleanup, fireEvent, render, screen, within} from "@testing-library/react" import {useState} from "react" import {afterEach, describe, expect, it} from "vitest" -import {ACCOUNT, FakeAdmin} from "../fakeAdmin.ts" +import {ACCOUNT, AT, FakeAdmin} from "../fakeAdmin.ts" +import {InspectPlayerResponse} from "../gen/grpc/planet/v1/admin_pb.ts" import {CountryTiles} from "../gen/grpc/player/v1/player_pb.ts" import {Opened} from "../navigation.ts" import {PlayersPage} from "./PlayersPage.tsx" @@ -156,12 +158,24 @@ describe("one player", () => { expect(admin.askedFor("inspectPlayer")).toHaveLength(2) }) + it("shows a pardon only while one runs", async () => { + const admin = new FakeAdmin() + render() + await screen.findByText(/^42 in /) + expect(screen.queryByText("Pardoned")).toBeNull() + cleanup() + + admin.inspection = new InspectPlayerResponse({clicks: 42, banned: false, pardonedUntil: Timestamp.fromDate(AT)}) + render() + expect(await screen.findByText("Pardoned")).toBeTruthy() + }) + it("lifts the latest ban, and says when there is none", async () => { const admin = new FakeAdmin() render() confirm("Unban", "Yes, unban") - expect(await screen.findByText(/^Lifted offence 2: its ban was until /)).toBeTruthy() + expect(await screen.findByText(/^Lifted offence 2: its ban was until .*\. Pardoned until .*\.$/)).toBeTruthy() expect(admin.askedFor("unbanPlayer")).toEqual([{scope: "203.0.113.7"}]) admin.refuse("unbanPlayer", Code.NotFound) diff --git a/apps/backoffice/src/players/actions.test.ts b/apps/backoffice/src/players/actions.test.ts index 94ad684fb..fab7dba26 100644 --- a/apps/backoffice/src/players/actions.test.ts +++ b/apps/backoffice/src/players/actions.test.ts @@ -67,7 +67,7 @@ describe("unban", () => { it("lifts the latest ban and says which", async () => { const admin = new FakeAdmin() - expect(await unban(admin, scope)).toEqual({offence: 2, until: AT}) + expect(await unban(admin, scope)).toEqual({offence: 2, until: AT, pardonedUntil: AT}) expect(admin.askedFor("unbanPlayer")).toEqual([{scope: "203.0.113.7"}]) }) diff --git a/apps/backoffice/src/players/actions.ts b/apps/backoffice/src/players/actions.ts index 43be3a6c7..e1a3edb05 100644 --- a/apps/backoffice/src/players/actions.ts +++ b/apps/backoffice/src/players/actions.ts @@ -18,7 +18,7 @@ export const LIMITS = [20, 50, 100, 500] export type Muted = {until: Date} export type Banned = {until: Date, offence: number, enforced: boolean} -export type Unbanned = {offence: number, until: Date | undefined} +export type Unbanned = {offence: number, until: Date | undefined, pardonedUntil: Date | undefined} export type Reverted = {touched: number, held: number, restored: number} export type Grant = {refill: boolean, bomb: boolean, enclosures: number, spreadClicks: number, shields: number} export type Granted = {before: ChargesHeld, after: ChargesHeld} @@ -47,7 +47,7 @@ export async function ban(planet: PlanetAdmin, caller: Caller, seconds: number): // Lifts the latest ban, running or ended, and forgets its offence. One call forgets one offence. export async function unban(planet: PlanetAdmin, caller: Caller): Promise { const answer = await planet.unbanPlayer(caller) - return {offence: answer.offence, until: answer.bannedUntil?.toDate()} + return {offence: answer.offence, until: answer.bannedUntil?.toDate(), pardonedUntil: answer.pardonedUntil?.toDate()} } // Paced on the server, a tile at a time on the live stream: a big revert takes seconds. diff --git a/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts b/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts index d30f51050..bbe76e0d9 100644 --- a/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts +++ b/apps/frontend/src/gen/grpc/planet/v1/admin_pb.ts @@ -468,6 +468,11 @@ export class InspectPlayerResponse extends Message { */ flags = 0; + /** + * @generated from field: google.protobuf.Timestamp pardoned_until = 18; + */ + pardonedUntil?: Timestamp; + /** * @generated from field: repeated planet.v1.WatchdogReading readings = 7; */ @@ -533,6 +538,7 @@ export class InspectPlayerResponse extends Message { { no: 4, name: "banned_until", kind: "message", T: Timestamp }, { no: 5, name: "offence", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 6, name: "flags", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 18, name: "pardoned_until", kind: "message", T: Timestamp }, { no: 7, name: "readings", kind: "message", T: WatchdogReading, repeated: true }, { no: 8, name: "suspects", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 9, name: "min_suspects", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, @@ -1077,6 +1083,11 @@ export class UnbanPlayerResponse extends Message { */ bannedUntil?: Timestamp; + /** + * @generated from field: google.protobuf.Timestamp pardoned_until = 5; + */ + pardonedUntil?: Timestamp; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -1089,6 +1100,7 @@ export class UnbanPlayerResponse extends Message { { no: 2, name: "account_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 3, name: "offence", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 4, name: "banned_until", kind: "message", T: Timestamp }, + { no: 5, name: "pardoned_until", kind: "message", T: Timestamp }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): UnbanPlayerResponse { diff --git a/proto/planet/v1/admin.proto b/proto/planet/v1/admin.proto index 93a9bdf7a..7b3bf7a64 100644 --- a/proto/planet/v1/admin.proto +++ b/proto/planet/v1/admin.proto @@ -95,6 +95,7 @@ message InspectPlayerResponse { google.protobuf.Timestamp banned_until = 4; uint32 offence = 5; uint32 flags = 6; + google.protobuf.Timestamp pardoned_until = 18; repeated WatchdogReading readings = 7; @@ -178,6 +179,7 @@ message UnbanPlayerResponse { string account_id = 2; uint32 offence = 3; google.protobuf.Timestamp banned_until = 4; + google.protobuf.Timestamp pardoned_until = 5; } message RevertPlayerRequest {