[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_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
# 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
+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',
},
},
}),
+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
* 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> = {
'password signup is disabled': PASSWORD_SIGNUP_DISABLED,
'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.',
'account limit reached for this platform account':
+8
View File
@@ -2024,6 +2024,14 @@ function LoginPage({
) : (
<>
<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">
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.
+9
View File
@@ -66,6 +66,15 @@ export type Env = SharedHonoEnv & {
* MAX_TOKEN_GIFT. Unset means DEFAULT_MAX_XP_GIFT (100).
*/
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
* 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 () => {
const res = await SELF.fetch('https://example.com/api/signup', {
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'
@@ -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
* 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(
env: Env
): 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.
import { DEFAULT_STARTING_TOKENS } from '../../econ/src/balance-db'
import { grantDiscordRoleGift } from '../../econ/src/discord-role-gift'
import { authUnreachable } from './auth-messages'
import { authUnreachable, PASSWORD_SIGNUP_DISABLED } from './auth-messages'
import {
AUTHORIZE_URL,
discordConfig,
@@ -58,7 +58,7 @@ import {
searchReportsHandler,
topReportedHandler,
} from './staff'
import { turnstileKeys, verifyTurnstile } from './turnstile'
import { passwordSignupOpen, turnstileKeys, verifyTurnstile } from './turnstile'
import {
accountsBase,
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
// 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.
//
// 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) => {
const [keys, discord] = await Promise.all([turnstileKeys(c.env), discordConfig(c.env)])
const signupEnabled = passwordSignupOpen(c.env) && keys !== null
return c.json({
signupEnabled: keys !== null,
turnstileSiteKey: keys?.siteKey ?? null,
signupEnabled,
turnstileSiteKey: signupEnabled ? keys.siteKey : null,
// 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.
// 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 -------------------------------------------------------------
// Create an account from the website, behind a Turnstile bot check. The check is what
// makes this safe to leave open: `auth` binds no platform identity to a web account, so
// its per-IP cap (3, never decaying) is the only other thing in front of this path —
// and `auth` has no bot check of its own, which is why this one endpoint can't simply
// be called from the browser like the rest.
// Create an account from the website, behind a Turnstile bot check. `auth` binds no
// platform identity to a web account, so its signup caps are the only other thing in
// front of this path — and `auth` has no bot check of its own, which is why this one
// endpoint can't simply 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
// 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
// that token — `create_account` takes no email, and `accounts` owns the field.
.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).
const keys = await turnstileKeys(c.env)
if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403)
+3
View File
@@ -24,6 +24,9 @@ export default defineConfig({
},
bindings: {
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
// 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
+30
View File
@@ -17,3 +17,33 @@ export function intVar(value: unknown, fallback: number): number {
const parsed = Number(value)
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
}
}