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