[www] flag to disable websignups

This commit is contained in:
Devin Zuczek
2026-09-27 23:02:42 -04:00
parent a499196692
commit ab15eb9768
13 changed files with 247 additions and 12 deletions
+31 -2
View File
@@ -25,6 +25,7 @@ import {
writeAuditLog,
} from '@repo/domain'
import {
flagVar,
intVar,
logger,
withCleanSpec,
@@ -144,6 +145,20 @@ const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3
/**
* Whether a `create_account` with no platform — a password account, which is what the
* website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default:
* such an account has no identity but the address it came from, so the caps above are
* the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts.
* That is how one player made 150 accounts in a weekend. A game-client signup always
* carries a Steam or Meta identity and is untouched by this switch.
*
* Refused as `invalid_grant` with this description, which `www` translates for the form
* (see its auth-messages.ts) — keep the two in step.
*/
const DEFAULT_PASSWORD_SIGNUP = false
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
/**
@@ -617,7 +632,10 @@ const app = new Hono<App>()
'',
'**`create_account`** — mints a new account with an auto-assigned random username',
'(players do not pick one initially) and places it in the Orientation room. A posted',
'`password` becomes the login credential. Subject to three independent signup caps,',
'`password` becomes the login credential. Without a `platform` this is a PASSWORD',
'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —',
'off by default, refused as `invalid_grant` "password signup is disabled". Subject',
'to three independent signup caps,',
'per verified platform id, per signup IP and per client `device_id`',
'(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;',
'any disabled by setting it to 0). If it asserts a',
@@ -762,7 +780,7 @@ const app = new Hono<App>()
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// allowed, but binds no platformId.)
// binds no platformId, and is open only while PASSWORD_SIGNUP says so.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
@@ -851,6 +869,17 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// The password-account path (no platform) is a switch, off by default: see
// DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted —
// a closed door costs no D1 round trip and burns no cap slot.
if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) {
logger.info('signup refused: password signup is disabled', { ip: clientIp })
return c.json(
{ error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION },
400
)
}
// A banned player's next move is a new account, so the evasion arms are checked
// BEFORE one is minted — against the only identity a signup has, the IP it came
// from and the platform identity it just proved. Refusing after the fact (as the
+10
View File
@@ -27,6 +27,16 @@ export type Env = SharedHonoEnv & {
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
MAX_ACCOUNTS_PER_DEVICE_ID?: string | number
/**
* Whether `create_account` accepts a signup with NO platform identity — the password
* account the website makes (`www` `/api/signup`), or anything else that posts the
* grant without a `platform`. `on`/`off` (or true/false, 1/0); unset means OFF, so a
* server opens web signup deliberately. Read through `flagVar`. The `www` worker reads
* the same knob to hide its form and refuse its endpoint, so the two agree for free.
* Platform signups (Steam, Meta) are never governed by this — they always have an
* identity to authenticate with later, and the caps above are what limit them.
*/
PASSWORD_SIGNUP?: string | number | boolean
/**
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
@@ -1076,6 +1076,59 @@ describe('auth worker routes', () => {
expect(login.status).toBe(200)
})
test('PASSWORD_SIGNUP=off refuses a no-platform create_account and mints nothing', async () => {
// The switch the website's signup hangs off. Every other create_account test here
// runs with it ON (see vitest.config.ts); this one is the closed door.
const original = env.PASSWORD_SIGNUP
try {
env.PASSWORD_SIGNUP = 'off'
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const refused = await postToken(
'grant_type=create_account&password=hunter2&device_id=dev-web&device_class=2',
'198.51.100.60'
)
expect(refused.status).toBe(400)
expect(refused.json.error).toBe('invalid_grant')
expect(refused.json.error_description).toBe('password signup is disabled')
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
// Logging in is not what the switch gates: an existing password account still gets
// its token, whatever the switch says.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`,
'198.51.100.60'
)
expect(login.status).toBe(200)
// A platform signup is a different door: it still reaches platform verification
// (and fails THERE, on the missing ticket) rather than being refused by the switch.
const steam = await postToken(
'grant_type=create_account&platform=0&platform_id=76561198000000001',
'198.51.100.60'
)
expect(steam.status).toBe(400)
expect(steam.json.error_description).not.toBe('password signup is disabled')
// Unset is off too — the default an operator has to opt out of.
delete env.PASSWORD_SIGNUP
const unset = await postToken('grant_type=create_account&password=hunter2', '198.51.100.61')
expect(unset.status).toBe(400)
expect(unset.json.error_description).toBe('password signup is disabled')
// And `on` reopens it.
env.PASSWORD_SIGNUP = 'on'
const open = await postToken('grant_type=create_account&password=hunter2', '198.51.100.62')
expect(open.status).toBe(200)
} finally {
env.PASSWORD_SIGNUP = original
}
})
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
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
miniflare: {
bindings: {
ENVIRONMENT: 'VITEST',
// Password (no-platform) signup is OFF by default, and most create_account tests
// post no `platform` — they'd all test the closed door. The switch itself is
// covered by a test that flips it off and back.
PASSWORD_SIGNUP: 'on',
},
},
}),