[2026] rollout support

This commit is contained in:
Devin Zuczek
2026-10-12 14:59:14 -04:00
parent 4d6a88f20e
commit a0345af765
8 changed files with 413 additions and 16 deletions
+21
View File
@@ -152,6 +152,13 @@ RECFLARE_DOMAIN=rec.example.com
# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts # same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts
# use the pool above. # use the pool above.
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778 # RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
#
# The pool players on a build under staged rollout (ROLLOUT_GAME_VERSIONS, currently
# `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 # 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 # 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 # The binding has to resolve for `www` to deploy, so create it with a placeholder even if
# you don't want the sweep — it then does nothing. It never touches hasPlus. # you don't want the sweep — it then does nothing. It never touches hasPlus.
# 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 # 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 # RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in # `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
+106
View File
@@ -14,6 +14,7 @@ import {
getPasswordHash, getPasswordHash,
getRoomById, getRoomById,
hashPassword, hashPassword,
isRolloutGameVersion,
RoomInstanceType, RoomInstanceType,
setLastLoginTime, setLastLoginTime,
setLoginContext, setLoginContext,
@@ -62,6 +63,7 @@ import {
} from './openapi' } from './openapi'
import { import {
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount,
getLinksForPlatformId, getLinksForPlatformId,
getLinksForPlatformIdentity, getLinksForPlatformIdentity,
isPlatformIdentityLinked, isPlatformIdentityLinked,
@@ -172,6 +174,61 @@ const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3
const DEFAULT_PASSWORD_SIGNUP = false const DEFAULT_PASSWORD_SIGNUP = false
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled' const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/**
* The rollout gate. A `/connect/token` grant posting a build under staged rollout as `ver`
* (`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<boolean> {
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. */ /** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13 const ORIENTATION_ROOM_ID = 13
/** /**
@@ -919,6 +976,14 @@ const app = new Hono<App>()
'refused BEFORE it mints anything: making a NEW account to evade with is a different', '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', 'act from signing into one. The description is deliberately vague — the signup',
'refused may belong to a housemate of the banned player rather than to them.', 'refused may belong to a housemate of the banned player rather than to them.',
'',
'**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'), ].join('\n'),
requestBody: form( requestBody: form(
TokenRequest, TokenRequest,
@@ -1113,6 +1178,21 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword. // via create_account or /account/me/changepassword.
let accountId: string let accountId: string
if (grantType === 'create_account') { 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 // The password-account path (no platform) is a switch, off by default: see
// DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted — // DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted —
// a closed door costs no D1 round trip and burns no cap slot. // a closed door costs no D1 round trip and burns no cap slot.
@@ -1340,6 +1420,32 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
} }
// The rollout gate: a build in ROLLOUT_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 // 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 // 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 // moderationBlockDetails, which is where a banned player is TOLD they are banned
+14
View File
@@ -49,6 +49,20 @@ export type Env = SharedHonoEnv & {
* Read through `banEvasionMatch`; the `match` worker reads the same knob. * Read through `banEvasionMatch`; the `match` worker reads the same knob.
*/ */
BAN_EVASION_MATCH?: string 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 * Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
* (`--var DOMAIN`). Studio's device-login response points the browser at * (`--var DOMAIN`). Studio's device-login response points the browser at
+134
View File
@@ -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<T>(value: string | undefined, run: () => Promise<T>): Promise<T> {
// `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 () => { 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 // 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 // auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
+8
View File
@@ -75,6 +75,14 @@ export type Env = SharedHonoEnv & {
* accounts use the live pool. See `tachyonPoolFor` in match.app.ts. * accounts use the live pool. See `tachyonPoolFor` in match.app.ts.
*/ */
TACHYON_HOST_PORT_SANDBOX?: string 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 * The Photon region every session is pinned to — both the region named in the connection
* info and the one stamped on every room instance, which must agree. Optional; unlike the * info and the one stamped on every room instance, which must agree. Optional; unlike the
+46 -15
View File
@@ -38,6 +38,7 @@ import {
isClubMember, isClubMember,
isMatchmakingPolicy, isMatchmakingPolicy,
isPlayerBannedFromRoom, isPlayerBannedFromRoom,
isRolloutGameVersion,
MatchmakingErrorCode, MatchmakingErrorCode,
MessageType, MessageType,
MOST_ACTIVE_CLUBHOUSE_LIMIT, 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`, * 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 * `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 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 * The pool the caller's instance is picked out of — one of three, each the same
* Tachyon servers, apart from the live pool, so the whole list is swapped for * comma-separated list, with selection inside it the same instance-id round-robin
* `TACHYON_HOST_PORT_SANDBOX` rather than any one entry: selection within it is the same * {@link tachyonServerFor} applies to all of them:
* 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 * - `TACHYON_HOST_PORT_SANDBOX` for a SANDBOX account, which runs against its own servers
* sandbox servers doesn't leave those accounts without voice. * 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, '') const sandbox = varOr(env.TACHYON_HOST_PORT_SANDBOX, '')
return isSandbox && sandbox.trim() !== '' if (caller.isSandbox && sandbox.trim() !== '') {
? tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX) return tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX)
: tachyonPool(varOr(env.TACHYON_HOST_PORT, '')) }
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<App>()
'`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is', '`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`,', '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', '…). 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,', 'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the',
'with ids `dev-1`, `dev-2`, ….', '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.', '`experiments` carries the client’s networking flags.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
@@ -3130,9 +3157,13 @@ const app = new Hono<App>()
} }
const account = await getAccount(c.env.DB, id) const account = await getAccount(c.env.DB, id)
// The instance's server, the same one every other player in it is handed — except // 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( const tachyon = tachyonServerFor(
tachyonPoolFor(c.env, account?.isSandbox === true), tachyonPoolFor(c.env, {
isSandbox: account?.isSandbox === true,
isRollout: isRolloutGameVersion(await callerVersion(c)),
}),
roomInstanceId roomInstanceId
) )
+64 -1
View File
@@ -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 // 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. // 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)') await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
@@ -1623,6 +1623,7 @@ describe('auth-gated endpoints', () => {
.run() .run()
const original = env.TACHYON_HOST_PORT const original = env.TACHYON_HOST_PORT
const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX
const originalRollout = env.TACHYON_HOST_PORT_ROLLOUT
try { try {
env.TACHYON_HOST_PORT = '198.51.100.12:7777' env.TACHYON_HOST_PORT = '198.51.100.12:7777'
env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778' env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778'
@@ -1667,6 +1668,67 @@ describe('auth-gated endpoints', () => {
// In no instance there is no server, sandbox or not. // In no instance there is no server, sandbox or not.
expect(await voiceFor('974')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' }) 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. // With no sandbox pool configured, a sandbox account falls back to the live one.
env.TACHYON_HOST_PORT_SANDBOX = undefined env.TACHYON_HOST_PORT_SANDBOX = undefined
const instance = await createRoomInstance(env.DB, { const instance = await createRoomInstance(env.DB, {
@@ -1682,6 +1744,7 @@ describe('auth-gated endpoints', () => {
} finally { } finally {
env.TACHYON_HOST_PORT = original env.TACHYON_HOST_PORT = original
env.TACHYON_HOST_PORT_SANDBOX = originalSandbox env.TACHYON_HOST_PORT_SANDBOX = originalSandbox
env.TACHYON_HOST_PORT_ROLLOUT = originalRollout
} }
}) })
+20
View File
@@ -62,6 +62,26 @@ export function isSupportedGameVersion(version: string | null | undefined): bool
return version != null && SUPPORTED_GAME_VERSIONS.includes(version) 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). */ /** Schema DDL (mirror of migrations/0006_presence.sql). */
export const PRESENCE_SCHEMA_DDL: string[] = [ export const PRESENCE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS presence ( `CREATE TABLE IF NOT EXISTS presence (