@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.
- package/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- package/templates/init/render.yaml +19 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-5:24px;--space-6:32px;--space-7:40px;--space-8:48px;--radius-small:4px;--radius-medium:8px;--radius-large:12px;--font-sans:system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--font-mono:ui-monospace, "SF Mono", Menlo, monospace;--font-size-small:12px;--font-size-body:14px;--font-size-large:16px;--font-size-title:22px;--font-size-figure:26px;--sidebar-width:240px;--top-bar-height:56px;--content-max-width:1320px;--motion-fast:.12s;--motion-medium:.2s;--easing-standard:cubic-bezier(.2, 0, 0, 1)}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--color-background:#f3f5f8;--color-surface:#fff;--color-surface-raised:#fff;--color-surface-sunken:#edf0f4;--color-border:#d8dde5;--color-border-strong:#b6bfcc;--color-text:#151a21;--color-text-muted:#545f6e;--color-accent:#2b54c7;--color-accent-hover:#2246a8;--color-accent-text:#fff;--color-accent-soft:#e7edfc;--color-success:#17734a;--color-success-soft:#e3f3ea;--color-warning:#8f5300;--color-warning-soft:#fbf0dc;--color-danger:#b42318;--color-danger-soft:#fce9e7;--color-info:#1b65a6;--color-info-soft:#e3effa;--color-overlay:#10161e6b;--shadow-raised:0 1px 2px #1018280f, 0 4px 12px #1018280f;--shadow-dialog:0 12px 40px #10182838;--chart-series-1:#2b54c7;--chart-series-2:#17734a;--chart-series-3:#b25e00;--chart-series-4:#8a3fc7;--chart-series-5:#c2185b;--chart-series-6:#0f7f8c}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--color-background:#0d1117;--color-surface:#151b23;--color-surface-raised:#1b222c;--color-surface-sunken:#10151c;--color-border:#2a3340;--color-border-strong:#3c4757;--color-text:#e6ebf1;--color-text-muted:#9ca8b7;--color-accent:#7fa3ff;--color-accent-hover:#9db8ff;--color-accent-text:#0b1226;--color-accent-soft:#1c2949;--color-success:#4cc38a;--color-success-soft:#11301f;--color-warning:#f0b545;--color-warning-soft:#33270d;--color-danger:#ff7b72;--color-danger-soft:#3a1714;--color-info:#6cb6ff;--color-info-soft:#0f2a44;--color-overlay:#0009;--shadow-raised:0 1px 2px #0006, 0 6px 16px #0000004d;--shadow-dialog:0 16px 48px #0009;--chart-series-1:#7fa3ff;--chart-series-2:#4cc38a;--chart-series-3:#f0b545;--chart-series-4:#c79bff;--chart-series-5:#ff7eb6;--chart-series-6:#4fd1d9}*,:before,:after{box-sizing:border-box}html,body{margin:0;padding:0}body{background:var(--color-background);color:var(--color-text);font-family:var(--font-sans);font-size:var(--font-size-body);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;line-height:1.5}#root{min-height:100vh}h1,h2,h3,h4{letter-spacing:-.01em;margin:0;font-weight:650;line-height:1.25}h1{font-size:var(--font-size-title)}h2{font-size:var(--font-size-large)}h3{font-size:var(--font-size-body)}p{margin:0}a{color:var(--color-accent);text-decoration:none}a:hover{text-decoration:underline}code,.mono{font-family:var(--font-mono);font-size:.92em}hr{border:0;border-top:1px solid var(--color-border);margin:var(--space-4) 0}:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px;border-radius:var(--radius-small)}.visually-hidden{clip:rect(0 0 0 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.muted{color:var(--color-text-muted)}.small{font-size:var(--font-size-small)}.nowrap{white-space:nowrap}.break-anywhere{overflow-wrap:anywhere}.stack{gap:var(--space-4);flex-direction:column;display:flex}.stack--tight{gap:var(--space-2)}.stack--loose{gap:var(--space-5)}.row{align-items:center;gap:var(--space-2);flex-wrap:wrap;display:flex}.row--between{justify-content:space-between}.row--end{justify-content:flex-end}.grow{flex:auto;min-width:0}.button{justify-content:center;align-items:center;gap:var(--space-2);min-height:34px;padding:0 var(--space-4);border:1px solid var(--color-border-strong);border-radius:var(--radius-medium);background:var(--color-surface);color:var(--color-text);font:inherit;cursor:pointer;white-space:nowrap;transition:background-color var(--motion-fast) var(--easing-standard), border-color var(--motion-fast) var(--easing-standard), transform var(--motion-fast) var(--easing-standard);font-weight:550;display:inline-flex}.button:hover{background:var(--color-surface-sunken);text-decoration:none}.button:active{transform:translateY(1px)}.button:disabled{opacity:.55;cursor:not-allowed;transform:none}.button--primary{background:var(--color-accent);border-color:var(--color-accent);color:var(--color-accent-text)}.button--primary:hover{background:var(--color-accent-hover);border-color:var(--color-accent-hover)}.button--danger{background:var(--color-danger);border-color:var(--color-danger);color:var(--color-surface)}.button--danger:hover{background:var(--color-danger);filter:brightness(1.08)}.button--ghost{background:0 0;border-color:#0000}.button--ghost:hover{background:var(--color-surface-sunken)}.button--small{min-height:28px;padding:0 var(--space-3);font-size:var(--font-size-small)}.button--block{width:100%}.link-button{font:inherit;color:var(--color-accent);cursor:pointer;text-align:left;background:0 0;border:0;padding:0}.link-button:hover{text-decoration:underline}.icon-button{border-radius:var(--radius-medium);width:34px;height:34px;color:var(--color-text);cursor:pointer;transition:background-color var(--motion-fast) var(--easing-standard);background:0 0;border:1px solid #0000;justify-content:center;align-items:center;display:inline-flex}.icon-button:hover{background:var(--color-surface-sunken)}.icon{flex:none;width:18px;height:18px}.field{gap:var(--space-1);flex-direction:column;min-width:0;display:flex}.field__label{font-weight:600;font-size:var(--font-size-small);color:var(--color-text)}.field__hint{font-size:var(--font-size-small);color:var(--color-text-muted)}.field__error{font-size:var(--font-size-small);color:var(--color-danger)}.input,.select,.textarea{width:100%;min-height:36px;padding:var(--space-2) var(--space-3);border:1px solid var(--color-border-strong);border-radius:var(--radius-medium);background:var(--color-surface);color:var(--color-text);font:inherit;transition:border-color var(--motion-fast) var(--easing-standard), box-shadow var(--motion-fast) var(--easing-standard)}.input::placeholder,.textarea::placeholder{color:var(--color-text-muted)}.input:focus-visible,.select:focus-visible,.textarea:focus-visible{border-color:var(--color-accent);box-shadow:0 0 0 2px var(--color-accent);outline:none}.textarea{resize:vertical;min-height:84px}.input--code{font-family:var(--font-mono);letter-spacing:.3em;text-align:center;font-size:20px}.check-row{align-items:flex-start;gap:var(--space-2);cursor:pointer;display:flex}.check-row input{accent-color:var(--color-accent);width:16px;height:16px;margin-top:3px}.choice-group{gap:var(--space-2);border:0;flex-wrap:wrap;margin:0;padding:0;display:flex}.choice-group legend{font-weight:600;font-size:var(--font-size-small);margin-bottom:var(--space-2);padding:0}.choice{align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);border:1px solid var(--color-border-strong);border-radius:var(--radius-medium);cursor:pointer;background:var(--color-surface);display:inline-flex}.choice:has(input:checked){border-color:var(--color-accent);background:var(--color-accent-soft)}.choice:has(input:focus-visible){outline:2px solid var(--color-accent);outline-offset:2px}.choice input{accent-color:var(--color-accent);margin:0}.form-grid{gap:var(--space-4);grid-template-columns:repeat(auto-fit,minmax(220px,1fr));display:grid}.form-actions{gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end;display:flex}.panel{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-large);padding:var(--space-5);box-shadow:var(--shadow-raised);min-width:0}.panel--flush{padding:0;overflow:hidden}.panel__header{justify-content:space-between;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap;display:flex}.panel--flush .panel__header{padding:var(--space-4) var(--space-5) 0}.panel__description{color:var(--color-text-muted);margin-top:var(--space-1)}.panel-grid{gap:var(--space-4);grid-template-columns:repeat(auto-fit,minmax(320px,1fr));display:grid}.panel-grid--wide{grid-template-columns:2fr 1fr}.stat-grid{gap:var(--space-3);grid-template-columns:repeat(auto-fill,minmax(176px,1fr));display:grid}.stat-tile{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-large);padding:var(--space-4);gap:var(--space-1);min-width:0;box-shadow:var(--shadow-raised);flex-direction:column;display:flex;position:relative;overflow:hidden}.stat-tile:before{content:"";background:var(--color-border-strong);width:3px;position:absolute;inset:0 auto 0 0}.stat-tile--success:before{background:var(--color-success)}.stat-tile--warning:before{background:var(--color-warning)}.stat-tile--danger:before{background:var(--color-danger)}.stat-tile--info:before{background:var(--color-accent)}.stat-tile__label{color:var(--color-text-muted);font-size:var(--font-size-small);text-transform:uppercase;letter-spacing:.04em;font-weight:600}.stat-tile__value{font-size:var(--font-size-figure);font-variant-numeric:tabular-nums;letter-spacing:-.02em;overflow-wrap:anywhere;font-weight:650;line-height:1.15}.stat-tile__hint{color:var(--color-text-muted);font-size:var(--font-size-small)}.badge{padding:2px var(--space-2);font-size:var(--font-size-small);white-space:nowrap;background:var(--color-surface-sunken);color:var(--color-text-muted);border:1px solid var(--color-border);border-radius:999px;align-items:center;gap:6px;font-weight:600;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:6px;height:6px}.badge--plain:before{display:none}.badge--success{background:var(--color-success-soft);color:var(--color-success);border-color:#0000}.badge--warning{background:var(--color-warning-soft);color:var(--color-warning);border-color:#0000}.badge--danger{background:var(--color-danger-soft);color:var(--color-danger);border-color:#0000}.badge--info{background:var(--color-info-soft);color:var(--color-info);border-color:#0000}.badge--pulse:before{animation:1.6s ease-in-out infinite badge-pulse}@keyframes badge-pulse{0%,to{opacity:1}50%{opacity:.25}}.banner{align-items:flex-start;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-radius:var(--radius-medium);background:var(--color-info-soft);color:var(--color-text);border:1px solid #0000;display:flex}.banner__icon{color:var(--color-info);margin-top:1px}.banner--warning{background:var(--color-warning-soft)}.banner--warning .banner__icon{color:var(--color-warning)}.banner--danger{background:var(--color-danger-soft)}.banner--danger .banner__icon{color:var(--color-danger)}.banner--success{background:var(--color-success-soft)}.banner--success .banner__icon{color:var(--color-success)}.banner__body{flex-direction:column;flex:auto;gap:2px;min-width:0;display:flex}.definition-list{gap:var(--space-2) var(--space-5);grid-template-columns:minmax(120px,max-content) 1fr;margin:0;display:grid}.definition-list dt{color:var(--color-text-muted)}.definition-list dd{overflow-wrap:anywhere;min-width:0;margin:0}.list-plain{flex-direction:column;margin:0;padding:0;list-style:none;display:flex}.list-plain>li{justify-content:space-between;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--color-border);display:flex}.list-plain>li:last-child{border-bottom:0}.list-plain>li.stack{flex-direction:column;justify-content:flex-start;align-items:stretch}.share-bar{appearance:none;background:var(--color-surface-sunken);border:0;border-radius:999px;width:100%;height:8px;overflow:hidden}.share-bar::-webkit-progress-bar{background:var(--color-surface-sunken);border-radius:999px}.share-bar::-webkit-progress-value{background:var(--color-accent);border-radius:999px}.share-bar::-moz-progress-bar{background:var(--color-accent);border-radius:999px}.code-box{font-family:var(--font-mono);background:var(--color-surface-sunken);border:1px solid var(--color-border);border-radius:var(--radius-medium);padding:var(--space-3);overflow-wrap:anywhere;font-size:13px}.code-box--tall{white-space:pre-wrap;max-height:420px;overflow:auto}.recovery-codes{gap:var(--space-2) var(--space-4);padding:var(--space-4);grid-template-columns:repeat(2,minmax(0,1fr));margin:0;list-style:none;display:grid}.recovery-codes li{font-family:var(--font-mono);letter-spacing:.04em;font-size:15px}.qr-code{width:184px;height:184px;padding:var(--space-2);border-radius:var(--radius-medium);image-rendering:pixelated;border:1px solid var(--color-border);background:#fff;display:block}.log-lines{font-family:var(--font-mono);padding:var(--space-3);background:var(--color-surface-sunken);border-radius:var(--radius-medium);max-height:320px;margin:0;font-size:12px;list-style:none;overflow:auto}.log-lines li{overflow-wrap:anywhere;padding:2px 0}.log-level{margin-right:var(--space-2);font-weight:700}.log-level--error{color:var(--color-danger)}.log-level--warn{color:var(--color-warning)}.log-level--info{color:var(--color-info)}.table-scroll{overflow-x:auto}.data-table{border-collapse:collapse;font-variant-numeric:tabular-nums;width:100%}.data-table th,.data-table td{padding:var(--space-3) var(--space-4);text-align:left;border-bottom:1px solid var(--color-border);vertical-align:middle}.data-table th{font-size:var(--font-size-small);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.04em;background:var(--color-surface-sunken);white-space:nowrap;font-weight:650}.data-table tbody tr{transition:background-color var(--motion-fast) var(--easing-standard)}.data-table tbody tr:hover{background:var(--color-surface-sunken)}.data-table tbody tr:last-child td{border-bottom:0}.data-table td.cell--number,.data-table th.cell--number{text-align:right}.data-table td.cell--actions{text-align:right;white-space:nowrap}.sort-button{font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;cursor:pointer;background:0 0;border:0;align-items:center;gap:4px;padding:0;display:inline-flex}.sort-button[aria-sort=none] .sort-arrow{opacity:.35}.sort-arrow{width:10px;height:10px;transition:transform var(--motion-fast) var(--easing-standard)}.sort-button[aria-sort=descending] .sort-arrow{transform:rotate(180deg)}.pagination{justify-content:space-between;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-top:1px solid var(--color-border);flex-wrap:wrap;display:flex}@media (width<=640px){.data-table--cards thead{display:none}.data-table--cards,.data-table--cards tbody,.data-table--cards tr,.data-table--cards td{width:100%;display:block}.data-table--cards tr{padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border)}.data-table--cards tbody tr:last-child{border-bottom:0}.data-table--cards td{justify-content:space-between;gap:var(--space-3);padding:var(--space-1) 0;text-align:right;border:0;display:flex}.data-table--cards td:before{content:attr(data-label);color:var(--color-text-muted);font-size:var(--font-size-small);text-align:left;font-weight:600}.data-table--cards td.cell--actions{padding-top:var(--space-2);justify-content:flex-end}.data-table--cards td.cell--actions:before{display:none}}.filter-bar{gap:var(--space-3);padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);flex-wrap:wrap;align-items:flex-end;display:flex}.filter-bar .field{flex:160px;max-width:280px}.filter-bar .field--search{flex:2 240px;max-width:420px}.filter-bar__extra{display:contents}.filter-bar__toggle{display:none}.empty-state,.error-state{text-align:center;align-items:center;gap:var(--space-2);padding:var(--space-7) var(--space-5);color:var(--color-text-muted);flex-direction:column;display:flex}.empty-state__icon,.error-state__icon{width:36px;height:36px;color:var(--color-border-strong)}.error-state__icon{color:var(--color-danger)}.empty-state__title,.error-state__title{color:var(--color-text);font-weight:650;font-size:var(--font-size-large)}.inline-error{color:var(--color-danger);font-size:var(--font-size-small);gap:var(--space-2);align-items:center;display:flex}.skeleton{background:linear-gradient(90deg, var(--color-surface-sunken), var(--color-border), var(--color-surface-sunken));border-radius:var(--radius-medium);background-size:200% 100%;min-height:88px;animation:1.4s linear infinite skeleton-shimmer}@keyframes skeleton-shimmer{0%{background-position:200% 0}to{background-position:-200% 0}}.app-shell{grid-template-columns:var(--sidebar-width) minmax(0, 1fr);min-height:100vh;display:grid}.side-navigation{gap:var(--space-4);background:var(--color-surface);border-right:1px solid var(--color-border);height:100vh;padding:var(--space-4) var(--space-3);flex-direction:column;display:flex;position:sticky;top:0;overflow-y:auto}.brand{align-items:center;gap:var(--space-3);padding:var(--space-1) var(--space-2) var(--space-2);color:var(--color-text);display:flex}.brand:hover{text-decoration:none}.brand__mark{flex:none;width:30px;height:30px}.brand__mark-back{fill:var(--color-accent)}.brand__mark-slot{fill:var(--color-accent-text)}.brand__name{font-weight:700;line-height:1.2}.brand__sub{color:var(--color-text-muted);font-size:var(--font-size-small)}.navigation-group{flex-direction:column;gap:2px;display:flex}.navigation-group__title{text-transform:uppercase;letter-spacing:.08em;color:var(--color-text-muted);padding:var(--space-2) var(--space-3) var(--space-1);font-size:11px;font-weight:700}.navigation-link{align-items:center;gap:var(--space-3);padding:7px var(--space-3);border-radius:var(--radius-medium);color:var(--color-text);transition:background-color var(--motion-fast) var(--easing-standard), color var(--motion-fast) var(--easing-standard);font-weight:520;display:flex}.navigation-link:hover{background:var(--color-surface-sunken);text-decoration:none}.navigation-link[aria-current=page]{background:var(--color-accent-soft);color:var(--color-accent);font-weight:650}.navigation-link .icon{color:var(--color-text-muted)}.navigation-link[aria-current=page] .icon{color:var(--color-accent)}.side-navigation__footer{padding:var(--space-3);color:var(--color-text-muted);font-size:var(--font-size-small);margin-top:auto}.main-column{flex-direction:column;min-width:0;display:flex}.top-bar{z-index:20;height:var(--top-bar-height);align-items:center;gap:var(--space-3);padding:0 var(--space-5);background:var(--color-surface);border-bottom:1px solid var(--color-border);display:flex;position:sticky;top:0}.top-bar__menu-button,.top-bar__brand{display:none}.top-bar__spacer{flex:auto}.segmented{border:1px solid var(--color-border-strong);border-radius:var(--radius-medium);display:inline-flex;overflow:hidden}.segmented button{background:var(--color-surface);color:var(--color-text-muted);font:inherit;font-size:var(--font-size-small);cursor:pointer;border:0;padding:5px 10px;font-weight:600}.segmented button+button{border-left:1px solid var(--color-border-strong)}.segmented button[aria-pressed=true]{background:var(--color-accent-soft);color:var(--color-accent)}.user-menu{position:relative}.user-menu__button{align-items:center;gap:var(--space-2);padding:4px var(--space-2) 4px 4px;border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text);font:inherit;cursor:pointer;border-radius:999px;display:inline-flex}.user-menu__button:hover{background:var(--color-surface-sunken)}.avatar{background:var(--color-accent);width:28px;height:28px;color:var(--color-accent-text);border-radius:50%;justify-content:center;align-items:center;font-size:12px;font-weight:700;display:inline-flex}.user-menu__name{text-overflow:ellipsis;white-space:nowrap;max-width:160px;font-weight:600;overflow:hidden}.menu{z-index:30;min-width:220px;padding:var(--space-2);background:var(--color-surface-raised);border:1px solid var(--color-border);border-radius:var(--radius-medium);box-shadow:var(--shadow-dialog);animation:menu-enter var(--motion-medium) var(--easing-standard);position:absolute;top:calc(100% + 6px);right:0}@keyframes menu-enter{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}.menu__header{padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--color-border);margin-bottom:var(--space-1)}.menu__item{align-items:center;gap:var(--space-2);width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-small);color:var(--color-text);font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;display:flex}.menu__item:hover{background:var(--color-surface-sunken);text-decoration:none}.page{width:100%;max-width:var(--content-max-width);padding:var(--space-5) var(--space-5) var(--space-8);gap:var(--space-5);animation:page-enter var(--motion-medium) var(--easing-standard);flex-direction:column;margin:0 auto;display:flex}@keyframes page-enter{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}.page-header{justify-content:space-between;align-items:flex-end;gap:var(--space-4);flex-wrap:wrap;display:flex}.page-header__text{gap:var(--space-1);flex-direction:column;min-width:0;display:flex}.page-header__eyebrow{font-size:var(--font-size-small);color:var(--color-text-muted);font-weight:600}.page-header__description{color:var(--color-text-muted);max-width:720px}.page-header__actions{gap:var(--space-2);flex-wrap:wrap;display:flex}.tabs{gap:var(--space-1);border-bottom:1px solid var(--color-border);scrollbar-width:none;display:flex;overflow:auto hidden}.tabs button{font:inherit;color:var(--color-text-muted);padding:var(--space-2) var(--space-3);cursor:pointer;white-space:nowrap;background:0 0;border:0;border-bottom:2px solid #0000;margin-bottom:-1px;font-weight:600}.tabs button[aria-selected=true]{color:var(--color-accent);border-bottom-color:var(--color-accent)}.tabs a{color:var(--color-text-muted);padding:var(--space-2) var(--space-3);white-space:nowrap;border-bottom:2px solid #0000;margin-bottom:-1px;font-weight:600}.tabs a:hover{color:var(--color-text);text-decoration:none}.tabs a[aria-selected=true]{color:var(--color-accent);border-bottom-color:var(--color-accent)}@media (width<=900px){.app-shell{grid-template-columns:minmax(0,1fr)}.side-navigation{top:var(--top-bar-height);height:auto;max-height:calc(100vh - var(--top-bar-height));z-index:25;border-right:0;border-bottom:1px solid var(--color-border);box-shadow:var(--shadow-dialog);opacity:0;visibility:hidden;transition:transform var(--motion-medium) var(--easing-standard), opacity var(--motion-medium) var(--easing-standard), visibility 0s linear var(--motion-medium);position:fixed;left:0;right:0;transform:translateY(-8px)}.side-navigation .brand{display:none}.app-shell--navigation-open .side-navigation{opacity:1;visibility:visible;transition-delay:0s;transform:none}.top-bar{padding:0 var(--space-4)}.top-bar__menu-button,.top-bar__brand{display:inline-flex}.panel-grid--wide{grid-template-columns:minmax(0,1fr)}}@media (width<=640px){.page{padding:var(--space-4) var(--space-4) var(--space-7);gap:var(--space-4)}.panel{padding:var(--space-4)}.panel--flush{padding:0}.panel--flush .panel__header{padding:var(--space-4) var(--space-4) 0}.panel-grid{grid-template-columns:minmax(0,1fr)}.stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.user-menu__name,.top-bar__time-zone{display:none}.filter-bar{padding:var(--space-3) var(--space-4)}.filter-bar .field,.filter-bar .field--search{flex-basis:100%;max-width:none}.filter-bar__toggle{display:inline-flex}.filter-bar__extra{display:none}.filter-bar--expanded .filter-bar__extra{gap:var(--space-3);flex-direction:column;width:100%;display:flex}.definition-list{grid-template-columns:minmax(0,1fr);gap:0}.definition-list dd{margin-bottom:var(--space-2)}.recovery-codes{grid-template-columns:minmax(0,1fr)}}.auth-layout{grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);min-height:100vh;display:grid}.auth-aside{padding:var(--space-8);background:var(--color-surface-sunken);border-right:1px solid var(--color-border);flex-direction:column;justify-content:space-between;display:flex;position:relative;overflow:hidden}.auth-aside__grid{width:100%;height:100%;color:var(--color-border);position:absolute;inset:0;-webkit-mask-image:radial-gradient(80% 70% at 30% 40%,#000 20%,#0000 75%);mask-image:radial-gradient(80% 70% at 30% 40%,#000 20%,#0000 75%)}.auth-aside>:not(.auth-aside__grid){position:relative}.auth-aside__headline{letter-spacing:-.03em;max-width:420px;font-size:34px;font-weight:700;line-height:1.1}.auth-aside__copy{color:var(--color-text-muted);max-width:400px;margin-top:var(--space-3);font-size:var(--font-size-large)}.auth-aside__facts{gap:var(--space-3);color:var(--color-text-muted);flex-direction:column;display:flex}.auth-aside__fact{gap:var(--space-3);align-items:center;display:flex}.auth-aside__fact .icon{color:var(--color-accent)}.auth-main{padding:var(--space-6) var(--space-4);justify-content:center;align-items:center;display:flex}.auth-card{gap:var(--space-5);width:100%;max-width:420px;animation:page-enter var(--motion-medium) var(--easing-standard);flex-direction:column;display:flex}.auth-card--wide{max-width:520px}.auth-card__header{gap:var(--space-2);flex-direction:column;display:flex}.step-indicator{gap:var(--space-2);color:var(--color-text-muted);font-size:var(--font-size-small);align-items:center;font-weight:600;display:flex}.step-indicator__dot{background:var(--color-border);border-radius:999px;width:22px;height:4px}.step-indicator__dot--done{background:var(--color-accent)}.enrolment-grid{gap:var(--space-5);grid-template-columns:auto minmax(0,1fr);align-items:start;display:grid}.loading-screen{justify-content:center;align-items:center;gap:var(--space-3);min-height:100vh;color:var(--color-text-muted);flex-direction:column;display:flex}.spinner{border:3px solid var(--color-border);border-top-color:var(--color-accent);border-radius:50%;width:28px;height:28px;animation:.9s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}@media (width<=900px){.auth-layout{grid-template-columns:minmax(0,1fr)}.auth-aside{display:none}}@media (width<=520px){.enrolment-grid{grid-template-columns:minmax(0,1fr);justify-items:center}}.dialog{border:1px solid var(--color-border);border-radius:var(--radius-large);background:var(--color-surface-raised);width:min(520px,100vw - 32px);max-height:calc(100vh - 48px);color:var(--color-text);box-shadow:var(--shadow-dialog);padding:0}.dialog[open]{animation:dialog-enter var(--motion-medium) var(--easing-standard)}.dialog::backdrop{background:var(--color-overlay);animation:fade-in var(--motion-medium) var(--easing-standard)}@keyframes dialog-enter{0%{opacity:0;transform:translateY(8px)scale(.98)}to{opacity:1;transform:none}}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.dialog__form{gap:var(--space-4);padding:var(--space-5);flex-direction:column;display:flex}.dialog__header{gap:var(--space-1);flex-direction:column;display:flex}.dialog__title{font-size:var(--font-size-large)}.dialog--wide{width:min(720px,100vw - 32px)}.drawer{border:0;border-left:1px solid var(--color-border);background:var(--color-surface-raised);width:min(480px,100vw);height:100vh;max-height:100vh;color:var(--color-text);box-shadow:var(--shadow-dialog);margin:0 0 0 auto;padding:0}.drawer[open]{animation:drawer-enter var(--motion-medium) var(--easing-standard)}.drawer::backdrop{background:var(--color-overlay)}@keyframes drawer-enter{0%{opacity:0;transform:translate(24px)}to{opacity:1;transform:none}}.toast-region{right:var(--space-5);bottom:var(--space-5);z-index:50;gap:var(--space-2);pointer-events:none;flex-direction:column;width:min(380px,100vw - 32px);display:flex;position:fixed}.toast{pointer-events:auto;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-radius:var(--radius-medium);background:var(--color-surface-raised);border:1px solid var(--color-border);border-left:4px solid var(--color-success);box-shadow:var(--shadow-dialog);animation:toast-enter var(--motion-medium) var(--easing-standard);align-items:flex-start;display:flex}.toast--error{border-left-color:var(--color-danger)}.toast--info{border-left-color:var(--color-accent)}.toast__text{flex:auto}@keyframes toast-enter{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@media (width<=640px){.toast-region{left:var(--space-4);right:var(--space-4);bottom:var(--space-4);width:auto}}.toolbar{align-items:flex-end;gap:var(--space-3);flex-wrap:wrap;display:flex}.toolbar .field{min-width:200px}.toolbar .segmented button{padding:9px 12px}.panel__body{padding:0 var(--space-5) var(--space-5)}.log-level--debug{color:var(--color-text-muted)}.chart-grid{gap:var(--space-4);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.chart{gap:var(--space-2);flex-direction:column;min-width:0;margin:0;display:flex}.chart__title{font-weight:650}.chart__plot{min-width:0;min-height:200px;position:relative}.chart__svg{font-family:var(--font-sans);display:block;overflow:visible}.chart__svg:focus-visible{outline:2px solid var(--color-accent);outline-offset:4px;border-radius:var(--radius-small)}.chart__empty{text-align:center;margin:0;padding-top:80px}.chart__grid{stroke:var(--color-border);stroke-width:1px;shape-rendering:crispedges}.chart__label{fill:var(--color-text-muted);font-variant-numeric:tabular-nums;font-size:11px}.chart-line{fill:none;stroke-width:2px;stroke-linejoin:round;stroke-linecap:round}.chart__hit{fill:#0000;cursor:crosshair}.chart__crosshair-line{stroke:var(--color-text-muted);stroke-width:1px;stroke-dasharray:3 3;shape-rendering:crispedges;pointer-events:none}.chart-dot{stroke:var(--color-surface);stroke-width:2px;pointer-events:none}.chart-marker{stroke:none;pointer-events:none}.chart__tooltip{pointer-events:none}.chart__tooltip-box{fill:var(--color-surface-raised);stroke:var(--color-border-strong);filter:drop-shadow(0 4px 10px #0000002e)}.chart__tooltip-time{fill:var(--color-text-muted);font-size:11px;font-weight:600}.chart__tooltip-text{fill:var(--color-text);font-variant-numeric:tabular-nums;font-size:12px}.chart__legend{gap:var(--space-1) var(--space-4);font-size:var(--font-size-small);color:var(--color-text-muted);flex-wrap:wrap;margin:0;padding:0;list-style:none;display:flex}.chart__legend li{align-items:center;gap:6px;display:inline-flex}.chart__legend strong{color:var(--color-text);font-variant-numeric:tabular-nums}.chart-swatch{border-radius:2px;width:10px;height:3px}.chart-line--1{stroke:var(--chart-series-1)}.chart-dot--1,.chart-text--1{fill:var(--chart-series-1)}.chart-swatch--1{background:var(--chart-series-1)}.chart-line--2{stroke:var(--chart-series-2)}.chart-dot--2,.chart-text--2{fill:var(--chart-series-2)}.chart-swatch--2{background:var(--chart-series-2)}.chart-line--3{stroke:var(--chart-series-3)}.chart-dot--3,.chart-text--3{fill:var(--chart-series-3)}.chart-swatch--3{background:var(--chart-series-3)}.chart-line--4{stroke:var(--chart-series-4)}.chart-dot--4,.chart-text--4{fill:var(--chart-series-4)}.chart-swatch--4{background:var(--chart-series-4)}.chart-line--5{stroke:var(--chart-series-5)}.chart-dot--5,.chart-text--5{fill:var(--chart-series-5)}.chart-swatch--5{background:var(--chart-series-5)}.chart-line--6{stroke:var(--chart-series-6)}.chart-dot--6,.chart-text--6{fill:var(--chart-series-6)}.chart-swatch--6{background:var(--chart-series-6)}.sparkline{align-items:center;gap:var(--space-2);min-width:140px;display:flex}.sparkline__plot{flex:auto;min-width:80px;height:36px}.sparkline .chart-line{stroke-width:1.5px}.sparkline__value{font-size:var(--font-size-small);font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right;min-width:56px}.log-tail{gap:var(--space-2);flex-direction:column;display:flex}.log-tail__list{padding:var(--space-2) var(--space-3);font-family:var(--font-mono);background:var(--color-surface-sunken);border:1px solid var(--color-border);border-radius:var(--radius-medium);overscroll-behavior:contain;height:min(62vh,640px);margin:0;font-size:12px;list-style:none;overflow:auto}.log-tail__list--compact{height:320px}.log-tail__line{gap:var(--space-2);align-items:baseline;padding:2px 0;display:flex}.log-tail__time{color:var(--color-text-muted);white-space:nowrap;flex:none}.log-tail__line .log-level{flex:none;width:44px;margin-right:0}.log-tail__instance{color:var(--color-info);text-overflow:ellipsis;white-space:nowrap;flex:none;max-width:12ch;overflow:hidden}.log-tail__message{overflow-wrap:anywhere;min-width:0}.log-tail__fields{color:var(--color-text-muted)}.log-tail__status{justify-content:space-between;align-items:center;gap:var(--space-2);min-height:28px;display:flex}.log-tail mark{background:var(--color-warning-soft);color:var(--color-text);box-shadow:0 0 0 1px var(--color-warning);border-radius:2px}.config-option{scroll-margin-top:calc(var(--top-bar-height) + var(--space-4))}.config-option--focused{padding:var(--space-2);margin:calc(var(--space-2) * -1);border-radius:var(--radius-medium);background:var(--color-accent-soft);box-shadow:inset 3px 0 0 var(--color-accent)}.snippet-lines{gap:var(--space-2);margin-top:var(--space-4);flex-direction:column;display:flex}.snippet-line{align-items:center;gap:var(--space-3);grid-template-columns:96px minmax(0,1fr) auto;display:grid}.claims-box{margin:var(--space-2) 0 0;white-space:pre-wrap;max-height:220px;overflow:auto}@media (width<=900px){.chart-grid{grid-template-columns:minmax(0,1fr)}}@media (width<=640px){.toolbar .field{flex:100%;min-width:0}.snippet-line{grid-template-columns:minmax(0,1fr) auto}.snippet-line>.small{grid-column:1/-1}.log-tail__line{column-gap:var(--space-2);flex-wrap:wrap;row-gap:0}.log-tail__message{flex:100%}.sparkline{min-width:0}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition-duration:1ms!important;animation-duration:1ms!important;animation-iteration-count:1!important}}.top-bar__search{align-items:center;gap:var(--space-2);min-width:220px;padding:6px var(--space-3);border:1px solid var(--color-border-strong);border-radius:var(--radius-medium);background:var(--color-surface);color:var(--color-text-muted);font:inherit;cursor:pointer;display:inline-flex}.top-bar__search:hover{background:var(--color-surface-sunken)}.top-bar__search .kbd{margin-left:auto}.kbd{border:1px solid var(--color-border-strong);border-radius:var(--radius-small);min-width:20px;font-family:var(--font-mono);color:var(--color-text-muted);background:var(--color-surface-sunken);border-bottom-width:2px;justify-content:center;align-items:center;padding:1px 6px;font-size:11px;display:inline-flex}.palette{width:min(640px,100vw - 32px);margin:12vh auto auto}.palette__input-row{align-items:center;gap:var(--space-2);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);display:flex}.palette__input{font:inherit;font-size:var(--font-size-large);color:var(--color-text);background:0 0;border:0;outline:none;flex:auto;min-width:0}.palette__list{padding:var(--space-2);max-height:min(420px,60vh);margin:0;list-style:none;overflow-y:auto}.palette__group{padding:var(--space-2) var(--space-3) var(--space-1);font-size:var(--font-size-small);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;font-weight:700}.palette__option{align-items:center;gap:var(--space-3);width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-medium);color:var(--color-text);font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;display:flex}.palette__option[aria-selected=true]{background:var(--color-accent-soft);color:var(--color-accent)}.palette__option-hint{font-size:var(--font-size-small);color:var(--color-text-muted);text-overflow:ellipsis;white-space:nowrap;max-width:50%;margin-left:auto;overflow:hidden}.palette__footer{gap:var(--space-4);padding:var(--space-2) var(--space-4);border-top:1px solid var(--color-border);font-size:var(--font-size-small);color:var(--color-text-muted);flex-wrap:wrap;display:flex}.shortcut-list{gap:var(--space-2) var(--space-4);grid-template-columns:max-content 1fr;align-items:center;margin:0;display:grid}.shortcut-list dt{gap:var(--space-1);display:flex}.shortcut-list dd{margin:0}.help-panel__body{gap:var(--space-4);flex-direction:column;display:flex}.help-panel__links{gap:var(--space-2);flex-direction:column;margin:0;padding:0;list-style:none;display:flex}.markdown{gap:var(--space-3);flex-direction:column;max-width:860px;line-height:1.6;display:flex}.markdown h1,.markdown h2,.markdown h3,.markdown h4{scroll-margin-top:calc(var(--top-bar-height) + var(--space-4));line-height:1.3}.markdown h1{font-size:var(--font-size-title)}.markdown h2{margin-top:var(--space-4);padding-bottom:var(--space-1);border-bottom:1px solid var(--color-border);font-size:19px}.markdown h3{font-size:var(--font-size-large);margin-top:var(--space-2)}.markdown h4,.markdown h5,.markdown h6{font-size:var(--font-size-body)}.markdown p,.markdown ul,.markdown ol,.markdown blockquote{margin:0}.markdown ul,.markdown ol{padding-left:var(--space-5);gap:var(--space-1);flex-direction:column;display:flex}.markdown__item--depth-1{margin-left:var(--space-5)}.markdown__item--depth-2{margin-left:var(--space-7)}.markdown__item--depth-3{margin-left:calc(var(--space-8) + var(--space-3))}.markdown code{font-family:var(--font-mono);background:var(--color-surface-sunken);border-radius:var(--radius-small);overflow-wrap:anywhere;padding:1px 4px;font-size:.92em}.markdown pre{padding:var(--space-3) var(--space-4);background:var(--color-surface-sunken);border:1px solid var(--color-border);border-radius:var(--radius-medium);margin:0;font-size:13px;line-height:1.5;overflow-x:auto}.markdown pre code{overflow-wrap:normal;background:0 0;padding:0}.markdown blockquote{padding:var(--space-2) var(--space-4);border-left:3px solid var(--color-accent);background:var(--color-accent-soft);border-radius:0 var(--radius-medium) var(--radius-medium) 0}.markdown hr{border:0;border-top:1px solid var(--color-border);margin:var(--space-2) 0;width:100%}.markdown table{border-collapse:collapse;width:100%;font-size:13px}.markdown th,.markdown td{border:1px solid var(--color-border);padding:var(--space-2) var(--space-3);text-align:left;vertical-align:top;overflow-wrap:anywhere}.markdown th{background:var(--color-surface-sunken)}.markdown__heading--target{background:var(--color-accent-soft);border-radius:var(--radius-small);box-shadow:inset 3px 0 0 var(--color-accent);padding-left:var(--space-2)}.docs-layout{gap:var(--space-5);grid-template-columns:220px minmax(0,1fr);align-items:start;display:grid}.docs-toc{top:calc(var(--top-bar-height) + var(--space-4));max-height:calc(100vh - var(--top-bar-height) - var(--space-6));flex-direction:column;gap:2px;font-size:13px;display:flex;position:sticky;overflow-y:auto}.docs-toc a{color:var(--color-text-muted);padding:2px var(--space-2);border-radius:var(--radius-small)}.docs-toc a:hover{color:var(--color-text);background:var(--color-surface-sunken);text-decoration:none}.docs-toc__sub{padding-left:var(--space-4)!important}.toggle{align-items:center;gap:var(--space-2);cursor:pointer;display:inline-flex;position:relative}.toggle input{appearance:none;background:var(--color-border-strong);cursor:pointer;width:34px;height:20px;transition:background var(--motion-fast) var(--easing-standard);border-radius:999px;margin:0;position:relative}.toggle input:after{content:"";background:var(--color-surface);width:16px;height:16px;box-shadow:var(--shadow-raised);transition:transform var(--motion-fast) var(--easing-standard);border-radius:50%;position:absolute;top:2px;left:2px}.toggle input:checked{background:var(--color-accent)}.toggle input:checked:after{transform:translate(14px)}.toggle input:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}.permission-groups{gap:var(--space-3) var(--space-4);border:0;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));margin:0;padding:0;display:grid}.permission-group{gap:var(--space-1);border:0;flex-direction:column;margin:0;padding:0;display:flex}.permission-group legend{font-weight:700;font-size:var(--font-size-small);margin-bottom:var(--space-1);padding:0}.secret-box{align-items:center;gap:var(--space-2);flex-wrap:wrap;display:flex}.secret-box .code-box{-webkit-user-select:all;user-select:all;flex:260px}.step-list{flex-direction:column;margin:0;padding:0;list-style:none;display:flex}.step-list__item{gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--color-border);grid-template-columns:28px minmax(0,1fr) auto;align-items:center;display:grid}.step-list__item:last-child{border-bottom:0}.step-list__marker{background:var(--color-surface-sunken);width:24px;height:24px;color:var(--color-text-muted);border:1px solid var(--color-border-strong);border-radius:50%;justify-content:center;align-items:center;font-size:12px;font-weight:700;display:inline-flex}.step-list__marker--done{background:var(--color-success-soft);color:var(--color-success);border-color:var(--color-success)}.step-list__marker--active{background:var(--color-accent-soft);color:var(--color-accent);border-color:var(--color-accent);animation:1.6s ease-in-out infinite step-pulse}.step-list__marker--failed{background:var(--color-danger-soft);color:var(--color-danger);border-color:var(--color-danger)}@keyframes step-pulse{50%{opacity:.55}}.result-box{gap:var(--space-2);flex-direction:column;display:flex}.extension-card{gap:var(--space-2);padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);flex-direction:column;display:flex}.extension-card:last-child{border-bottom:0}.chain-ok{color:var(--color-success)}@media (width<=900px){.docs-layout{grid-template-columns:minmax(0,1fr)}.docs-toc{border:1px solid var(--color-border);border-radius:var(--radius-medium);max-height:240px;padding:var(--space-2);position:static}.top-bar__search{background:0 0;border-color:#0000;min-width:0;padding:6px}.top-bar__search-text,.top-bar__search .kbd{display:none}}.menu__item--danger{color:var(--color-danger)}.divider-text{align-items:center;gap:var(--space-3);color:var(--color-text-muted);font-size:var(--font-size-small);display:flex}.divider-text:before,.divider-text:after{content:"";border-top:1px solid var(--color-border);flex:auto}.dialog,.drawer{text-align:left;white-space:normal}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2b54c7"/><path d="M9 10h14v4H9zM9 18h14v4H9z" fill="#fff" opacity=".92"/><circle cx="20.5" cy="12" r="1.4" fill="#2b54c7"/><circle cx="20.5" cy="20" r="1.4" fill="#2b54c7"/></svg>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<meta name="referrer" content="no-referrer" />
|
|
8
|
+
<link rel="icon" type="image/svg+xml" href="/game-server/admin/favicon.svg" />
|
|
9
|
+
<title>Game Server Console</title>
|
|
10
|
+
<script type="module" crossorigin src="/game-server/admin/assets/index-CI5N7c-T.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/game-server/admin/assets/index-DPitvzLp.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DiscordSignInConfig } from "../GameServerConfig.js";
|
|
2
|
+
import type { DiscordProfile } from "./DiscordSessions.js";
|
|
3
|
+
/** Discord's three addresses. */
|
|
4
|
+
export interface DiscordEndpoints {
|
|
5
|
+
/** Where the browser asks for consent. */
|
|
6
|
+
authorizeUrl: string;
|
|
7
|
+
/** Where the server trades the code. */
|
|
8
|
+
tokenUrl: string;
|
|
9
|
+
/** Where the server reads the user. */
|
|
10
|
+
userUrl: string;
|
|
11
|
+
}
|
|
12
|
+
/** GameServerOptions.discordTesting: values only tests and the live bed replace (D15). */
|
|
13
|
+
export interface DiscordTestingOptions {
|
|
14
|
+
/** Replaces https://discord.com. */
|
|
15
|
+
apiBaseUrl: string;
|
|
16
|
+
}
|
|
17
|
+
/** Why a Discord call failed: busy (429, 5xx, network, timeout) or refused (any other answer). */
|
|
18
|
+
export declare class DiscordApiError extends Error {
|
|
19
|
+
/** The failure kind. */
|
|
20
|
+
readonly kind: "busy" | "refused";
|
|
21
|
+
/**
|
|
22
|
+
* Creates the error.
|
|
23
|
+
* @param kind The failure kind.
|
|
24
|
+
* @param message What happened (logged; never shown to players).
|
|
25
|
+
*/
|
|
26
|
+
constructor(kind: "busy" | "refused", message: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Discord's addresses under a base URL.
|
|
30
|
+
* @param apiBaseUrl https://discord.com, or a test server.
|
|
31
|
+
* @returns base + "/oauth2/authorize", base + "/api/v10/oauth2/token", base + "/api/v10/users/@me".
|
|
32
|
+
*/
|
|
33
|
+
export declare function discordEndpoints(apiBaseUrl: string): DiscordEndpoints;
|
|
34
|
+
/**
|
|
35
|
+
* Trades an authorization code for an access token (client_secret_post, PKCE).
|
|
36
|
+
* @param endpoints Discord's addresses.
|
|
37
|
+
* @param config The Discord sign-in config.
|
|
38
|
+
* @param code The authorization code.
|
|
39
|
+
* @param codeVerifier The PKCE verifier stored with the challenge.
|
|
40
|
+
* @returns The access token (kept only by the caller's next call).
|
|
41
|
+
* @throws DiscordApiError busy or refused.
|
|
42
|
+
*/
|
|
43
|
+
export declare function exchangeDiscordCode(endpoints: DiscordEndpoints, config: DiscordSignInConfig, code: string, codeVerifier: string): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Reads the signed-in user (GET users/@me) and turns it into a profile.
|
|
46
|
+
* @param endpoints Discord's addresses.
|
|
47
|
+
* @param accessToken The access token.
|
|
48
|
+
* @returns The profile.
|
|
49
|
+
* @throws DiscordApiError busy or refused (refused also when the body is not a user).
|
|
50
|
+
*/
|
|
51
|
+
export declare function fetchDiscordProfile(endpoints: DiscordEndpoints, accessToken: string): Promise<DiscordProfile>;
|
|
52
|
+
/**
|
|
53
|
+
* A profile from Discord's user object: id and username are required strings; global_name and avatar may be null.
|
|
54
|
+
* @param user The parsed JSON.
|
|
55
|
+
* @returns The profile, or null when the object is not a user.
|
|
56
|
+
*/
|
|
57
|
+
export declare function profileFromDiscordUser(user: unknown): DiscordProfile | null;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// discord/DiscordOAuth.ts — Discord's OAuth2 and user API, used once per sign-in (discord-sign-in D15).
|
|
2
|
+
import { DISCORD_REQUEST_TIMEOUT_MS } from "./DiscordSignInConstants.js";
|
|
3
|
+
/** The first HTTP status that counts as a server failure (busy). */
|
|
4
|
+
const HTTP_SERVER_ERROR = 500;
|
|
5
|
+
/** Discord's rate-limit status (busy). */
|
|
6
|
+
const HTTP_TOO_MANY_REQUESTS = 429;
|
|
7
|
+
/** Why a Discord call failed: busy (429, 5xx, network, timeout) or refused (any other answer). */
|
|
8
|
+
export class DiscordApiError extends Error {
|
|
9
|
+
/** The failure kind. */
|
|
10
|
+
kind;
|
|
11
|
+
/**
|
|
12
|
+
* Creates the error.
|
|
13
|
+
* @param kind The failure kind.
|
|
14
|
+
* @param message What happened (logged; never shown to players).
|
|
15
|
+
*/
|
|
16
|
+
constructor(kind, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "DiscordApiError";
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Discord's addresses under a base URL.
|
|
24
|
+
* @param apiBaseUrl https://discord.com, or a test server.
|
|
25
|
+
* @returns base + "/oauth2/authorize", base + "/api/v10/oauth2/token", base + "/api/v10/users/@me".
|
|
26
|
+
*/
|
|
27
|
+
export function discordEndpoints(apiBaseUrl) {
|
|
28
|
+
return {
|
|
29
|
+
authorizeUrl: apiBaseUrl + "/oauth2/authorize",
|
|
30
|
+
tokenUrl: apiBaseUrl + "/api/v10/oauth2/token",
|
|
31
|
+
userUrl: apiBaseUrl + "/api/v10/users/@me"
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Trades an authorization code for an access token (client_secret_post, PKCE).
|
|
36
|
+
* @param endpoints Discord's addresses.
|
|
37
|
+
* @param config The Discord sign-in config.
|
|
38
|
+
* @param code The authorization code.
|
|
39
|
+
* @param codeVerifier The PKCE verifier stored with the challenge.
|
|
40
|
+
* @returns The access token (kept only by the caller's next call).
|
|
41
|
+
* @throws DiscordApiError busy or refused.
|
|
42
|
+
*/
|
|
43
|
+
export async function exchangeDiscordCode(endpoints, config, code, codeVerifier) {
|
|
44
|
+
const form = new URLSearchParams({
|
|
45
|
+
grant_type: "authorization_code",
|
|
46
|
+
code: code,
|
|
47
|
+
redirect_uri: config.redirectUri ?? "",
|
|
48
|
+
client_id: config.clientId ?? "",
|
|
49
|
+
client_secret: config.clientSecret ?? "",
|
|
50
|
+
code_verifier: codeVerifier
|
|
51
|
+
});
|
|
52
|
+
const answer = await requestDiscord("the code exchange", endpoints.tokenUrl, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
55
|
+
body: form.toString()
|
|
56
|
+
});
|
|
57
|
+
const accessToken = isRecord(answer) ? answer.access_token : null;
|
|
58
|
+
if (typeof accessToken !== "string" || accessToken === "")
|
|
59
|
+
throw new DiscordApiError("refused", "the code exchange answered without an access token");
|
|
60
|
+
return accessToken;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Reads the signed-in user (GET users/@me) and turns it into a profile.
|
|
64
|
+
* @param endpoints Discord's addresses.
|
|
65
|
+
* @param accessToken The access token.
|
|
66
|
+
* @returns The profile.
|
|
67
|
+
* @throws DiscordApiError busy or refused (refused also when the body is not a user).
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchDiscordProfile(endpoints, accessToken) {
|
|
70
|
+
const answer = await requestDiscord("the user read", endpoints.userUrl, { method: "GET", headers: { authorization: "Bearer " + accessToken } });
|
|
71
|
+
const profile = profileFromDiscordUser(answer);
|
|
72
|
+
if (profile == null)
|
|
73
|
+
throw new DiscordApiError("refused", "the user read answered with something that is not a user");
|
|
74
|
+
return profile;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A profile from Discord's user object: id and username are required strings; global_name and avatar may be null.
|
|
78
|
+
* @param user The parsed JSON.
|
|
79
|
+
* @returns The profile, or null when the object is not a user.
|
|
80
|
+
*/
|
|
81
|
+
export function profileFromDiscordUser(user) {
|
|
82
|
+
if (!isRecord(user))
|
|
83
|
+
return null;
|
|
84
|
+
const discordId = user.id;
|
|
85
|
+
const username = user.username;
|
|
86
|
+
if (typeof discordId !== "string" || discordId === "" || typeof username !== "string" || username === "")
|
|
87
|
+
return null;
|
|
88
|
+
const globalName = user.global_name;
|
|
89
|
+
const avatar = user.avatar;
|
|
90
|
+
const displayName = (typeof globalName === "string" && globalName !== "") ? globalName : username;
|
|
91
|
+
return { discordId: discordId, username: username, displayName: displayName, avatarHash: (typeof avatar === "string" && avatar !== "") ? avatar : null };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Sends one Discord request with the request timeout and sorts its failure.
|
|
95
|
+
* @param purpose What the request is for (the error text; never contains a token).
|
|
96
|
+
* @param url The address.
|
|
97
|
+
* @param init The method, headers and body.
|
|
98
|
+
* @returns The parsed JSON body.
|
|
99
|
+
* @throws DiscordApiError busy for a network failure, a timeout, 429 or 5xx; refused for another non-2xx or a non-JSON body.
|
|
100
|
+
*/
|
|
101
|
+
async function requestDiscord(purpose, url, init) {
|
|
102
|
+
let response;
|
|
103
|
+
try {
|
|
104
|
+
response = await fetch(url, { ...init, signal: AbortSignal.timeout(DISCORD_REQUEST_TIMEOUT_MS) });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new DiscordApiError("busy", purpose + " failed: " + String(error));
|
|
108
|
+
}
|
|
109
|
+
if (response.status === HTTP_TOO_MANY_REQUESTS || response.status >= HTTP_SERVER_ERROR)
|
|
110
|
+
throw new DiscordApiError("busy", purpose + " answered " + response.status);
|
|
111
|
+
if (!response.ok)
|
|
112
|
+
throw new DiscordApiError("refused", purpose + " answered " + response.status);
|
|
113
|
+
try {
|
|
114
|
+
return await response.json();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new DiscordApiError("refused", purpose + " answered with a body that is not JSON");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether a value is a plain JSON object.
|
|
122
|
+
* @param value The value.
|
|
123
|
+
* @returns True for a non-null, non-array object.
|
|
124
|
+
*/
|
|
125
|
+
function isRecord(value) {
|
|
126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type AuthIdentity } from "../Authentication.js";
|
|
2
|
+
import type { AdminClock } from "../admin/AdminClock.js";
|
|
3
|
+
import { type AdminStore } from "../admin/AdminStore.js";
|
|
4
|
+
/** A Discord account as one sign-in read it (scope identify). */
|
|
5
|
+
export interface DiscordProfile {
|
|
6
|
+
/** The Discord user id (a snowflake, as text). */
|
|
7
|
+
discordId: string;
|
|
8
|
+
/** The Discord username. */
|
|
9
|
+
username: string;
|
|
10
|
+
/** The display name: global_name, else username. */
|
|
11
|
+
displayName: string;
|
|
12
|
+
/** The avatar hash, or null for Discord's default avatar. */
|
|
13
|
+
avatarHash: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** A session the server issued. */
|
|
16
|
+
export interface DiscordSessionGrant {
|
|
17
|
+
/** The session token (an HS256 JWT) the game sends as its auth token. */
|
|
18
|
+
token: string;
|
|
19
|
+
/** ISO time the token expires. */
|
|
20
|
+
expiresAt: string;
|
|
21
|
+
/** The server user id, discord:<id>. */
|
|
22
|
+
userId: string;
|
|
23
|
+
/** The profile the token carries. */
|
|
24
|
+
profile: DiscordProfile;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Issues and verifies this server's Discord player sessions: HS256 JWTs signed with a key the server creates once and
|
|
28
|
+
* keeps in the admin store, so every process and restart accepts the same tokens.
|
|
29
|
+
*/
|
|
30
|
+
export declare class DiscordSessions {
|
|
31
|
+
/** The signing key bytes. */
|
|
32
|
+
private readonly key;
|
|
33
|
+
/** The clock every "now" comes from. */
|
|
34
|
+
private readonly clock;
|
|
35
|
+
/** How long a session lasts, in days. */
|
|
36
|
+
private readonly sessionDays;
|
|
37
|
+
/**
|
|
38
|
+
* Creates the sessions around a loaded key (use open).
|
|
39
|
+
* @param key The signing key bytes.
|
|
40
|
+
* @param clock The clock.
|
|
41
|
+
* @param sessionDays How long a session lasts, in days.
|
|
42
|
+
*/
|
|
43
|
+
private constructor();
|
|
44
|
+
/**
|
|
45
|
+
* Opens the sessions, creating the signing key on first use (a create race between processes re-reads the winner).
|
|
46
|
+
* @param store The admin store.
|
|
47
|
+
* @param clock The clock (the admin testing clock in tests).
|
|
48
|
+
* @param sessionDays How long a session lasts.
|
|
49
|
+
* @returns The sessions.
|
|
50
|
+
*/
|
|
51
|
+
static open(store: AdminStore, clock: AdminClock, sessionDays: number): Promise<DiscordSessions>;
|
|
52
|
+
/**
|
|
53
|
+
* Whether a token is one of these sessions (its unverified payload names DISCORD_SESSION_ISSUER). Never throws.
|
|
54
|
+
* @param token The token.
|
|
55
|
+
* @returns True when only these sessions may verify it.
|
|
56
|
+
*/
|
|
57
|
+
isSessionToken(token: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Issues a session for a profile.
|
|
60
|
+
* @param profile The Discord profile.
|
|
61
|
+
* @returns The grant.
|
|
62
|
+
*/
|
|
63
|
+
issue(profile: DiscordProfile): Promise<DiscordSessionGrant>;
|
|
64
|
+
/**
|
|
65
|
+
* Verifies a session token.
|
|
66
|
+
* @param token The token.
|
|
67
|
+
* @returns The identity: userId discord:<id>, with the token's claims.
|
|
68
|
+
* @throws AuthRefusal("AuthenticationTicketExpired", "the Discord session has expired") when expired;
|
|
69
|
+
* AuthRefusal("InvalidAuthentication", "the Discord session is not valid") otherwise.
|
|
70
|
+
*/
|
|
71
|
+
verify(token: string): Promise<AuthIdentity>;
|
|
72
|
+
/**
|
|
73
|
+
* Reads the stored signing key, creating it when there is none; a lost create race re-reads the winner's key.
|
|
74
|
+
* @param store The admin store.
|
|
75
|
+
* @param clock The clock (for the record's creation time).
|
|
76
|
+
* @returns The key record.
|
|
77
|
+
*/
|
|
78
|
+
private static loadOrCreateKey;
|
|
79
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// discord/DiscordSessions.ts — this server's own player sessions for Discord sign-in (discord-sign-in D11).
|
|
2
|
+
import { SignJWT, decodeJwt, errors as joseErrors, jwtVerify } from "jose";
|
|
3
|
+
import { AuthRefusal } from "../Authentication.js";
|
|
4
|
+
import { randomToken } from "../admin/AdminCrypto.js";
|
|
5
|
+
import { AdminConflictError } from "../admin/AdminStore.js";
|
|
6
|
+
import { DISCORD_COLLECTION, DISCORD_PROVIDER_NAME, DISCORD_SESSION_AUDIENCE, DISCORD_SESSION_ISSUER, DISCORD_USER_ID_PREFIX, RANDOM_VALUE_BYTES, SESSION_KEY_DOCUMENT_ID } from "./DiscordSignInConstants.js";
|
|
7
|
+
/** The JWT signing algorithm of the sessions. */
|
|
8
|
+
const SESSION_ALGORITHM = "HS256";
|
|
9
|
+
/** Milliseconds in a second. */
|
|
10
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
11
|
+
/** Seconds in a day. */
|
|
12
|
+
const SECONDS_PER_DAY = 86400;
|
|
13
|
+
/**
|
|
14
|
+
* Issues and verifies this server's Discord player sessions: HS256 JWTs signed with a key the server creates once and
|
|
15
|
+
* keeps in the admin store, so every process and restart accepts the same tokens.
|
|
16
|
+
*/
|
|
17
|
+
export class DiscordSessions {
|
|
18
|
+
/** The signing key bytes. */
|
|
19
|
+
key;
|
|
20
|
+
/** The clock every "now" comes from. */
|
|
21
|
+
clock;
|
|
22
|
+
/** How long a session lasts, in days. */
|
|
23
|
+
sessionDays;
|
|
24
|
+
/**
|
|
25
|
+
* Creates the sessions around a loaded key (use open).
|
|
26
|
+
* @param key The signing key bytes.
|
|
27
|
+
* @param clock The clock.
|
|
28
|
+
* @param sessionDays How long a session lasts, in days.
|
|
29
|
+
*/
|
|
30
|
+
constructor(key, clock, sessionDays) {
|
|
31
|
+
this.key = key;
|
|
32
|
+
this.clock = clock;
|
|
33
|
+
this.sessionDays = sessionDays;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Opens the sessions, creating the signing key on first use (a create race between processes re-reads the winner).
|
|
37
|
+
* @param store The admin store.
|
|
38
|
+
* @param clock The clock (the admin testing clock in tests).
|
|
39
|
+
* @param sessionDays How long a session lasts.
|
|
40
|
+
* @returns The sessions.
|
|
41
|
+
*/
|
|
42
|
+
static async open(store, clock, sessionDays) {
|
|
43
|
+
const record = await DiscordSessions.loadOrCreateKey(store, clock);
|
|
44
|
+
return new DiscordSessions(Buffer.from(record.key, "base64url"), clock, sessionDays);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether a token is one of these sessions (its unverified payload names DISCORD_SESSION_ISSUER). Never throws.
|
|
48
|
+
* @param token The token.
|
|
49
|
+
* @returns True when only these sessions may verify it.
|
|
50
|
+
*/
|
|
51
|
+
isSessionToken(token) {
|
|
52
|
+
try {
|
|
53
|
+
return decodeJwt(token).iss === DISCORD_SESSION_ISSUER;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Issues a session for a profile.
|
|
61
|
+
* @param profile The Discord profile.
|
|
62
|
+
* @returns The grant.
|
|
63
|
+
*/
|
|
64
|
+
async issue(profile) {
|
|
65
|
+
const nowSeconds = Math.floor(this.clock.now() / MILLISECONDS_PER_SECOND);
|
|
66
|
+
const expirySeconds = nowSeconds + this.sessionDays * SECONDS_PER_DAY;
|
|
67
|
+
const userId = DISCORD_USER_ID_PREFIX + profile.discordId;
|
|
68
|
+
const claims = {
|
|
69
|
+
name: profile.displayName,
|
|
70
|
+
avatar: profile.avatarHash,
|
|
71
|
+
provider: DISCORD_PROVIDER_NAME,
|
|
72
|
+
name_managed: true,
|
|
73
|
+
discord_username: profile.username
|
|
74
|
+
};
|
|
75
|
+
const token = await new SignJWT(claims)
|
|
76
|
+
.setProtectedHeader({ alg: SESSION_ALGORITHM, typ: "JWT" })
|
|
77
|
+
.setIssuer(DISCORD_SESSION_ISSUER)
|
|
78
|
+
.setAudience(DISCORD_SESSION_AUDIENCE)
|
|
79
|
+
.setSubject(userId)
|
|
80
|
+
.setIssuedAt(nowSeconds)
|
|
81
|
+
.setExpirationTime(expirySeconds)
|
|
82
|
+
.sign(this.key);
|
|
83
|
+
return { token: token, expiresAt: new Date(expirySeconds * MILLISECONDS_PER_SECOND).toISOString(), userId: userId, profile: profile };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Verifies a session token.
|
|
87
|
+
* @param token The token.
|
|
88
|
+
* @returns The identity: userId discord:<id>, with the token's claims.
|
|
89
|
+
* @throws AuthRefusal("AuthenticationTicketExpired", "the Discord session has expired") when expired;
|
|
90
|
+
* AuthRefusal("InvalidAuthentication", "the Discord session is not valid") otherwise.
|
|
91
|
+
*/
|
|
92
|
+
async verify(token) {
|
|
93
|
+
let payload;
|
|
94
|
+
try {
|
|
95
|
+
const verified = await jwtVerify(token, this.key, {
|
|
96
|
+
issuer: DISCORD_SESSION_ISSUER,
|
|
97
|
+
audience: DISCORD_SESSION_AUDIENCE,
|
|
98
|
+
algorithms: [SESSION_ALGORITHM],
|
|
99
|
+
requiredClaims: ["exp", "sub"],
|
|
100
|
+
currentDate: new Date(this.clock.now())
|
|
101
|
+
});
|
|
102
|
+
payload = verified.payload;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof joseErrors.JWTExpired)
|
|
106
|
+
throw new AuthRefusal("AuthenticationTicketExpired", "the Discord session has expired");
|
|
107
|
+
throw new AuthRefusal("InvalidAuthentication", "the Discord session is not valid");
|
|
108
|
+
}
|
|
109
|
+
return { userId: payload.sub ?? null, claims: payload };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Reads the stored signing key, creating it when there is none; a lost create race re-reads the winner's key.
|
|
113
|
+
* @param store The admin store.
|
|
114
|
+
* @param clock The clock (for the record's creation time).
|
|
115
|
+
* @returns The key record.
|
|
116
|
+
*/
|
|
117
|
+
static async loadOrCreateKey(store, clock) {
|
|
118
|
+
const stored = await store.getDocument(DISCORD_COLLECTION, SESSION_KEY_DOCUMENT_ID);
|
|
119
|
+
if (stored != null)
|
|
120
|
+
return stored.value;
|
|
121
|
+
const created = { kind: "sessionKey", key: randomToken(RANDOM_VALUE_BYTES), createdAt: new Date(clock.now()).toISOString() };
|
|
122
|
+
try {
|
|
123
|
+
await store.putDocument(DISCORD_COLLECTION, SESSION_KEY_DOCUMENT_ID, created, 0);
|
|
124
|
+
return created;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (!(error instanceof AdminConflictError))
|
|
128
|
+
throw error;
|
|
129
|
+
const winner = await store.getDocument(DISCORD_COLLECTION, SESSION_KEY_DOCUMENT_ID);
|
|
130
|
+
if (winner == null)
|
|
131
|
+
throw error;
|
|
132
|
+
return winner.value;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Path prefix of the Discord sign-in routes. */
|
|
2
|
+
export declare const DISCORD_ROUTE_PREFIX: string;
|
|
3
|
+
/** GET: starts a sign-in (D13). */
|
|
4
|
+
export declare const DISCORD_START_PATH: string;
|
|
5
|
+
/** GET: Discord's redirect back to this server. */
|
|
6
|
+
export declare const DISCORD_CALLBACK_PATH: string;
|
|
7
|
+
/** POST: trades a handoff code for the player session. */
|
|
8
|
+
export declare const DISCORD_SESSION_PATH: string;
|
|
9
|
+
/** GET: the verified Discord players of one network session (D17). */
|
|
10
|
+
export declare const DISCORD_SESSION_PLAYERS_PATH: string;
|
|
11
|
+
/** The issuer of this server's player session tokens; a token naming it is verified by DiscordSessions only (D10). */
|
|
12
|
+
export declare const DISCORD_SESSION_ISSUER: string;
|
|
13
|
+
/** The audience of the session tokens. */
|
|
14
|
+
export declare const DISCORD_SESSION_AUDIENCE: string;
|
|
15
|
+
/** The prefix of a Discord player's server user id. */
|
|
16
|
+
export declare const DISCORD_USER_ID_PREFIX: string;
|
|
17
|
+
/** The value of the session's provider claim. */
|
|
18
|
+
export declare const DISCORD_PROVIDER_NAME: string;
|
|
19
|
+
/** Discord's issuer, as sent in the authorization response's iss parameter. */
|
|
20
|
+
export declare const DISCORD_ISSUER: string;
|
|
21
|
+
/** Discord's site and API base URL (tests override it through GameServerOptions.discordTesting only). */
|
|
22
|
+
export declare const DISCORD_API_BASE_URL: string;
|
|
23
|
+
/** The OAuth2 scope: identity only, never email or guilds. */
|
|
24
|
+
export declare const DISCORD_SCOPE: string;
|
|
25
|
+
/** Admin store collection of the session key, challenges and handoffs. */
|
|
26
|
+
export declare const DISCORD_COLLECTION: "discordSignIn";
|
|
27
|
+
/** Document id of the session signing key. */
|
|
28
|
+
export declare const SESSION_KEY_DOCUMENT_ID: string;
|
|
29
|
+
/** Document id prefix of a pending challenge (followed by the state's SHA-256 hex). */
|
|
30
|
+
export declare const CHALLENGE_ID_PREFIX: string;
|
|
31
|
+
/** Document id prefix of a handoff (followed by the code's SHA-256 hex). */
|
|
32
|
+
export declare const HANDOFF_ID_PREFIX: string;
|
|
33
|
+
/** How long a started sign-in may take at Discord, in milliseconds. */
|
|
34
|
+
export declare const CHALLENGE_LIFETIME_MS: number;
|
|
35
|
+
/** How long the game has to trade a handoff code, in milliseconds. */
|
|
36
|
+
export declare const HANDOFF_LIFETIME_MS: number;
|
|
37
|
+
/** Bytes of randomness in a state, a PKCE verifier, a handoff code and the session key. */
|
|
38
|
+
export declare const RANDOM_VALUE_BYTES: number;
|
|
39
|
+
/** Milliseconds before a Discord request counts as failed (busy). */
|
|
40
|
+
export declare const DISCORD_REQUEST_TIMEOUT_MS: number;
|
|
41
|
+
/** Milliseconds a session room's instance has to answer discordPlayers (the admin console's room-call budget). */
|
|
42
|
+
export declare const DISCORD_ROOM_CALL_TIMEOUT_MS: number;
|
|
43
|
+
/** The cookie carrying the validated return address through Discord's page (D13). */
|
|
44
|
+
export declare const RETURN_COOKIE_NAME: string;
|
|
45
|
+
/** The cookie's path: only the Discord routes see it. */
|
|
46
|
+
export declare const RETURN_COOKIE_PATH: string;
|
|
47
|
+
/** The cookie's lifetime in seconds (the challenge lifetime). */
|
|
48
|
+
export declare const RETURN_COOKIE_MAX_AGE_SECONDS: number;
|
|
49
|
+
/** Shortest allowed session, in days. */
|
|
50
|
+
export declare const MINIMUM_SESSION_DAYS: number;
|
|
51
|
+
/** Longest allowed session, in days. */
|
|
52
|
+
export declare const MAXIMUM_SESSION_DAYS: number;
|
|
53
|
+
/** Milliseconds in a day. */
|
|
54
|
+
export declare const MILLISECONDS_PER_DAY: number;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// discord/DiscordSignInConstants.ts — every fixed value of Discord sign-in (discord-sign-in D11–D15).
|
|
2
|
+
/** Path prefix of the Discord sign-in routes. */
|
|
3
|
+
export const DISCORD_ROUTE_PREFIX = "/game-server/discord/";
|
|
4
|
+
/** GET: starts a sign-in (D13). */
|
|
5
|
+
export const DISCORD_START_PATH = DISCORD_ROUTE_PREFIX + "start";
|
|
6
|
+
/** GET: Discord's redirect back to this server. */
|
|
7
|
+
export const DISCORD_CALLBACK_PATH = DISCORD_ROUTE_PREFIX + "callback";
|
|
8
|
+
/** POST: trades a handoff code for the player session. */
|
|
9
|
+
export const DISCORD_SESSION_PATH = DISCORD_ROUTE_PREFIX + "session";
|
|
10
|
+
/** GET: the verified Discord players of one network session (D17). */
|
|
11
|
+
export const DISCORD_SESSION_PLAYERS_PATH = DISCORD_ROUTE_PREFIX + "session-players";
|
|
12
|
+
/** The issuer of this server's player session tokens; a token naming it is verified by DiscordSessions only (D10). */
|
|
13
|
+
export const DISCORD_SESSION_ISSUER = "babylon-game-server/discord-session";
|
|
14
|
+
/** The audience of the session tokens. */
|
|
15
|
+
export const DISCORD_SESSION_AUDIENCE = "babylon-game-server-player";
|
|
16
|
+
/** The prefix of a Discord player's server user id. */
|
|
17
|
+
export const DISCORD_USER_ID_PREFIX = "discord:";
|
|
18
|
+
/** The value of the session's provider claim. */
|
|
19
|
+
export const DISCORD_PROVIDER_NAME = "discord";
|
|
20
|
+
/** Discord's issuer, as sent in the authorization response's iss parameter. */
|
|
21
|
+
export const DISCORD_ISSUER = "https://discord.com";
|
|
22
|
+
/** Discord's site and API base URL (tests override it through GameServerOptions.discordTesting only). */
|
|
23
|
+
export const DISCORD_API_BASE_URL = "https://discord.com";
|
|
24
|
+
/** The OAuth2 scope: identity only, never email or guilds. */
|
|
25
|
+
export const DISCORD_SCOPE = "identify";
|
|
26
|
+
/** Admin store collection of the session key, challenges and handoffs. */
|
|
27
|
+
export const DISCORD_COLLECTION = "discordSignIn";
|
|
28
|
+
/** Document id of the session signing key. */
|
|
29
|
+
export const SESSION_KEY_DOCUMENT_ID = "sessionKey";
|
|
30
|
+
/** Document id prefix of a pending challenge (followed by the state's SHA-256 hex). */
|
|
31
|
+
export const CHALLENGE_ID_PREFIX = "challenge:";
|
|
32
|
+
/** Document id prefix of a handoff (followed by the code's SHA-256 hex). */
|
|
33
|
+
export const HANDOFF_ID_PREFIX = "handoff:";
|
|
34
|
+
/** How long a started sign-in may take at Discord, in milliseconds. */
|
|
35
|
+
export const CHALLENGE_LIFETIME_MS = 600000;
|
|
36
|
+
/** How long the game has to trade a handoff code, in milliseconds. */
|
|
37
|
+
export const HANDOFF_LIFETIME_MS = 120000;
|
|
38
|
+
/** Bytes of randomness in a state, a PKCE verifier, a handoff code and the session key. */
|
|
39
|
+
export const RANDOM_VALUE_BYTES = 32;
|
|
40
|
+
/** Milliseconds before a Discord request counts as failed (busy). */
|
|
41
|
+
export const DISCORD_REQUEST_TIMEOUT_MS = 10000;
|
|
42
|
+
/** Milliseconds a session room's instance has to answer discordPlayers (the admin console's room-call budget). */
|
|
43
|
+
export const DISCORD_ROOM_CALL_TIMEOUT_MS = 5000;
|
|
44
|
+
/** The cookie carrying the validated return address through Discord's page (D13). */
|
|
45
|
+
export const RETURN_COOKIE_NAME = "bt_discord_return";
|
|
46
|
+
/** The cookie's path: only the Discord routes see it. */
|
|
47
|
+
export const RETURN_COOKIE_PATH = "/game-server/discord";
|
|
48
|
+
/** The cookie's lifetime in seconds (the challenge lifetime). */
|
|
49
|
+
export const RETURN_COOKIE_MAX_AGE_SECONDS = 600;
|
|
50
|
+
/** Shortest allowed session, in days. */
|
|
51
|
+
export const MINIMUM_SESSION_DAYS = 1;
|
|
52
|
+
/** Longest allowed session, in days. */
|
|
53
|
+
export const MAXIMUM_SESSION_DAYS = 365;
|
|
54
|
+
/** Milliseconds in a day. */
|
|
55
|
+
export const MILLISECONDS_PER_DAY = 86400000;
|