mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[2026] rollout support, to be short lived I suppose
This commit is contained in:
+23
-4
@@ -153,8 +153,8 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# use the pool above.
|
||||
# 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
|
||||
# `20260323.14`; see RECFLARE_DISCORD_ROLLOUT_ROLE_IDS below) are served from, same format,
|
||||
# The pool players on a build under staged rollout (RECFLARE_ROLLOUT_GAME_VERSIONS below,
|
||||
# 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
|
||||
# out. A sandbox account on that build still gets the sandbox pool. Unset, rollout players
|
||||
# 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
|
||||
# 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
|
||||
# Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — currently
|
||||
# `20260323.14`, listed in ROLLOUT_GAME_VERSIONS in packages/domain/src/presence-db.ts. A login
|
||||
# Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — by default
|
||||
# `20260323`, see RECFLARE_ROLLOUT_GAME_VERSIONS above. A login
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
<game-clients>
|
||||
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
|
||||
(`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).
|
||||
- `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`;
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,6 +17,22 @@ export type Env = SharedHonoEnv & {
|
||||
DOMAIN: string
|
||||
/** Maximum accepted size of each API-owned image upload, in bytes. */
|
||||
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
|
||||
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
|
||||
DB: D1Database
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
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 apiConfigV2 from '../../static/api-config-v2.json'
|
||||
@@ -106,13 +106,17 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Client version check',
|
||||
description:
|
||||
'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 ' +
|
||||
'client is on one of them, 1 when it is on some other build.',
|
||||
'the builds we serve — the operator’s `SUPPORTED_GAME_VERSIONS` var (or the ' +
|
||||
'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') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
|
||||
VersionStatus: isSupportedGameVersion(c.req.query('v'), supportedGameBuilds(c.env)) ? 0 : 1,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
|
||||
@@ -323,6 +323,56 @@ describe('public endpoints', () => {
|
||||
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
|
||||
// the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog
|
||||
// has, so its presence identifies which body was served.
|
||||
|
||||
+60
-23
@@ -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', {
|
||||
|
||||
@@ -64,6 +64,25 @@ export type Env = SharedHonoEnv & {
|
||||
* Logins on every other build are never touched by this.
|
||||
*/
|
||||
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
|
||||
* (`--var DOMAIN`). Studio's device-login response points the browser at
|
||||
|
||||
@@ -1415,6 +1415,140 @@ describe('auth worker routes', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -83,6 +83,15 @@ export type Env = SharedHonoEnv & {
|
||||
* means rollout callers use the live pool. See `tachyonPoolFor` in match.app.ts.
|
||||
*/
|
||||
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
|
||||
* info and the one stamped on every room instance, which must agree. Optional; unlike the
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
recordStat,
|
||||
refreshInstanceFullness,
|
||||
Role,
|
||||
rolloutGameBuilds,
|
||||
RoomInstanceType,
|
||||
roomRoles,
|
||||
setPresence,
|
||||
@@ -253,7 +254,7 @@ const ROLLOUT_TACHYON_SERVER_ID_PREFIX = 'rollout-'
|
||||
interface TachyonCaller {
|
||||
/** The account's `isSandbox` flag. */
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
'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',
|
||||
'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',
|
||||
'both apply, and either special pool left unset falls back to the live one.',
|
||||
'`experiments` carries the client’s networking flags.',
|
||||
@@ -3264,7 +3265,7 @@ const app = new Hono<App>()
|
||||
const tachyon = tachyonServerFor(
|
||||
tachyonPoolFor(c.env, {
|
||||
isSandbox: account?.isSandbox === true,
|
||||
isRollout: isRolloutGameVersion(await callerVersion(c)),
|
||||
isRollout: isRolloutGameVersion(await callerVersion(c), rolloutGameBuilds(c.env)),
|
||||
}),
|
||||
roomInstanceId
|
||||
)
|
||||
|
||||
@@ -1814,6 +1814,7 @@ describe('auth-gated endpoints', () => {
|
||||
const original = env.TACHYON_HOST_PORT
|
||||
const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX
|
||||
const originalRollout = env.TACHYON_HOST_PORT_ROLLOUT
|
||||
const originalRolloutVersions = env.ROLLOUT_GAME_VERSIONS
|
||||
try {
|
||||
env.TACHYON_HOST_PORT = '198.51.100.12:7777'
|
||||
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
|
||||
// the sandbox one.
|
||||
env.TACHYON_HOST_PORT_ROLLOUT = undefined
|
||||
@@ -1935,6 +1960,7 @@ describe('auth-gated endpoints', () => {
|
||||
env.TACHYON_HOST_PORT = original
|
||||
env.TACHYON_HOST_PORT_SANDBOX = originalSandbox
|
||||
env.TACHYON_HOST_PORT_ROLLOUT = originalRollout
|
||||
env.ROLLOUT_GAME_VERSIONS = originalRolloutVersions
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
* README publishes. They talk close enough to the same protocol to get past the update
|
||||
* prompt; how much beyond that is tested differs by tier:
|
||||
* The DEFAULT set of client builds this server serves — what `/api/versioncheck/v4`
|
||||
* answers "current" for and `auth` hands a token to when the operator has not set
|
||||
* `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
|
||||
* 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
|
||||
'20231207', // alpha
|
||||
'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.
|
||||
* Compared as a build, so the client's point release never decides it.
|
||||
*/
|
||||
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:
|
||||
* The DEFAULT builds under STAGED ROLLOUT to Discord supporters, applied when the
|
||||
* operator has not set `ROLLOUT_GAME_VERSIONS` (see {@link rolloutGameBuilds}). A build
|
||||
* here is kept apart from the live side of the stack:
|
||||
*
|
||||
* - `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).
|
||||
@@ -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
|
||||
* 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
|
||||
* build from here when it ships to everyone. Bare dates, like `SUPPORTED_GAME_VERSIONS`:
|
||||
* every point release of a listed date is under rollout.
|
||||
* One list so the two gates can't drift: a build is either rolling out or it isn't. A
|
||||
* build under rollout is always SERVED (it is folded into {@link supportedGameBuilds}), it
|
||||
* 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']
|
||||
|
||||
/**
|
||||
* Whether a version (a token's `rn.ver`, a grant's posted `ver`) names a build under
|
||||
* staged rollout.
|
||||
* The two worker vars that override the lists above, both OPTIONAL and set for every
|
||||
* 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)
|
||||
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). */
|
||||
|
||||
Reference in New Issue
Block a user