@babylonjs-toolkit/server 0.0.0-stage → 9.35.1

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,389 @@
1
+ // The public leaderboard HTTP API (game-server-leaderboards D5, D6, D19, DR §6): one catch-all endpoint under
2
+ // /game-server/leaderboards/ with its own route table. Every request is checked in a fixed order — route, origin,
3
+ // address, auth, bans, read limit, body — before its handler runs. Player keys travel only in JSON bodies.
4
+ import { createEndpoint } from "colyseus";
5
+ import { isOriginAllowed } from "../AbuseLimits.js";
6
+ import { getLogger } from "../GameServerLogger.js";
7
+ import { needsVerification, verifyPlayerToken } from "../PlayerTokens.js";
8
+ import { jsonResponse } from "../ServerEndpoints.js";
9
+ import { requestOf } from "../admin/AdminApiRouter.js";
10
+ import { AdminRequestGate } from "../admin/AdminRequestGate.js";
11
+ import { LEADERBOARD_API_PREFIX, LEADERBOARD_DEFAULT_PAGE_SIZE, LEADERBOARD_MAX_BODY_BYTES, LEADERBOARD_MAXIMUM_AROUND_WINDOW, LEADERBOARD_MAXIMUM_PAGE_SIZE, LEADERBOARD_MAXIMUM_TARGETS } from "./LeaderboardConstants.js";
12
+ import { LeaderboardError } from "./LeaderboardError.js";
13
+ /** HTTP OK. */
14
+ const HTTP_OK = 200;
15
+ /** HTTP internal server error. */
16
+ const HTTP_INTERNAL_ERROR = 500;
17
+ /** The bearer scheme prefix of the Authorization header. */
18
+ const BEARER_PREFIX = "Bearer ";
19
+ /** Default window of an around-me read. */
20
+ const DEFAULT_AROUND_WINDOW = 3;
21
+ /** Most player ids in one players read. */
22
+ const MAXIMUM_PLAYER_IDS = 50;
23
+ /** Marker of a path parameter segment in a route pattern. */
24
+ const PARAMETER_MARKER = ":";
25
+ /**
26
+ * The public leaderboard endpoint for createRouter.
27
+ * @param service The leaderboard service.
28
+ * @param state The server state (config, verifier, bans, metrics).
29
+ * @returns The endpoint, keyed by name.
30
+ */
31
+ export function createLeaderboardRoutes(service, state) {
32
+ const routes = buildRoutes(service);
33
+ return {
34
+ leaderboardApi: createEndpoint(LEADERBOARD_API_PREFIX + "**:apiPath", { method: ["GET", "POST"], disableBody: true }, async (context) => handleLeaderboardRequest(service, state, routes, requestOf(context)))
35
+ };
36
+ }
37
+ /**
38
+ * Answers one public request, turning a LeaderboardError into its JSON error body.
39
+ * @param service The leaderboard service.
40
+ * @param state The server state.
41
+ * @param routes The route table.
42
+ * @param request The Fetch request.
43
+ * @returns The response.
44
+ */
45
+ async function handleLeaderboardRequest(service, state, routes, request) {
46
+ try {
47
+ return jsonResponse(HTTP_OK, await runRequest(service, state, routes, request));
48
+ }
49
+ catch (error) {
50
+ if (error instanceof LeaderboardError) {
51
+ state.metrics?.countLeaderboardRequestRefusal?.(error.reason);
52
+ return jsonResponse(error.status, { error: error.message, reason: error.reason });
53
+ }
54
+ getLogger().error("leaderboard request failed", { error: (error instanceof Error) ? error.message : String(error) });
55
+ return jsonResponse(HTTP_INTERNAL_ERROR, { error: "leaderboard request failed", reason: "invalidRequest" });
56
+ }
57
+ }
58
+ /**
59
+ * The request pipeline in its binding order: route, origin, address, auth, bans, read limit, body, handler.
60
+ * @param service The leaderboard service.
61
+ * @param state The server state.
62
+ * @param routes The route table.
63
+ * @param request The Fetch request.
64
+ * @returns The handler's answer.
65
+ * @throws LeaderboardError for every refusal.
66
+ */
67
+ async function runRequest(service, state, routes, request) {
68
+ const url = new URL(request.url);
69
+ const match = matchRoute(routes, request.method.toUpperCase(), url.pathname);
70
+ if (match == null)
71
+ throw new LeaderboardError(404, "invalidRequest", "no such leaderboard route");
72
+ const origin = request.headers.get("origin");
73
+ if (origin != null && !isOriginAllowed(origin, state.config.limits))
74
+ throw new LeaderboardError(403, "invalidRequest", "origin not allowed");
75
+ const clientAddress = AdminRequestGate.clientAddressOf(request.headers);
76
+ const identity = await authenticate(state, request, clientAddress, origin);
77
+ const userId = (identity != null) ? identity.userId : null;
78
+ if (state.banList?.playerBan(userId, clientAddress) != null)
79
+ throw new LeaderboardError(403, "banned", "this player or address is banned");
80
+ if (match.route.isRead)
81
+ await service.consumeReadRate(clientAddress);
82
+ const body = (match.route.method === "POST") ? await readJsonBody(request) : {};
83
+ const caller = {
84
+ userId: userId,
85
+ userDisplayName: (identity != null) ? displayNameClaimOf(identity) : null,
86
+ userAvatar: (identity != null && typeof identity.claims.avatar === "string") ? identity.claims.avatar : null,
87
+ isNameManaged: identity != null && identity.claims.name_managed === true,
88
+ playerKey: (typeof body.playerKey === "string") ? body.playerKey : null,
89
+ clientAddress: clientAddress
90
+ };
91
+ return await match.route.handler({ params: match.params, query: url.searchParams, body: body, caller: caller });
92
+ }
93
+ /**
94
+ * Verifies the bearer token when the server's auth is on.
95
+ * @param state The server state.
96
+ * @param request The Fetch request.
97
+ * @param clientAddress The caller's address.
98
+ * @param origin The Origin header, or null.
99
+ * @returns The identity, or null when auth is off.
100
+ * @throws LeaderboardError 401 authRequired without a bearer token, 401 authRefused when the verifier refuses it.
101
+ */
102
+ async function authenticate(state, request, clientAddress, origin) {
103
+ const authorization = request.headers.get("authorization") ?? "";
104
+ const token = authorization.startsWith(BEARER_PREFIX) ? authorization.slice(BEARER_PREFIX.length).trim() : "";
105
+ if (!needsVerification(state, token))
106
+ return null;
107
+ if (token === "")
108
+ throw new LeaderboardError(401, "authRequired", "this server needs an Authorization: Bearer token");
109
+ try {
110
+ return await verifyPlayerToken(state, { token: token, kind: "lobby", sessionName: null, gameMode: null, clientAddress: clientAddress, origin: origin });
111
+ }
112
+ catch {
113
+ state.metrics?.countAuthFailure(state.config.auth.mode);
114
+ throw new LeaderboardError(401, "authRefused", "the token was refused");
115
+ }
116
+ }
117
+ /**
118
+ * The display-name claim of an auth identity: name, else preferred_username.
119
+ * @param identity The identity.
120
+ * @returns The claim, or null.
121
+ */
122
+ function displayNameClaimOf(identity) {
123
+ const name = identity.claims.name ?? identity.claims.preferred_username;
124
+ return (typeof name === "string") ? name : null;
125
+ }
126
+ /**
127
+ * Finds the route of a method and path, segment by segment.
128
+ * @param routes The route table.
129
+ * @param method The upper-case method.
130
+ * @param pathname The URL path.
131
+ * @returns The match, or null.
132
+ */
133
+ function matchRoute(routes, method, pathname) {
134
+ if (!pathname.startsWith(LEADERBOARD_API_PREFIX))
135
+ return null;
136
+ const segments = pathname.slice(LEADERBOARD_API_PREFIX.length).split("/").filter((segment) => segment !== "");
137
+ for (const route of routes) {
138
+ if (route.method !== method)
139
+ continue;
140
+ const params = matchSegments(route.pattern.split("/"), segments);
141
+ if (params != null)
142
+ return { route: route, params: params };
143
+ }
144
+ return null;
145
+ }
146
+ /**
147
+ * Matches pattern segments against path segments.
148
+ * @param patternSegments The pattern's segments.
149
+ * @param pathSegments The path's segments.
150
+ * @returns The decoded parameters, or null when they do not match.
151
+ */
152
+ function matchSegments(patternSegments, pathSegments) {
153
+ if (patternSegments.length !== pathSegments.length)
154
+ return null;
155
+ const params = {};
156
+ for (let i = 0; i < patternSegments.length; i++) {
157
+ const patternSegment = patternSegments[i];
158
+ if (patternSegment.startsWith(PARAMETER_MARKER)) {
159
+ const decoded = decodeSegment(pathSegments[i]);
160
+ if (decoded == null)
161
+ return null;
162
+ params[patternSegment.slice(PARAMETER_MARKER.length)] = decoded;
163
+ }
164
+ else if (patternSegment !== pathSegments[i]) {
165
+ return null;
166
+ }
167
+ }
168
+ return params;
169
+ }
170
+ /**
171
+ * Decodes one URL path segment.
172
+ * @param segment The segment.
173
+ * @returns The decoded text, or null when it is not valid percent-encoding.
174
+ */
175
+ function decodeSegment(segment) {
176
+ try {
177
+ return decodeURIComponent(segment);
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * Reads a POST body as a JSON object, at most LEADERBOARD_MAX_BODY_BYTES.
185
+ * @param request The Fetch request.
186
+ * @returns The object ({} for an empty body).
187
+ * @throws LeaderboardError 400 invalidRequest when too large, not JSON or not an object.
188
+ */
189
+ export async function readJsonBody(request) {
190
+ const text = await readLimitedBodyText(request, LEADERBOARD_MAX_BODY_BYTES);
191
+ if (text == null)
192
+ throw new LeaderboardError(400, "invalidRequest", "request body too large");
193
+ if (text.trim() === "")
194
+ return {};
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(text);
198
+ }
199
+ catch {
200
+ throw new LeaderboardError(400, "invalidRequest", "the request body must be JSON");
201
+ }
202
+ if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed))
203
+ throw new LeaderboardError(400, "invalidRequest", "the request body must be a JSON object");
204
+ return parsed;
205
+ }
206
+ /**
207
+ * Reads a request body, stopping as soon as it grows past the cap.
208
+ * @param request The Fetch request.
209
+ * @param maximumBytes The cap.
210
+ * @returns The body text, or null past the cap.
211
+ */
212
+ async function readLimitedBodyText(request, maximumBytes) {
213
+ if (Number(request.headers.get("content-length") ?? 0) > maximumBytes)
214
+ return null;
215
+ if (request.body == null)
216
+ return "";
217
+ const reader = request.body.getReader();
218
+ const chunks = [];
219
+ let totalBytes = 0;
220
+ for (;;) {
221
+ const { done, value } = await reader.read();
222
+ if (done)
223
+ break;
224
+ totalBytes += value.byteLength;
225
+ if (totalBytes > maximumBytes) {
226
+ await reader.cancel();
227
+ return null;
228
+ }
229
+ chunks.push(value);
230
+ }
231
+ return Buffer.concat(chunks).toString("utf8");
232
+ }
233
+ /**
234
+ * The public route table (DR §6).
235
+ * @param service The leaderboard service.
236
+ * @returns The routes.
237
+ */
238
+ function buildRoutes(service) {
239
+ return [
240
+ { method: "POST", pattern: "players", isRead: false, handler: async (request) => service.openPlayerSession(request.caller) },
241
+ { method: "POST", pattern: "players/me", isRead: false, handler: async (request) => service.getOwnPlayer(request.caller) },
242
+ { method: "POST", pattern: "players/me/name", isRead: false, handler: async (request) => service.renameOwnPlayer(request.caller, request.body.displayName) },
243
+ { method: "POST", pattern: "players/me/delete", isRead: false, handler: async (request) => ({ erasedEntries: await service.eraseOwnPlayer(request.caller) }) },
244
+ { method: "GET", pattern: "boards", isRead: true, handler: async () => service.listPublicBoards() },
245
+ { method: "GET", pattern: "boards/:boardId/seasons", isRead: true, handler: async (request) => service.listSeasons(request.params.boardId, "public") },
246
+ { method: "GET", pattern: "boards/:boardId/entries", isRead: true, handler: async (request) => readEntriesPage(service, request) },
247
+ { method: "POST", pattern: "boards/:boardId/around", isRead: true, handler: async (request) => readAroundPage(service, request) },
248
+ { method: "POST", pattern: "boards/:boardId/players", isRead: true, handler: async (request) => readPlayersPage(service, request) },
249
+ { method: "POST", pattern: "entries/mine", isRead: true, handler: async (request) => readMyEntries(service, request) },
250
+ { method: "POST", pattern: "submissions", isRead: false, handler: async (request) => service.submit(request.caller, request.body, "public") }
251
+ ];
252
+ }
253
+ /**
254
+ * GET boards/:boardId/entries: `variant` (JSON object), `season`, `offset` and `limit` from the query.
255
+ * @param service The leaderboard service.
256
+ * @param request The parsed request.
257
+ * @returns The page.
258
+ */
259
+ async function readEntriesPage(service, request) {
260
+ const variantText = request.query.get("variant");
261
+ const variant = (variantText != null) ? requireVariant(parseVariantText(variantText)) : null;
262
+ const seasonId = optionalInteger(request.query.get("season"), "season", 1, Number.MAX_SAFE_INTEGER);
263
+ const offset = optionalInteger(request.query.get("offset"), "offset", 0, Number.MAX_SAFE_INTEGER) ?? 0;
264
+ const limit = optionalInteger(request.query.get("limit"), "limit", 1, LEADERBOARD_MAXIMUM_PAGE_SIZE) ?? LEADERBOARD_DEFAULT_PAGE_SIZE;
265
+ const page = await service.readEntries(request.params.boardId, variant, seasonId, offset, limit, "public");
266
+ return { ...page, entries: await service.attachAvatars(page.entries) };
267
+ }
268
+ /**
269
+ * POST boards/:boardId/around: the caller's window of `window` (0–10, default 3) entries above and below.
270
+ * @param service The leaderboard service.
271
+ * @param request The parsed request.
272
+ * @returns The page.
273
+ */
274
+ async function readAroundPage(service, request) {
275
+ const player = await service.resolvePlayer(request.caller);
276
+ const variant = requireVariant(request.body.variant);
277
+ const seasonId = optionalBodyInteger(request.body.seasonId, "seasonId", 1, Number.MAX_SAFE_INTEGER);
278
+ const window = optionalBodyInteger(request.body.window, "window", 0, LEADERBOARD_MAXIMUM_AROUND_WINDOW) ?? DEFAULT_AROUND_WINDOW;
279
+ const page = await service.readAround(request.params.boardId, variant, seasonId, player.playerId, window);
280
+ return { ...page, entries: await service.attachAvatars(page.entries) };
281
+ }
282
+ /**
283
+ * POST boards/:boardId/players: the entries of 1–50 named players.
284
+ * @param service The leaderboard service.
285
+ * @param request The parsed request.
286
+ * @returns The entries.
287
+ */
288
+ async function readPlayersPage(service, request) {
289
+ const playerIds = request.body.playerIds;
290
+ const areIdsValid = Array.isArray(playerIds) && playerIds.length >= 1 && playerIds.length <= MAXIMUM_PLAYER_IDS && playerIds.every((playerId) => typeof playerId === "string");
291
+ if (!areIdsValid)
292
+ throw new LeaderboardError(400, "invalidRequest", "playerIds must be a list of 1–" + MAXIMUM_PLAYER_IDS + " player ids");
293
+ const variant = requireVariant(request.body.variant);
294
+ const seasonId = optionalBodyInteger(request.body.seasonId, "seasonId", 1, Number.MAX_SAFE_INTEGER);
295
+ const entries = await service.readPlayersEntries(request.params.boardId, variant, seasonId, playerIds);
296
+ return await service.attachAvatars(entries);
297
+ }
298
+ /**
299
+ * POST entries/mine: the caller's entry in each of 1–LEADERBOARD_MAXIMUM_TARGETS rankings.
300
+ * @param service The leaderboard service.
301
+ * @param request The parsed request.
302
+ * @returns One answer per target.
303
+ */
304
+ async function readMyEntries(service, request) {
305
+ const player = await service.resolvePlayer(request.caller);
306
+ const targets = request.body.targets;
307
+ if (!Array.isArray(targets) || targets.length < 1 || targets.length > LEADERBOARD_MAXIMUM_TARGETS) {
308
+ throw new LeaderboardError(400, "invalidRequest", "targets must be a list of 1–" + LEADERBOARD_MAXIMUM_TARGETS + " rankings");
309
+ }
310
+ return await service.readTargets(player.playerId, targets.map(requireTarget));
311
+ }
312
+ /**
313
+ * Checks one my-entries target.
314
+ * @param value The target (any JSON).
315
+ * @returns The target.
316
+ * @throws LeaderboardError 400 invalidRequest.
317
+ */
318
+ function requireTarget(value) {
319
+ if (typeof value !== "object" || value == null || Array.isArray(value))
320
+ throw new LeaderboardError(400, "invalidRequest", "each target must be an object");
321
+ const target = value;
322
+ if (typeof target.boardId !== "string")
323
+ throw new LeaderboardError(400, "invalidRequest", "each target needs a boardId");
324
+ const seasonId = optionalBodyInteger(target.seasonId, "seasonId", 1, Number.MAX_SAFE_INTEGER);
325
+ const parsed = { boardId: target.boardId, variant: requireVariant(target.variant) };
326
+ if (seasonId != null)
327
+ parsed.seasonId = seasonId;
328
+ return parsed;
329
+ }
330
+ /**
331
+ * Parses the `variant` query parameter.
332
+ * @param text The parameter.
333
+ * @returns The parsed JSON.
334
+ * @throws LeaderboardError 400 invalidRequest when it is not JSON.
335
+ */
336
+ function parseVariantText(text) {
337
+ try {
338
+ return JSON.parse(text);
339
+ }
340
+ catch {
341
+ throw new LeaderboardError(400, "invalidRequest", "variant must be a JSON object");
342
+ }
343
+ }
344
+ /**
345
+ * Checks a variant: null/absent or an object whose values are strings.
346
+ * @param value The variant (any JSON).
347
+ * @returns The variant, or null.
348
+ * @throws LeaderboardError 400 invalidRequest.
349
+ */
350
+ function requireVariant(value) {
351
+ if (value == null)
352
+ return null;
353
+ const isObject = typeof value === "object" && !Array.isArray(value);
354
+ if (!isObject || !Object.values(value).every((entry) => typeof entry === "string")) {
355
+ throw new LeaderboardError(400, "invalidRequest", "variant must be an object whose values are texts");
356
+ }
357
+ return value;
358
+ }
359
+ /**
360
+ * Parses an optional integer query parameter.
361
+ * @param text The parameter, or null.
362
+ * @param name Its name (for the sentence).
363
+ * @param minimum The smallest value.
364
+ * @param maximum The largest value.
365
+ * @returns The value, or null when absent.
366
+ * @throws LeaderboardError 400 invalidRequest when it is not an integer in range.
367
+ */
368
+ function optionalInteger(text, name, minimum, maximum) {
369
+ if (text == null || text === "")
370
+ return null;
371
+ return optionalBodyInteger(Number(text), name, minimum, maximum);
372
+ }
373
+ /**
374
+ * Checks an optional integer body field.
375
+ * @param value The field (any JSON).
376
+ * @param name Its name (for the sentence).
377
+ * @param minimum The smallest value.
378
+ * @param maximum The largest value.
379
+ * @returns The value, or null when absent.
380
+ * @throws LeaderboardError 400 invalidRequest when it is not an integer in range.
381
+ */
382
+ function optionalBodyInteger(value, name, minimum, maximum) {
383
+ if (value == null)
384
+ return null;
385
+ const isValid = typeof value === "number" && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
386
+ if (!isValid)
387
+ throw new LeaderboardError(400, "invalidRequest", name + " must be a whole number from " + minimum + " to " + maximum);
388
+ return value;
389
+ }
@@ -0,0 +1,296 @@
1
+ import type { Redis } from "ioredis";
2
+ import type { LeaderboardKeepPolicy, LeaderboardMetadata, LeaderboardSortOrder, LeaderboardTrustLevel } from "../admin/AdminApiTypes.js";
3
+ /** One ranking: a board, a season and a variant. */
4
+ export interface LeaderboardRankKey {
5
+ /** The board id. */
6
+ boardId: string;
7
+ /** The season (1, 2, 3 …). */
8
+ seasonId: number;
9
+ /** The canonical variant key (D16), "all" for a board without dimensions. */
10
+ variantKey: string;
11
+ }
12
+ /** An entry as stored (scores are integers, times are ISO strings plus the tie-break milliseconds). */
13
+ export interface StoredLeaderboardEntry {
14
+ /** The player who owns the entry. */
15
+ playerId: string;
16
+ /** The player's name when the entry was written. */
17
+ displayName: string;
18
+ /** The stored score (an integer, D12). */
19
+ score: number;
20
+ /** Display-only details, or null. */
21
+ metadata: LeaderboardMetadata | null;
22
+ /** How far the write that set this entry was trusted. */
23
+ trustLevel: LeaderboardTrustLevel;
24
+ /** The network session the score came from, or null. */
25
+ sessionName: string | null;
26
+ /** The game's mode name, or null. */
27
+ gameMode: string | null;
28
+ /** The game's build version, or null. */
29
+ buildVersion: string | null;
30
+ /** ISO time of the last accepted submission. */
31
+ submittedAt: string;
32
+ /** ISO time the stored score was reached. */
33
+ achievedAt: string;
34
+ /** achievedAt in epoch milliseconds (the ranking tie-break, D13). */
35
+ achievedAtMs: number;
36
+ /** Accepted submissions that changed this entry (1 for a new entry). */
37
+ submissionCount: number;
38
+ }
39
+ /** A stored entry with its 1-based rank. */
40
+ export interface RankedLeaderboardEntry extends StoredLeaderboardEntry {
41
+ /** 1-based rank in its ranking. */
42
+ rank: number;
43
+ }
44
+ /** A removed entry and why. */
45
+ export interface RemovedLeaderboardEntry {
46
+ /** The entry as it was when removed. */
47
+ entry: StoredLeaderboardEntry;
48
+ /** Why it was removed ("playerHidden" for a hidden player). */
49
+ reason: string;
50
+ /** Who removed it. */
51
+ removedBy: string;
52
+ /** ISO time of the removal. */
53
+ removedAt: string;
54
+ }
55
+ /** One write. */
56
+ export interface LeaderboardWriteRequest {
57
+ /** The ranking written. */
58
+ key: LeaderboardRankKey;
59
+ /** What the write does to an existing entry (D14). */
60
+ keepPolicy: LeaderboardKeepPolicy;
61
+ /** Which scores rank first. */
62
+ sortOrder: LeaderboardSortOrder;
63
+ /** The incoming entry; its score is the submitted score. */
64
+ entry: StoredLeaderboardEntry;
65
+ /** `<submissionId>|<boardId>|<variantKey>`, or null for admin writes (no dedupe). */
66
+ dedupeId: string | null;
67
+ /** The caller's clock (dedupe expiry in the file store). The store never sets entry times: callers do. */
68
+ nowMs: number;
69
+ }
70
+ /** What a write did. */
71
+ export interface LeaderboardWriteResult {
72
+ /** "duplicate" when dedupeId was seen; "overflow" when a sum passed the score bound (nothing written). */
73
+ status: "written" | "kept" | "duplicate" | "overflow";
74
+ /** The keep-policy outcome, or null for an overflow. */
75
+ outcome: "created" | "improved" | "replaced" | "added" | "kept" | null;
76
+ /** The stored entry after the write, or null for an overflow. */
77
+ entry: StoredLeaderboardEntry | null;
78
+ /** The score stored before the write, or null for a new entry. */
79
+ previousScore: number | null;
80
+ /** The entry's 1-based rank after the write, or null for an overflow. */
81
+ rank: number | null;
82
+ /** Entries in the ranking after the write. */
83
+ total: number;
84
+ }
85
+ /** A leaderboard player as stored. */
86
+ export interface LeaderboardPlayerRecord {
87
+ /** `anon-<uuid>`, `user:<userId>` or `manual-<uuid>` (D8). */
88
+ playerId: string;
89
+ /** How the player was created. */
90
+ kind: "anonymous" | "user" | "manual";
91
+ /** sha256 hex of the anonymous key's secret, else null. */
92
+ keyHash: string | null;
93
+ /** The current display name. */
94
+ displayName: string;
95
+ /** The avatar claim of an auth user's session (a Discord avatar hash for user:discord: players); absent or null otherwise. */
96
+ avatar?: string | null;
97
+ /** True while a moderator hides the player (D18). */
98
+ hidden: boolean;
99
+ /** ISO creation time. */
100
+ createdAt: string;
101
+ /** ISO time of the last submission or identity use. */
102
+ lastSeenAt: string;
103
+ }
104
+ /** Durability as D20 measures it. */
105
+ export interface LeaderboardDurability {
106
+ /** Whether the data survives a restart or redeploy. */
107
+ durability: "durable" | "notDurable" | "unknown";
108
+ /** What to configure when not durable or unknown, else null. */
109
+ advice: string | null;
110
+ }
111
+ /** Rankings, players, rate counters and session presence (D1). */
112
+ export interface LeaderboardRankStore {
113
+ /** "redis" or "file". */
114
+ readonly kind: "redis" | "file";
115
+ /**
116
+ * Writes one score with dedupe, the keep policy and the ranking, atomically.
117
+ * @param request The write.
118
+ * @returns What the write did.
119
+ */
120
+ write(request: LeaderboardWriteRequest): Promise<LeaderboardWriteResult>;
121
+ /**
122
+ * Reads a page of a ranking.
123
+ * @param key The ranking.
124
+ * @param offset Zero-based index of the first entry.
125
+ * @param limit Most entries returned.
126
+ * @returns The entries with their ranks, best first.
127
+ */
128
+ readRange(key: LeaderboardRankKey, offset: number, limit: number): Promise<RankedLeaderboardEntry[]>;
129
+ /**
130
+ * Counts the live entries of a ranking.
131
+ * @param key The ranking.
132
+ * @returns The count.
133
+ */
134
+ count(key: LeaderboardRankKey): Promise<number>;
135
+ /**
136
+ * Reads one player's live entry.
137
+ * @param key The ranking.
138
+ * @param playerId The player.
139
+ * @returns The entry with its rank, or null.
140
+ */
141
+ readEntry(key: LeaderboardRankKey, playerId: string): Promise<RankedLeaderboardEntry | null>;
142
+ /**
143
+ * Reads the entries around a player's.
144
+ * @param key The ranking.
145
+ * @param playerId The player.
146
+ * @param window Entries above and below.
147
+ * @returns The entries, or [] when the player has none.
148
+ */
149
+ readAround(key: LeaderboardRankKey, playerId: string, window: number): Promise<RankedLeaderboardEntry[]>;
150
+ /**
151
+ * Finds entries whose display name or player id contains the text, case-insensitively.
152
+ * @param key The ranking.
153
+ * @param text The text.
154
+ * @param limit Most entries returned.
155
+ * @returns The matches by rank.
156
+ */
157
+ searchEntries(key: LeaderboardRankKey, text: string, limit: number): Promise<RankedLeaderboardEntry[]>;
158
+ /**
159
+ * Reads the removed entries of a ranking.
160
+ * @param key The ranking.
161
+ * @returns The removed entries.
162
+ */
163
+ readRemoved(key: LeaderboardRankKey): Promise<RemovedLeaderboardEntry[]>;
164
+ /**
165
+ * Moves a live entry to the removed set.
166
+ * @param key The ranking.
167
+ * @param playerId The player.
168
+ * @param reason Why.
169
+ * @param removedBy Who.
170
+ * @param removedAt ISO time.
171
+ * @returns True when there was a live entry.
172
+ */
173
+ removeEntry(key: LeaderboardRankKey, playerId: string, reason: string, removedBy: string, removedAt: string): Promise<boolean>;
174
+ /**
175
+ * Moves a removed entry back into the ranking.
176
+ * @param key The ranking.
177
+ * @param playerId The player.
178
+ * @param sortOrder The board's sort order.
179
+ * @returns "restored", "missing" when nothing was removed, or "liveEntryExists".
180
+ */
181
+ restoreEntry(key: LeaderboardRankKey, playerId: string, sortOrder: LeaderboardSortOrder): Promise<"restored" | "missing" | "liveEntryExists">;
182
+ /**
183
+ * Lists the variant keys written in a season.
184
+ * @param boardId The board.
185
+ * @param seasonId The season.
186
+ * @returns The variant keys, sorted.
187
+ */
188
+ listVariantKeys(boardId: string, seasonId: number): Promise<string[]>;
189
+ /**
190
+ * Reads the time of a board's last accepted write.
191
+ * @param boardId The board.
192
+ * @returns ISO time, or null.
193
+ */
194
+ readLastSubmissionAt(boardId: string): Promise<string | null>;
195
+ /**
196
+ * Deletes every ranking of a season.
197
+ * @param boardId The board.
198
+ * @param seasonId The season.
199
+ * @returns How many entries (live and removed) were deleted.
200
+ */
201
+ deleteSeason(boardId: string, seasonId: number): Promise<number>;
202
+ /**
203
+ * Deletes every ranking of a board.
204
+ * @param boardId The board.
205
+ * @param seasonIds Its seasons.
206
+ */
207
+ deleteBoard(boardId: string, seasonIds: number[]): Promise<void>;
208
+ /**
209
+ * Lists every ranking a player has a live or removed entry in.
210
+ * @param playerId The player.
211
+ * @returns The rank keys.
212
+ */
213
+ listPlayerRankKeys(playerId: string): Promise<LeaderboardRankKey[]>;
214
+ /**
215
+ * Sets the display name on every entry of a player (live and removed).
216
+ * @param playerId The player.
217
+ * @param displayName The new name.
218
+ * @returns How many entries changed.
219
+ */
220
+ renamePlayerEntries(playerId: string, displayName: string): Promise<number>;
221
+ /**
222
+ * Deletes every entry of a player (live and removed).
223
+ * @param playerId The player.
224
+ * @returns How many entries were deleted.
225
+ */
226
+ deletePlayerEntries(playerId: string): Promise<number>;
227
+ /**
228
+ * Reads a player.
229
+ * @param playerId The player.
230
+ * @returns The record, or null.
231
+ */
232
+ getPlayer(playerId: string): Promise<LeaderboardPlayerRecord | null>;
233
+ /**
234
+ * Writes a player.
235
+ * @param record The record.
236
+ */
237
+ putPlayer(record: LeaderboardPlayerRecord): Promise<void>;
238
+ /**
239
+ * Deletes a player record.
240
+ * @param playerId The player.
241
+ * @returns True when it existed.
242
+ */
243
+ deletePlayer(playerId: string): Promise<boolean>;
244
+ /**
245
+ * Finds players whose display name or id contains the text, case-insensitively.
246
+ * @param text The text ("" lists any).
247
+ * @param limit Most players returned.
248
+ * @returns The players.
249
+ */
250
+ searchPlayers(text: string, limit: number): Promise<LeaderboardPlayerRecord[]>;
251
+ /**
252
+ * Counts every player.
253
+ * @returns The count.
254
+ */
255
+ countPlayers(): Promise<number>;
256
+ /**
257
+ * Counts one use in a fixed window (D19).
258
+ * @param bucket What is limited (e.g. "submit-player:<id>").
259
+ * @param limit Most uses per window.
260
+ * @param windowMs The window length, in milliseconds.
261
+ * @param nowMs The caller's clock.
262
+ * @returns True while the count is within the limit.
263
+ */
264
+ consumeRate(bucket: string, limit: number, windowMs: number, nowMs: number): Promise<boolean>;
265
+ /**
266
+ * Records a session join for each subject; the earliest join wins (D10).
267
+ * @param sessionName The session.
268
+ * @param subjects `address:<clientAddress>` and, when known, `user:<userId>`.
269
+ * @param joinedAtMs The join time.
270
+ * @param ttlMs How long the record lasts.
271
+ */
272
+ recordSessionPresence(sessionName: string, subjects: string[], joinedAtMs: number, ttlMs: number): Promise<void>;
273
+ /**
274
+ * Reads a subject's join time in a session.
275
+ * @param sessionName The session.
276
+ * @param subject The subject.
277
+ * @param nowMs The caller's clock (expiry).
278
+ * @returns The join time, or null.
279
+ */
280
+ readSessionPresence(sessionName: string, subject: string, nowMs: number): Promise<number | null>;
281
+ /**
282
+ * Measures whether the data survives a restart (D20).
283
+ * @returns The durability and advice.
284
+ */
285
+ measureDurability(): Promise<LeaderboardDurability>;
286
+ /** Flushes and releases what the store holds (never the shared Redis client). */
287
+ close(): Promise<void>;
288
+ }
289
+ /**
290
+ * Opens the rank store on the same Redis client the admin store uses, or on files under admin.dataDir.
291
+ * @param dataDir The resolved admin data folder (used without Redis).
292
+ * @param redisClient The shared ioredis client, or null.
293
+ * @param isFolderPersistent The admin store's isPersistent (the file store's durability).
294
+ * @returns The store.
295
+ */
296
+ export declare function openLeaderboardRankStore(dataDir: string, redisClient: Redis | null, isFolderPersistent: boolean): Promise<LeaderboardRankStore>;