[2026] rollout support, to be short lived I suppose

This commit is contained in:
Devin Zuczek
2026-10-19 22:54:18 -04:00
parent a3291bc9ce
commit 05d7fe4c32
13 changed files with 462 additions and 56 deletions
+60 -23
View File
@@ -15,12 +15,15 @@ import {
getRoomById,
hashPassword,
isRolloutGameVersion,
isSupportedGameVersion,
rolloutGameBuilds,
RoomInstanceType,
setLastLoginTime,
setLoginContext,
setPasswordHash,
setPresence,
subRoomDataBlob,
supportedGameBuilds,
updateAccount,
verifyPassword,
writeAuditLog,
@@ -175,25 +178,34 @@ const DEFAULT_PASSWORD_SIGNUP = false
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/**
* The rollout gate. A `/connect/token` grant posting a build under staged rollout as `ver`
* (`ROLLOUT_GAME_VERSIONS` in @repo/domain — shared with `match`, which serves those builds
* the sandbox Tachyon pool) is refused unless the account is STAFF (developer or moderator)
* or its Discord link holds a role named in `DISCORD_ROLLOUT_ROLE_IDS` (see context.ts and
* `accountMayUseRolloutBuild`). Every other build, and a grant posting no `ver` (a refresh,
* the website), is untouched.
* The build gates on `/connect/token`, both keyed on the grant's posted `ver` and both
* driven by the operator's game-version vars (`SUPPORTED_GAME_VERSIONS`,
* `ROLLOUT_GAME_VERSIONS` — see context.ts and `supportedGameBuilds`/`rolloutGameBuilds`
* in @repo/domain, which `api` and `match` read the same way). A grant posting no `ver`
* (a refresh, the website) is untouched by either.
*
* 1. The SUPPORT gate: a build outside `supportedGameBuilds` is refused as `invalid_grant`
* with UNSUPPORTED_BUILD_DESCRIPTION, for every grant and every account — staff
* included. `api`'s `/api/versioncheck/v4` tells the same builds to update; this is what
* makes the update prompt stick, since a client that got a token anyway would play on.
* Dropping a build from the list is how its players are made to upgrade.
* 2. The ROLLOUT gate: a build under staged rollout is refused unless the account is STAFF
* (developer or moderator) or its Discord link holds a role named in
* `DISCORD_ROLLOUT_ROLE_IDS` (see `accountMayUseRolloutBuild`), as `invalid_grant` with
* ROLLOUT_GATED_DESCRIPTION. `create_account` is refused before anything is minted: a
* brand-new account has no Discord link to hold a role, so the player makes their
* account on a released build, links Discord on the website, and comes back. Ending the
* rollout (`ROLLOUT_GAME_VERSIONS=off`) opens the build to everyone.
*
* `ver` is client-reported and unverified, like everything else about the build. That is
* fine for what this is: a rollout gate, not a security boundary. A player who lies about
* their build gets a token the 2023 server surfaces would have handed them anyway, and
* `api`'s `/api/versioncheck/v4` is still what decides which builds are supported at all.
*
* Refused as `invalid_grant` with ROLLOUT_GATED_DESCRIPTION. `create_account` is refused
* before anything is minted: a brand-new account has no Discord link to hold a role, so
* the player makes their account on a released build, links Discord on the website, and
* comes back.
* fine for what these are: rollout and upgrade gates, not a security boundary. A player
* who lies about their build gets a token the surfaces of the build they claim would have
* handed them anyway, and then runs a client the server isn't answering for.
*/
const ROLLOUT_GATED_DESCRIPTION =
'this build is limited to Discord supporters; link your Discord on the website first'
const UNSUPPORTED_BUILD_DESCRIPTION =
'this game version is not supported by this server; update your game'
/**
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
@@ -988,8 +1000,16 @@ const app = new Hono<App>()
'act from signing into one. The description is deliberately vague — the signup',
'refused may belong to a housemate of the banned player rather than to them.',
'',
'**Support gate.** A grant whose `ver` names a build this server does not serve —',
'outside the operator’s `SUPPORTED_GAME_VERSIONS` (default: the list in `@repo/domain`)',
'and not under rollout — is refused as `invalid_grant` "this game version is not',
'supported by this server; update your game", for every grant and every account.',
'`api`’s `/api/versioncheck/v4` flags the same builds, so dropping a build from the',
'list is how its players are made to upgrade. Grants posting no `ver` are untouched.',
'',
'**Rollout gate.** A grant whose `ver` names a build under staged rollout',
'(`ROLLOUT_GAME_VERSIONS`, currently `20260323` — every point release of it) is refused as `invalid_grant` "this build is limited to',
'(`ROLLOUT_GAME_VERSIONS`, by default `20260323` — every point release of it; `off` ends',
'the rollout and opens the build to everyone) is refused as `invalid_grant` "this build is limited to',
'Discord supporters" unless the account is staff (developer or moderator) or its',
'Discord link (made by the website’s benefits claim) holds one of the roles in the',
'operator’s `DISCORD_ROLLOUT_ROLE_IDS`. With none configured the gate is closed to',
@@ -1075,9 +1095,9 @@ const app = new Hono<App>()
// The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's
// `rn.ver` claim so everything downstream reports the build the player is ACTUALLY
// running rather than this server's GAME_VERSION — `match` reads it back off the
// token when it writes presence. Unverified like the device fields, and only ever
// echoed, never trusted for a decision (the version CHECK is `api`'s
// `/api/versioncheck/v4`, against its own list).
// token when it writes presence. Unverified like the device fields: echoed, and
// gated (below, and the rollout gate further down), but never trusted as a
// credential.
//
// A grant that posts none — a refresh, or a caller that isn't the game — leaves it
// undefined and generateToken falls back to GAME_VERSION. An empty string is
@@ -1091,6 +1111,23 @@ const app = new Hono<App>()
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// The support gate (see UNSUPPORTED_BUILD_DESCRIPTION): a posted build this server
// doesn't serve gets no token, whatever the grant and whoever the account. Decided
// before any credential is read — it depends on nothing but the operator's list, so
// there is nothing about an account to probe here, and a closed door costs no D1
// round trip. The same list `api`'s `/api/versioncheck/v4` flags for an update.
if (version !== undefined && !isSupportedGameVersion(version, supportedGameBuilds(c.env))) {
logger.info('token refused: game version is not supported', {
grantType,
version,
ip: clientIp,
})
return c.json(
{ error: 'invalid_grant', error_description: UNSUPPORTED_BUILD_DESCRIPTION },
400
)
}
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
@@ -1194,7 +1231,7 @@ const app = new Hono<App>()
// link, so it could never hold the role the gate wants. Refused before anything
// is read or minted, like the switch below — no row left behind, no cap slot
// burned.
if (isRolloutGameVersion(version)) {
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
logger.info('signup refused: build is limited to Discord supporters', {
version,
ip: clientIp,
@@ -1432,15 +1469,15 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// The rollout gate: a build in ROLLOUT_GAME_VERSIONS is for staff and Discord
// supporters only, so the account signing in on it must be staff or hold one of the
// configured roles.
// The rollout gate: a build under staged rollout (`rolloutGameBuilds`) is for staff
// and Discord supporters only, so the account signing in on it must be staff or hold
// one of the configured roles.
// Deliberately AFTER the credential checks, like the ban lookup below — a wrong
// password on a gated build is still "invalid account_id or password", so the
// gate can't be used to probe whether an account exists. Unlike a ban, this IS a
// refusal: there is no block screen to reach, and the client surfaces the
// description on its sign-in failure.
if (isRolloutGameVersion(version)) {
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS)
if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) {
logger.info('login refused: build is limited to Discord supporters', {