mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[2026] rollout support, to be short lived I suppose
This commit is contained in:
+60
-23
@@ -15,12 +15,15 @@ import {
|
||||
getRoomById,
|
||||
hashPassword,
|
||||
isRolloutGameVersion,
|
||||
isSupportedGameVersion,
|
||||
rolloutGameBuilds,
|
||||
RoomInstanceType,
|
||||
setLastLoginTime,
|
||||
setLoginContext,
|
||||
setPasswordHash,
|
||||
setPresence,
|
||||
subRoomDataBlob,
|
||||
supportedGameBuilds,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
writeAuditLog,
|
||||
@@ -175,25 +178,34 @@ const DEFAULT_PASSWORD_SIGNUP = false
|
||||
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
|
||||
|
||||
/**
|
||||
* The rollout gate. A `/connect/token` grant posting a build under staged rollout as `ver`
|
||||
* (`ROLLOUT_GAME_VERSIONS` in @repo/domain — shared with `match`, which serves those builds
|
||||
* the sandbox Tachyon pool) is refused unless the account is STAFF (developer or moderator)
|
||||
* or its Discord link holds a role named in `DISCORD_ROLLOUT_ROLE_IDS` (see context.ts and
|
||||
* `accountMayUseRolloutBuild`). Every other build, and a grant posting no `ver` (a refresh,
|
||||
* the website), is untouched.
|
||||
* The build gates on `/connect/token`, both keyed on the grant's posted `ver` and both
|
||||
* driven by the operator's game-version vars (`SUPPORTED_GAME_VERSIONS`,
|
||||
* `ROLLOUT_GAME_VERSIONS` — see context.ts and `supportedGameBuilds`/`rolloutGameBuilds`
|
||||
* in @repo/domain, which `api` and `match` read the same way). A grant posting no `ver`
|
||||
* (a refresh, the website) is untouched by either.
|
||||
*
|
||||
* 1. The SUPPORT gate: a build outside `supportedGameBuilds` is refused as `invalid_grant`
|
||||
* with UNSUPPORTED_BUILD_DESCRIPTION, for every grant and every account — staff
|
||||
* included. `api`'s `/api/versioncheck/v4` tells the same builds to update; this is what
|
||||
* makes the update prompt stick, since a client that got a token anyway would play on.
|
||||
* Dropping a build from the list is how its players are made to upgrade.
|
||||
* 2. The ROLLOUT gate: a build under staged rollout is refused unless the account is STAFF
|
||||
* (developer or moderator) or its Discord link holds a role named in
|
||||
* `DISCORD_ROLLOUT_ROLE_IDS` (see `accountMayUseRolloutBuild`), as `invalid_grant` with
|
||||
* ROLLOUT_GATED_DESCRIPTION. `create_account` is refused before anything is minted: a
|
||||
* brand-new account has no Discord link to hold a role, so the player makes their
|
||||
* account on a released build, links Discord on the website, and comes back. Ending the
|
||||
* rollout (`ROLLOUT_GAME_VERSIONS=off`) opens the build to everyone.
|
||||
*
|
||||
* `ver` is client-reported and unverified, like everything else about the build. That is
|
||||
* fine for what this is: a rollout gate, not a security boundary. A player who lies about
|
||||
* their build gets a token the 2023 server surfaces would have handed them anyway, and
|
||||
* `api`'s `/api/versioncheck/v4` is still what decides which builds are supported at all.
|
||||
*
|
||||
* Refused as `invalid_grant` with ROLLOUT_GATED_DESCRIPTION. `create_account` is refused
|
||||
* before anything is minted: a brand-new account has no Discord link to hold a role, so
|
||||
* the player makes their account on a released build, links Discord on the website, and
|
||||
* comes back.
|
||||
* fine for what these are: rollout and upgrade gates, not a security boundary. A player
|
||||
* who lies about their build gets a token the surfaces of the build they claim would have
|
||||
* handed them anyway, and then runs a client the server isn't answering for.
|
||||
*/
|
||||
const ROLLOUT_GATED_DESCRIPTION =
|
||||
'this build is limited to Discord supporters; link your Discord on the website first'
|
||||
const UNSUPPORTED_BUILD_DESCRIPTION =
|
||||
'this game version is not supported by this server; update your game'
|
||||
|
||||
/**
|
||||
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
|
||||
@@ -988,8 +1000,16 @@ const app = new Hono<App>()
|
||||
'act from signing into one. The description is deliberately vague — the signup',
|
||||
'refused may belong to a housemate of the banned player rather than to them.',
|
||||
'',
|
||||
'**Support gate.** A grant whose `ver` names a build this server does not serve —',
|
||||
'outside the operator’s `SUPPORTED_GAME_VERSIONS` (default: the list in `@repo/domain`)',
|
||||
'and not under rollout — is refused as `invalid_grant` "this game version is not',
|
||||
'supported by this server; update your game", for every grant and every account.',
|
||||
'`api`’s `/api/versioncheck/v4` flags the same builds, so dropping a build from the',
|
||||
'list is how its players are made to upgrade. Grants posting no `ver` are untouched.',
|
||||
'',
|
||||
'**Rollout gate.** A grant whose `ver` names a build under staged rollout',
|
||||
'(`ROLLOUT_GAME_VERSIONS`, currently `20260323` — every point release of it) is refused as `invalid_grant` "this build is limited to',
|
||||
'(`ROLLOUT_GAME_VERSIONS`, by default `20260323` — every point release of it; `off` ends',
|
||||
'the rollout and opens the build to everyone) is refused as `invalid_grant` "this build is limited to',
|
||||
'Discord supporters" unless the account is staff (developer or moderator) or its',
|
||||
'Discord link (made by the website’s benefits claim) holds one of the roles in the',
|
||||
'operator’s `DISCORD_ROLLOUT_ROLE_IDS`. With none configured the gate is closed to',
|
||||
@@ -1075,9 +1095,9 @@ const app = new Hono<App>()
|
||||
// The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's
|
||||
// `rn.ver` claim so everything downstream reports the build the player is ACTUALLY
|
||||
// running rather than this server's GAME_VERSION — `match` reads it back off the
|
||||
// token when it writes presence. Unverified like the device fields, and only ever
|
||||
// echoed, never trusted for a decision (the version CHECK is `api`'s
|
||||
// `/api/versioncheck/v4`, against its own list).
|
||||
// token when it writes presence. Unverified like the device fields: echoed, and
|
||||
// gated (below, and the rollout gate further down), but never trusted as a
|
||||
// credential.
|
||||
//
|
||||
// A grant that posts none — a refresh, or a caller that isn't the game — leaves it
|
||||
// undefined and generateToken falls back to GAME_VERSION. An empty string is
|
||||
@@ -1091,6 +1111,23 @@ const app = new Hono<App>()
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
|
||||
// The support gate (see UNSUPPORTED_BUILD_DESCRIPTION): a posted build this server
|
||||
// doesn't serve gets no token, whatever the grant and whoever the account. Decided
|
||||
// before any credential is read — it depends on nothing but the operator's list, so
|
||||
// there is nothing about an account to probe here, and a closed door costs no D1
|
||||
// round trip. The same list `api`'s `/api/versioncheck/v4` flags for an update.
|
||||
if (version !== undefined && !isSupportedGameVersion(version, supportedGameBuilds(c.env))) {
|
||||
logger.info('token refused: game version is not supported', {
|
||||
grantType,
|
||||
version,
|
||||
ip: clientIp,
|
||||
})
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: UNSUPPORTED_BUILD_DESCRIPTION },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// A platform-authenticated login proves who you are with the platform itself, and
|
||||
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
|
||||
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
||||
@@ -1194,7 +1231,7 @@ const app = new Hono<App>()
|
||||
// link, so it could never hold the role the gate wants. Refused before anything
|
||||
// is read or minted, like the switch below — no row left behind, no cap slot
|
||||
// burned.
|
||||
if (isRolloutGameVersion(version)) {
|
||||
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
|
||||
logger.info('signup refused: build is limited to Discord supporters', {
|
||||
version,
|
||||
ip: clientIp,
|
||||
@@ -1432,15 +1469,15 @@ const app = new Hono<App>()
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// The rollout gate: a build in ROLLOUT_GAME_VERSIONS is for staff and Discord
|
||||
// supporters only, so the account signing in on it must be staff or hold one of the
|
||||
// configured roles.
|
||||
// The rollout gate: a build under staged rollout (`rolloutGameBuilds`) is for staff
|
||||
// and Discord supporters only, so the account signing in on it must be staff or hold
|
||||
// one of the configured roles.
|
||||
// Deliberately AFTER the credential checks, like the ban lookup below — a wrong
|
||||
// password on a gated build is still "invalid account_id or password", so the
|
||||
// gate can't be used to probe whether an account exists. Unlike a ban, this IS a
|
||||
// refusal: there is no block screen to reach, and the client surfaces the
|
||||
// description on its sign-in failure.
|
||||
if (isRolloutGameVersion(version)) {
|
||||
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
|
||||
const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS)
|
||||
if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) {
|
||||
logger.info('login refused: build is limited to Discord supporters', {
|
||||
|
||||
@@ -64,6 +64,25 @@ export type Env = SharedHonoEnv & {
|
||||
* Logins on every other build are never touched by this.
|
||||
*/
|
||||
DISCORD_ROLLOUT_ROLE_IDS?: string
|
||||
/**
|
||||
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
|
||||
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
|
||||
* the default `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the
|
||||
* root `.env` (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so this refuses exactly the builds
|
||||
* `api`'s `/api/versioncheck/v4` tells to update — dropping a build from the list is how
|
||||
* its players are made to upgrade. Read through `supportedGameBuilds`, which also folds
|
||||
* in every build under rollout (below). A grant posting no `ver` is never gated.
|
||||
*/
|
||||
SUPPORTED_GAME_VERSIONS?: string
|
||||
/**
|
||||
* The builds under staged rollout — the ones the Discord-role gate above applies to —
|
||||
* overriding `ROLLOUT_GAME_VERSIONS` in @repo/domain: same format, or `off` to end the
|
||||
* rollout and open those builds to every account. Set for every worker
|
||||
* (`RECFLARE_ROLLOUT_GAME_VERSIONS`), so `match`'s rollout voice pool follows the same
|
||||
* list. A build here is served whether or not `SUPPORTED_GAME_VERSIONS` names it. Read
|
||||
* through `rolloutGameBuilds`.
|
||||
*/
|
||||
ROLLOUT_GAME_VERSIONS?: string
|
||||
/**
|
||||
* Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
|
||||
* (`--var DOMAIN`). Studio's device-login response points the browser at
|
||||
|
||||
@@ -1415,6 +1415,140 @@ describe('auth worker routes', () => {
|
||||
expect(after?.n).toBe(before?.n)
|
||||
})
|
||||
})
|
||||
|
||||
// The two lists behind the gates are vars (`SUPPORTED_GAME_VERSIONS`,
|
||||
// `ROLLOUT_GAME_VERSIONS`), so an operator can end the rollout or drop a build without
|
||||
// a deploy of new code. Same env-override discipline as withRoleIds.
|
||||
describe('the lists come from vars', () => {
|
||||
const UNSUPPORTED = 'this game version is not supported by this server; update your game'
|
||||
|
||||
async function withVars<T>(
|
||||
vars: { SUPPORTED_GAME_VERSIONS?: string; ROLLOUT_GAME_VERSIONS?: string },
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const originals = {
|
||||
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
|
||||
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
|
||||
}
|
||||
const apply = (values: typeof originals) => {
|
||||
for (const key of ['SUPPORTED_GAME_VERSIONS', 'ROLLOUT_GAME_VERSIONS'] as const) {
|
||||
if (values[key] === undefined) delete env[key]
|
||||
else env[key] = values[key]
|
||||
}
|
||||
}
|
||||
apply({ ...originals, ...vars })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
apply(originals)
|
||||
}
|
||||
}
|
||||
|
||||
test('a build dropped from SUPPORTED_GAME_VERSIONS is refused a token, staff included', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414,20260323' }, async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
// The 2025 build, every point release of it: this is the forced upgrade.
|
||||
for (const version of ['20250718', '20250718.01', '20250718.2']) {
|
||||
const res = await login('gate_outsider', version)
|
||||
expect(res.status, version).toBe(400)
|
||||
expect(res.json, version).toEqual({
|
||||
error: 'invalid_grant',
|
||||
error_description: UNSUPPORTED,
|
||||
})
|
||||
}
|
||||
// Staff get no exception here — the build is not served, full stop.
|
||||
expect((await login('gate_developer', '20250718.01')).status).toBe(400)
|
||||
// The builds that ARE listed, and a grant posting no `ver`, are untouched;
|
||||
// the rollout gate still applies to the one under rollout.
|
||||
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
|
||||
expect((await login('gate_outsider')).status).toBe(200)
|
||||
expect((await login('gate_developer', GATED)).status).toBe(200)
|
||||
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('create_account on an unsupported build is refused before anything is minted', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
|
||||
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
const res = await postToken(
|
||||
'grant_type=create_account&platform_id=steam-unsupported&password=hunter2&ver=20250718.01'
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json).toEqual({ error: 'invalid_grant', error_description: UNSUPPORTED })
|
||||
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
expect(after?.n).toBe(before?.n)
|
||||
})
|
||||
})
|
||||
|
||||
test('ROLLOUT_GAME_VERSIONS=off ends the rollout: the build opens to everyone', async () => {
|
||||
await withVars({ ROLLOUT_GAME_VERSIONS: 'off' }, async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
const res = await login('gate_outsider', GATED)
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string)['rn.ver']).toBe(GATED)
|
||||
expect((await login('gate_unlinked', GATED)).status).toBe(200)
|
||||
// ...and accounts can be created on it, since nothing needs a Discord link.
|
||||
const created = await postToken(
|
||||
`grant_type=create_account&platform_id=steam-rollout-over&password=hunter2&ver=${GATED}`
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('ROLLOUT_GAME_VERSIONS names which builds are gated', async () => {
|
||||
// Move the gate onto the beta build: it is now supporters-only and the 2026
|
||||
// build is open. Both stay served.
|
||||
await withVars({ ROLLOUT_GAME_VERSIONS: '20250718' }, async () => {
|
||||
await withRoleIds(supporter, async () => {
|
||||
const outsider = await login('gate_outsider', '20250718.01')
|
||||
expect(outsider.status).toBe(400)
|
||||
expect(outsider.json.error_description).toBe(REFUSED)
|
||||
expect((await login('gate_supporter', '20250718.01')).status).toBe(200)
|
||||
expect((await login('gate_outsider', GATED)).status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('a build under rollout is served even when SUPPORTED_GAME_VERSIONS omits it', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
|
||||
await withRoleIds(supporter, async () => {
|
||||
// Rolling out, so the ROLLOUT gate answers, not the support one.
|
||||
expect((await login('gate_supporter', GATED)).status).toBe(200)
|
||||
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
|
||||
})
|
||||
})
|
||||
// In neither list, nobody reaches it — a rollout pulled back entirely.
|
||||
await withVars(
|
||||
{ SUPPORTED_GAME_VERSIONS: '20230414', ROLLOUT_GAME_VERSIONS: 'off' },
|
||||
async () => {
|
||||
await withRoleIds(supporter, async () => {
|
||||
for (const username of ['gate_supporter', 'gate_developer']) {
|
||||
const res = await login(username, GATED)
|
||||
expect(res.status, username).toBe(400)
|
||||
expect(res.json.error_description, username).toBe(UNSUPPORTED)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('entries are read as builds, so a copied point release still matches', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: ' 20230414, 20250718.01 ' }, async () => {
|
||||
expect((await login('gate_outsider', '20250718.2')).status).toBe(200)
|
||||
expect((await login('gate_outsider', '20250718')).status).toBe(200)
|
||||
})
|
||||
// Blank is unset: the default list applies, including the alpha builds.
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: ' ' }, async () => {
|
||||
expect((await login('gate_outsider', '20231207')).status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('the signup caps come from vars, and 0 disables an arm', async () => {
|
||||
|
||||
Reference in New Issue
Block a user