From a0345af765c3fe2bceda7e21c6bffb40095e215d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 12 Oct 2026 14:59:14 -0400 Subject: [PATCH] [2026] rollout support --- .env.example | 21 +++ apps/auth/src/auth.app.ts | 106 ++++++++++++++++ apps/auth/src/context.ts | 14 ++ apps/auth/src/test/integration/api.test.ts | 134 ++++++++++++++++++++ apps/match/src/context.ts | 8 ++ apps/match/src/match.app.ts | 61 ++++++--- apps/match/src/test/integration/api.test.ts | 65 +++++++++- packages/domain/src/presence-db.ts | 20 +++ 8 files changed, 413 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index e9f94c0..182caba 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,13 @@ RECFLARE_DOMAIN=rec.example.com # same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts # 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, +# 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. +# RECFLARE_TACHYON_HOST_PORT_ROLLOUT=198.51.100.13:7777 # The Photon region every session is pinned to (`match`). Unlike the app ids above this # does default, to `us` (us-east1) — an instance stamped with an empty region is one the @@ -227,6 +234,20 @@ 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. +# 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 +# posting that build as `ver` is refused ("this build is limited to Discord supporters") +# unless the account's 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 +# qualifies. Roles come from the snapshot the benefits claim and the daily sweep keep, so a +# player has to have claimed on the website, and a role granted today opens the gate once +# the sweep runs (or they re-claim). UNSET — or parsing to no roles — CLOSES the gate: nobody +# signs in on the gated build until a role is named here. Accounts can't be CREATED on a +# gated build (a new account has no Discord link); players make one on a released build, +# link Discord at /claim, and come back. +# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002 + # OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the # RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as # `=` pairs. How often is the schedule's call: `triggers.crons` in diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 9ddbd89..1487da6 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -14,6 +14,7 @@ import { getPasswordHash, getRoomById, hashPassword, + isRolloutGameVersion, RoomInstanceType, setLastLoginTime, setLoginContext, @@ -62,6 +63,7 @@ import { } from './openapi' import { countAccountsForPlatformIdentity, + getLinksForAccount, getLinksForPlatformId, getLinksForPlatformIdentity, isPlatformIdentityLinked, @@ -172,6 +174,61 @@ const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3 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's Discord link holds a role named + * in `DISCORD_ROLLOUT_ROLE_IDS` (see context.ts and `accountHoldsRolloutRole`). Every other + * build, and a grant posting no `ver` (a refresh, the website), is untouched. + * + * `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. + */ +const ROLLOUT_GATED_DESCRIPTION = + 'this build is limited to Discord supporters; link your Discord on the website first' + +/** + * `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or + * whitespace (the same grammar `www`'s parseRoleIds reads DISCORD_BENEFITS_ROLE_IDS with). + * Unset or blank parses to an empty list, which `accountHoldsRolloutRole` treats as a + * closed gate. + */ +function rolloutRoleIds(raw: string | undefined): string[] { + return (raw ?? '') + .split(/[\s,]+/) + .map((id) => id.trim()) + .filter((id) => id !== '') +} + +/** + * Whether an account may sign in on a rollout-gated build: its Discord link (platform 101, + * written by the website's benefits claim and refreshed by `www`'s daily sweep) records at + * least one of the configured roles. The roles are a SNAPSHOT (see PlatformLink.roles), so + * a role granted in Discord today opens the gate once the player re-claims or the sweep + * runs — not instantly. + * + * An empty role list is answered false without touching the database: with no supporter + * role named, nobody is a supporter. + */ +async function accountHoldsRolloutRole( + db: D1Database, + accountId: number, + roleIds: readonly string[] +): Promise { + if (roleIds.length === 0) return false + const links = await getLinksForAccount(db, accountId) + return links.some( + (link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r)) + ) +} + /** New players start in the Orientation room (RoomId 13) — the new-user flow. */ const ORIENTATION_ROOM_ID = 13 /** @@ -919,6 +976,14 @@ const app = new Hono() 'refused BEFORE it mints anything: making a NEW account to evade with is a different', '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.', + '', + '**Rollout gate.** A grant whose `ver` names a build under staged rollout', + '(`ROLLOUT_GAME_VERSIONS`, currently `20260323.14`) is refused as `invalid_grant` "this build is limited to', + 'Discord supporters" unless the account’s 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 everyone. `create_account` on such a', + 'build is refused before it mints anything, since a new account has no Discord link.', + 'Grants posting no `ver` (a refresh, the website) and every other build are untouched.', ].join('\n'), requestBody: form( TokenRequest, @@ -1113,6 +1178,21 @@ const app = new Hono() // via create_account or /account/me/changepassword. let accountId: string if (grantType === 'create_account') { + // A rollout-gated build can't sign UP: the account it would mint has no Discord + // 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)) { + logger.info('signup refused: build is limited to Discord supporters', { + version, + ip: clientIp, + }) + return c.json( + { error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION }, + 400 + ) + } + // The password-account path (no platform) is a switch, off by default: see // DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted — // a closed door costs no D1 round trip and burns no cap slot. @@ -1340,6 +1420,32 @@ const app = new Hono() await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) } + // The rollout gate: a build in ROLLOUT_GATED_VERSIONS is for Discord supporters + // only, so the account signing in on it must 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)) { + const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS) + if (!(await accountHoldsRolloutRole(c.env.DB, Number(accountId), roleIds))) { + logger.info('login refused: build is limited to Discord supporters', { + accountId, + grantType, + version, + // The COUNT, not the ids: zero here means the gate is closed because + // nothing is configured, which reads very differently from "not a + // supporter". + roleIdCount: roleIds.length, + }) + return c.json( + { error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION }, + 400 + ) + } + } + // EVERY account gets its token, banned or not — a ban of its own, or a link to // somebody else's through the evasion arms. The client needs one to reach `api`'s // moderationBlockDetails, which is where a banned player is TOLD they are banned diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index be8dc50..6ffc1f4 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -49,6 +49,20 @@ export type Env = SharedHonoEnv & { * Read through `banEvasionMatch`; the `match` worker reads the same knob. */ BAN_EVASION_MATCH?: string + /** + * The Discord role ids that may sign in on a ROLLOUT-GATED client build (see + * `ROLLOUT_GAME_VERSIONS` in @repo/domain) — numeric snowflakes, separated by commas + * and/or whitespace, exactly like `www`'s DISCORD_BENEFITS_ROLE_IDS. ANY one of them + * qualifies. A login whose posted `ver` is a gated build is refused unless the account's + * Discord link (`platform_account`, platform 101, written by the website's benefits + * claim) records one of these roles. + * + * Unset — or parsing to no ids — leaves the gate CLOSED: nobody gets a token on a gated + * build. That is the safe reading of "supporters only" when no supporter role has been + * named, and it keeps a build that isn't ready from being reachable by accident. + * Logins on every other build are never touched by this. + */ + DISCORD_ROLLOUT_ROLE_IDS?: 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 614377c..0956c48 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1246,6 +1246,140 @@ describe('auth worker routes', () => { } }) + // The rollout gate: build 20260323.14 is for Discord supporters only. A login posting it + // as `ver` is refused unless the account's Discord link holds a role named in + // DISCORD_ROLLOUT_ROLE_IDS; every other build, and a grant posting no `ver`, is untouched. + describe('rollout gate on build 20260323.14', () => { + const GATED = '20260323.14' + const REFUSED = + 'this build is limited to Discord supporters; link your Discord on the website first' + const supporter = '1077000000000000001' + const booster = '1077000000000000002' + const outsider = '1077000000000000009' + + async function seedAccount(id: number, username: string) { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: id, + username, + passwordHash: await hashPassword(LOGIN_PASSWORD), + }) + ) + .run() + } + + const login = (username: string, ver?: string) => + postToken( + `grant_type=password&username=${username}&password=${LOGIN_PASSWORD}` + + (ver === undefined ? '' : `&ver=${ver}`) + ) + + async function withRoleIds(value: string | undefined, run: () => Promise): Promise { + // `env` is shared by every test in this file, so the override is restored in + // `finally` rather than leaking a configured gate into the tests around this one. + const original = env.DISCORD_ROLLOUT_ROLE_IDS + if (value === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS + else env.DISCORD_ROLLOUT_ROLE_IDS = value + try { + return await run() + } finally { + if (original === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS + else env.DISCORD_ROLLOUT_ROLE_IDS = original + } + } + + beforeAll(async () => { + await seedAccount(9301, 'gate_supporter') + await linkPlatformIdentity(env.DB, 9301, PlatformType.Discord, '500000000000000001', [ + supporter, + ]) + await seedAccount(9302, 'gate_outsider') + await linkPlatformIdentity(env.DB, 9302, PlatformType.Discord, '500000000000000002', [ + outsider, + ]) + await seedAccount(9303, 'gate_unlinked') + // A Steam link is not a Discord link — it carries no roles to hold. + await linkPlatformIdentity(env.DB, 9303, PlatformType.Steam, '76561198000009303') + }) + + test('with no roles configured the gate is closed to everyone on the build', async () => { + await withRoleIds(undefined, async () => { + const res = await login('gate_supporter', GATED) + expect(res.status).toBe(400) + expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED }) + }) + // A value that parses to no ids is the same as unset. + await withRoleIds(' , ', async () => { + expect((await login('gate_supporter', GATED)).status).toBe(400) + }) + }) + + test('a configured role lets its holder in, and the token carries the build', async () => { + await withRoleIds(`${booster},${supporter}`, async () => { + const res = await login('gate_supporter', GATED) + expect(res.status).toBe(200) + const payload = decodePayload(res.json.access_token as string) + expect(payload.sub).toBe('9301') + expect(payload['rn.ver']).toBe(GATED) + }) + }) + + test('the list is split on commas and/or whitespace, like DISCORD_BENEFITS_ROLE_IDS', async () => { + await withRoleIds(` ${booster} ,\n${supporter} `, async () => { + expect((await login('gate_supporter', GATED)).status).toBe(200) + }) + }) + + test('a Discord link holding only other roles, or no Discord link at all, is refused', async () => { + await withRoleIds(`${supporter},${booster}`, async () => { + const other = await login('gate_outsider', GATED) + expect(other.status).toBe(400) + expect(other.json.error_description).toBe(REFUSED) + + const unlinked = await login('gate_unlinked', GATED) + expect(unlinked.status).toBe(400) + expect(unlinked.json.error_description).toBe(REFUSED) + }) + }) + + test('other builds, and grants posting no ver, are never gated', async () => { + await withRoleIds(undefined, async () => { + expect((await login('gate_outsider')).status).toBe(200) + expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200) + expect((await login('gate_outsider', '20250718.01')).status).toBe(200) + expect((await login('gate_unlinked', '20230414')).status).toBe(200) + }) + }) + + test('the credential is checked first, so the gate cannot probe for accounts', async () => { + await withRoleIds(undefined, async () => { + const res = await postToken( + `grant_type=password&username=gate_outsider&password=wrong&ver=${GATED}` + ) + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('invalid account_id or password') + }) + }) + + test('create_account on the build is refused before anything is minted', async () => { + await withRoleIds(`${supporter}`, 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-gated&password=hunter2&ver=${GATED}` + ) + expect(res.status).toBe(400) + expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED }) + const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ + n: number + }>() + expect(after?.n).toBe(before?.n) + }) + }) + }) + test('the signup caps come from vars, and 0 disables an arm', async () => { // The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in // auth.app.ts is only the fallback. `env` is shared by every test in this file, so the diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index e9da14a..71a4e49 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -75,6 +75,14 @@ export type Env = SharedHonoEnv & { * accounts use the live pool. See `tachyonPoolFor` in match.app.ts. */ TACHYON_HOST_PORT_SANDBOX?: string + /** + * The Tachyon pool a caller on a build under staged rollout (`ROLLOUT_GAME_VERSIONS` in + * @repo/domain, read off their token's `rn.ver`) is served from + * (`RECFLARE_TACHYON_HOST_PORT_ROLLOUT` in `.env`) — same list format, ids `rollout-1`, + * `rollout-2`, …. A sandbox account is served the sandbox pool even on that build. Unset + * means rollout callers use the live pool. See `tachyonPoolFor` in match.app.ts. + */ + TACHYON_HOST_PORT_ROLLOUT?: 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 b55a69d..6b8114e 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -38,6 +38,7 @@ import { isClubMember, isMatchmakingPolicy, isPlayerBannedFromRoom, + isRolloutGameVersion, MatchmakingErrorCode, MessageType, MOST_ACTIVE_CLUBHOUSE_LIMIT, @@ -241,23 +242,46 @@ function tachyonServerFor(pool: TachyonServer[], roomInstanceId: number): Tachyo /** * The prefix of a sandbox server's generated id (see {@link tachyonPoolFor}) — `dev-1`, * `dev-2` rather than the live pool's `tachyon-N`, so the (cosmetic) id the client - * displays says which pool it is on. + * displays says which pool it is on. The rollout pool's is `rollout-N` for the same reason. */ const SANDBOX_TACHYON_SERVER_ID_PREFIX = 'dev-' +const ROLLOUT_TACHYON_SERVER_ID_PREFIX = 'rollout-' + +/** What decides which Tachyon pool a caller is served from; see {@link tachyonPoolFor}. */ +interface TachyonCaller { + /** The account's `isSandbox` flag. */ + isSandbox: boolean + /** Running a build under staged rollout (`ROLLOUT_GAME_VERSIONS`, off the token's `rn.ver`). */ + isRollout: boolean +} /** - * The pool the caller's instance is picked out of. A SANDBOX account runs against its own - * Tachyon servers, apart from the live pool, so the whole list is swapped for - * `TACHYON_HOST_PORT_SANDBOX` rather than any one entry: selection within it is the same - * instance-id round-robin {@link tachyonServerFor} applies to the live pool. When that var - * is unset a sandbox account falls back to the live pool, so an operator who runs no - * sandbox servers doesn't leave those accounts without voice. + * The pool the caller's instance is picked out of — one of three, each the same + * comma-separated list, with selection inside it the same instance-id round-robin + * {@link tachyonServerFor} applies to all of them: + * + * - `TACHYON_HOST_PORT_SANDBOX` for a SANDBOX account, which runs against its own servers + * apart from the live pool; + * - `TACHYON_HOST_PORT_ROLLOUT` for a caller on a build under staged rollout — the build + * `auth` only lets Discord supporters sign in on, which gets its own voice servers while + * it rolls out. Sessions are already split by build ({@link callerGameVersion}), so a + * rollout instance's players are all on that build and all land here together; + * - `TACHYON_HOST_PORT`, the live pool, for everyone else. + * + * Sandbox wins when both apply: a sandbox account on the rollout build is still a sandbox + * account. Either special pool left unset falls back to the live one, so an operator who + * runs no sandbox or rollout servers doesn't leave those callers without voice. */ -function tachyonPoolFor(env: Env, isSandbox: boolean): TachyonServer[] { +function tachyonPoolFor(env: Env, caller: TachyonCaller): TachyonServer[] { const sandbox = varOr(env.TACHYON_HOST_PORT_SANDBOX, '') - return isSandbox && sandbox.trim() !== '' - ? tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX) - : tachyonPool(varOr(env.TACHYON_HOST_PORT, '')) + if (caller.isSandbox && sandbox.trim() !== '') { + return tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX) + } + const rollout = varOr(env.TACHYON_HOST_PORT_ROLLOUT, '') + if (!caller.isSandbox && caller.isRollout && rollout.trim() !== '') { + return tachyonPool(rollout, ROLLOUT_TACHYON_SERVER_ID_PREFIX) + } + return tachyonPool(varOr(env.TACHYON_HOST_PORT, '')) } /** @@ -3088,8 +3112,11 @@ const app = new Hono() '`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is', 'handed the same one, with a generated `voiceServerId` (`tachyon-1`, `tachyon-2`,', '…). 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`, ….', + '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.14`) 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.', ].join(' '), security: AUTHED, @@ -3130,9 +3157,13 @@ const app = new Hono() } const account = await getAccount(c.env.DB, id) // The instance's server, the same one every other player in it is handed — except - // that a SANDBOX account is picked out of the sandbox pool instead of the live one. + // that a SANDBOX account is picked out of the sandbox pool, and a caller on a + // rollout build out of the rollout pool, instead of the live one. const tachyon = tachyonServerFor( - tachyonPoolFor(c.env, account?.isSandbox === true), + tachyonPoolFor(c.env, { + isSandbox: account?.isSandbox === true, + isRollout: isRolloutGameVersion(await callerVersion(c)), + }), roomInstanceId ) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index b35a6db..95bc08d 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1615,7 +1615,7 @@ describe('auth-gated endpoints', () => { } }) - test('a sandbox account is served out of the sandbox Tachyon pool', async () => { + test('a sandbox account is served out of the sandbox Tachyon pool, a rollout build out of the rollout one', async () => { // Sandbox accounts run against their own Tachyon servers, so the whole pool is swapped // for them; selection inside it is the same instance-id round-robin as the live pool. await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') @@ -1623,6 +1623,7 @@ describe('auth-gated endpoints', () => { .run() const original = env.TACHYON_HOST_PORT const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX + const originalRollout = env.TACHYON_HOST_PORT_ROLLOUT 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' @@ -1667,6 +1668,67 @@ describe('auth-gated endpoints', () => { // In no instance there is no server, sandbox or not. expect(await voiceFor('974')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' }) + // A caller on a build under staged rollout gets the ROLLOUT pool, a third list with + // its own ids, read off the token's rn.ver. A sandbox account on that build still + // gets the sandbox pool: sandbox wins. + env.TACHYON_HOST_PORT_ROLLOUT = '198.51.100.13:7777,198.51.100.13:7778' + const rolloutPool = [ + { voiceConnectionInfo: '198.51.100.13:7777', voiceServerId: 'rollout-1' }, + { voiceConnectionInfo: '198.51.100.13:7778', voiceServerId: 'rollout-2' }, + ] + const rolloutVoiceFor = async (player: string, version: string, roomInstanceId: number) => { + const res = await exports.default.fetch( + `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`, + { headers: await bearer(player, version) } + ) + const body = (await res.json()) as { + value: { voiceConnectionInfo: string; voiceServerId: string } + } + return { + voiceConnectionInfo: body.value.voiceConnectionInfo, + voiceServerId: body.value.voiceServerId, + } + } + for (const photonRoomId of ['tachyon-rollout-a', 'tachyon-rollout-b']) { + const rollout = await createRoomInstance(env.DB, { + ownerAccountId: 976, + roomId: 2, + photonRoomId, + maxCapacity: 12, + }) + expect(await rolloutVoiceFor('976', '20260323.14', rollout.roomInstanceId)).toEqual( + rolloutPool[rollout.roomInstanceId % rolloutPool.length] + ) + expect(await rolloutVoiceFor('974', '20260323.14', rollout.roomInstanceId)).toEqual( + sandboxPool[rollout.roomInstanceId % sandboxPool.length] + ) + // Released builds — the default, the beta, and a token carrying no build — stay live. + for (const version of [GAME_VERSION, '20250718.01']) { + expect(await rolloutVoiceFor('976', version, rollout.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7777', + voiceServerId: 'tachyon-1', + }) + } + expect(await voiceFor('976', rollout.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7777', + voiceServerId: 'tachyon-1', + }) + } + + // With no rollout pool configured, a rollout build falls back to the live one, not + // the sandbox one. + env.TACHYON_HOST_PORT_ROLLOUT = undefined + const unsetRollout = await createRoomInstance(env.DB, { + ownerAccountId: 976, + roomId: 2, + photonRoomId: 'tachyon-rollout-unset', + maxCapacity: 12, + }) + expect(await rolloutVoiceFor('976', '20260323.14', unsetRollout.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7777', + voiceServerId: 'tachyon-1', + }) + // With no sandbox pool configured, a sandbox account falls back to the live one. env.TACHYON_HOST_PORT_SANDBOX = undefined const instance = await createRoomInstance(env.DB, { @@ -1682,6 +1744,7 @@ describe('auth-gated endpoints', () => { } finally { env.TACHYON_HOST_PORT = original env.TACHYON_HOST_PORT_SANDBOX = originalSandbox + env.TACHYON_HOST_PORT_ROLLOUT = originalRollout } }) diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 4961b89..d511f2b 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -62,6 +62,26 @@ export function isSupportedGameVersion(version: string | null | undefined): bool return version != null && SUPPORTED_GAME_VERSIONS.includes(version) } +/** + * 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 + * holds a role in `DISCORD_ROLLOUT_ROLE_IDS` (closed to everyone when none is set). + * - `match` hands `GET /player/connection-info` the `TACHYON_HOST_PORT_ROLLOUT` pool for a + * 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. + */ +export const ROLLOUT_GAME_VERSIONS: readonly string[] = ['20260323.14'] + +/** Whether a build (a token's `rn.ver`, a grant's posted `ver`) is one under staged rollout. */ +export function isRolloutGameVersion(version: string | null | undefined): boolean { + return version != null && ROLLOUT_GAME_VERSIONS.includes(version) +} + /** Schema DDL (mirror of migrations/0006_presence.sql). */ export const PRESENCE_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS presence (