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.
+5
View File
@@ -36,6 +36,11 @@
"head_sampling_rate": 1 // 100%
}
},
// The signup caps (MAX_ACCOUNTS_PER_PLATFORM_ID, MAX_ACCOUNTS_PER_IP) are deliberately
// NOT set here. They're injected at deploy time from the gitignored .env
// (RECFLARE_MAX_ACCOUNTS_*, see .env.example), so tuning them never means editing a
// versioned file. Unset — the default — falls back to the DEFAULT_MAX_ACCOUNTS_*
// constants in src/auth.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
+43 -17
View File
@@ -68,16 +68,26 @@ const SPENDABLE: readonly number[] = [
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
/**
* What a player starts with, granted lazily the first time their balances are read
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
* players start broke.
*/
export const DEFAULT_STARTING_TOKENS = 10_000
/**
* What a player starts with, granted lazily the first time their balances are touched
* (see `ensureStartingBalances`). Currencies absent here start at 0.
*
* This is the whole signup grant — change the number here and it applies to every
* player who hasn't been granted yet. It is NOT re-granted: a player who spends down
* to 0 keeps a 0 row, and the grant is skipped because the row exists.
* This is the whole signup grant. It is NOT re-granted: a player who spends down to 0
* keeps a 0 row, and the grant is skipped because the row exists. That also means
* raising `STARTING_TOKENS` later only affects players who haven't been granted yet —
* existing players keep the amount they were granted under the old setting.
*/
export const STARTING_BALANCES: ReadonlyArray<{ currencyType: number; amount: number }> = [
{ currencyType: CurrencyType.RecCenterTokens, amount: 10_000 },
]
export function startingBalances(
startingTokens: number
): ReadonlyArray<{ currencyType: number; amount: number }> {
return [{ currencyType: CurrencyType.RecCenterTokens, amount: startingTokens }]
}
/**
* `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track
@@ -108,19 +118,32 @@ export interface Balance {
*
* Called on read rather than at account creation so accounts that predate this table
* (every existing player) get their grant too.
*
* `startingTokens` is passed in rather than read from a module constant because it's
* operator configuration (`STARTING_TOKENS`), and every path that can trigger the grant
* has to agree on it — a caller that skipped it would quietly grant the built-in default
* to whichever player happened to touch that path first.
*/
export async function ensureStartingBalances(db: D1Database, accountId: number): Promise<void> {
export async function ensureStartingBalances(
db: D1Database,
accountId: number,
startingTokens: number
): Promise<void> {
const stmt = db.prepare(
'INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES (?1, ?2, ?3)'
)
await db.batch(
STARTING_BALANCES.map((b) => stmt.bind(accountId, b.currencyType, b.amount))
startingBalances(startingTokens).map((b) => stmt.bind(accountId, b.currencyType, b.amount))
)
}
/** Every balance an account holds (after its starting grant is applied). */
export async function getBalances(db: D1Database, accountId: number): Promise<Balance[]> {
await ensureStartingBalances(db, accountId)
export async function getBalances(
db: D1Database,
accountId: number,
startingTokens: number
): Promise<Balance[]> {
await ensureStartingBalances(db, accountId, startingTokens)
const { results } = await db
.prepare(
'SELECT currency_type, amount FROM balance WHERE account_id = ?1 ORDER BY currency_type'
@@ -134,9 +157,10 @@ export async function getBalances(db: D1Database, accountId: number): Promise<Ba
export async function getBalance(
db: D1Database,
accountId: number,
currencyType: number
currencyType: number,
startingTokens: number
): Promise<number> {
await ensureStartingBalances(db, accountId)
await ensureStartingBalances(db, accountId, startingTokens)
const row = await db
.prepare('SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = ?2')
.bind(accountId, currencyType)
@@ -155,7 +179,8 @@ export async function creditCurrency(
db: D1Database,
accountId: number,
currencyType: number,
amount: number
amount: number,
startingTokens: number
): Promise<number> {
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error(`creditCurrency: amount must be a positive integer, got ${amount}`)
@@ -167,7 +192,7 @@ export async function creditCurrency(
)
.bind(accountId, currencyType, amount)
.run()
return getBalance(db, accountId, currencyType)
return getBalance(db, accountId, currencyType, startingTokens)
}
/**
@@ -182,12 +207,13 @@ export async function spendCurrency(
db: D1Database,
accountId: number,
currencyType: number,
amount: number
amount: number,
startingTokens: number
): Promise<boolean> {
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error(`spendCurrency: amount must be a positive integer, got ${amount}`)
}
await ensureStartingBalances(db, accountId)
await ensureStartingBalances(db, accountId, startingTokens)
const { meta } = await db
.prepare(
`UPDATE balance SET amount = amount - ?3
+9
View File
@@ -10,6 +10,15 @@ export type Env = SharedHonoEnv & {
DB: D1Database
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
ASSETS: Fetcher
/**
* The RecCenterTokens a new player is granted (see balance-db.ts). Optional — unset
* falls back to DEFAULT_STARTING_TOKENS, and 0 means players start broke.
*
* 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 it through `intVar`, never as a bare number.
*/
STARTING_TOKENS?: string | number
}
/** Variables can be extended */
+10 -3
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import defaultAvatarItems from '../static/default-avatar-items.json'
@@ -9,7 +9,7 @@ import defaultAvatar from '../static/default-avatar.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import { ALL_PLATFORMS, getBalance, isSpendable } from './balance-db'
import { ALL_PLATFORMS, DEFAULT_STARTING_TOKENS, getBalance, isSpendable } from './balance-db'
import { getOutfits, setOutfit } from './outfit-db'
import type { Context } from 'hono'
@@ -220,7 +220,14 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c)
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
if (Number.isNaN(currencyType)) return c.body(null, 400)
const amount = isSpendable(currencyType) ? await getBalance(c.env.DB, id, currencyType) : 0
const amount = isSpendable(currencyType)
? await getBalance(
c.env.DB,
id,
currencyType,
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
)
: 0
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
})
+40 -5
View File
@@ -8,6 +8,7 @@ import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
CurrencyType,
DEFAULT_STARTING_TOKENS,
getBalance,
spendCurrency,
} from '../../balance-db'
@@ -423,7 +424,9 @@ describe('econ endpoints', () => {
test('GET /api/storefronts/v4/balance/2 reflects what the player has spent', async () => {
// Spend from account 7 (a fresh account: the read below grants it first).
expect(await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500)).toBe(true)
expect(
await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('7'),
})
@@ -431,15 +434,47 @@ describe('econ endpoints', () => {
})
test('a spend the player cannot afford changes nothing', async () => {
const before = await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)
expect(await spendCurrency(env.DB, 8, CurrencyType.RecCenterTokens, before + 1)).toBe(false)
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)).toBe(before)
const before = await getBalance(
env.DB,
8,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect(
await spendCurrency(
env.DB,
8,
CurrencyType.RecCenterTokens,
before + 1,
DEFAULT_STARTING_TOKENS
)
).toBe(false)
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)).toBe(
before
)
})
test('the starting grant comes from the STARTING_TOKENS var', async () => {
// The grant an operator actually runs is the var; DEFAULT_STARTING_TOKENS is only the
// fallback. `env` is shared by every test in this file, so restore it in `finally`.
const original = env.STARTING_TOKENS
try {
env.STARTING_TOKENS = 250
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('11'),
})
expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 250 }])
} finally {
env.STARTING_TOKENS = original
}
})
test('the starting grant is not re-granted after spending down to zero', async () => {
// The grant is INSERT OR IGNORE against the row, not a top-up: a player who spends
// everything stays at 0 rather than being refilled by their next balance read.
expect(await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000)).toBe(true)
expect(
await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000, DEFAULT_STARTING_TOKENS)
).toBe(true)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('9'),
})
+4
View File
@@ -44,6 +44,10 @@
"head_sampling_rate": 1 // 100%
}
},
// The signup token grant (STARTING_TOKENS) is deliberately NOT set here. It's injected
// at deploy time from the gitignored .env (RECFLARE_STARTING_TOKENS, see .env.example),
// so tuning it never means editing a versioned file. Unset — the default — falls back
// to DEFAULT_STARTING_TOKENS in src/balance-db.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment