From 05d7fe4c32147bd22962a9e766cb3ba5508d19bb Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 19 Oct 2026 22:54:18 -0400 Subject: [PATCH] [2026] rollout support, to be short lived I suppose --- .env.example | 27 +++- CLAUDE.md | 7 + README.md | 6 + apps/api/src/context.ts | 16 +++ apps/api/src/routes/config.ts | 12 +- apps/api/src/test/integration/api.test.ts | 50 ++++++++ apps/auth/src/auth.app.ts | 83 ++++++++---- apps/auth/src/context.ts | 19 +++ apps/auth/src/test/integration/api.test.ts | 134 ++++++++++++++++++++ apps/match/src/context.ts | 9 ++ apps/match/src/match.app.ts | 7 +- apps/match/src/test/integration/api.test.ts | 26 ++++ packages/domain/src/presence-db.ts | 122 ++++++++++++++---- 13 files changed, 462 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index 8469f5a..2e7884e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7c6fd86..a5afc08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,13 @@ 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`; diff --git a/README.md b/README.md index 46943e4..fbda28b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 09aab31..298ca00 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -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 diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index 1d2109c..87ec2ef 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -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({ 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, diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index a990a4b..4a2cc12 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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. diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 667ce04..8e220b4 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -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() '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() // 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() // 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() // 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() 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', { diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 867bc96..4c1861b 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -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 diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 1914d52..f8d862f 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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( + vars: { SUPPORTED_GAME_VERSIONS?: string; ROLLOUT_GAME_VERSIONS?: string }, + run: () => Promise + ): Promise { + 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 () => { diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index 71a4e49..c761af3 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -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 diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index e67695a..532496f 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -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() '…). 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() const tachyon = tachyonServerFor( tachyonPoolFor(c.env, { isSandbox: account?.isSandbox === true, - isRollout: isRolloutGameVersion(await callerVersion(c)), + isRollout: isRolloutGameVersion(await callerVersion(c), rolloutGameBuilds(c.env)), }), roomInstanceId ) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index ee63438..5ff5264 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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 } }) diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 68bf6de..b4f2b0b 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -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). */