add game config configuration

This commit is contained in:
Devin Zuczek
2026-07-14 18:01:10 -04:00
parent df2365fa8f
commit 3e8f66b620
17 changed files with 376 additions and 65 deletions
+22 -8
View File
@@ -15,7 +15,7 @@ import {
setPasswordHash,
setPresence,
} from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { hashPassword, verifyPassword } from './password'
@@ -57,9 +57,15 @@ const PLATFORM_TYPES: Record<number, string> = {
* legitimate players behind one address, so this WILL be the arm that produces false
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
* count by hopping networks.
*
* These are the defaults. An operator overrides either arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
* setting one to 0 disables that arm entirely — which a small private server that trusts
* everyone it invites will want, and which the IP arm in particular is worth reaching for
* if a shared network is being locked out.
*/
const MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const MAX_ACCOUNTS_PER_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
@@ -301,12 +307,19 @@ const app = new Hono<App>()
let accountId: string
if (grantType === 'create_account') {
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when its identity is unknown (no verified
// platform id / no client IP) — an unattributable signup can't be counted against
// anyone, and lumping them together would lock out real players.
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP) — an unattributable
// signup can't be counted against anyone, and lumping them together would lock out
// real players. The disabled check comes first so a disabled arm costs no D1 read.
const maxPerPlatformId = intVar(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= MAX_ACCOUNTS_PER_PLATFORM_ID
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
return c.json(
@@ -318,8 +331,9 @@ const app = new Hono<App>()
)
}
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= MAX_ACCOUNTS_PER_IP
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
return c.json(
+7
View File
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: SecretsStoreSecret
// Signup caps, both optional (see auth.app.ts for what each arm counts and why).
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
// number while the same var set from the dashboard or `--var` arrives as a string —
// read them through `intVar`, never as a bare number.
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
}
/** Variables can be extended */
+27 -1
View File
@@ -419,10 +419,36 @@ describe('auth worker routes', () => {
expect(capped.json.error_description).toMatch(/network/)
// A different IP is unaffected, and the capped IP can still LOG IN to what it has.
const other = await postToken('grant_type=create_account&platform_id=steam-cap4', '198.51.100.23')
const other = await postToken(
'grant_type=create_account&platform_id=steam-cap4',
'198.51.100.23'
)
expect(other.status).toBe(200)
})
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
// override is restored in `finally` rather than leaking a cap of 0 into the tests above.
const original = env.MAX_ACCOUNTS_PER_IP
try {
env.MAX_ACCOUNTS_PER_IP = 1
const ip = '198.51.100.30'
const first = await postToken('grant_type=create_account&platform_id=steam-var0', ip)
expect(first.status).toBe(200)
const capped = await postToken('grant_type=create_account&platform_id=steam-var1', ip)
expect(capped.status).toBe(400)
expect(capped.json.error_description).toMatch(/network/)
// 0 disables the arm entirely: the IP that was just capped can sign up again.
env.MAX_ACCOUNTS_PER_IP = 0
const uncapped = await postToken('grant_type=create_account&platform_id=steam-var2', ip)
expect(uncapped.status).toBe(200)
} finally {
env.MAX_ACCOUNTS_PER_IP = original
}
})
test('POST /connect/token does not cap logins, only signups', async () => {
// Account 42's owner may be over the signup cap; that must never lock them out of
// an account they already have.