[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
+23 -4
View File
@@ -153,8 +153,8 @@ RECFLARE_DOMAIN=rec.example.com
# use the pool above. # use the pool above.
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778 # RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
# #
# The pool players on a build under staged rollout (ROLLOUT_GAME_VERSIONS, currently # The pool players on a build under staged rollout (RECFLARE_ROLLOUT_GAME_VERSIONS below,
# `20260323.14`; see RECFLARE_DISCORD_ROLLOUT_ROLE_IDS below) are served from, same format, # by default `20260323`; see RECFLARE_DISCORD_ROLLOUT_ROLE_IDS too) are served from, same format,
# ids `rollout-1`, `rollout-2`, … — the new build gets its own voice servers while it rolls # ids `rollout-1`, `rollout-2`, … — the new build gets its own voice servers while it rolls
# out. A sandbox account on that build still gets the sandbox pool. Unset, rollout players # out. A sandbox account on that build still gets the sandbox pool. Unset, rollout players
# use the pool above. # use the pool above.
@@ -234,9 +234,28 @@ RECFLARE_DOMAIN=rec.example.com
# The binding has to resolve for `www` to deploy, so create it with a placeholder even if # The binding has to resolve for `www` to deploy, so create it with a placeholder even if
# you don't want the sweep — it then does nothing. It never touches hasPlus. # you don't want the sweep — it then does nothing. It never touches hasPlus.
# Which client BUILDS this server serves (`auth` and `api`, with `match` reading the rollout
# list). Both are comma-separated lists of builds — the `YYYYMMDD` of the client version;
# a copied `20250718.01` is read as `20250718` — or `off` for an empty list. Unset means
# the defaults in packages/domain/src/presence-db.ts (SUPPORTED_GAME_VERSIONS: the 2023
# and 2025 builds plus the alphas; ROLLOUT_GAME_VERSIONS: `20260323`).
#
# A build outside SUPPORTED is told to update by `api`'s version check AND refused a token
# by `auth` (every grant, staff included), so dropping a build here is how its players are
# made to upgrade. A build in ROLLOUT is served too, whether or not SUPPORTED names it, but
# only to staff and the Discord roles below. The lifecycle these exist for:
# 1. Rollout (both unset): the 2026 build is for staff and supporters only.
# 2. General availability: ROLLOUT_GAME_VERSIONS=off — the 2026 build opens to everyone.
# 3. Forced upgrade: SUPPORTED_GAME_VERSIONS=20230414,20260323 (with `off` above) — the
# 2025 build is refused and has to update.
# To pull a rollout back instead, list neither: SUPPORTED_GAME_VERSIONS=20230414,20250718
# with ROLLOUT_GAME_VERSIONS=off.
# RECFLARE_SUPPORTED_GAME_VERSIONS=20230414,20250718,20260323
# RECFLARE_ROLLOUT_GAME_VERSIONS=20260323
# OPTIONAL: a staged rollout of a new client build to Discord supporters (`auth`). The # OPTIONAL: a staged rollout of a new client build to Discord supporters (`auth`). The
# Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — currently # Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — by default
# `20260323.14`, listed in ROLLOUT_GAME_VERSIONS in packages/domain/src/presence-db.ts. A login # `20260323`, see RECFLARE_ROLLOUT_GAME_VERSIONS above. A login
# posting that build as `ver` is refused ("this build is limited to Discord supporters") # posting that build as `ver` is refused ("this build is limited to Discord supporters")
# unless the account is staff (developer or moderator — always let in) or its Discord link # unless the account is staff (developer or moderator — always let in) or its Discord link
# holds one of these roles; every other build is never touched. Same rules as ROLE_IDS above: snowflakes, not names; commas, no spaces; ANY one # holds one of these roles; every other build is never touched. Same rules as ROLE_IDS above: snowflakes, not names; commas, no spaces; ANY one
+7
View File
@@ -47,6 +47,13 @@
<game-clients> <game-clients>
Supported client builds live in `SUPPORTED_GAME_VERSIONS` Supported client builds live in `SUPPORTED_GAME_VERSIONS`
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets. (`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
Those lists are DEFAULTS: the `SUPPORTED_GAME_VERSIONS` and `ROLLOUT_GAME_VERSIONS` worker
vars (`RECFLARE_*` in `.env`, reaching every worker) override them — read through
`supportedGameBuilds(env)` / `rolloutGameBuilds(env)`, never the constants, in anything
that gates on "is this build served / under rollout" (`auth` tokens, `api` versioncheck,
`match` rollout voice pool). A build outside the supported set is told to update by `api`
AND refused a token by `auth`; a rollout build is always served, but gated to staff and
Discord roles until `ROLLOUT_GAME_VERSIONS=off`.
- `20230414` — default, official. Manifest `7859140924515540835` (2023). - `20230414` — default, official. Manifest `7859140924515540835` (2023).
- `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`; - `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`;
+6
View File
@@ -57,6 +57,12 @@ Alpha builds get past the version check and largely work, but nothing else in th
stack targets them, so expect protocol differences. Other client or game versions stack targets them, so expect protocol differences. Other client or game versions
may expect different endpoints and response shapes and are not supported. may expect different endpoints and response shapes and are not supported.
The table is the default. An operator narrows it with `RECFLARE_SUPPORTED_GAME_VERSIONS`
in `.env` (a build left out is told to update and refused a sign-in, which is how players
are moved to a newer build), and `RECFLARE_ROLLOUT_GAME_VERSIONS` names the builds under
staged rollout to Discord supporters — `20260323` by default, `off` to open it to
everyone. See `.env.example`.
Generally speaking any client that effectively rewrites the nameserver with the Generally speaking any client that effectively rewrites the nameserver with the
right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server. right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server.
+16
View File
@@ -17,6 +17,22 @@ export type Env = SharedHonoEnv & {
DOMAIN: string DOMAIN: string
/** Maximum accepted size of each API-owned image upload, in bytes. */ /** Maximum accepted size of each API-owned image upload, in bytes. */
RECFLARE_MAX_API_UPLOAD_BYTES?: string RECFLARE_MAX_API_UPLOAD_BYTES?: string
/**
* The client builds `/api/versioncheck/v4` answers "current" for, as a comma-separated
* list of builds (`20230414,20250718`), or `off` for none. Unset means the default
* `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the root `.env`
* (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so `auth` refuses a token to exactly the builds
* this flags for an update. Read through `supportedGameBuilds`, which also folds in
* every build under rollout (below).
*/
SUPPORTED_GAME_VERSIONS?: string
/**
* The builds under staged rollout (`RECFLARE_ROLLOUT_GAME_VERSIONS`), same format;
* `off` ends the rollout. Unset means the default `ROLLOUT_GAME_VERSIONS` in
* @repo/domain. A rollout build is always "current" here — it is `auth` that decides who
* may sign in on it — so the version check never sends a supporter to update.
*/
ROLLOUT_GAME_VERSIONS?: string
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used // Shared rooms database (schema/migrations owned by the `rooms` worker). Used
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`. // read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
DB: D1Database DB: D1Database
+8 -4
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' import { describeRoute } from 'hono-openapi'
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain' import { GAME_VERSION, isSupportedGameVersion, supportedGameBuilds } from '@repo/domain'
import { validateAndGetBuild } from '@repo/jwt' import { validateAndGetBuild } from '@repo/jwt'
import apiConfigV2 from '../../static/api-config-v2.json' import apiConfigV2 from '../../static/api-config-v2.json'
@@ -106,13 +106,17 @@ export const configRoutes = new Hono<App>({ strict: false })
summary: 'Client version check', summary: 'Client version check',
description: description:
'Whether the client build is current. Compares the client’s `?v=` build against ' + 'Whether the client build is current. Compares the client’s `?v=` build against ' +
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' + 'the builds we serve — the operator’s `SUPPORTED_GAME_VERSIONS` var (or the ' +
'client is on one of them, 1 when it is on some other build.', 'default list in `@repo/domain`) plus every build under rollout ' +
'(`ROLLOUT_GAME_VERSIONS`): `VersionStatus` is 0 when the client is on one of ' +
'them, 1 when it is on some other build. The same lists decide which builds ' +
'`auth` hands a token to, so a build dropped from them is told to update here ' +
'and refused there.',
responses: { 200: json(VersionCheck, 'Version status') }, responses: { 200: json(VersionCheck, 'Version status') },
}), }),
(c) => (c) =>
c.json({ c.json({
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1, VersionStatus: isSupportedGameVersion(c.req.query('v'), supportedGameBuilds(c.env)) ? 0 : 1,
UpdateNotificationStage: 0, UpdateNotificationStage: 0,
IsVersionIslanded: false, IsVersionIslanded: false,
IsCrossPlayDisabled: false, IsCrossPlayDisabled: false,
+50
View File
@@ -323,6 +323,56 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ VersionStatus: 1 }) expect(await res.json()).toMatchObject({ VersionStatus: 1 })
}) })
// The list is the operator's SUPPORTED_GAME_VERSIONS var when set, plus every build
// under rollout (ROLLOUT_GAME_VERSIONS) — the same lists `auth` gates tokens on, so a
// build told to update here is refused there too.
test('GET /api/versioncheck/v4 reads the lists from vars', async () => {
const status = async (version: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
return ((await res.json()) as { VersionStatus: number }).VersionStatus
}
const originals = {
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
}
try {
// Forced upgrade: the 2025 build drops out, every point release of it.
env.SUPPORTED_GAME_VERSIONS = '20230414,20260323'
expect(await status('20250718.01')).toBe(1)
expect(await status('20250718')).toBe(1)
expect(await status(GAME_VERSION)).toBe(0)
expect(await status('20260323.14')).toBe(0)
// Alphas not named in the var are gone too: the var replaces the default list.
expect(await status('20231207')).toBe(1)
// A build under rollout is current even when the var omits it: who may sign in on
// it is `auth`'s call, and the version check must not send a supporter to update.
env.SUPPORTED_GAME_VERSIONS = '20230414'
expect(await status('20260323.14')).toBe(0)
env.ROLLOUT_GAME_VERSIONS = 'off'
expect(await status('20260323.14')).toBe(1)
env.ROLLOUT_GAME_VERSIONS = '20250718'
expect(await status('20250718.01')).toBe(0)
// Entries are builds: a copied point release matches, and `off` empties the list.
env.ROLLOUT_GAME_VERSIONS = undefined
env.SUPPORTED_GAME_VERSIONS = '20250718.01'
expect(await status('20250718.2')).toBe(0)
expect(await status(GAME_VERSION)).toBe(1)
env.SUPPORTED_GAME_VERSIONS = 'off'
env.ROLLOUT_GAME_VERSIONS = 'off'
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(1)
// Blank is unset: the default list.
env.SUPPORTED_GAME_VERSIONS = ''
env.ROLLOUT_GAME_VERSIONS = ''
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(0)
} finally {
env.SUPPORTED_GAME_VERSIONS = originals.SUPPORTED_GAME_VERSIONS
env.ROLLOUT_GAME_VERSIONS = originals.ROLLOUT_GAME_VERSIONS
}
})
// Two catalogs, picked off the token's `rn.ver`: a build newer than GAME_VERSION gets // Two catalogs, picked off the token's `rn.ver`: a build newer than GAME_VERSION gets
// the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog // the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog
// has, so its presence identifies which body was served. // has, so its presence identifies which body was served.
+60 -23
View File
@@ -15,12 +15,15 @@ import {
getRoomById, getRoomById,
hashPassword, hashPassword,
isRolloutGameVersion, isRolloutGameVersion,
isSupportedGameVersion,
rolloutGameBuilds,
RoomInstanceType, RoomInstanceType,
setLastLoginTime, setLastLoginTime,
setLoginContext, setLoginContext,
setPasswordHash, setPasswordHash,
setPresence, setPresence,
subRoomDataBlob, subRoomDataBlob,
supportedGameBuilds,
updateAccount, updateAccount,
verifyPassword, verifyPassword,
writeAuditLog, writeAuditLog,
@@ -175,25 +178,34 @@ const DEFAULT_PASSWORD_SIGNUP = false
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled' const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/** /**
* The rollout gate. A `/connect/token` grant posting a build under staged rollout as `ver` * The build gates on `/connect/token`, both keyed on the grant's posted `ver` and both
* (`ROLLOUT_GAME_VERSIONS` in @repo/domain — shared with `match`, which serves those builds * driven by the operator's game-version vars (`SUPPORTED_GAME_VERSIONS`,
* the sandbox Tachyon pool) is refused unless the account is STAFF (developer or moderator) * `ROLLOUT_GAME_VERSIONS` — see context.ts and `supportedGameBuilds`/`rolloutGameBuilds`
* or its Discord link holds a role named in `DISCORD_ROLLOUT_ROLE_IDS` (see context.ts and * in @repo/domain, which `api` and `match` read the same way). A grant posting no `ver`
* `accountMayUseRolloutBuild`). Every other build, and a grant posting no `ver` (a refresh, * (a refresh, the website) is untouched by either.
* the website), is untouched. *
* 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 * `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 * fine for what these are: rollout and upgrade gates, not a security boundary. A player
* their build gets a token the 2023 server surfaces would have handed them anyway, and * who lies about their build gets a token the surfaces of the build they claim would have
* `api`'s `/api/versioncheck/v4` is still what decides which builds are supported at all. * handed them anyway, and then runs a client the server isn't answering for.
*
* 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.
*/ */
const ROLLOUT_GATED_DESCRIPTION = const ROLLOUT_GATED_DESCRIPTION =
'this build is limited to Discord supporters; link your Discord on the website first' '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 * `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', '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.', '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 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 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', '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', '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 // 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 // `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 // 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 // token when it writes presence. Unverified like the device fields: echoed, and
// echoed, never trusted for a decision (the version CHECK is `api`'s // gated (below, and the rollout gate further down), but never trusted as a
// `/api/versioncheck/v4`, against its own list). // credential.
// //
// A grant that posts none — a refresh, or a caller that isn't the game — leaves it // 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 // 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. // login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? '' 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 // 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, // 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 // 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 // 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 // is read or minted, like the switch below — no row left behind, no cap slot
// burned. // burned.
if (isRolloutGameVersion(version)) { if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
logger.info('signup refused: build is limited to Discord supporters', { logger.info('signup refused: build is limited to Discord supporters', {
version, version,
ip: clientIp, ip: clientIp,
@@ -1432,15 +1469,15 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
} }
// The rollout gate: a build in ROLLOUT_GAME_VERSIONS is for staff and Discord // The rollout gate: a build under staged rollout (`rolloutGameBuilds`) is for staff
// supporters only, so the account signing in on it must be staff or hold one of the // and Discord supporters only, so the account signing in on it must be staff or hold
// configured roles. // one of the configured roles.
// Deliberately AFTER the credential checks, like the ban lookup below — a wrong // 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 // 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 // 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 // refusal: there is no block screen to reach, and the client surfaces the
// description on its sign-in failure. // description on its sign-in failure.
if (isRolloutGameVersion(version)) { if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS) const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS)
if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) { if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) {
logger.info('login refused: build is limited to Discord supporters', { logger.info('login refused: build is limited to Discord supporters', {
+19
View File
@@ -64,6 +64,25 @@ export type Env = SharedHonoEnv & {
* Logins on every other build are never touched by this. * Logins on every other build are never touched by this.
*/ */
DISCORD_ROLLOUT_ROLE_IDS?: string DISCORD_ROLLOUT_ROLE_IDS?: string
/**
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
* the default `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the
* root `.env` (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so this refuses exactly the builds
* `api`'s `/api/versioncheck/v4` tells to update — dropping a build from the list is how
* its players are made to upgrade. Read through `supportedGameBuilds`, which also folds
* in every build under rollout (below). A grant posting no `ver` is never gated.
*/
SUPPORTED_GAME_VERSIONS?: string
/**
* The builds under staged rollout — the ones the Discord-role gate above applies to —
* overriding `ROLLOUT_GAME_VERSIONS` in @repo/domain: same format, or `off` to end the
* rollout and open those builds to every account. Set for every worker
* (`RECFLARE_ROLLOUT_GAME_VERSIONS`), so `match`'s rollout voice pool follows the same
* list. A build here is served whether or not `SUPPORTED_GAME_VERSIONS` names it. Read
* through `rolloutGameBuilds`.
*/
ROLLOUT_GAME_VERSIONS?: string
/** /**
* Base domain, e.g. `recflare.net`. Injected for every worker at deploy time * Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
* (`--var DOMAIN`). Studio's device-login response points the browser at * (`--var DOMAIN`). Studio's device-login response points the browser at
+134
View File
@@ -1415,6 +1415,140 @@ describe('auth worker routes', () => {
expect(after?.n).toBe(before?.n) expect(after?.n).toBe(before?.n)
}) })
}) })
// The two lists behind the gates are vars (`SUPPORTED_GAME_VERSIONS`,
// `ROLLOUT_GAME_VERSIONS`), so an operator can end the rollout or drop a build without
// a deploy of new code. Same env-override discipline as withRoleIds.
describe('the lists come from vars', () => {
const UNSUPPORTED = 'this game version is not supported by this server; update your game'
async function withVars<T>(
vars: { SUPPORTED_GAME_VERSIONS?: string; ROLLOUT_GAME_VERSIONS?: string },
run: () => Promise<T>
): Promise<T> {
const originals = {
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
}
const apply = (values: typeof originals) => {
for (const key of ['SUPPORTED_GAME_VERSIONS', 'ROLLOUT_GAME_VERSIONS'] as const) {
if (values[key] === undefined) delete env[key]
else env[key] = values[key]
}
}
apply({ ...originals, ...vars })
try {
return await run()
} finally {
apply(originals)
}
}
test('a build dropped from SUPPORTED_GAME_VERSIONS is refused a token, staff included', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414,20260323' }, async () => {
await withRoleIds(undefined, async () => {
// The 2025 build, every point release of it: this is the forced upgrade.
for (const version of ['20250718', '20250718.01', '20250718.2']) {
const res = await login('gate_outsider', version)
expect(res.status, version).toBe(400)
expect(res.json, version).toEqual({
error: 'invalid_grant',
error_description: UNSUPPORTED,
})
}
// Staff get no exception here — the build is not served, full stop.
expect((await login('gate_developer', '20250718.01')).status).toBe(400)
// The builds that ARE listed, and a grant posting no `ver`, are untouched;
// the rollout gate still applies to the one under rollout.
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
expect((await login('gate_outsider')).status).toBe(200)
expect((await login('gate_developer', GATED)).status).toBe(200)
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
})
})
})
test('create_account on an unsupported build is refused before anything is minted', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const res = await postToken(
'grant_type=create_account&platform_id=steam-unsupported&password=hunter2&ver=20250718.01'
)
expect(res.status).toBe(400)
expect(res.json).toEqual({ error: 'invalid_grant', error_description: UNSUPPORTED })
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
})
})
test('ROLLOUT_GAME_VERSIONS=off ends the rollout: the build opens to everyone', async () => {
await withVars({ ROLLOUT_GAME_VERSIONS: 'off' }, async () => {
await withRoleIds(undefined, async () => {
const res = await login('gate_outsider', GATED)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string)['rn.ver']).toBe(GATED)
expect((await login('gate_unlinked', GATED)).status).toBe(200)
// ...and accounts can be created on it, since nothing needs a Discord link.
const created = await postToken(
`grant_type=create_account&platform_id=steam-rollout-over&password=hunter2&ver=${GATED}`
)
expect(created.status).toBe(200)
})
})
})
test('ROLLOUT_GAME_VERSIONS names which builds are gated', async () => {
// Move the gate onto the beta build: it is now supporters-only and the 2026
// build is open. Both stay served.
await withVars({ ROLLOUT_GAME_VERSIONS: '20250718' }, async () => {
await withRoleIds(supporter, async () => {
const outsider = await login('gate_outsider', '20250718.01')
expect(outsider.status).toBe(400)
expect(outsider.json.error_description).toBe(REFUSED)
expect((await login('gate_supporter', '20250718.01')).status).toBe(200)
expect((await login('gate_outsider', GATED)).status).toBe(200)
})
})
})
test('a build under rollout is served even when SUPPORTED_GAME_VERSIONS omits it', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
await withRoleIds(supporter, async () => {
// Rolling out, so the ROLLOUT gate answers, not the support one.
expect((await login('gate_supporter', GATED)).status).toBe(200)
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
})
})
// In neither list, nobody reaches it — a rollout pulled back entirely.
await withVars(
{ SUPPORTED_GAME_VERSIONS: '20230414', ROLLOUT_GAME_VERSIONS: 'off' },
async () => {
await withRoleIds(supporter, async () => {
for (const username of ['gate_supporter', 'gate_developer']) {
const res = await login(username, GATED)
expect(res.status, username).toBe(400)
expect(res.json.error_description, username).toBe(UNSUPPORTED)
}
})
}
)
})
test('entries are read as builds, so a copied point release still matches', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: ' 20230414, 20250718.01 ' }, async () => {
expect((await login('gate_outsider', '20250718.2')).status).toBe(200)
expect((await login('gate_outsider', '20250718')).status).toBe(200)
})
// Blank is unset: the default list applies, including the alpha builds.
await withVars({ SUPPORTED_GAME_VERSIONS: ' ' }, async () => {
expect((await login('gate_outsider', '20231207')).status).toBe(200)
})
})
})
}) })
test('the signup caps come from vars, and 0 disables an arm', async () => { test('the signup caps come from vars, and 0 disables an arm', async () => {
+9
View File
@@ -83,6 +83,15 @@ export type Env = SharedHonoEnv & {
* means rollout callers use the live pool. See `tachyonPoolFor` in match.app.ts. * means rollout callers use the live pool. See `tachyonPoolFor` in match.app.ts.
*/ */
TACHYON_HOST_PORT_ROLLOUT?: string TACHYON_HOST_PORT_ROLLOUT?: string
/**
* The builds under staged rollout, overriding `ROLLOUT_GAME_VERSIONS` in @repo/domain:
* a comma-separated list of builds, or `off` for none. Set for every worker from the
* root `.env` (`RECFLARE_ROLLOUT_GAME_VERSIONS`), so this agrees with the gate `auth`
* applies by construction. Read through `rolloutGameBuilds`; decides who is served the
* rollout Tachyon pool above. Ending the rollout (`off`) moves those callers to the
* live pool.
*/
ROLLOUT_GAME_VERSIONS?: string
/** /**
* The Photon region every session is pinned to — both the region named in the connection * The Photon region every session is pinned to — both the region named in the connection
* info and the one stamped on every room instance, which must agree. Optional; unlike the * info and the one stamped on every room instance, which must agree. Optional; unlike the
+4 -3
View File
@@ -49,6 +49,7 @@ import {
recordStat, recordStat,
refreshInstanceFullness, refreshInstanceFullness,
Role, Role,
rolloutGameBuilds,
RoomInstanceType, RoomInstanceType,
roomRoles, roomRoles,
setPresence, setPresence,
@@ -253,7 +254,7 @@ const ROLLOUT_TACHYON_SERVER_ID_PREFIX = 'rollout-'
interface TachyonCaller { interface TachyonCaller {
/** The account's `isSandbox` flag. */ /** The account's `isSandbox` flag. */
isSandbox: boolean isSandbox: boolean
/** Running a build under staged rollout (`ROLLOUT_GAME_VERSIONS`, off the token's `rn.ver`). */ /** Running a build under staged rollout (`rolloutGameBuilds`, off the token's `rn.ver`). */
isRollout: boolean isRollout: boolean
} }
@@ -3216,7 +3217,7 @@ const app = new Hono<App>()
'…). Both are empty when the pool is unset or the caller is in no instance. A', '…). Both are empty when the pool is unset or the caller is in no instance. A',
'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the', 'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the',
'same way, with ids `dev-1`, `dev-2`, …; a caller whose token’s `rn.ver` is a build', 'same way, with ids `dev-1`, `dev-2`, …; a caller whose token’s `rn.ver` is a build',
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323`) out of', 'under staged rollout (`ROLLOUT_GAME_VERSIONS`, by default `20260323`) out of',
'`TACHYON_HOST_PORT_ROLLOUT`, with ids `rollout-1`, `rollout-2`, …. Sandbox wins when', '`TACHYON_HOST_PORT_ROLLOUT`, with ids `rollout-1`, `rollout-2`, …. Sandbox wins when',
'both apply, and either special pool left unset falls back to the live one.', 'both apply, and either special pool left unset falls back to the live one.',
'`experiments` carries the client’s networking flags.', '`experiments` carries the client’s networking flags.',
@@ -3264,7 +3265,7 @@ const app = new Hono<App>()
const tachyon = tachyonServerFor( const tachyon = tachyonServerFor(
tachyonPoolFor(c.env, { tachyonPoolFor(c.env, {
isSandbox: account?.isSandbox === true, isSandbox: account?.isSandbox === true,
isRollout: isRolloutGameVersion(await callerVersion(c)), isRollout: isRolloutGameVersion(await callerVersion(c), rolloutGameBuilds(c.env)),
}), }),
roomInstanceId roomInstanceId
) )
@@ -1814,6 +1814,7 @@ describe('auth-gated endpoints', () => {
const original = env.TACHYON_HOST_PORT const original = env.TACHYON_HOST_PORT
const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX
const originalRollout = env.TACHYON_HOST_PORT_ROLLOUT const originalRollout = env.TACHYON_HOST_PORT_ROLLOUT
const originalRolloutVersions = env.ROLLOUT_GAME_VERSIONS
try { try {
env.TACHYON_HOST_PORT = '198.51.100.12:7777' env.TACHYON_HOST_PORT = '198.51.100.12:7777'
env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778' env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778'
@@ -1905,6 +1906,30 @@ describe('auth-gated endpoints', () => {
}) })
} }
// WHICH builds are under rollout is the ROLLOUT_GAME_VERSIONS var, the same one
// `auth` gates on. `off` ends the rollout and its callers join the live pool; naming
// another build moves the pool to that build's players instead.
const movedRollout = await createRoomInstance(env.DB, {
ownerAccountId: 976,
roomId: 2,
photonRoomId: 'tachyon-rollout-moved',
maxCapacity: 12,
})
env.ROLLOUT_GAME_VERSIONS = 'off'
expect(await rolloutVoiceFor('976', '20260323.14', movedRollout.roomInstanceId)).toEqual({
voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1',
})
env.ROLLOUT_GAME_VERSIONS = '20250718'
expect(await rolloutVoiceFor('976', '20250718.01', movedRollout.roomInstanceId)).toEqual(
rolloutPool[movedRollout.roomInstanceId % rolloutPool.length]
)
expect(await rolloutVoiceFor('976', '20260323.14', movedRollout.roomInstanceId)).toEqual({
voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1',
})
env.ROLLOUT_GAME_VERSIONS = originalRolloutVersions
// With no rollout pool configured, a rollout build falls back to the live one, not // With no rollout pool configured, a rollout build falls back to the live one, not
// the sandbox one. // the sandbox one.
env.TACHYON_HOST_PORT_ROLLOUT = undefined env.TACHYON_HOST_PORT_ROLLOUT = undefined
@@ -1935,6 +1960,7 @@ describe('auth-gated endpoints', () => {
env.TACHYON_HOST_PORT = original env.TACHYON_HOST_PORT = original
env.TACHYON_HOST_PORT_SANDBOX = originalSandbox env.TACHYON_HOST_PORT_SANDBOX = originalSandbox
env.TACHYON_HOST_PORT_ROLLOUT = originalRollout env.TACHYON_HOST_PORT_ROLLOUT = originalRollout
env.ROLLOUT_GAME_VERSIONS = originalRolloutVersions
} }
}) })
+100 -22
View File
@@ -51,9 +51,11 @@ export function gameBuild(version: string | null | undefined): string | null {
} }
/** /**
* Client builds `/api/versioncheck/v4` answers "current" for, in the support tiers the * The DEFAULT set of client builds this server serves — what `/api/versioncheck/v4`
* README publishes. They talk close enough to the same protocol to get past the update * answers "current" for and `auth` hands a token to when the operator has not set
* prompt; how much beyond that is tested differs by tier: * `SUPPORTED_GAME_VERSIONS` (see {@link supportedGameBuilds}). In the support tiers the
* README publishes; they talk close enough to the same protocol to get past the update
* prompt, and how much beyond that is tested differs by tier:
* *
* - `GAME_VERSION` (`20230414`, manifest `7859140924515540835`) is the DEFAULT — the * - `GAME_VERSION` (`20230414`, manifest `7859140924515540835`) is the DEFAULT — the
* build the rest of the stack targets and the one it reports for itself. * build the rest of the stack targets and the one it reports for itself.
@@ -74,21 +76,13 @@ export const SUPPORTED_GAME_VERSIONS: string[] = [
'20230616', // alpha '20230616', // alpha
'20231207', // alpha '20231207', // alpha
'20250424', // alpha (20250424.01) '20250424', // alpha (20250424.01)
'20260323', // alpha (20260323.14) '20260323', // alpha (20260323.14), under staged rollout — see ROLLOUT_GAME_VERSIONS
] ]
/** /**
* Whether a client-supplied version (the version check's `?v=`) names a build we serve. * The DEFAULT builds under STAGED ROLLOUT to Discord supporters, applied when the
* Compared as a build, so the client's point release never decides it. * operator has not set `ROLLOUT_GAME_VERSIONS` (see {@link rolloutGameBuilds}). A build
*/ * here is kept apart from the live side of the stack:
export function isSupportedGameVersion(version: string | null | undefined): boolean {
const build = gameBuild(version)
return build !== null && SUPPORTED_GAME_VERSIONS.includes(build)
}
/**
* Supported builds under STAGED ROLLOUT to Discord supporters — kept apart from the live
* side of the stack:
* *
* - `auth` refuses `/connect/token` on one of these unless the account's Discord link * - `auth` refuses `/connect/token` on one of these unless the account's Discord link
* holds a role in `DISCORD_ROLLOUT_ROLE_IDS` (closed to everyone when none is set). * holds a role in `DISCORD_ROLLOUT_ROLE_IDS` (closed to everyone when none is set).
@@ -96,19 +90,103 @@ export function isSupportedGameVersion(version: string | null | undefined): bool
* caller whose token's `rn.ver` is one of these (a sandbox account keeps its sandbox * caller whose token's `rn.ver` is one of these (a sandbox account keeps its sandbox
* pool) — the new build runs against its own voice servers, apart from the live pool. * pool) — the new build runs against its own voice servers, apart from the live pool.
* *
* One list so the two gates can't drift: a build is either rolling out or it isn't. Remove a * One list so the two gates can't drift: a build is either rolling out or it isn't. A
* build from here when it ships to everyone. Bare dates, like `SUPPORTED_GAME_VERSIONS`: * build under rollout is always SERVED (it is folded into {@link supportedGameBuilds}), it
* every point release of a listed date is under rollout. * is just gated. Bare dates, like `SUPPORTED_GAME_VERSIONS`: every point release of a
* listed date is under rollout.
*/ */
export const ROLLOUT_GAME_VERSIONS: readonly string[] = ['20260323'] export const ROLLOUT_GAME_VERSIONS: readonly string[] = ['20260323']
/** /**
* Whether a version (a token's `rn.ver`, a grant's posted `ver`) names a build under * The two worker vars that override the lists above, both OPTIONAL and set for every
* staged rollout. * worker at once from the root `.env` (`RECFLARE_SUPPORTED_GAME_VERSIONS`,
* `RECFLARE_ROLLOUT_GAME_VERSIONS` — see .env.example and `recflare_vars`). Each is a
* list of builds separated by commas (no whitespace survives the deploy script, but it is
* accepted), and either may be `off`/`none` for an EMPTY list. Entries are reduced to
* their build ({@link gameBuild}), so an operator who copies the client's `20250718.01`
* off a screen gets the same gate as `20250718`.
*
* A worker that reads either declares it in its context.ts and passes its env here;
* `auth`, `api` and `match` do. The lifecycle the knobs exist for:
*
* 1. ROLLOUT: both unset. The 2026 build is served to staff and Discord supporters only.
* 2. GENERAL AVAILABILITY: `ROLLOUT_GAME_VERSIONS=off`. The 2026 build is open to all.
* 3. FORCED UPGRADE: `SUPPORTED_GAME_VERSIONS=20230414,20260323` (and `off` as above). The
* 2025 build fails the version check and is refused a token, so it has to update.
*
* Or, to pull a rollout back entirely: `SUPPORTED_GAME_VERSIONS=20230414,20250718` with
* `ROLLOUT_GAME_VERSIONS=off` — the build is in neither list, so nobody reaches it.
*/ */
export function isRolloutGameVersion(version: string | null | undefined): boolean { export interface GameVersionVars {
/** The served builds; unset means {@link SUPPORTED_GAME_VERSIONS}. */
SUPPORTED_GAME_VERSIONS?: string
/** The builds under staged rollout; unset means {@link ROLLOUT_GAME_VERSIONS}. */
ROLLOUT_GAME_VERSIONS?: string
}
/**
* A game-version var as a list of builds, or null when it is UNSET — absent, not a string
* (a var declared in wrangler.jsonc keeps its JSON type) or blank — so the caller applies
* its default. `off` or `none` (any case) is an EMPTY list, which is distinct from unset:
* it is how an operator empties a list that has a non-empty default. Entries are split on
* commas and/or whitespace, reduced to builds and de-duplicated; a lone `.NN` or an empty
* entry between two commas contributes nothing.
*/
export function parseGameBuilds(raw: unknown): string[] | null {
if (typeof raw !== 'string' || raw.trim() === '') return null
const trimmed = raw.trim()
if (/^(off|none)$/i.test(trimmed)) return []
const builds: string[] = []
for (const entry of trimmed.split(/[\s,]+/)) {
const build = gameBuild(entry)
if (build !== null && !builds.includes(build)) builds.push(build)
}
return builds
}
/** The builds under staged rollout: the operator's `ROLLOUT_GAME_VERSIONS`, or the default. */
export function rolloutGameBuilds(env: GameVersionVars): string[] {
return parseGameBuilds(env.ROLLOUT_GAME_VERSIONS) ?? [...ROLLOUT_GAME_VERSIONS]
}
/**
* The builds this server serves: the operator's `SUPPORTED_GAME_VERSIONS` (or the default
* list) PLUS every build under rollout — a build being rolled out is being served, to
* whoever the rollout gate lets through, whether or not the operator also listed it.
*/
export function supportedGameBuilds(env: GameVersionVars): string[] {
const supported = parseGameBuilds(env.SUPPORTED_GAME_VERSIONS) ?? [...SUPPORTED_GAME_VERSIONS]
for (const build of rolloutGameBuilds(env)) {
if (!supported.includes(build)) supported.push(build)
}
return supported
}
/**
* Whether a client-supplied version (the version check's `?v=`, a grant's posted `ver`)
* names a build we serve. Compared as a build, so the client's point release never
* decides it. `builds` is {@link supportedGameBuilds} for a worker with the vars in hand;
* the default list otherwise.
*/
export function isSupportedGameVersion(
version: string | null | undefined,
builds: readonly string[] = SUPPORTED_GAME_VERSIONS
): boolean {
const build = gameBuild(version) const build = gameBuild(version)
return build !== null && ROLLOUT_GAME_VERSIONS.includes(build) return build !== null && builds.includes(build)
}
/**
* Whether a version (a token's `rn.ver`, a grant's posted `ver`) names a build under
* staged rollout. `builds` is {@link rolloutGameBuilds} for a worker with the vars in
* hand; the default list otherwise.
*/
export function isRolloutGameVersion(
version: string | null | undefined,
builds: readonly string[] = ROLLOUT_GAME_VERSIONS
): boolean {
const build = gameBuild(version)
return build !== null && builds.includes(build)
} }
/** Schema DDL (mirror of migrations/0006_presence.sql). */ /** Schema DDL (mirror of migrations/0006_presence.sql). */