@babylonjs-toolkit/server 0.0.0-stage → 9.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/CUSTOM.md +1690 -0
  2. package/DEPLOY.md +2012 -0
  3. package/README.md +1081 -2
  4. package/lib/AbuseLimits.d.ts +64 -0
  5. package/lib/AbuseLimits.js +121 -0
  6. package/lib/Authentication.d.ts +86 -0
  7. package/lib/Authentication.js +272 -0
  8. package/lib/BootSessionOwner.d.ts +86 -0
  9. package/lib/BootSessionOwner.js +116 -0
  10. package/lib/ColyseusLoggerAdapter.d.ts +32 -0
  11. package/lib/ColyseusLoggerAdapter.js +73 -0
  12. package/lib/DedicatedSceneRegistry.d.ts +52 -0
  13. package/lib/DedicatedSceneRegistry.js +74 -0
  14. package/lib/DedicatedSession.d.ts +152 -0
  15. package/lib/DedicatedSession.js +173 -0
  16. package/lib/DualTransport.d.ts +112 -0
  17. package/lib/DualTransport.js +197 -0
  18. package/lib/GameServer.d.ts +64 -0
  19. package/lib/GameServer.js +647 -0
  20. package/lib/GameServerConfig.d.ts +387 -0
  21. package/lib/GameServerConfig.js +1059 -0
  22. package/lib/GameServerLobbyRoom.d.ts +33 -0
  23. package/lib/GameServerLobbyRoom.js +56 -0
  24. package/lib/GameServerLogger.d.ts +111 -0
  25. package/lib/GameServerLogger.js +190 -0
  26. package/lib/GameServerMetrics.d.ts +158 -0
  27. package/lib/GameServerMetrics.js +276 -0
  28. package/lib/GameServerState.d.ts +164 -0
  29. package/lib/GameServerState.js +26 -0
  30. package/lib/HeadlessRuntime.d.ts +316 -0
  31. package/lib/HeadlessRuntime.js +380 -0
  32. package/lib/JoinGate.d.ts +34 -0
  33. package/lib/JoinGate.js +221 -0
  34. package/lib/NetworkAddresses.d.ts +33 -0
  35. package/lib/NetworkAddresses.js +90 -0
  36. package/lib/PlayerTokens.d.ts +17 -0
  37. package/lib/PlayerTokens.js +24 -0
  38. package/lib/RoomServerTransport.d.ts +73 -0
  39. package/lib/RoomServerTransport.js +90 -0
  40. package/lib/ServerEndpoints.d.ts +133 -0
  41. package/lib/ServerEndpoints.js +247 -0
  42. package/lib/ServerInit.d.ts +28 -0
  43. package/lib/ServerInit.js +113 -0
  44. package/lib/SessionRoom.d.ts +391 -0
  45. package/lib/SessionRoom.js +930 -0
  46. package/lib/TlsCertificates.d.ts +35 -0
  47. package/lib/TlsCertificates.js +130 -0
  48. package/lib/admin/AccountRoutes.d.ts +8 -0
  49. package/lib/admin/AccountRoutes.js +502 -0
  50. package/lib/admin/AdminAccounts.d.ts +329 -0
  51. package/lib/admin/AdminAccounts.js +607 -0
  52. package/lib/admin/AdminApiRouter.d.ts +202 -0
  53. package/lib/admin/AdminApiRouter.js +465 -0
  54. package/lib/admin/AdminApiTypes.d.ts +1782 -0
  55. package/lib/admin/AdminApiTypes.js +4 -0
  56. package/lib/admin/AdminAudit.d.ts +113 -0
  57. package/lib/admin/AdminAudit.js +266 -0
  58. package/lib/admin/AdminCli.d.ts +7 -0
  59. package/lib/admin/AdminCli.js +351 -0
  60. package/lib/admin/AdminClock.d.ts +51 -0
  61. package/lib/admin/AdminClock.js +10 -0
  62. package/lib/admin/AdminConsole.d.ts +39 -0
  63. package/lib/admin/AdminConsole.js +325 -0
  64. package/lib/admin/AdminConstants.d.ts +147 -0
  65. package/lib/admin/AdminConstants.js +167 -0
  66. package/lib/admin/AdminCrypto.d.ts +19 -0
  67. package/lib/admin/AdminCrypto.js +29 -0
  68. package/lib/admin/AdminExtensionsHost.d.ts +176 -0
  69. package/lib/admin/AdminExtensionsHost.js +307 -0
  70. package/lib/admin/AdminPasswords.d.ts +33 -0
  71. package/lib/admin/AdminPasswords.js +92 -0
  72. package/lib/admin/AdminPermissions.d.ts +28 -0
  73. package/lib/admin/AdminPermissions.js +74 -0
  74. package/lib/admin/AdminRecords.d.ts +196 -0
  75. package/lib/admin/AdminRecords.js +3 -0
  76. package/lib/admin/AdminRequestBodies.d.ts +47 -0
  77. package/lib/admin/AdminRequestBodies.js +84 -0
  78. package/lib/admin/AdminRequestGate.d.ts +38 -0
  79. package/lib/admin/AdminRequestGate.js +59 -0
  80. package/lib/admin/AdminResponses.d.ts +103 -0
  81. package/lib/admin/AdminResponses.js +164 -0
  82. package/lib/admin/AdminServices.d.ts +99 -0
  83. package/lib/admin/AdminServices.js +4 -0
  84. package/lib/admin/AdminStaticFiles.d.ts +29 -0
  85. package/lib/admin/AdminStaticFiles.js +97 -0
  86. package/lib/admin/AdminStore.d.ts +152 -0
  87. package/lib/admin/AdminStore.js +46 -0
  88. package/lib/admin/AlertChannels.d.ts +43 -0
  89. package/lib/admin/AlertChannels.js +120 -0
  90. package/lib/admin/AlertEngine.d.ts +231 -0
  91. package/lib/admin/AlertEngine.js +681 -0
  92. package/lib/admin/AlertRoutes.d.ts +8 -0
  93. package/lib/admin/AlertRoutes.js +180 -0
  94. package/lib/admin/ApiKeyRoutes.d.ts +8 -0
  95. package/lib/admin/ApiKeyRoutes.js +53 -0
  96. package/lib/admin/ApiKeys.d.ts +63 -0
  97. package/lib/admin/ApiKeys.js +167 -0
  98. package/lib/admin/AuditRoutes.d.ts +8 -0
  99. package/lib/admin/AuditRoutes.js +106 -0
  100. package/lib/admin/AuthToolRoutes.d.ts +13 -0
  101. package/lib/admin/AuthToolRoutes.js +164 -0
  102. package/lib/admin/BanList.d.ts +87 -0
  103. package/lib/admin/BanList.js +152 -0
  104. package/lib/admin/CborDecoder.d.ts +18 -0
  105. package/lib/admin/CborDecoder.js +201 -0
  106. package/lib/admin/ColyseusCloudClient.d.ts +313 -0
  107. package/lib/admin/ColyseusCloudClient.js +272 -0
  108. package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
  109. package/lib/admin/ColyseusCloudRoutes.js +91 -0
  110. package/lib/admin/ColyseusCloudService.d.ts +180 -0
  111. package/lib/admin/ColyseusCloudService.js +476 -0
  112. package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
  113. package/lib/admin/ColyseusCloudSettings.js +29 -0
  114. package/lib/admin/ColyseusCloudViews.d.ts +105 -0
  115. package/lib/admin/ColyseusCloudViews.js +371 -0
  116. package/lib/admin/CommonPasswords.d.ts +6 -0
  117. package/lib/admin/CommonPasswords.js +19 -0
  118. package/lib/admin/ConfigOverrides.d.ts +174 -0
  119. package/lib/admin/ConfigOverrides.js +406 -0
  120. package/lib/admin/ConfigRoutes.d.ts +8 -0
  121. package/lib/admin/ConfigRoutes.js +91 -0
  122. package/lib/admin/ConsoleSessions.d.ts +112 -0
  123. package/lib/admin/ConsoleSessions.js +201 -0
  124. package/lib/admin/DedicatedRoutes.d.ts +21 -0
  125. package/lib/admin/DedicatedRoutes.js +254 -0
  126. package/lib/admin/ExtensionRoutes.d.ts +8 -0
  127. package/lib/admin/ExtensionRoutes.js +145 -0
  128. package/lib/admin/FileAdminStore.d.ts +157 -0
  129. package/lib/admin/FileAdminStore.js +349 -0
  130. package/lib/admin/FleetDirectory.d.ts +323 -0
  131. package/lib/admin/FleetDirectory.js +489 -0
  132. package/lib/admin/FleetLeader.d.ts +43 -0
  133. package/lib/admin/FleetLeader.js +89 -0
  134. package/lib/admin/FleetRoutes.d.ts +55 -0
  135. package/lib/admin/FleetRoutes.js +465 -0
  136. package/lib/admin/HealthWarnings.d.ts +10 -0
  137. package/lib/admin/HealthWarnings.js +95 -0
  138. package/lib/admin/LeaderboardRoutes.d.ts +8 -0
  139. package/lib/admin/LeaderboardRoutes.js +569 -0
  140. package/lib/admin/LogBuffer.d.ts +60 -0
  141. package/lib/admin/LogBuffer.js +92 -0
  142. package/lib/admin/LogRedaction.d.ts +15 -0
  143. package/lib/admin/LogRedaction.js +72 -0
  144. package/lib/admin/LogRoutes.d.ts +45 -0
  145. package/lib/admin/LogRoutes.js +307 -0
  146. package/lib/admin/MaintenanceMode.d.ts +41 -0
  147. package/lib/admin/MaintenanceMode.js +74 -0
  148. package/lib/admin/MaintenanceWindows.d.ts +91 -0
  149. package/lib/admin/MaintenanceWindows.js +207 -0
  150. package/lib/admin/MetricsRoutes.d.ts +8 -0
  151. package/lib/admin/MetricsRoutes.js +209 -0
  152. package/lib/admin/MetricsSampler.d.ts +124 -0
  153. package/lib/admin/MetricsSampler.js +211 -0
  154. package/lib/admin/OneTimeCodes.d.ts +60 -0
  155. package/lib/admin/OneTimeCodes.js +162 -0
  156. package/lib/admin/OpenIdConnect.d.ts +146 -0
  157. package/lib/admin/OpenIdConnect.js +335 -0
  158. package/lib/admin/Passkeys.d.ts +130 -0
  159. package/lib/admin/Passkeys.js +432 -0
  160. package/lib/admin/PlayerRoutes.d.ts +20 -0
  161. package/lib/admin/PlayerRoutes.js +676 -0
  162. package/lib/admin/RedisAdminStore.d.ts +114 -0
  163. package/lib/admin/RedisAdminStore.js +258 -0
  164. package/lib/admin/RolloutCoordinator.d.ts +99 -0
  165. package/lib/admin/RolloutCoordinator.js +321 -0
  166. package/lib/admin/ServerEvents.d.ts +57 -0
  167. package/lib/admin/ServerEvents.js +89 -0
  168. package/lib/admin/SessionRoutes.d.ts +113 -0
  169. package/lib/admin/SessionRoutes.js +386 -0
  170. package/lib/admin/UserRoutes.d.ts +8 -0
  171. package/lib/admin/UserRoutes.js +214 -0
  172. package/lib/cli.d.ts +2 -0
  173. package/lib/cli.js +221 -0
  174. package/lib/console/assets/index-CI5N7c-T.js +21 -0
  175. package/lib/console/assets/index-DPitvzLp.css +1 -0
  176. package/lib/console/favicon.svg +1 -0
  177. package/lib/console/index.html +16 -0
  178. package/lib/discord/DiscordOAuth.d.ts +57 -0
  179. package/lib/discord/DiscordOAuth.js +127 -0
  180. package/lib/discord/DiscordSessions.d.ts +79 -0
  181. package/lib/discord/DiscordSessions.js +135 -0
  182. package/lib/discord/DiscordSignInConstants.d.ts +54 -0
  183. package/lib/discord/DiscordSignInConstants.js +55 -0
  184. package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
  185. package/lib/discord/DiscordSignInRoutes.js +359 -0
  186. package/lib/index.d.ts +24 -0
  187. package/lib/index.js +18 -0
  188. package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
  189. package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
  190. package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
  191. package/lib/leaderboards/LeaderboardConstants.js +70 -0
  192. package/lib/leaderboards/LeaderboardError.d.ts +15 -0
  193. package/lib/leaderboards/LeaderboardError.js +20 -0
  194. package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
  195. package/lib/leaderboards/LeaderboardIdentity.js +72 -0
  196. package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
  197. package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
  198. package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
  199. package/lib/leaderboards/LeaderboardRankStore.js +18 -0
  200. package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
  201. package/lib/leaderboards/LeaderboardRules.js +472 -0
  202. package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
  203. package/lib/leaderboards/LeaderboardScheduler.js +103 -0
  204. package/lib/leaderboards/LeaderboardService.d.ts +669 -0
  205. package/lib/leaderboards/LeaderboardService.js +1667 -0
  206. package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
  207. package/lib/leaderboards/LeaderboardTransfer.js +580 -0
  208. package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
  209. package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
  210. package/package.json +64 -4
  211. package/templates/init/Caddyfile +6 -0
  212. package/templates/init/DEPLOY.md +233 -0
  213. package/templates/init/Dockerfile +12 -0
  214. package/templates/init/app.json +21 -0
  215. package/templates/init/aws/README.md +118 -0
  216. package/templates/init/aws/ecs-fargate-alb.yml +207 -0
  217. package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
  218. package/templates/init/babylon-game-server.config.mjs +158 -0
  219. package/templates/init/colyseus-cloud/README.md +133 -0
  220. package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
  221. package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
  222. package/templates/init/colyseus-cloud/gitignore +6 -0
  223. package/templates/init/colyseus-cloud/package.json +15 -0
  224. package/templates/init/digitalocean-app.yaml +30 -0
  225. package/templates/init/docker-compose.full.yml +45 -0
  226. package/templates/init/docker-compose.websocket.yml +41 -0
  227. package/templates/init/fly.toml +56 -0
  228. package/templates/init/heroku.yml +11 -0
  229. package/templates/init/kubernetes/deployment.yaml +71 -0
  230. package/templates/init/kubernetes/redis.yaml +73 -0
  231. package/templates/init/kubernetes/service.yaml +24 -0
  232. package/templates/init/monitoring/grafana-dashboard.json +539 -0
  233. package/templates/init/monitoring/prometheus-alerts.yml +51 -0
  234. package/templates/init/monitoring/prometheus-scrape.yml +17 -0
  235. package/templates/init/railway.json +13 -0
  236. package/templates/init/render.yaml +19 -0
