mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[2026] rollout support
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user