mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] flag to disable websignups
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user