@@ -0,0 +1,676 @@
1
+ // The player and ban routes (game-server-admin-console DR §6.11 players, lobby and bans rows, DR §7.4, §7.5, §13 R29,
2
+ // R30): fleet-wide player search, a player's page, kick, notes, lobby kick and every bans/* route, plus this
3
+ // instance's searchPlayers, disconnectBanned and kickFromLobby fleet commands (R15).
4
+ import { randomUUID } from "node:crypto";
5
+ import { BlockList, isIP } from "node:net";
6
+ import { addNetworkEntry, isAddressInList, normalizeAddress } from "../NetworkAddresses.js";
7
+ import { AdminRequestError } from "./AdminApiRouter.js";
8
+ import { SHARED_ADDRESS_PLAYER_THRESHOLD } from "./AdminConstants.js";
9
+ import { hasPermission } from "./AdminPermissions.js";
10
+ import { bodyObject, booleanField, numberField, optionalStringField, stringField } from "./AdminRequestBodies.js";
11
+ import { RECORD_QUERY_MAX_LIMIT } from "./AdminStore.js";
12
+ import { BanList, banRefusalDetail, isBanActive } from "./BanList.js";
13
+ import { noticeTextField } from "./FleetRoutes.js";
14
+ import { canViewAddresses, pageItems, pageOf } from "./SessionRoutes.js";
15
+ /** HTTP status of a successful read or change. */
16
+ const HTTP_OK = 200;
17
+ /** HTTP status of a created ban or note. */
18
+ const HTTP_CREATED = 201;
19
+ /** HTTP status of an invalid request. */
20
+ const HTTP_BAD_REQUEST = 400;
21
+ /** HTTP status of a missing permission. */
22
+ const HTTP_FORBIDDEN = 403;
23
+ /** HTTP status of an unknown player or ban. */
24
+ const HTTP_NOT_FOUND = 404;
25
+ /** HTTP status of a ban that needs a confirmation. */
26
+ const HTTP_CONFLICT = 409;
27
+ /** Milliseconds per second. */
28
+ const MILLISECONDS_PER_SECOND = 1000;
29
+ /** Longest player note, in characters. */
30
+ const PLAYER_NOTE_MAX_LENGTH = 2000;
31
+ /** Most past sessions a player page lists. */
32
+ const PLAYER_HISTORY_LIMIT = 20;
33
+ /** Subject prefix of a player known by user id. */
34
+ const USER_SUBJECT_PREFIX = "user:";
35
+ /** Subject prefix of a player known by address. */
36
+ const ADDRESS_SUBJECT_PREFIX = "address:";
37
+ /** Ban kinds the API accepts. */
38
+ const BAN_KINDS = new Set(["user", "address", "network"]);
39
+ /** The version of the bans.json format (R30). */
40
+ const BAN_FILE_VERSION = 1;
41
+ /** The refusal of a user-id ban while player authentication is off (DR §7.5). */
42
+ const USER_BAN_NEEDS_AUTH_MESSAGE = "bans by user id need player authentication (auth.enabled)";
43
+ /** The refusal of a ban that may hit a shared proxy (DR §7.5). */
44
+ const SHARED_ADDRESS_MESSAGE = "this address may be a proxy shared by many players";
45
+ /**
46
+ * Registers the players/*, lobby/kick and bans/* routes.
47
+ * @param router The admin API router.
48
+ * @param services The shared services.
49
+ */
50
+ export function registerPlayerRoutes(router, services) {
51
+ const routes = [
52
+ { method: "GET", pattern: "players", permission: "players.view", requiresStepUp: false, auditAction: null, handler: async (request) => ({ status: HTTP_OK, body: await searchFleetPlayers(services, request) }) },
53
+ { method: "GET", pattern: "players/detail", permission: "players.view", requiresStepUp: false, auditAction: null, handler: async (request) => ({ status: HTTP_OK, body: await describePlayer(services, request) }) },
54
+ { method: "POST", pattern: "players/kick", permission: "players.manage", requiresStepUp: false, auditAction: "player.kick", handler: async (request) => kickPlayer(services, request) },
55
+ { method: "POST", pattern: "players/notes", permission: "players.notes", requiresStepUp: false, auditAction: "player.note", handler: async (request) => addNote(services, request) },
56
+ { method: "POST", pattern: "lobby/kick", permission: "players.manage", requiresStepUp: false, auditAction: "lobby.kick", handler: async (request) => kickFromLobby(services, request) },
57
+ { method: "GET", pattern: "bans", permission: "bans.view", requiresStepUp: false, auditAction: null, handler: async (request) => ({ status: HTTP_OK, body: await listBans(services, request) }) },
58
+ { method: "POST", pattern: "bans", permission: "bans.manage", requiresStepUp: false, auditAction: "ban.create", handler: async (request) => createBan(services, request) },
59
+ { method: "GET", pattern: "bans/export", permission: "bans.view", requiresStepUp: false, auditAction: null, handler: async () => exportBans(services) },
60
+ { method: "POST", pattern: "bans/import", permission: "bans.manage", requiresStepUp: true, auditAction: "ban.import", handler: async (request) => importBans(services, request) },
61
+ { method: "PUT", pattern: "bans/:banId", permission: "bans.manage", requiresStepUp: false, auditAction: "ban.update", handler: async (request) => updateBan(services, request) },
62
+ { method: "DELETE", pattern: "bans/:banId", permission: "bans.manage", requiresStepUp: false, auditAction: "ban.remove", handler: async (request) => removeBan(services, request) }
63
+ ];
64
+ for (const route of routes)
65
+ router.register(route);
66
+ }
67
+ /**
68
+ * Registers this instance's searchPlayers, disconnectBanned and kickFromLobby fleet commands (R15).
69
+ * @param services The shared services.
70
+ */
71
+ export function registerPlayerCommands(services) {
72
+ services.fleet.handle("searchPlayers", async (command) => searchLocalPlayers(services.state, command.search, command.includeAddresses));
73
+ services.fleet.handle("disconnectBanned", async (command) => ({ disconnected: await disconnectLocalBanned(services, command.banIds, command.message) }));
74
+ services.fleet.handle("kickFromLobby", async (command) => ({ disconnected: kickLocalLobbyClients(services.state, command) }));
75
+ }
76
+ /**
77
+ * Reads every stored ban into this instance's BanList (at start and on every "bans" change announcement, D15).
78
+ * @param services The shared services.
79
+ * @returns Every stored ban.
80
+ */
81
+ export async function reloadBanList(services) {
82
+ const stored = await services.store.listDocuments("bans");
83
+ const records = stored.map((document) => document.value);
84
+ services.banList.reload(records);
85
+ return records;
86
+ }
87
+ /**
88
+ * The players of this instance's sessions that match a search: user id, display name, session name, the exact player
89
+ * id, and the address when addresses may be shown.
90
+ * @param state The server state.
91
+ * @param search The search text ("" for every player).
92
+ * @param includeAddresses True to match and return addresses.
93
+ * @returns The players.
94
+ */
95
+ function searchLocalPlayers(state, search, includeAddresses) {
96
+ const needle = search.trim().toLowerCase();
97
+ const rows = [];
98
+ for (const room of state.sessionRooms) {
99
+ const session = room.adminDescribe(true);
100
+ for (const player of session.playerList) {
101
+ if (!playerMatches(player, session.sessionName, needle, includeAddresses))
102
+ continue;
103
+ rows.push({
104
+ roomId: session.roomId,
105
+ sessionName: session.sessionName,
106
+ processId: session.processId,
107
+ playerId: player.playerId,
108
+ userId: player.userId,
109
+ displayName: player.displayName,
110
+ clientAddress: includeAddresses ? player.clientAddress : null,
111
+ transport: player.transport,
112
+ joinedAt: player.joinedAt
113
+ });
114
+ }
115
+ }
116
+ return rows;
117
+ }
118
+ /**
119
+ * Whether a player matches a search.
120
+ * @param player The player.
121
+ * @param sessionName Its session.
122
+ * @param needle The lower-case search ("" matches everyone).
123
+ * @param includeAddresses True to also match the address.
124
+ * @returns True on a match.
125
+ */
126
+ function playerMatches(player, sessionName, needle, includeAddresses) {
127
+ if (needle === "")
128
+ return true;
129
+ const texts = [player.userId, player.displayName, sessionName, includeAddresses ? player.clientAddress : null];
130
+ if (texts.some((text) => text != null && text.toLowerCase().includes(needle)))
131
+ return true;
132
+ return String(player.playerId) === needle;
133
+ }
134
+ /**
135
+ * Disconnects this instance's playing clients that the named bans match (R15). The BanList is reloaded from the store
136
+ * first, so every instance enforces the new ban for fresh joins too; ranges are matched here (CIDR bans).
137
+ * @param services The shared services.
138
+ * @param banIds The new or changed bans.
139
+ * @param message Shown to the disconnected players.
140
+ * @returns How many clients were disconnected.
141
+ */
142
+ async function disconnectLocalBanned(services, banIds, message) {
143
+ const records = await reloadBanList(services);
144
+ const matcher = new BanList(() => services.clock.now());
145
+ matcher.reload(records.filter((record) => banIds.includes(record.banId)));
146
+ let disconnected = 0;
147
+ for (const room of Array.from(services.state.sessionRooms))
148
+ disconnected += disconnectRoomBanned(room, matcher, message);
149
+ return disconnected;
150
+ }
151
+ /**
152
+ * Disconnects the clients of one room that a ban list matches.
153
+ * @param room The room.
154
+ * @param matcher The bans to apply.
155
+ * @param message Shown to the disconnected players.
156
+ * @returns How many clients were disconnected.
157
+ */
158
+ function disconnectRoomBanned(room, matcher, message) {
159
+ const banned = room.adminDescribe(true).playerList.filter((player) => matcher.playerBan(player.userId, player.clientAddress ?? "") != null);
160
+ if (banned.length === 0)
161
+ return 0;
162
+ const userIds = banned.map((player) => player.userId).filter((userId) => userId != null);
163
+ const addresses = banned.map((player) => player.clientAddress ?? "");
164
+ return room.adminDisconnectBanned(userIds, addresses, message);
165
+ }
166
+ /**
167
+ * Removes matching clients from this instance's lobby rooms.
168
+ * @param state The server state.
169
+ * @param request The user id and address to remove.
170
+ * @returns How many clients were removed.
171
+ */
172
+ function kickLocalLobbyClients(state, request) {
173
+ const userIds = (request.userId != null) ? [request.userId] : [];
174
+ const addresses = (request.address != null) ? [request.address] : [];
175
+ return Array.from(state.lobbyRooms).reduce((total, room) => total + room.adminKickMatching(userIds, addresses), 0);
176
+ }
177
+ /**
178
+ * Every player of the fleet that matches a search.
179
+ * @param services The shared services.
180
+ * @param search The search text.
181
+ * @param includeAddresses True to match and return addresses.
182
+ * @returns The players, unsorted.
183
+ */
184
+ async function fleetPlayers(services, search, includeAddresses) {
185
+ const found = await services.fleet.runOnAll("searchPlayers", { search: search, includeAddresses: includeAddresses });
186
+ return found.results.flatMap((entry) => entry.result);
187
+ }
188
+ /**
189
+ * GET players: the fleet's players matching the search, by session and player id.
190
+ * @param services The shared services.
191
+ * @param request The request.
192
+ * @returns The page.
193
+ */
194
+ async function searchFleetPlayers(services, request) {
195
+ const players = await fleetPlayers(services, request.query.get("search") ?? "", canViewAddresses(request));
196
+ players.sort((left, right) => left.sessionName.localeCompare(right.sessionName) || left.playerId - right.playerId);
197
+ return pageItems(players, pageOf(request));
198
+ }
199
+ /**
200
+ * GET players/detail?userId= or ?address=: where the player is online, its recent sessions, its bans and (with
201
+ * players.notes) its notes and kicks.
202
+ * @param services The shared services.
203
+ * @param request The request.
204
+ * @returns The player page.
205
+ * @throws AdminRequestError 400 without userId or address, 403 for an address without players.viewAddresses.
206
+ */
207
+ async function describePlayer(services, request) {
208
+ const userId = nonEmpty(request.query.get("userId"));
209
+ const address = nonEmpty(request.query.get("address"));
210
+ if (userId == null && address == null)
211
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "userId or address is needed");
212
+ const includeAddresses = canViewAddresses(request);
213
+ if (address != null && !includeAddresses)
214
+ throw new AdminRequestError(HTTP_FORBIDDEN, "you do not have permission for this", "players.viewAddresses");
215
+ const subject = playerSubject(userId, address);
216
+ const online = (await fleetPlayers(services, userId ?? address ?? "", true))
217
+ .filter((player) => (userId != null) ? player.userId === userId : normalizeAddress(player.clientAddress ?? "") === normalizeAddress(address ?? ""))
218
+ .map((player) => Object.assign({}, player, { clientAddress: includeAddresses ? player.clientAddress : null }));
219
+ const seesNotes = request.caller != null && hasPermission(request.caller.permissions, "players.notes");
220
+ return {
221
+ userId: userId,
222
+ address: address,
223
+ // Verified claims stay inside the rooms; the player page shows identity through the online rows.
224
+ claims: null,
225
+ online: online,
226
+ recentSessions: await recentSessionsOf(services, userId, address),
227
+ bans: (await storedBans(services)).filter((stored) => banMatchesSubject(stored.value, userId, address)).map((stored) => toBanView(stored, services.clock.now())),
228
+ notes: seesNotes ? await notesOf(services, subject) : [],
229
+ kicks: seesNotes ? await kicksOf(services, subject) : []
230
+ };
231
+ }
232
+ /**
233
+ * A player's recent sessions from the playerHistory stream, newest first.
234
+ * @param services The shared services.
235
+ * @param userId The user id, or null.
236
+ * @param address The address, or null.
237
+ * @returns At most PLAYER_HISTORY_LIMIT sessions.
238
+ */
239
+ async function recentSessionsOf(services, userId, address) {
240
+ // The newest records are read, then walked oldest first so each leave closes its join.
241
+ const records = (await services.store.readRecords("playerHistory", { limit: RECORD_QUERY_MAX_LIMIT, newestFirst: true })).reverse();
242
+ const sessions = new Map();
243
+ for (const record of records) {
244
+ const event = record.value;
245
+ const isSubject = (userId != null) ? event.userId === userId : normalizeAddress(event.clientAddress) === normalizeAddress(address ?? "");
246
+ if (!isSubject)
247
+ continue;
248
+ const key = event.processId + "/" + event.roomId + "/" + event.playerId;
249
+ const joined = sessions.get(key);
250
+ if (event.kind === "joined")
251
+ sessions.set(key, { roomId: event.roomId, sessionName: event.sessionName, processId: event.processId, playerId: event.playerId, joinedAt: event.at, leftAt: null });
252
+ else if (joined != null)
253
+ joined.leftAt = event.at;
254
+ }
255
+ return Array.from(sessions.values()).sort((left, right) => right.joinedAt.localeCompare(left.joinedAt)).slice(0, PLAYER_HISTORY_LIMIT);
256
+ }
257
+ /**
258
+ * The notes on a subject, newest first.
259
+ * @param services The shared services.
260
+ * @param subject "user:<id>" or "address:<address>".
261
+ * @returns The notes.
262
+ */
263
+ async function notesOf(services, subject) {
264
+ const stored = await services.store.listDocuments("playerNotes");
265
+ return stored.map((document) => document.value).filter((note) => note.subject === subject).sort((left, right) => right.at.localeCompare(left.at));
266
+ }
267
+ /**
268
+ * The audited kicks of a subject (the kick route audits its target as the player's subject).
269
+ * @param services The shared services.
270
+ * @param subject "user:<id>" or "address:<address>".
271
+ * @returns The kick records.
272
+ */
273
+ async function kicksOf(services, subject) {
274
+ const page = await services.audit.query({ action: "player.kick", target: subject }, 1);
275
+ return page.items.filter((record) => record.target === subject);
276
+ }
277
+ /**
278
+ * POST players/kick (DR §7.4).
279
+ * @param services The shared services.
280
+ * @param request The request.
281
+ * @returns ok.
282
+ * @throws AdminRequestError 404 when the session or the connected player does not exist.
283
+ */
284
+ async function kickPlayer(services, request) {
285
+ const body = bodyObject(request.body);
286
+ const roomId = stringField(body, "roomId");
287
+ const playerId = numberField(body, "playerId");
288
+ const message = noticeTextField(body, "message") ?? "";
289
+ const allowRejoin = (body.allowRejoin == null) ? false : booleanField(body, "allowRejoin");
290
+ const session = await services.roomClient.describe(roomId, true);
291
+ const player = session.playerList.find((candidate) => candidate.playerId === playerId);
292
+ const kicked = player != null && await services.roomClient.kick(roomId, playerId, message, allowRejoin);
293
+ if (player == null || !kicked)
294
+ throw new AdminRequestError(HTTP_NOT_FOUND, "no such player in this session");
295
+ services.extensions.callHook("onPlayerKicked", { roomId: roomId, playerId: playerId, userId: player.userId, message: message, by: callerName(request) });
296
+ const parameters = { roomId: roomId, sessionName: session.sessionName, playerId: playerId, message: message, allowRejoin: allowRejoin };
297
+ return { status: HTTP_OK, body: { ok: true }, audit: { target: playerSubject(player.userId, player.clientAddress), parameters: parameters } };
298
+ }
299
+ /**
300
+ * Calls the onBanCreated admin hook (R29).
301
+ * @param services The shared services.
302
+ * @param record The new ban.
303
+ * @param by The admin who created it.
304
+ */
305
+ function callBanCreatedHook(services, record, by) {
306
+ services.extensions.callHook("onBanCreated", { banId: record.banId, kind: record.kind, value: record.value, expiresAt: record.expiresAt, by: by });
307
+ }
308
+ /**
309
+ * POST players/notes: a note on a user id or an address.
310
+ * @param services The shared services.
311
+ * @param request The request.
312
+ * @returns The note.
313
+ */
314
+ async function addNote(services, request) {
315
+ const body = bodyObject(request.body);
316
+ const subjectValue = stringField(body, "subject").trim();
317
+ const text = stringField(body, "text").trim();
318
+ if (subjectValue === "" || text === "")
319
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "subject and text must not be empty");
320
+ if (text.length > PLAYER_NOTE_MAX_LENGTH)
321
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "text is longer than " + PLAYER_NOTE_MAX_LENGTH + " characters");
322
+ const isAddress = isIP(normalizeAddress(subjectValue)) !== 0;
323
+ const subject = isAddress ? playerSubject(null, subjectValue) : playerSubject(subjectValue, null);
324
+ const note = { noteId: randomUUID(), subject: subject, text: text, by: callerName(request), at: new Date(services.clock.now()).toISOString() };
325
+ await services.store.putDocument("playerNotes", note.noteId, note, 0);
326
+ return { status: HTTP_CREATED, body: note, audit: { target: subject, parameters: { noteId: note.noteId } } };
327
+ }
328
+ /**
329
+ * POST lobby/kick: removes matching clients from every instance's lobby.
330
+ * @param services The shared services.
331
+ * @param request The request.
332
+ * @returns How many clients were removed.
333
+ * @throws AdminRequestError 400 without a user id or an address.
334
+ */
335
+ async function kickFromLobby(services, request) {
336
+ const body = bodyObject(request.body);
337
+ const command = { userId: nonEmpty(optionalStringField(body, "userId")), address: nonEmpty(optionalStringField(body, "address")) };
338
+ if (command.userId == null && command.address == null)
339
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "userId or address is needed");
340
+ const kicked = await services.fleet.runOnAll("kickFromLobby", command);
341
+ const disconnected = kicked.results.reduce((total, entry) => total + entry.result.disconnected, 0);
342
+ return { status: HTTP_OK, body: { disconnected: disconnected }, audit: { target: playerSubject(command.userId, command.address), parameters: { disconnected: disconnected } } };
343
+ }
344
+ /**
345
+ * GET bans?search&kind&active, newest first.
346
+ * @param services The shared services.
347
+ * @param request The request.
348
+ * @returns The bans.
349
+ */
350
+ async function listBans(services, request) {
351
+ const search = (request.query.get("search") ?? "").toLowerCase();
352
+ const kind = nonEmpty(request.query.get("kind"));
353
+ const active = nonEmpty(request.query.get("active"));
354
+ const views = (await storedBans(services)).map((stored) => toBanView(stored, services.clock.now()));
355
+ return views
356
+ .filter((ban) => search === "" || [ban.value, ban.reason, ban.note].some((text) => text.toLowerCase().includes(search)))
357
+ .filter((ban) => kind == null || ban.kind === kind)
358
+ .filter((ban) => active == null || ban.active === (active === "true"))
359
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
360
+ }
361
+ /**
362
+ * POST bans (DR §7.5): validates, refuses a likely shared proxy address without confirmation, stores the ban and makes
363
+ * every instance enforce it and disconnect matching players.
364
+ * @param services The shared services.
365
+ * @param request The request.
366
+ * @returns The ban.
367
+ * @throws AdminRequestError 400 for an invalid ban or a user-id ban with auth off, 409 for an unconfirmed shared address.
368
+ */
369
+ async function createBan(services, request) {
370
+ const body = bodyObject(request.body);
371
+ const kind = banKindField(body);
372
+ const value = banValueOf(services, kind, stringField(body, "value"));
373
+ const durationSeconds = (body.durationSeconds == null) ? null : numberField(body, "durationSeconds");
374
+ if (durationSeconds != null && durationSeconds <= 0)
375
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "durationSeconds must be more than 0, or null for a permanent ban");
376
+ if (kind !== "user" && body.confirmSharedAddress !== true)
377
+ await refuseSharedAddress(services, value);
378
+ const now = services.clock.now();
379
+ const expiresAt = (durationSeconds != null) ? new Date(now + durationSeconds * MILLISECONDS_PER_SECOND).toISOString() : null;
380
+ const record = newBanRecord(services, request, { kind: kind, value: value, reason: optionalStringField(body, "reason") ?? "", note: optionalStringField(body, "note") ?? "", expiresAt: expiresAt }, "created");
381
+ const stored = await services.store.putDocument("bans", record.banId, record, 0);
382
+ await applyBanChange(services, [record.banId], banRefusalDetail(record));
383
+ callBanCreatedHook(services, record, callerName(request));
384
+ return { status: HTTP_CREATED, body: toBanView(stored, now), audit: { target: record.banId, parameters: { kind: kind, value: value, expiresAt: expiresAt, reason: record.reason } } };
385
+ }
386
+ /**
387
+ * PUT bans/:banId: a new end time, reason or note, with an optimistic revision.
388
+ * @param services The shared services.
389
+ * @param request The request.
390
+ * @returns The ban.
391
+ * @throws AdminRequestError 404 for an unknown ban, 400 for a bad end time; AdminConflictError when the revision moved.
392
+ */
393
+ async function updateBan(services, request) {
394
+ const body = bodyObject(request.body);
395
+ const stored = await requireBan(services, request.params.banId);
396
+ const expiresAt = optionalStringField(body, "expiresAt");
397
+ if (expiresAt != null && !Number.isFinite(Date.parse(expiresAt)))
398
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "expiresAt must be an ISO time or null");
399
+ const now = services.clock.now();
400
+ const updated = Object.assign({}, stored.value, {
401
+ expiresAt: (expiresAt != null) ? new Date(expiresAt).toISOString() : null,
402
+ reason: optionalStringField(body, "reason") ?? stored.value.reason,
403
+ note: optionalStringField(body, "note") ?? stored.value.note,
404
+ history: stored.value.history.concat([{ at: new Date(now).toISOString(), by: callerName(request), change: "updated" }])
405
+ });
406
+ const saved = await services.store.putDocument("bans", stored.id, updated, numberField(body, "expectedRevision"));
407
+ await applyBanChange(services, [updated.banId], banRefusalDetail(updated));
408
+ return { status: HTTP_OK, body: toBanView(saved, now), audit: { target: updated.banId, parameters: { expiresAt: updated.expiresAt, reason: updated.reason } } };
409
+ }
410
+ /**
411
+ * DELETE bans/:banId: every instance admits the player again.
412
+ * @param services The shared services.
413
+ * @param request The request.
414
+ * @returns ok.
415
+ * @throws AdminRequestError 404 for an unknown ban.
416
+ */
417
+ async function removeBan(services, request) {
418
+ const stored = await requireBan(services, request.params.banId);
419
+ await services.store.deleteDocument("bans", stored.id);
420
+ await reloadBanList(services);
421
+ await services.fleet.announceChange("bans");
422
+ services.extensions.callHook("onBanRemoved", { banId: stored.id, by: callerName(request) });
423
+ return { status: HTTP_OK, body: { ok: true }, audit: { target: stored.id, parameters: { kind: stored.value.kind, value: stored.value.value } } };
424
+ }
425
+ /**
426
+ * GET bans/export: the active bans as bans.json (R30).
427
+ * @param services The shared services.
428
+ * @returns The download.
429
+ */
430
+ async function exportBans(services) {
431
+ const now = services.clock.now();
432
+ const active = (await storedBans(services)).map((stored) => stored.value).filter((record) => isBanActive(record, now));
433
+ const file = {
434
+ version: BAN_FILE_VERSION,
435
+ exportedAt: new Date(now).toISOString(),
436
+ bans: active.map((record) => ({ kind: record.kind, value: record.value, reason: record.reason, note: record.note, expiresAt: record.expiresAt }))
437
+ };
438
+ const bytes = new TextEncoder().encode(JSON.stringify(file, null, 2));
439
+ return { status: HTTP_OK, body: null, download: { bytes: bytes, contentType: "application/json", fileName: "bans.json" } };
440
+ }
441
+ /**
442
+ * POST bans/import: adds the bans of a bans.json file; expired entries, duplicates of an active ban, invalid entries and
443
+ * user-id bans while auth is off are skipped (R30).
444
+ * @param services The shared services.
445
+ * @param request The request.
446
+ * @returns How many were imported and skipped.
447
+ * @throws AdminRequestError 400 for a file of another format.
448
+ */
449
+ async function importBans(services, request) {
450
+ const body = bodyObject(request.body);
451
+ if (body.version !== BAN_FILE_VERSION || !Array.isArray(body.bans))
452
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "the body must be a bans.json file (version 1)");
453
+ const now = services.clock.now();
454
+ const activeKeys = new Set((await storedBans(services)).map((stored) => stored.value).filter((record) => isBanActive(record, now)).map(banKeyOf));
455
+ const importedIds = [];
456
+ for (const entry of body.bans) {
457
+ const record = importedBanRecord(services, request, entry, now);
458
+ if (record == null || activeKeys.has(banKeyOf(record)))
459
+ continue;
460
+ activeKeys.add(banKeyOf(record));
461
+ await services.store.putDocument("bans", record.banId, record, 0);
462
+ importedIds.push(record.banId);
463
+ callBanCreatedHook(services, record, callerName(request));
464
+ }
465
+ if (importedIds.length > 0)
466
+ await applyBanChange(services, importedIds, "you are banned");
467
+ const result = { imported: importedIds.length, skipped: body.bans.length - importedIds.length };
468
+ return { status: HTTP_OK, body: result, audit: { target: "bans", parameters: result } };
469
+ }
470
+ /**
471
+ * The ban record of one bans.json entry.
472
+ * @param services The shared services.
473
+ * @param request The request (its caller is the creator).
474
+ * @param entry The entry.
475
+ * @param now The current time.
476
+ * @returns The record, or null when the entry is invalid, expired or a user-id ban while auth is off.
477
+ */
478
+ function importedBanRecord(services, request, entry, now) {
479
+ if (entry == null || typeof entry !== "object")
480
+ return null;
481
+ const fields = entry;
482
+ const expiresAt = (typeof fields.expiresAt === "string") ? fields.expiresAt : null;
483
+ if (expiresAt != null && !(Date.parse(expiresAt) > now))
484
+ return null;
485
+ try {
486
+ const kind = banKindField(fields);
487
+ const value = banValueOf(services, kind, stringField(fields, "value"));
488
+ const reason = (typeof fields.reason === "string") ? fields.reason : "";
489
+ const note = (typeof fields.note === "string") ? fields.note : "";
490
+ return newBanRecord(services, request, { kind: kind, value: value, reason: reason, note: note, expiresAt: expiresAt }, "imported");
491
+ }
492
+ catch (error) {
493
+ if (error instanceof AdminRequestError)
494
+ return null;
495
+ throw error;
496
+ }
497
+ }
498
+ /**
499
+ * Makes every instance enforce changed bans: this instance reloads at once, the change is announced, and every
500
+ * instance disconnects the playing clients the bans match (DR §7.5 steps 3–4).
501
+ * @param services The shared services.
502
+ * @param banIds The new or changed bans.
503
+ * @param message Shown to the disconnected players.
504
+ */
505
+ async function applyBanChange(services, banIds, message) {
506
+ await reloadBanList(services);
507
+ await services.fleet.announceChange("bans");
508
+ await services.fleet.runOnAll("disconnectBanned", { banIds: banIds, message: message });
509
+ }
510
+ /**
511
+ * Refuses an address or network ban that matches an address more than SHARED_ADDRESS_PLAYER_THRESHOLD live players
512
+ * share, unless the server trusts a proxy (then addresses are the players' own, DR §7.5).
513
+ * @param services The shared services.
514
+ * @param value The banned address or range.
515
+ * @throws AdminRequestError 409 with detail "confirmSharedAddress".
516
+ */
517
+ async function refuseSharedAddress(services, value) {
518
+ if (services.config.trustProxy)
519
+ return;
520
+ const matcher = new BlockList();
521
+ addNetworkEntry(matcher, value);
522
+ const playersByAddress = new Map();
523
+ for (const player of await fleetPlayers(services, "", true)) {
524
+ const address = normalizeAddress(player.clientAddress ?? "");
525
+ playersByAddress.set(address, (playersByAddress.get(address) ?? 0) + 1);
526
+ }
527
+ for (const [address, players] of playersByAddress) {
528
+ if (players > SHARED_ADDRESS_PLAYER_THRESHOLD && isAddressInList(matcher, address))
529
+ throw new AdminRequestError(HTTP_CONFLICT, SHARED_ADDRESS_MESSAGE, "confirmSharedAddress");
530
+ }
531
+ }
532
+ /**
533
+ * A new ban record.
534
+ * @param services The shared services.
535
+ * @param request The request (its caller is the creator).
536
+ * @param fields What the ban matches, why, and until when.
537
+ * @param change The history entry ("created" or "imported").
538
+ * @returns The record.
539
+ */
540
+ function newBanRecord(services, request, fields, change) {
541
+ const at = new Date(services.clock.now()).toISOString();
542
+ const by = callerName(request);
543
+ return Object.assign({ banId: randomUUID(), createdBy: by, createdAt: at, history: [{ at: at, by: by, change: change }] }, fields);
544
+ }
545
+ /**
546
+ * The kind field of a ban.
547
+ * @param body The body or file entry.
548
+ * @returns The kind.
549
+ * @throws AdminRequestError 400 for another value.
550
+ */
551
+ function banKindField(body) {
552
+ const kind = String(body.kind ?? "");
553
+ if (!BAN_KINDS.has(kind))
554
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "kind must be user, address or network");
555
+ return kind;
556
+ }
557
+ /**
558
+ * Validates and normalizes a ban's value.
559
+ * @param services The shared services.
560
+ * @param kind The ban kind.
561
+ * @param rawValue The value sent.
562
+ * @returns The value stored (addresses normalized).
563
+ * @throws AdminRequestError 400 for a user-id ban with auth off, an empty user id, or a bad address or range.
564
+ */
565
+ function banValueOf(services, kind, rawValue) {
566
+ const value = rawValue.trim();
567
+ if (kind === "user") {
568
+ if (!services.config.auth.enabled)
569
+ throw new AdminRequestError(HTTP_BAD_REQUEST, USER_BAN_NEEDS_AUTH_MESSAGE);
570
+ if (value === "")
571
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "value must be a user id");
572
+ return value;
573
+ }
574
+ if (kind === "address") {
575
+ const address = normalizeAddress(value);
576
+ if (isIP(address) === 0)
577
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "value must be an IP address");
578
+ return address;
579
+ }
580
+ if (!value.includes("/") || !addNetworkEntry(new BlockList(), value))
581
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "value must be a CIDR range such as 203.0.113.0/24");
582
+ return value;
583
+ }
584
+ /**
585
+ * Whether a ban applies to a player page's subject.
586
+ * @param record The ban.
587
+ * @param userId The user id, or null.
588
+ * @param address The address, or null.
589
+ * @returns True when it matches.
590
+ */
591
+ function banMatchesSubject(record, userId, address) {
592
+ if (record.kind === "user")
593
+ return userId != null && record.value === userId;
594
+ if (address == null)
595
+ return false;
596
+ const matcher = new BlockList();
597
+ return addNetworkEntry(matcher, record.value) && isAddressInList(matcher, address);
598
+ }
599
+ /**
600
+ * Every stored ban.
601
+ * @param services The shared services.
602
+ * @returns The documents.
603
+ */
604
+ async function storedBans(services) {
605
+ return services.store.listDocuments("bans");
606
+ }
607
+ /**
608
+ * A stored ban.
609
+ * @param services The shared services.
610
+ * @param banId The ban.
611
+ * @returns The document.
612
+ * @throws AdminRequestError 404 when it does not exist.
613
+ */
614
+ async function requireBan(services, banId) {
615
+ const stored = await services.store.getDocument("bans", banId);
616
+ if (stored == null)
617
+ throw new AdminRequestError(HTTP_NOT_FOUND, "no such ban");
618
+ return stored;
619
+ }
620
+ /**
621
+ * The console view of a stored ban.
622
+ * @param stored The document.
623
+ * @param now The current time (whether it is still active).
624
+ * @returns The view.
625
+ */
626
+ function toBanView(stored, now) {
627
+ const record = stored.value;
628
+ return {
629
+ banId: record.banId,
630
+ kind: record.kind,
631
+ value: record.value,
632
+ reason: record.reason,
633
+ note: record.note,
634
+ expiresAt: record.expiresAt,
635
+ createdBy: record.createdBy,
636
+ createdAt: record.createdAt,
637
+ active: isBanActive(record, now),
638
+ revision: stored.revision,
639
+ history: record.history.slice()
640
+ };
641
+ }
642
+ /**
643
+ * The duplicate key of a ban: its kind and value.
644
+ * @param record The ban.
645
+ * @returns The key.
646
+ */
647
+ function banKeyOf(record) {
648
+ return record.kind + "\u0000" + record.value;
649
+ }
650
+ /**
651
+ * The subject a note, a kick and a lobby kick are filed under: "user:<id>" when the player has a user id, else
652
+ * "address:<address>".
653
+ * @param userId The user id, or null.
654
+ * @param address The address, or null.
655
+ * @returns The subject.
656
+ */
657
+ function playerSubject(userId, address) {
658
+ return (userId != null) ? USER_SUBJECT_PREFIX + userId : ADDRESS_SUBJECT_PREFIX + normalizeAddress(address ?? "");
659
+ }
660
+ /**
661
+ * The display name of the request's caller, for history entries and notes.
662
+ * @param request The request.
663
+ * @returns The name.
664
+ */
665
+ function callerName(request) {
666
+ const caller = request.caller;
667
+ return (caller != null) ? caller.name : "unknown";
668
+ }
669
+ /**
670
+ * A query or body value that counts only when it is non-empty text.
671
+ * @param value The value.
672
+ * @returns The text, or null.
673
+ */
674
+ function nonEmpty(value) {
675
+ return (value != null && value.trim() !== "") ? value.trim() : null;
676
+ }