[auth] allow staff in rollout

This commit is contained in:
Devin Zuczek
2026-10-14 12:10:33 -04:00
parent 9902de39a4
commit eb5c0d28ff
4 changed files with 59 additions and 26 deletions
+3 -3
View File
@@ -238,12 +238,12 @@ RECFLARE_DOMAIN=rec.example.com
# Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — currently # 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 # `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") # 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 # unless the account is staff (developer or moderator — always let in) or its Discord link
# touched. Same rules as ROLE_IDS above: snowflakes, not names; commas, no spaces; ANY one # 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 # 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 # 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 # 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 # but staff 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, # gated build (a new account has no Discord link); players make one on a released build,
# link Discord at /claim, and come back. # link Discord at /claim, and come back.
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002 # RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
+26 -19
View File
@@ -177,9 +177,10 @@ const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/** /**
* The rollout gate. A `/connect/token` grant posting a build under staged rollout as `ver` * 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 * (`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 * the sandbox Tachyon pool) is refused unless the account is STAFF (developer or moderator)
* in `DISCORD_ROLLOUT_ROLE_IDS` (see context.ts and `accountHoldsRolloutRole`). Every other * or its Discord link holds a role named in `DISCORD_ROLLOUT_ROLE_IDS` (see context.ts and
* build, and a grant posting no `ver` (a refresh, the website), is untouched. * `accountMayUseRolloutBuild`). 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 * `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 * fine for what this is: a rollout gate, not a security boundary. A player who lies about
@@ -197,8 +198,8 @@ const ROLLOUT_GATED_DESCRIPTION =
/** /**
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or * `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). * 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 * Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a
* closed gate. * gate closed to everyone but staff.
*/ */
function rolloutRoleIds(raw: string | undefined): string[] { function rolloutRoleIds(raw: string | undefined): string[] {
return (raw ?? '') return (raw ?? '')
@@ -208,20 +209,24 @@ function rolloutRoleIds(raw: string | undefined): string[] {
} }
/** /**
* Whether an account may sign in on a rollout-gated build: its Discord link (platform 101, * Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
* written by the website's benefits claim and refreshed by `www`'s daily sweep) records at * flag on the account, the same two `staff_login` audits — always may, configured roles or
* least one of the configured roles. The roles are a SNAPSHOT (see PlatformLink.roles), so * not: they are who tests a build before it reaches supporters, so a closed gate stays open
* a role granted in Discord today opens the gate once the player re-claims or the sweep * to them. Anyone else needs a Discord link (platform 101, written by the website's benefits
* runs — not instantly. * claim and refreshed by `www`'s daily sweep) recording 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 * An empty role list skips the link read: with no supporter role named, nobody is a
* role named, nobody is a supporter. * supporter.
*/ */
async function accountHoldsRolloutRole( async function accountMayUseRolloutBuild(
db: D1Database, db: D1Database,
accountId: number, accountId: number,
roleIds: readonly string[] roleIds: readonly string[]
): Promise<boolean> { ): Promise<boolean> {
const account = await getAccount(db, accountId)
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
if (roleIds.length === 0) return false if (roleIds.length === 0) return false
const links = await getLinksForAccount(db, accountId) const links = await getLinksForAccount(db, accountId)
return links.some( return links.some(
@@ -979,9 +984,10 @@ const app = new Hono<App>()
'', '',
'**Rollout gate.** A grant whose `ver` names a build under staged rollout', '**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`, currently `20260323` — every point release of it) is refused as `invalid_grant` "this build is limited to',
'Discord supporters" unless the account’s Discord link (made by the website’s', 'Discord supporters" unless the account is staff (developer or moderator) or its',
'benefits claim) holds one of the roles in the operator’s `DISCORD_ROLLOUT_ROLE_IDS`.', 'Discord link (made by the website’s benefits claim) holds one of the roles in the',
'With none configured the gate is closed to everyone. `create_account` on such a', 'operator’s `DISCORD_ROLLOUT_ROLE_IDS`. With none configured the gate is closed to',
'everyone but staff. `create_account` on such a',
'build is refused before it mints anything, since a new account has no Discord link.', '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.', 'Grants posting no `ver` (a refresh, the website) and every other build are untouched.',
].join('\n'), ].join('\n'),
@@ -1420,8 +1426,9 @@ 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 // The rollout gate: a build in ROLLOUT_GAME_VERSIONS is for staff and Discord
// only, so the account signing in on it must hold one of the configured roles. // 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 // 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 // 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 // gate can't be used to probe whether an account exists. Unlike a ban, this IS a
@@ -1429,7 +1436,7 @@ const app = new Hono<App>()
// description on its sign-in failure. // description on its sign-in failure.
if (isRolloutGameVersion(version)) { if (isRolloutGameVersion(version)) {
const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS) const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS)
if (!(await accountHoldsRolloutRole(c.env.DB, Number(accountId), roleIds))) { if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) {
logger.info('login refused: build is limited to Discord supporters', { logger.info('login refused: build is limited to Discord supporters', {
accountId, accountId,
grantType, grantType,
+4 -3
View File
@@ -55,10 +55,11 @@ export type Env = SharedHonoEnv & {
* and/or whitespace, exactly like `www`'s DISCORD_BENEFITS_ROLE_IDS. ANY one of them * 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 * 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 * Discord link (`platform_account`, platform 101, written by the website's benefits
* claim) records one of these roles. * claim) records one of these roles — or the account is STAFF (developer or moderator),
* who are always let in, configured roles or not.
* *
* Unset — or parsing to no ids — leaves the gate CLOSED: nobody gets a token on a gated * Unset — or parsing to no ids — leaves the gate CLOSED: nobody but staff gets a token on
* build. That is the safe reading of "supporters only" when no supporter role has been * 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. * 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. * Logins on every other build are never touched by this.
*/ */
+26 -1
View File
@@ -1301,9 +1301,34 @@ describe('auth worker routes', () => {
await seedAccount(9303, 'gate_unlinked') await seedAccount(9303, 'gate_unlinked')
// A Steam link is not a Discord link — it carries no roles to hold. // A Steam link is not a Discord link — it carries no roles to hold.
await linkPlatformIdentity(env.DB, 9303, PlatformType.Steam, '76561198000009303') await linkPlatformIdentity(env.DB, 9303, PlatformType.Steam, '76561198000009303')
// Staff, with no Discord link at all.
for (const [id, username, flag] of [
[9304, 'gate_developer', 'isDeveloper'],
[9305, 'gate_moderator', 'isModerator'],
] as const) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
[flag]: true,
})
)
.run()
}
}) })
test('with no roles configured the gate is closed to everyone on the build', async () => { test('staff are let in with no Discord link, even with the gate closed', async () => {
for (const roleIds of [undefined, supporter]) {
await withRoleIds(roleIds, async () => {
expect((await login('gate_developer', GATED)).status).toBe(200)
expect((await login('gate_moderator', GATED)).status).toBe(200)
})
}
})
test('with no roles configured the gate is closed to every non-staff account', async () => {
await withRoleIds(undefined, async () => { await withRoleIds(undefined, async () => {
const res = await login('gate_supporter', GATED) const res = await login('gate_supporter', GATED)
expect(res.status).toBe(400) expect(res.status).toBe(400)