diff --git a/readme.md b/readme.md index 93530f0..370e075 100644 --- a/readme.md +++ b/readme.md @@ -129,6 +129,7 @@ method calling another service's method that needs more. | `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/stats/public` | public | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | +| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token | | `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | @@ -173,9 +174,9 @@ flowchart LR Igdb -->|"OAuth token"| Twitch["Twitch"] Speedrun --> SpeedrunApi["speedrun.com API"] LibRepo --> Db[("PostgreSQL tbp_core")] - Stats -->|"listed_user_game, deleted_user"| Db + Stats -->|"listed_user_game, deleted_user, blocked_user"| Db Auth -->|"service token
/internal/accounts"| Account - Account -->|"per-user rows, deleted_user"| Db + Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db ``` | Module | Content | @@ -183,15 +184,16 @@ flowchart LR | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `stats` | Public numbers for the landing page | | `version` | `GET /api/version`: commit and build time of the running image | -| `account` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows | +| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, Steam ID changes stop the nightly sync | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs | **One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in -SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game` and `deleted_user`; `account` deletes -the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into -its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls. +SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account` +deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes +into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library` +reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls. The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view `listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. @@ -222,7 +224,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` dates finished games by the unlock of their ending achievement (`FinishedEstimateService`, `user_game.finished_at_estimate`). 3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for - a user in `deleted_user`. + a user in `deleted_user` or `blocked_user`. 4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings @@ -310,6 +312,16 @@ flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned user ids, which removes the rows of users Auth does not know (refused for more than `app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows. +### Blocked users and Steam ID changes + +Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account +took by a verified link, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in +`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until +it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs +the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once +more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one +nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block. + ### Manual games next to Steam games A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share diff --git a/src/main/java/core_service/modules/account/api/InternalAccountController.java b/src/main/java/core_service/modules/account/api/InternalAccountController.java index aca473a..24b6a6c 100644 --- a/src/main/java/core_service/modules/account/api/InternalAccountController.java +++ b/src/main/java/core_service/modules/account/api/InternalAccountController.java @@ -1,11 +1,14 @@ package core_service.modules.account.api; +import core_service.modules.account.api.dto.AccountStatesRequest; import core_service.modules.account.api.dto.CleanupResultDto; import core_service.modules.account.api.dto.KnownUsersRequest; import core_service.modules.account.api.dto.PurgeRequest; import core_service.modules.account.api.dto.UserIdsRequest; +import core_service.modules.account.domain.AccountState; import core_service.modules.account.domain.CleanupResult; import core_service.modules.account.service.AccountCleanupService; +import core_service.modules.account.service.AccountStateService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -15,12 +18,15 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequestMapping("/internal/accounts") @RequiredArgsConstructor public class InternalAccountController { private final AccountCleanupService accountCleanupService; + private final AccountStateService accountStateService; @PostMapping("/deleted") public ResponseEntity markDeleted(@Valid @RequestBody UserIdsRequest request) { @@ -44,6 +50,24 @@ public class InternalAccountController { return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun()))); } + @PostMapping("/states") + public ResponseEntity applyStates(@Valid @RequestBody AccountStatesRequest request) { + accountStateService.apply(toStates(request)); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/states") + public ResponseEntity replaceStates(@Valid @RequestBody AccountStatesRequest request) { + accountStateService.replaceAll(toStates(request)); + return ResponseEntity.noContent().build(); + } + + private static List toStates(AccountStatesRequest request) { + return request.accounts().stream() + .map(account -> new AccountState(account.userId(), account.steamId(), account.blocked())) + .toList(); + } + private static CleanupResultDto toDto(CleanupResult result) { return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused()); } diff --git a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java new file mode 100644 index 0000000..6b55b1e --- /dev/null +++ b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java @@ -0,0 +1,12 @@ +package core_service.modules.account.api.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) { + + public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) { + } +} diff --git a/src/main/java/core_service/modules/account/domain/AccountState.java b/src/main/java/core_service/modules/account/domain/AccountState.java new file mode 100644 index 0000000..839a3c2 --- /dev/null +++ b/src/main/java/core_service/modules/account/domain/AccountState.java @@ -0,0 +1,9 @@ +package core_service.modules.account.domain; + +/** + * What Core copies from Auth for one user. + * + * @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link) + */ +public record AccountState(long userId, Long steamId, boolean blocked) { +} diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index f412cf7..1b4f0f5 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -21,11 +21,11 @@ public class AccountCleanupRepository { // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. static final List TABLES = List.of("user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", - "user_steam_sync", "user_game", "game_import", "deleted_user"); + "user_steam_sync", "user_game", "game_import", "blocked_user", "deleted_user"); // Every user with data has a row in one of these; deleteRows then removes the child rows too. private static final List ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", - "game_import", "deleted_user"); + "game_import", "blocked_user", "deleted_user"); private final NamedParameterJdbcTemplate jdbc; diff --git a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java new file mode 100644 index 0000000..ac03481 --- /dev/null +++ b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java @@ -0,0 +1,54 @@ +package core_service.modules.account.repo; + +import core_service.modules.account.domain.AccountState; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +/** + * Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library + * module owns the entities. + */ +@Repository +@RequiredArgsConstructor +public class AccountStateRepository { + + private final NamedParameterJdbcTemplate jdbc; + + public void apply(AccountState state) { + MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId()) + .addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId())); + if (state.blocked()) { + jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params); + } else { + jdbc.update("delete from blocked_user where user_id = :userId", params); + } + if (state.blocked() || state.steamId() == null) { + jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null", + params); + } else { + // A new ID: no nightly sync with the old one, and the next visit syncs at once. steam_id stays, so that + // sync sees the change and drops the old account's games. + jdbc.update(""" + update user_steam_sync set user_seen_at = null, last_synced_at = null + where user_id = :userId and steam_id is distinct from :steamId""", params); + } + } + + /** + * After this, exactly the blocked users of the list are blocked. + */ + public void replaceAll(Collection states) { + states.forEach(this::apply); + List blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList(); + if (blocked.isEmpty()) { + jdbc.update("delete from blocked_user", new MapSqlParameterSource()); + } else { + jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked)); + } + } +} diff --git a/src/main/java/core_service/modules/account/service/AccountStateService.java b/src/main/java/core_service/modules/account/service/AccountStateService.java new file mode 100644 index 0000000..f340be4 --- /dev/null +++ b/src/main/java/core_service/modules/account/service/AccountStateService.java @@ -0,0 +1,39 @@ +package core_service.modules.account.service; + +import core_service.modules.account.domain.AccountState; +import core_service.modules.account.repo.AccountStateRepository; +import core_service.shared.security.Internal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Steam ID and block of each user, driven by Auth. Auth sends a change at once and the whole set nightly, so a + * missed call heals by the next night. + */ +@Service +@Internal +@RequiredArgsConstructor +@Slf4j +public class AccountStateService { + + private final AccountStateRepository repository; + + @Transactional + @CacheEvict(value = "publicStats", allEntries = true) + public void apply(List states) { + states.forEach(repository::apply); + log.info("Account state: {} users updated", states.size()); + } + + @Transactional + @CacheEvict(value = "publicStats", allEntries = true) + public void replaceAll(List states) { + repository.replaceAll(states); + log.info("Account state: {} users replaced", states.size()); + } +} diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java index db4e4e6..ff153b6 100644 --- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java @@ -26,7 +26,8 @@ public interface UserSteamSyncRepository extends JpaRepository 0", Long.class); + "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class); return v != null ? v : 0L; } public long sumPlaytimeMinutes() { Long v = jdbc.queryForObject( - "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); + "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class); return v != null ? v : 0L; } public long countUsersWithLibrary() { Long v = jdbc.queryForObject( - "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); + "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class); return v != null ? v : 0L; } @@ -55,7 +56,7 @@ public class StatsRepository { SELECT COUNT(*) AS c FROM listed_user_game WHERE %s GROUP BY user_id ) t - """.formatted(NOT_DELETED), Long.class); + """.formatted(NOT_DELETED_OR_BLOCKED), Long.class); return v != null ? v : 0L; } @@ -65,7 +66,7 @@ public class StatsRepository { SELECT COUNT(*) AS c FROM listed_user_game WHERE %s AND playtime_minutes = 0 GROUP BY user_id ) t - """.formatted(NOT_DELETED), Long.class); + """.formatted(NOT_DELETED_OR_BLOCKED), Long.class); return v != null ? v : 0L; } @@ -78,7 +79,7 @@ public class StatsRepository { GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? - """.formatted(NOT_DELETED), limit); + """.formatted(NOT_DELETED_OR_BLOCKED), limit); } public List> topPlayedGames(int limit) { @@ -90,6 +91,6 @@ public class StatsRepository { GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? - """.formatted(NOT_DELETED), limit); + """.formatted(NOT_DELETED_OR_BLOCKED), limit); } } diff --git a/src/main/resources/db/migration/V25__Blocked_User.sql b/src/main/resources/db/migration/V25__Blocked_User.sql new file mode 100644 index 0000000..2909144 --- /dev/null +++ b/src/main/resources/db/migration/V25__Blocked_User.sql @@ -0,0 +1,6 @@ +-- Users Auth has blocked (item 28 X4). Like deleted_user, but a block can be lifted: no nightly sync, not in the +-- stats, no new sync run. The rows stay; unblock deletes the marker. +CREATE TABLE blocked_user ( + user_id BIGINT PRIMARY KEY, + blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java index bfe916a..3691159 100644 --- a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java +++ b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java @@ -45,6 +45,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { @AfterEach void cleanUp() { jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID); + jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID); } private static String serviceToken() { diff --git a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java new file mode 100644 index 0000000..5a0fdc7 --- /dev/null +++ b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java @@ -0,0 +1,103 @@ +package core_service.modules.account.repo; + +import core_service.modules.account.domain.AccountState; +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(AccountStateRepository.class) +class AccountStateRepositoryTest extends AbstractPersistenceTest { + + private static final long OLD_STEAM_ID = 76561198000000001L; + private static final long NEW_STEAM_ID = 76561198000000002L; + + @Autowired + private AccountStateRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + jdbc.update("delete from blocked_user"); + for (long userId : List.of(1L, 2L)) { + jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) " + + "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID)); + } + } + + private Map sync(long userId) { + return jdbc.queryForMap("select steam_id, last_synced_at, user_seen_at from user_steam_sync where user_id = ?", + userId); + } + + private List blocked() { + return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class); + } + + @Test + void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() { + repository.apply(new AccountState(1L, OLD_STEAM_ID, true)); + + assertThat(blocked()).containsExactly(1L); + assertThat(sync(1L).get("user_seen_at")).isNull(); + assertThat(sync(2L).get("user_seen_at")).isNotNull(); + + repository.apply(new AccountState(1L, OLD_STEAM_ID, false)); + + assertThat(blocked()).isEmpty(); + } + + // steam_id stays: the next sync with the new ID compares with it and drops the old account's games. + @Test + void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() { + repository.apply(new AccountState(1L, NEW_STEAM_ID, false)); + + assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); + assertThat(sync(1L).get("last_synced_at")).isNull(); + assertThat(sync(1L).get("user_seen_at")).isNull(); + } + + @Test + void sameSteamId_KeepsTheLastSync() { + repository.apply(new AccountState(1L, OLD_STEAM_ID, false)); + + assertThat(sync(1L).get("last_synced_at")).isNotNull(); + } + + @Test + void noSteamId_StopsTheNightlySync() { + repository.apply(new AccountState(1L, null, false)); + + assertThat(sync(1L).get("user_seen_at")).isNull(); + assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); + } + + @Test + void userWithoutASyncRow_GetsOnlyTheBlock() { + repository.apply(new AccountState(3L, NEW_STEAM_ID, true)); + + assertThat(blocked()).containsExactly(3L); + assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty(); + } + + @Test + void replaceAll_LiftsTheBlocksAuthNoLongerSends() { + repository.apply(new AccountState(1L, OLD_STEAM_ID, true)); + repository.apply(new AccountState(2L, OLD_STEAM_ID, true)); + + repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, true))); + assertThat(blocked()).containsExactly(2L); + + repository.replaceAll(List.of()); + assertThat(blocked()).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java index ca4c5e0..89c4db1 100644 --- a/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java @@ -45,6 +45,14 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest { assertThat(claim(3L)).isZero(); } + @Test + void tryClaimRun_RefusesABlockedUser() { + repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID)); + jdbc.update("insert into blocked_user (user_id) values (3)"); + + assertThat(claim(3L)).isZero(); + } + @Test void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() { jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " + diff --git a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java index 10083d2..24b4c3c 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -76,4 +76,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest { hiddenGames_LeaveEveryNumber(); hiddenGames_LeaveTheTopLists(); } + + @Test + void blockedUsers_LeaveEveryNumberAndTheTopLists() { + steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now())); + userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()), + new UserGame(3L, 220L, 0, Instant.now(), Instant.now()))); + jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)"); + + hiddenGames_LeaveEveryNumber(); + hiddenGames_LeaveTheTopLists(); + } } diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index d83d832..138efa1 100644 --- a/src/test/resources/contract/auth-core-contract.json +++ b/src/test/resources/contract/auth-core-contract.json @@ -55,6 +55,16 @@ "path": "/internal/accounts/deleted", "request": {"userIds": []} }, + "applyStates": { + "method": "POST", + "path": "/internal/accounts/states", + "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]} + }, + "replaceStates": { + "method": "PUT", + "path": "/internal/accounts/states", + "request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]} + }, "purge": { "method": "POST", "path": "/internal/accounts/purge",