[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
+7
View File
@@ -70,6 +70,13 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ACCOUNTS_PER_IP=3 # RECFLARE_MAX_ACCOUNTS_PER_IP=3
# RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3 # RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3
# Whether accounts can be created from the WEBSITE (`auth` and `www`) — a password
# account with no Steam/Meta identity, which nothing but the caps above can limit, and a
# VPN or Tor exit resets the IP one. `on` or `off`; unset is OFF. Signing in to an
# existing password account is never affected, nor is creating one by launching the game.
# Turning it on also needs the Turnstile keypair in the Secrets Store (see www).
# RECFLARE_PASSWORD_SIGNUP=off
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a # How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means # comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
# BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a # BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a
+31 -2
View File
@@ -25,6 +25,7 @@ import {
writeAuditLog, writeAuditLog,
} from '@repo/domain' } from '@repo/domain'
import { import {
flagVar,
intVar, intVar,
logger, logger,
withCleanSpec, 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_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 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. */ /** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13 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', '**`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', '(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`', '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`;', '(`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', '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. // - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't // - 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 — // 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 // 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 // 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. // via create_account or /account/me/changepassword.
let accountId: string let accountId: string
if (grantType === 'create_account') { 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 // 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 // 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 // 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_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number MAX_ACCOUNTS_PER_IP?: string | number
MAX_ACCOUNTS_PER_DEVICE_ID?: 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` * 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 * 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) 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 () => { 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 // 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 // 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: { miniflare: {
bindings: { bindings: {
ENVIRONMENT: 'VITEST', 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',
}, },
}, },
}), }),
+9
View File
@@ -19,7 +19,16 @@ export type AuthAction = 'signup' | 'login'
* with no platform, sign-in posts `password` — but they're mapped anyway so a future web * with no platform, sign-in posts `password` — but they're mapped anyway so a future web
* flow that does assert one can't regress to a bare code. * flow that does assert one can't regress to a bare code.
*/ */
/**
* Web signup switched off (`PASSWORD_SIGNUP`, enforced by auth). Exported because www's
* own `/api/signup` answers with it before ever calling auth, and the two must read the
* same on the form.
*/
export const PASSWORD_SIGNUP_DISABLED =
'Account creation from the website is disabled. Launch the game to create an account.'
const AUTH_MESSAGES: Record<string, string> = { const AUTH_MESSAGES: Record<string, string> = {
'password signup is disabled': PASSWORD_SIGNUP_DISABLED,
'too many accounts created from this network': 'too many accounts created from this network':
'Too many accounts have already been created from your network. Try again later, or from a different connection.', 'Too many accounts have already been created from your network. Try again later, or from a different connection.',
'account limit reached for this platform account': 'account limit reached for this platform account':
+8
View File
@@ -2024,6 +2024,14 @@ function LoginPage({
) : ( ) : (
<> <>
<h2>Sign in</h2> <h2>Sign in</h2>
{/* Someone who arrived at /signup while it's closed gets told so, instead of a
sign-in form appearing where they expected to create an account. Only once
the config has landed: before that, closed is just the not-yet-known state. */}
{tab === 'signup' && config && !config.signupEnabled && (
<p className="warn">
Account creation from the website is disabled. Launch the game to create an account.
</p>
)}
<p className="muted"> <p className="muted">
Use your username and password. Launching the game also creates an account, linked to Use your username and password. Launching the game also creates an account, linked to
your Steam ID — set a password on it and it signs in here too. your Steam ID — set a password on it and it signs in here too.
+9
View File
@@ -66,6 +66,15 @@ export type Env = SharedHonoEnv & {
* MAX_TOKEN_GIFT. Unset means DEFAULT_MAX_XP_GIFT (100). * MAX_TOKEN_GIFT. Unset means DEFAULT_MAX_XP_GIFT (100).
*/ */
MAX_XP_GIFT?: string | number MAX_XP_GIFT?: string | number
/**
* Whether web signup is open — the same `PASSWORD_SIGNUP` knob `auth` reads to accept
* or refuse a no-platform `create_account`, delivered to both workers by the deploy
* scripts. `on`/`off`; unset means OFF. Read here so `/api/config` can report signup
* closed (the SPA hides the form) and `/api/signup` can refuse with the player-facing
* sentence, instead of every visitor discovering it from auth's refusal. It is a
* courtesy, not the enforcement: auth refuses regardless of what www says.
*/
PASSWORD_SIGNUP?: string | number | boolean
/** /**
* Service binding to the `auth` worker — how the BFF reaches it, so the browser's real * Service binding to the `auth` worker — how the BFF reaches it, so the browser's real
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`). * IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
+54
View File
@@ -235,6 +235,60 @@ it('treats an unresolvable or half-configured keypair as signup being off', asyn
}) })
}) })
// The operator's switch, off by default. `auth` is what enforces it (see its tests); www
// reads the same knob so the SPA hides the form and the endpoint answers in the sentence
// the form would show, rather than every visitor learning it from auth's refusal.
it('PASSWORD_SIGNUP=off reports signup closed and refuses the endpoint', async () => {
const original = env.PASSWORD_SIGNUP
try {
env.PASSWORD_SIGNUP = 'off'
const config = (await (await SELF.fetch('https://example.com/api/config')).json()) as {
signupEnabled: boolean
turnstileSiteKey: string | null
}
// Closed, and the site key withheld with it: no widget to mount for a form that
// isn't offered.
expect(config.signupEnabled).toBe(false)
expect(config.turnstileSiteKey).toBeNull()
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'hunter2', turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX' }),
})
expect(res.status).toBe(403)
expect(await res.json()).toEqual({
error: 'Account creation from the website is disabled. Launch the game to create an account.',
})
// Unset is off as well.
delete env.PASSWORD_SIGNUP
const unset = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'hunter2', turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX' }),
})
expect(unset.status).toBe(403)
} finally {
env.PASSWORD_SIGNUP = original
}
})
// auth's own refusal of the grant, should a request reach it anyway, reads the same.
it('translates auth’s password-signup refusal into the same sentence', async () => {
const failure = await readAuthError(
new Response(
JSON.stringify({ error: 'invalid_grant', error_description: 'password signup is disabled' }),
{ status: 400, headers: { 'content-type': 'application/json' } }
),
'signup'
)
expect(failure.message).toBe(
'Account creation from the website is disabled. Launch the game to create an account.'
)
expect(failure.status).toBe(400)
})
it('refuses a signup with no Turnstile token', async () => { it('refuses a signup with no Turnstile token', async () => {
const res = await SELF.fetch('https://example.com/api/signup', { const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST', method: 'POST',
+9 -1
View File
@@ -1,4 +1,4 @@
import { logger } from '@repo/hono-helpers' import { flagVar, logger } from '@repo/hono-helpers'
import type { Env } from './context' import type { Env } from './context'
@@ -44,6 +44,14 @@ const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverif
* pair belongs to no account and passes without a human. Deliberately not a built-in * pair belongs to no account and passes without a human. Deliberately not a built-in
* fallback: the same code path then runs everywhere. * fallback: the same code path then runs everywhere.
*/ */
/**
* Whether the operator has switched web (password) signup on — `PASSWORD_SIGNUP`, the
* knob `auth` enforces on its `create_account` grant, read here for the form and the
* endpoint's early refusal. Unset is OFF: a web account has no identity but its address,
* so a server opens this deliberately. See `Env.PASSWORD_SIGNUP`.
*/
export const passwordSignupOpen = (env: Env): boolean => flagVar(env.PASSWORD_SIGNUP, false)
export async function turnstileKeys( export async function turnstileKeys(
env: Env env: Env
): Promise<{ siteKey: string; secretKey: string } | null> { ): Promise<{ siteKey: string; secretKey: string } | null> {
+20 -9
View File
@@ -25,7 +25,7 @@ import {
// it the box one cron run would, so the amount and the box are econ's and not a copy. // it the box one cron run would, so the amount and the box are econ's and not a copy.
import { DEFAULT_STARTING_TOKENS } from '../../econ/src/balance-db' import { DEFAULT_STARTING_TOKENS } from '../../econ/src/balance-db'
import { grantDiscordRoleGift } from '../../econ/src/discord-role-gift' import { grantDiscordRoleGift } from '../../econ/src/discord-role-gift'
import { authUnreachable } from './auth-messages' import { authUnreachable, PASSWORD_SIGNUP_DISABLED } from './auth-messages'
import { import {
AUTHORIZE_URL, AUTHORIZE_URL,
discordConfig, discordConfig,
@@ -58,7 +58,7 @@ import {
searchReportsHandler, searchReportsHandler,
topReportedHandler, topReportedHandler,
} from './staff' } from './staff'
import { turnstileKeys, verifyTurnstile } from './turnstile' import { passwordSignupOpen, turnstileKeys, verifyTurnstile } from './turnstile'
import { import {
accountsBase, accountsBase,
apiBase, apiBase,
@@ -166,11 +166,16 @@ const app = new Hono<App>()
// it calls directly. All three are served rather than baked into the client build so // it calls directly. All three are served rather than baked into the client build so
// one build works for any operator. The site key is public (it ships in the widget // one build works for any operator. The site key is public (it ships in the widget
// markup either way); the secret never leaves the worker. // markup either way); the secret never leaves the worker.
//
// Signup is open only when BOTH hold: the operator has switched password signup on
// (`PASSWORD_SIGNUP`, the knob auth enforces) and a Turnstile keypair is configured to
// guard it. Either missing and the SPA offers no form.
.get('/api/config', async (c) => { .get('/api/config', async (c) => {
const [keys, discord] = await Promise.all([turnstileKeys(c.env), discordConfig(c.env)]) const [keys, discord] = await Promise.all([turnstileKeys(c.env), discordConfig(c.env)])
const signupEnabled = passwordSignupOpen(c.env) && keys !== null
return c.json({ return c.json({
signupEnabled: keys !== null, signupEnabled,
turnstileSiteKey: keys?.siteKey ?? null, turnstileSiteKey: signupEnabled ? keys.siteKey : null,
// The benefits claim, on the same terms: open only when it's fully configured, and // The benefits claim, on the same terms: open only when it's fully configured, and
// the SPA is handed a ready-made consent URL rather than the parts to build one. // the SPA is handed a ready-made consent URL rather than the parts to build one.
// Nothing secret is served — the client id inside it is public — and the guild/role // Nothing secret is served — the client id inside it is public — and the guild/role
@@ -250,11 +255,16 @@ const app = new Hono<App>()
// ---- Signup ------------------------------------------------------------- // ---- Signup -------------------------------------------------------------
// Create an account from the website, behind a Turnstile bot check. The check is what // Create an account from the website, behind a Turnstile bot check. `auth` binds no
// makes this safe to leave open: `auth` binds no platform identity to a web account, so // platform identity to a web account, so its signup caps are the only other thing in
// its per-IP cap (3, never decaying) is the only other thing in front of this path — // front of this path — and `auth` has no bot check of its own, which is why this one
// and `auth` has no bot check of its own, which is why this one endpoint can't simply // endpoint can't simply be called from the browser like the rest.
// be called from the browser like the rest. //
// The whole path is behind the `PASSWORD_SIGNUP` switch, OFF by default: a web account
// has no identity but its address, and the per-IP cap is reset by every VPN or Tor exit
// (one player made 150 accounts that way in a weekend). `auth` is what enforces the
// switch — it refuses the grant whatever this worker does — so the check here only
// spares a visitor the Turnstile round trip and answers in the sentence the form shows.
// //
// Deliberately passes NO `platform`: create_account treats an asserted platform as one // Deliberately passes NO `platform`: create_account treats an asserted platform as one
// to verify against Steam and would reject RecNet, so this is the platform-less // to verify against Steam and would reject RecNet, so this is the platform-less
@@ -265,6 +275,7 @@ const app = new Hono<App>()
// account's email, when the player gave one, is saved by the client afterwards with // account's email, when the player gave one, is saved by the client afterwards with
// that token — `create_account` takes no email, and `accounts` owns the field. // that token — `create_account` takes no email, and `accounts` owns the field.
.post('/api/signup', async (c) => { .post('/api/signup', async (c) => {
if (!passwordSignupOpen(c.env)) return c.json({ error: PASSWORD_SIGNUP_DISABLED }, 403)
// No usable keypair means signup is closed rather than unprotected (see turnstile.ts). // No usable keypair means signup is closed rather than unprotected (see turnstile.ts).
const keys = await turnstileKeys(c.env) const keys = await turnstileKeys(c.env)
if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403) if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403)
+3
View File
@@ -24,6 +24,9 @@ export default defineConfig({
}, },
bindings: { bindings: {
ENVIRONMENT: 'VITEST', ENVIRONMENT: 'VITEST',
// Web signup is OFF unless the operator switches it on; the signup tests below
// exercise the open door, and one flips this off to pin the closed one.
PASSWORD_SIGNUP: 'on',
// The Turnstile keypair is NOT bound here: both keys come from the Secrets // The Turnstile keypair is NOT bound here: both keys come from the Secrets
// Store now, and the tests seed the local store with the test pair (see // Store now, and the tests seed the local store with the test pair (see
// src/test/integration/api.test.ts). A plain binding of the same name would // src/test/integration/api.test.ts). A plain binding of the same name would
+30
View File
@@ -17,3 +17,33 @@ export function intVar(value: unknown, fallback: number): number {
const parsed = Number(value) const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback return Number.isInteger(parsed) ? parsed : fallback
} }
/**
* Read an on/off worker var, falling back to `fallback` when it is unset or unusable.
*
* Accepts the spellings an operator plausibly types — `on`/`off`, `true`/`false`,
* `yes`/`no`, `1`/`0`, in any case — plus a real boolean or number, since a var declared
* in wrangler.jsonc `vars` keeps its JSON type while one set with `--var` is a string (see
* `intVar`). Anything else (an empty string, `enabled`, a typo) is treated as unset rather
* than read as true or false: a switch that guards something sensitive must not flip on a
* misspelling, so the documented default is the safe failure.
*/
export function flagVar(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value === 1 ? true : value === 0 ? false : fallback
if (typeof value !== 'string') return fallback
switch (value.trim().toLowerCase()) {
case 'on':
case 'true':
case 'yes':
case '1':
return true
case 'off':
case 'false':
case 'no':
case '0':
return false
default:
return fallback
}
}