diff --git a/.env.example b/.env.example index 182caba..8469f5a 100644 --- a/.env.example +++ b/.env.example @@ -238,12 +238,12 @@ RECFLARE_DOMAIN=rec.example.com # 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 +# unless the account is staff (developer or moderator — always let in) or its 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 +# 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, # link Discord at /claim, and come back. # RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002 diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index d3e49aa..f71ad66 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -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` * (`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. + * 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. * * `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 @@ -197,8 +198,8 @@ const ROLLOUT_GATED_DESCRIPTION = /** * `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. + * Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a + * gate closed to everyone but staff. */ function rolloutRoleIds(raw: string | undefined): string[] { 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, - * 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. + * Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator + * flag on the account, the same two `staff_login` audits — always may, configured roles or + * not: they are who tests a build before it reaches supporters, so a closed gate stays open + * to them. Anyone else needs a Discord link (platform 101, written by the website's benefits + * 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 - * role named, nobody is a supporter. + * An empty role list skips the link read: with no supporter role named, nobody is a + * supporter. */ -async function accountHoldsRolloutRole( +async function accountMayUseRolloutBuild( db: D1Database, accountId: number, roleIds: readonly string[] ): Promise { + const account = await getAccount(db, accountId) + if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true if (roleIds.length === 0) return false const links = await getLinksForAccount(db, accountId) return links.some( @@ -979,9 +984,10 @@ const app = new Hono() '', '**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', - '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', + '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', + 'everyone but staff. `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'), @@ -1420,8 +1426,9 @@ const app = new Hono() 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. + // 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. // 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 @@ -1429,7 +1436,7 @@ const app = new Hono() // 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))) { + if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) { logger.info('login refused: build is limited to Discord supporters', { accountId, grantType, diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 6ffc1f4..867bc96 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -55,10 +55,11 @@ export type Env = SharedHonoEnv & { * 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. + * 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 - * build. That is the safe reading of "supporters only" when no supporter role has been + * Unset — or parsing to no ids — leaves the gate CLOSED: nobody but staff 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. */ diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index e0492c1..bf661e7 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1301,9 +1301,34 @@ describe('auth worker routes', () => { 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') + // 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 () => { const res = await login('gate_supporter', GATED) expect(res.status).toBe(400)