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