@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,29 @@
1
+ // Small crypto helpers the admin services share: random tokens, SHA-256 ids and constant-time comparison.
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ /**
4
+ * A random token, URL-safe.
5
+ * @param byteCount Random bytes in the token.
6
+ * @returns base64url text.
7
+ */
8
+ export function randomToken(byteCount) {
9
+ return randomBytes(byteCount).toString("base64url");
10
+ }
11
+ /**
12
+ * The SHA-256 of a text, as stored ids of secrets (session cookies, pending tokens, key secrets).
13
+ * @param text The text.
14
+ * @returns Lower-case hex.
15
+ */
16
+ export function sha256Hex(text) {
17
+ return createHash("sha256").update(text).digest("hex");
18
+ }
19
+ /**
20
+ * Compares two texts in constant time when their lengths are equal.
21
+ * @param left One text.
22
+ * @param right The other.
23
+ * @returns True when equal.
24
+ */
25
+ export function textsMatch(left, right) {
26
+ const leftBytes = Buffer.from(left);
27
+ const rightBytes = Buffer.from(right);
28
+ return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
29
+ }
@@ -0,0 +1,176 @@
1
+ import type { AdminCaller } from "./AdminApiRouter.js";
2
+ import type { AdminActionField, AdminActionResult, AdminActionTarget, AdminActionTargetIds, AdminAlertSeverity, AdminPanelContent, AdminPanelPlacement, AdminPanelView, AdminPermission, BanKind, ExtensionsView } from "./AdminApiTypes.js";
3
+ /** What a custom action handler receives. */
4
+ export interface AdminActionContext {
5
+ /** The typed inputs: each field's value, or its default. */
6
+ inputs: Record<string, string | number | boolean>;
7
+ /** The target ids: instance processId, session roomId, or { roomId, playerId, userId }. */
8
+ target: AdminActionTargetIds;
9
+ /** Who runs the action. */
10
+ caller: {
11
+ kind: AdminCaller["kind"];
12
+ id: string;
13
+ name: string;
14
+ };
15
+ }
16
+ /** A custom admin action registered from code (D25). */
17
+ export interface AdminActionDefinition {
18
+ /** Unique, /^[a-z][a-zA-Z0-9-]{2,40}$/. */
19
+ name: string;
20
+ label: string;
21
+ description: string;
22
+ target: AdminActionTarget;
23
+ fields: AdminActionField[];
24
+ /** "operators": everyone with extensions.run (default); "developer": extensions.runDeveloper. */
25
+ audience?: "operators" | "developer";
26
+ /** Asks for confirmation before running. Default true. */
27
+ confirm?: boolean;
28
+ /**
29
+ * Runs the action.
30
+ * @param context The inputs, target and caller.
31
+ * @returns The outcome shown in the console.
32
+ */
33
+ handler(context: AdminActionContext): Promise<AdminActionResult>;
34
+ }
35
+ /** A read-only custom panel registered from code. */
36
+ export interface AdminPanelDefinition {
37
+ /** Unique, /^[a-z][a-zA-Z0-9-]{2,40}$/. */
38
+ name: string;
39
+ title: string;
40
+ placement: AdminPanelPlacement;
41
+ /**
42
+ * Returns a table or key/value data for the panel.
43
+ * @param context The target ids of the page that shows it.
44
+ * @returns The content.
45
+ */
46
+ handler(context: {
47
+ target: AdminActionTargetIds;
48
+ }): Promise<AdminPanelContent>;
49
+ }
50
+ /** Admin events a developer can mirror (spec 13). Hooks are called after the action succeeded; a throw is logged. */
51
+ export interface AdminEventHooks {
52
+ onPlayerKicked?(event: {
53
+ roomId: string;
54
+ playerId: number;
55
+ userId: string | null;
56
+ message: string;
57
+ by: string;
58
+ }): void;
59
+ onBanCreated?(event: {
60
+ banId: string;
61
+ kind: BanKind;
62
+ value: string;
63
+ expiresAt: string | null;
64
+ by: string;
65
+ }): void;
66
+ onBanRemoved?(event: {
67
+ banId: string;
68
+ by: string;
69
+ }): void;
70
+ onMaintenanceChanged?(event: {
71
+ enabled: boolean;
72
+ scope: "fleet" | string;
73
+ message: string | null;
74
+ by: string;
75
+ }): void;
76
+ onAlertFired?(event: {
77
+ alertId: string;
78
+ ruleId: string;
79
+ severity: AdminAlertSeverity;
80
+ summary: string;
81
+ }): void;
82
+ }
83
+ /** GameServerOptions.admin (D25). */
84
+ export interface AdminExtensions {
85
+ actions?: AdminActionDefinition[];
86
+ panels?: AdminPanelDefinition[];
87
+ hooks?: AdminEventHooks;
88
+ }
89
+ /** The event a hook receives. */
90
+ export type AdminHookEvent<Name extends keyof AdminEventHooks> = Parameters<NonNullable<AdminEventHooks[Name]>>[0];
91
+ /** Checks, lists and runs the extensions of one server. */
92
+ export declare class AdminExtensionsHost {
93
+ /** Actions by name. */
94
+ private readonly actions;
95
+ /** Panels by name. */
96
+ private readonly panels;
97
+ /** The event hooks. */
98
+ private readonly hooks;
99
+ /**
100
+ * Checks the definitions and keeps them.
101
+ * @param extensions GameServerOptions.admin (empty when none).
102
+ * @throws Error "admin extension <name>: <problem>" for a duplicate or badly named action or panel, a bad target,
103
+ * duplicate or badly typed fields, or a select without options.
104
+ */
105
+ constructor(extensions: AdminExtensions);
106
+ /**
107
+ * The permission an action needs: extensions.runDeveloper for the developer audience, else extensions.run.
108
+ * @param actionName The action (an unknown one needs extensions.run, then answers 404).
109
+ * @returns The permission.
110
+ */
111
+ permissionFor(actionName: string): AdminPermission;
112
+ /**
113
+ * The actions a caller may run and every panel (GET extensions).
114
+ * @param caller The caller.
115
+ * @returns The actions and panels.
116
+ */
117
+ listFor(caller: AdminCaller): ExtensionsView;
118
+ /**
119
+ * The panels shown at a placement, or every panel.
120
+ * @param placement The placement, or null for all.
121
+ * @returns The panels.
122
+ */
123
+ panelViews(placement: AdminPanelPlacement | null): AdminPanelView[];
124
+ /**
125
+ * Runs an action after checking its target and inputs; the router has already checked its permission.
126
+ * @param actionName The action.
127
+ * @param target The target ids.
128
+ * @param inputs The raw inputs.
129
+ * @param caller The caller.
130
+ * @returns The handler's outcome.
131
+ * @throws AdminRequestError 404 for an unknown action, 400 for a missing target id or a missing or mistyped input,
132
+ * 500 when the handler throws.
133
+ */
134
+ run(actionName: string, target: AdminActionTargetIds, inputs: Record<string, unknown>, caller: AdminCaller): Promise<AdminActionResult>;
135
+ /**
136
+ * The audit parameters of an action call: the inputs without its secret fields (D25).
137
+ * @param actionName The action.
138
+ * @param inputs The raw inputs.
139
+ * @returns The inputs that may be audited.
140
+ */
141
+ auditParametersOf(actionName: string, inputs: Record<string, unknown>): Record<string, unknown>;
142
+ /**
143
+ * A panel's content.
144
+ * @param panelName The panel.
145
+ * @param target The target ids of the page.
146
+ * @returns The content.
147
+ * @throws AdminRequestError 404 for an unknown panel, 500 when the handler throws.
148
+ */
149
+ panel(panelName: string, target: AdminActionTargetIds): Promise<AdminPanelContent>;
150
+ /**
151
+ * Calls an admin event hook. A throw is logged as "admin hook <name> threw" and a returned promise's rejection is
152
+ * caught and logged, never awaited (matching SessionRoom.callHook's policy).
153
+ * @param name The hook.
154
+ * @param event Its event.
155
+ */
156
+ callHook<Name extends keyof AdminEventHooks>(name: Name, event: AdminHookEvent<Name>): void;
157
+ /**
158
+ * Checks and adds an action.
159
+ * @param action The definition.
160
+ * @throws Error naming the problem.
161
+ */
162
+ private addAction;
163
+ /**
164
+ * Checks and adds a panel.
165
+ * @param panel The definition.
166
+ * @throws Error naming the problem.
167
+ */
168
+ private addPanel;
169
+ /**
170
+ * A registered action.
171
+ * @param actionName The action.
172
+ * @returns The definition.
173
+ * @throws AdminRequestError 404 when there is none.
174
+ */
175
+ private requireAction;
176
+ }
@@ -0,0 +1,307 @@
1
+ // Developer extensions of the admin console (game-server-admin-console D25, DR §6.10): custom actions, read-only panels
2
+ // and admin event hooks registered from code through GameServerOptions.admin. Definitions are checked when the server is
3
+ // created; actions run only with their built-in permission, with typed inputs, and are audited without secret fields.
4
+ import { getLogger } from "../GameServerLogger.js";
5
+ import { AdminRequestError } from "./AdminApiRouter.js";
6
+ import { hasPermission } from "./AdminPermissions.js";
7
+ /** HTTP statuses of the host's refusals. */
8
+ const HTTP_BAD_REQUEST = 400;
9
+ const HTTP_NOT_FOUND = 404;
10
+ const HTTP_INTERNAL_ERROR = 500;
11
+ /** The name rule of actions and panels (DR §6.10). */
12
+ const EXTENSION_NAME_PATTERN = /^[a-z][a-zA-Z0-9-]{2,40}$/;
13
+ /** Action targets. */
14
+ const ACTION_TARGETS = new Set(["server", "instance", "session", "player"]);
15
+ /** Field types. */
16
+ const FIELD_TYPES = new Set(["text", "number", "boolean", "select", "secret"]);
17
+ /** Panel placements. */
18
+ const PANEL_PLACEMENTS = new Set(["overview", "session", "player"]);
19
+ /** The permission of operator actions and of developer actions (D25). */
20
+ const OPERATOR_ACTION_PERMISSION = "extensions.run";
21
+ const DEVELOPER_ACTION_PERMISSION = "extensions.runDeveloper";
22
+ /** Checks, lists and runs the extensions of one server. */
23
+ export class AdminExtensionsHost {
24
+ /** Actions by name. */
25
+ actions = new Map();
26
+ /** Panels by name. */
27
+ panels = new Map();
28
+ /** The event hooks. */
29
+ hooks;
30
+ /**
31
+ * Checks the definitions and keeps them.
32
+ * @param extensions GameServerOptions.admin (empty when none).
33
+ * @throws Error "admin extension <name>: <problem>" for a duplicate or badly named action or panel, a bad target,
34
+ * duplicate or badly typed fields, or a select without options.
35
+ */
36
+ constructor(extensions) {
37
+ for (const action of extensions.actions ?? [])
38
+ this.addAction(action);
39
+ for (const panel of extensions.panels ?? [])
40
+ this.addPanel(panel);
41
+ this.hooks = extensions.hooks ?? {};
42
+ }
43
+ /**
44
+ * The permission an action needs: extensions.runDeveloper for the developer audience, else extensions.run.
45
+ * @param actionName The action (an unknown one needs extensions.run, then answers 404).
46
+ * @returns The permission.
47
+ */
48
+ permissionFor(actionName) {
49
+ return (this.actions.get(actionName)?.audience === "developer") ? DEVELOPER_ACTION_PERMISSION : OPERATOR_ACTION_PERMISSION;
50
+ }
51
+ /**
52
+ * The actions a caller may run and every panel (GET extensions).
53
+ * @param caller The caller.
54
+ * @returns The actions and panels.
55
+ */
56
+ listFor(caller) {
57
+ const actions = Array.from(this.actions.values())
58
+ .filter((action) => hasPermission(caller.permissions, this.permissionFor(action.name)))
59
+ .map(actionView);
60
+ return { actions: actions, panels: this.panelViews(null) };
61
+ }
62
+ /**
63
+ * The panels shown at a placement, or every panel.
64
+ * @param placement The placement, or null for all.
65
+ * @returns The panels.
66
+ */
67
+ panelViews(placement) {
68
+ return Array.from(this.panels.values())
69
+ .filter((panel) => placement == null || panel.placement === placement)
70
+ .map((panel) => ({ name: panel.name, title: panel.title, placement: panel.placement }));
71
+ }
72
+ /**
73
+ * Runs an action after checking its target and inputs; the router has already checked its permission.
74
+ * @param actionName The action.
75
+ * @param target The target ids.
76
+ * @param inputs The raw inputs.
77
+ * @param caller The caller.
78
+ * @returns The handler's outcome.
79
+ * @throws AdminRequestError 404 for an unknown action, 400 for a missing target id or a missing or mistyped input,
80
+ * 500 when the handler throws.
81
+ */
82
+ async run(actionName, target, inputs, caller) {
83
+ const action = this.requireAction(actionName);
84
+ requireTargetIds(action.target, target);
85
+ const typedInputs = typedInputsOf(action, inputs);
86
+ try {
87
+ return await action.handler({ inputs: typedInputs, target: target, caller: { kind: caller.kind, id: caller.id, name: caller.name } });
88
+ }
89
+ catch (error) {
90
+ getLogger().error("admin action " + actionName + " threw", { error: String(error) });
91
+ throw new AdminRequestError(HTTP_INTERNAL_ERROR, "the action " + actionName + " failed: " + ((error instanceof Error) ? error.message : String(error)));
92
+ }
93
+ }
94
+ /**
95
+ * The audit parameters of an action call: the inputs without its secret fields (D25).
96
+ * @param actionName The action.
97
+ * @param inputs The raw inputs.
98
+ * @returns The inputs that may be audited.
99
+ */
100
+ auditParametersOf(actionName, inputs) {
101
+ const action = this.actions.get(actionName);
102
+ if (action == null)
103
+ return {};
104
+ const parameters = {};
105
+ for (const field of action.fields) {
106
+ if (field.type !== "secret" && Object.hasOwn(inputs, field.name))
107
+ parameters[field.name] = inputs[field.name];
108
+ }
109
+ return parameters;
110
+ }
111
+ /**
112
+ * A panel's content.
113
+ * @param panelName The panel.
114
+ * @param target The target ids of the page.
115
+ * @returns The content.
116
+ * @throws AdminRequestError 404 for an unknown panel, 500 when the handler throws.
117
+ */
118
+ async panel(panelName, target) {
119
+ const panel = this.panels.get(panelName);
120
+ if (panel == null)
121
+ throw new AdminRequestError(HTTP_NOT_FOUND, "no such panel");
122
+ try {
123
+ return await panel.handler({ target: target });
124
+ }
125
+ catch (error) {
126
+ getLogger().error("admin panel " + panelName + " threw", { error: String(error) });
127
+ throw new AdminRequestError(HTTP_INTERNAL_ERROR, "the panel " + panelName + " failed: " + ((error instanceof Error) ? error.message : String(error)));
128
+ }
129
+ }
130
+ /**
131
+ * Calls an admin event hook. A throw is logged as "admin hook <name> threw" and a returned promise's rejection is
132
+ * caught and logged, never awaited (matching SessionRoom.callHook's policy).
133
+ * @param name The hook.
134
+ * @param event Its event.
135
+ */
136
+ callHook(name, event) {
137
+ const hook = this.hooks[name];
138
+ if (hook == null)
139
+ return;
140
+ const logThrow = (error) => getLogger().error("admin hook " + name + " threw", { error: String(error) });
141
+ try {
142
+ const returned = hook.call(this.hooks, event);
143
+ if (returned instanceof Promise)
144
+ returned.catch(logThrow);
145
+ }
146
+ catch (error) {
147
+ logThrow(error);
148
+ }
149
+ }
150
+ /**
151
+ * Checks and adds an action.
152
+ * @param action The definition.
153
+ * @throws Error naming the problem.
154
+ */
155
+ addAction(action) {
156
+ const problem = actionProblemOf(action, this.actions.has(action.name));
157
+ if (problem != null)
158
+ throw new Error("admin extension " + action.name + ": " + problem);
159
+ this.actions.set(action.name, action);
160
+ }
161
+ /**
162
+ * Checks and adds a panel.
163
+ * @param panel The definition.
164
+ * @throws Error naming the problem.
165
+ */
166
+ addPanel(panel) {
167
+ const problem = panelProblemOf(panel, this.panels.has(panel.name));
168
+ if (problem != null)
169
+ throw new Error("admin extension " + panel.name + ": " + problem);
170
+ this.panels.set(panel.name, panel);
171
+ }
172
+ /**
173
+ * A registered action.
174
+ * @param actionName The action.
175
+ * @returns The definition.
176
+ * @throws AdminRequestError 404 when there is none.
177
+ */
178
+ requireAction(actionName) {
179
+ const action = this.actions.get(actionName);
180
+ if (action == null)
181
+ throw new AdminRequestError(HTTP_NOT_FOUND, "no such action");
182
+ return action;
183
+ }
184
+ }
185
+ /**
186
+ * An action as the console sees it.
187
+ * @param action The definition.
188
+ * @returns The view.
189
+ */
190
+ function actionView(action) {
191
+ return {
192
+ name: action.name,
193
+ label: action.label,
194
+ description: action.description,
195
+ target: action.target,
196
+ fields: action.fields.map((field) => Object.assign({}, field)),
197
+ confirm: action.confirm !== false
198
+ };
199
+ }
200
+ /**
201
+ * What is wrong with an action definition.
202
+ * @param action The definition.
203
+ * @param isDuplicate True when the name is already registered.
204
+ * @returns The problem, or null.
205
+ */
206
+ function actionProblemOf(action, isDuplicate) {
207
+ if (isDuplicate)
208
+ return "an action with this name is already registered";
209
+ if (!EXTENSION_NAME_PATTERN.test(action.name))
210
+ return "the name must match " + EXTENSION_NAME_PATTERN.source;
211
+ if (!ACTION_TARGETS.has(action.target))
212
+ return "target must be server, instance, session or player";
213
+ if (typeof action.handler !== "function")
214
+ return "handler must be a function";
215
+ const fieldNames = new Set();
216
+ for (const field of action.fields) {
217
+ if (fieldNames.has(field.name))
218
+ return "the field " + field.name + " is defined twice";
219
+ fieldNames.add(field.name);
220
+ if (!FIELD_TYPES.has(field.type))
221
+ return "the field " + field.name + " has an unknown type " + field.type;
222
+ if (field.type === "select" && (field.options == null || field.options.length === 0))
223
+ return "the select field " + field.name + " needs options";
224
+ }
225
+ return null;
226
+ }
227
+ /**
228
+ * What is wrong with a panel definition.
229
+ * @param panel The definition.
230
+ * @param isDuplicate True when the name is already registered.
231
+ * @returns The problem, or null.
232
+ */
233
+ function panelProblemOf(panel, isDuplicate) {
234
+ if (isDuplicate)
235
+ return "a panel with this name is already registered";
236
+ if (!EXTENSION_NAME_PATTERN.test(panel.name))
237
+ return "the name must match " + EXTENSION_NAME_PATTERN.source;
238
+ if (!PANEL_PLACEMENTS.has(panel.placement))
239
+ return "placement must be overview, session or player";
240
+ if (typeof panel.handler !== "function")
241
+ return "handler must be a function";
242
+ return null;
243
+ }
244
+ /**
245
+ * Checks that a target names what the action's target kind needs.
246
+ * @param kind The action's target kind.
247
+ * @param target The target ids.
248
+ * @throws AdminRequestError 400 for a missing id.
249
+ */
250
+ function requireTargetIds(kind, target) {
251
+ const needsRoom = kind === "session" || kind === "player";
252
+ if (kind === "instance" && typeof target.processId !== "string")
253
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "target.processId is required");
254
+ if (needsRoom && typeof target.roomId !== "string")
255
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "target.roomId is required");
256
+ if (kind === "player" && typeof target.playerId !== "number")
257
+ throw new AdminRequestError(HTTP_BAD_REQUEST, "target.playerId is required");
258
+ }
259
+ /**
260
+ * The typed inputs of an action call: each field's value (or default), checked against its type. Inputs no field names
261
+ * are dropped.
262
+ * @param action The action.
263
+ * @param inputs The raw inputs.
264
+ * @returns The typed inputs.
265
+ * @throws AdminRequestError 400 for a missing required field or a value of the wrong type.
266
+ */
267
+ function typedInputsOf(action, inputs) {
268
+ const typed = {};
269
+ for (const field of action.fields) {
270
+ const value = inputs[field.name] ?? field.defaultValue;
271
+ if (value == null || value === "") {
272
+ if (field.required)
273
+ throw new AdminRequestError(HTTP_BAD_REQUEST, field.label + " is required", field.name);
274
+ continue;
275
+ }
276
+ typed[field.name] = typedValueOf(field, value);
277
+ }
278
+ return typed;
279
+ }
280
+ /**
281
+ * One input checked against its field's type.
282
+ * @param field The field.
283
+ * @param value The raw value.
284
+ * @returns The typed value.
285
+ * @throws AdminRequestError 400 when the value does not fit the type.
286
+ */
287
+ function typedValueOf(field, value) {
288
+ const wrongType = (expected) => new AdminRequestError(HTTP_BAD_REQUEST, field.label + " must be " + expected, field.name);
289
+ switch (field.type) {
290
+ case "number":
291
+ if (typeof value !== "number" || !Number.isFinite(value))
292
+ throw wrongType("a number");
293
+ return value;
294
+ case "boolean":
295
+ if (typeof value !== "boolean")
296
+ throw wrongType("true or false");
297
+ return value;
298
+ case "select":
299
+ if (typeof value !== "string" || !(field.options ?? []).includes(value))
300
+ throw wrongType("one of " + (field.options ?? []).join(", "));
301
+ return value;
302
+ default:
303
+ if (typeof value !== "string")
304
+ throw wrongType("text");
305
+ return value;
306
+ }
307
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Hashes a password: `scrypt$16384$8$1$<salt base64>$<hash base64>` (D8).
3
+ * @param password The password.
4
+ * @returns The stored hash.
5
+ */
6
+ export declare function hashPassword(password: string): Promise<string>;
7
+ /**
8
+ * Checks a password against a stored hash, in constant time for keys of equal length.
9
+ * @param password The password given.
10
+ * @param storedHash The stored hash.
11
+ * @returns True when it matches; false for a malformed hash.
12
+ */
13
+ export declare function verifyPassword(password: string, storedHash: string): Promise<boolean>;
14
+ /**
15
+ * A hash of an unknowable random password, with the same scrypt parameters as real hashes. A sign-in for an unknown,
16
+ * disabled or password-less account checks against it, so every refused sign-in costs one scrypt and the answer time
17
+ * does not reveal whether the username exists. Made once, on first use.
18
+ * @returns The hash.
19
+ */
20
+ export declare function timingEqualizerPasswordHash(): Promise<string>;
21
+ /**
22
+ * How many password checks ran scrypt in this process, so tests can prove a refused sign-in paid for one without
23
+ * measuring time.
24
+ * @returns The count since the process started.
25
+ */
26
+ export declare function passwordVerificationCount(): number;
27
+ /**
28
+ * The first rule a new password breaks (D8), as a sentence for the admin.
29
+ * @param password The new password.
30
+ * @param username The account's username.
31
+ * @returns The problem, or null when the password is acceptable.
32
+ */
33
+ export declare function passwordProblem(password: string, username: string): string | null;
@@ -0,0 +1,92 @@
1
+ // Admin password hashing and rules (game-server-admin-console D8, DR §8): Node's scrypt, constant-time checks.
2
+ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
3
+ import { MINIMUM_PASSWORD_LENGTH, SCRYPT_BLOCK_SIZE, SCRYPT_COST, SCRYPT_KEY_BYTES, SCRYPT_PARALLELISM, SCRYPT_SALT_BYTES } from "./AdminConstants.js";
4
+ import { COMMON_PASSWORDS } from "./CommonPasswords.js";
5
+ /** First field of a stored hash. */
6
+ const HASH_SCHEME = "scrypt";
7
+ /** Separator of the stored hash fields. */
8
+ const HASH_SEPARATOR = "$";
9
+ /** Fields of a stored hash: scheme, N, r, p, salt, key. */
10
+ const HASH_FIELD_COUNT = 6;
11
+ /** Memory scrypt may use, in bytes (N = 16384, r = 8 needs 16 MiB). */
12
+ const SCRYPT_MAX_MEMORY_BYTES = 64 * 1024 * 1024;
13
+ /** Random bytes of the password behind the timing-equalizer hash (nobody knows it, so it never matches). */
14
+ const TIMING_EQUALIZER_PASSWORD_BYTES = 32;
15
+ /** The timing-equalizer hash, made once on first use. */
16
+ let timingEqualizerHash = null;
17
+ /** How many password checks ran scrypt in this process. */
18
+ let passwordVerificationTotal = 0;
19
+ /**
20
+ * Runs scrypt as a promise.
21
+ * @param password The password.
22
+ * @param salt The salt.
23
+ * @param keyLength The key length in bytes.
24
+ * @param options The cost parameters.
25
+ * @returns The derived key.
26
+ */
27
+ function deriveKey(password, salt, keyLength, options) {
28
+ return new Promise((resolve, reject) => {
29
+ scrypt(password.normalize("NFKC"), salt, keyLength, options, (error, key) => (error != null) ? reject(error) : resolve(key));
30
+ });
31
+ }
32
+ /**
33
+ * Hashes a password: `scrypt$16384$8$1$<salt base64>$<hash base64>` (D8).
34
+ * @param password The password.
35
+ * @returns The stored hash.
36
+ */
37
+ export async function hashPassword(password) {
38
+ const salt = randomBytes(SCRYPT_SALT_BYTES);
39
+ const key = await deriveKey(password, salt, SCRYPT_KEY_BYTES, { N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM, maxmem: SCRYPT_MAX_MEMORY_BYTES });
40
+ return [HASH_SCHEME, SCRYPT_COST, SCRYPT_BLOCK_SIZE, SCRYPT_PARALLELISM, salt.toString("base64"), key.toString("base64")].join(HASH_SEPARATOR);
41
+ }
42
+ /**
43
+ * Checks a password against a stored hash, in constant time for keys of equal length.
44
+ * @param password The password given.
45
+ * @param storedHash The stored hash.
46
+ * @returns True when it matches; false for a malformed hash.
47
+ */
48
+ export async function verifyPassword(password, storedHash) {
49
+ const fields = storedHash.split(HASH_SEPARATOR);
50
+ if (fields.length !== HASH_FIELD_COUNT || fields[0] !== HASH_SCHEME)
51
+ return false;
52
+ const [, costText, blockSizeText, parallelismText, saltText, keyText] = fields;
53
+ const expectedKey = Buffer.from(keyText, "base64");
54
+ const options = { N: Number(costText), r: Number(blockSizeText), p: Number(parallelismText), maxmem: SCRYPT_MAX_MEMORY_BYTES };
55
+ passwordVerificationTotal++;
56
+ const actualKey = await deriveKey(password, Buffer.from(saltText, "base64"), expectedKey.length, options);
57
+ return actualKey.length === expectedKey.length && timingSafeEqual(actualKey, expectedKey);
58
+ }
59
+ /**
60
+ * A hash of an unknowable random password, with the same scrypt parameters as real hashes. A sign-in for an unknown,
61
+ * disabled or password-less account checks against it, so every refused sign-in costs one scrypt and the answer time
62
+ * does not reveal whether the username exists. Made once, on first use.
63
+ * @returns The hash.
64
+ */
65
+ export function timingEqualizerPasswordHash() {
66
+ timingEqualizerHash ??= hashPassword(randomBytes(TIMING_EQUALIZER_PASSWORD_BYTES).toString("base64"));
67
+ return timingEqualizerHash;
68
+ }
69
+ /**
70
+ * How many password checks ran scrypt in this process, so tests can prove a refused sign-in paid for one without
71
+ * measuring time.
72
+ * @returns The count since the process started.
73
+ */
74
+ export function passwordVerificationCount() {
75
+ return passwordVerificationTotal;
76
+ }
77
+ /**
78
+ * The first rule a new password breaks (D8), as a sentence for the admin.
79
+ * @param password The new password.
80
+ * @param username The account's username.
81
+ * @returns The problem, or null when the password is acceptable.
82
+ */
83
+ export function passwordProblem(password, username) {
84
+ if (password.length < MINIMUM_PASSWORD_LENGTH)
85
+ return "use at least " + MINIMUM_PASSWORD_LENGTH + " characters";
86
+ const lowerCase = password.toLowerCase();
87
+ if (COMMON_PASSWORDS.has(lowerCase))
88
+ return "this password is too common";
89
+ if (username !== "" && lowerCase.includes(username.toLowerCase()))
90
+ return "the password must not contain your username";
91
+ return null;
92
+ }
@@ -0,0 +1,28 @@
1
+ import type { AdminPermission, AdminRole } from "./AdminApiTypes.js";
2
+ /** Every action the console and API can check (D17), in the order of the AdminPermission union. */
3
+ export declare const ADMIN_PERMISSIONS: readonly AdminPermission[];
4
+ /** The fixed roles (D17), highest first. */
5
+ export declare const ADMIN_ROLES: readonly AdminRole[];
6
+ /** What each role may do (spec 1 role table, R24). */
7
+ export declare const ROLE_PERMISSIONS: Record<AdminRole, ReadonlySet<AdminPermission>>;
8
+ /** What the configured admin.token may do as an API key: everything except users.manage and audit.retention (D18). */
9
+ export declare const LEGACY_TOKEN_PERMISSIONS: ReadonlySet<AdminPermission>;
10
+ /**
11
+ * True when a role or key with these permissions may perform the action.
12
+ * @param granted The permissions the caller holds.
13
+ * @param required The permission the route needs.
14
+ * @returns Whether it is allowed.
15
+ */
16
+ export declare function hasPermission(granted: ReadonlySet<AdminPermission>, required: AdminPermission): boolean;
17
+ /**
18
+ * Whether a text names a permission (API key requests and the CLI pass names as text).
19
+ * @param name The text.
20
+ * @returns True for one of ADMIN_PERMISSIONS.
21
+ */
22
+ export declare function isAdminPermission(name: string): name is AdminPermission;
23
+ /**
24
+ * Whether a text names a role.
25
+ * @param name The text.
26
+ * @returns True for one of ADMIN_ROLES.
27
+ */
28
+ export declare function isAdminRole(name: string): name is AdminRole;