mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
Add account signup and turnstile, subroom perms (#24)
* turnstile * require turnstile * homepage refresh * implemented rooms visited endpoint for friends * update default profile pic * add a meta download button * add subroom permissions * enable signup
This commit is contained in:
+47
-1
@@ -25,8 +25,9 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
||||
|
||||
| Method | Path | Upstream |
|
||||
| ------ | --------------- | -------------------------------------------------------- |
|
||||
| GET | `/api/config` | none — whether signup is open, plus the Turnstile key |
|
||||
| POST | `/api/signup` | auth `POST /connect/token` (`grant_type=create_account`) |
|
||||
| POST | `/api/login` | auth `POST /connect/token` (account id + password) |
|
||||
| POST | `/api/login` | auth `POST /connect/token` (username + password) |
|
||||
| POST | `/api/logout` | clears the session cookie |
|
||||
| GET | `/api/me` | accounts `GET /account/me` |
|
||||
| POST | `/api/email` | accounts `POST /account/me/email` |
|
||||
@@ -35,6 +36,51 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
||||
On signup/login the access token returned by `auth` is stored in an httpOnly
|
||||
`rf_token` cookie; the other routes read it and forward it as a Bearer token.
|
||||
|
||||
`/api/signup` also takes an optional `email`, saved with a second call to accounts
|
||||
`POST /account/me/email` once the session exists — `create_account` has no email
|
||||
field, the accounts worker owns it. The address is format-checked before the
|
||||
account is created, since a rejection afterwards would leave a player with an
|
||||
account whose email silently didn't save; a failure of the save itself is logged
|
||||
and does not fail the signup, because by then the account is real and a retry
|
||||
would spend another slot against auth's per-IP cap.
|
||||
|
||||
### Signup and Turnstile
|
||||
|
||||
`POST /api/signup` creates an account with no platform identity (a password
|
||||
account), so it's the one BFF route a bot could farm — `auth` binds no Steam id to
|
||||
it and only its coarse per-IP cap applies. It therefore runs behind a
|
||||
[Turnstile](https://developers.cloudflare.com/turnstile/) check: the browser posts
|
||||
the widget's token, and the worker verifies it against Turnstile's `siteverify`
|
||||
server-side before calling `auth`. The secret key never leaves the worker, and the
|
||||
browser never talks to `siteverify` itself.
|
||||
|
||||
Two Secrets Store secrets configure it, `TURNSTILE_SITE_KEY` and
|
||||
`TURNSTILE_SECRET_KEY`, bound from the same account-level store every worker uses
|
||||
for `JWT_SECRET` (see `wrangler.jsonc` and `src/turnstile.ts`) — the site key is
|
||||
public, but keeping it with its secret makes the pair the single switch. Creating
|
||||
both is what opens signup; if either fails to resolve, `/api/config` reports
|
||||
`signupEnabled: false` (so the SPA shows sign-in only) and `/api/signup` returns
|
||||
403, so an unconfigured worker serves no signup rather than an unprotected one.
|
||||
A store read that throws is treated the same as a missing key — `/api/config` is on
|
||||
the homepage's critical path and must not 500 when signup isn't set up.
|
||||
|
||||
Because `.get()` caches per isolate, changing either value in the store needs a
|
||||
`www` redeploy before a warm worker picks it up.
|
||||
|
||||
For local dev, seed the two names into the **local** store (miniflare's, keyed by
|
||||
the literal `local` store id — it is per-directory, so run these in `apps/www`)
|
||||
with Turnstile's documented always-passes test keypair, which needs no widget and
|
||||
no account:
|
||||
|
||||
```sh
|
||||
printf '1x00000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers
|
||||
printf '1x0000000000000000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers
|
||||
```
|
||||
|
||||
The tests seed the same pair into their own local store in `beforeAll`.
|
||||
|
||||
## Development
|
||||
|
||||
### Run in dev mode
|
||||
|
||||
+379
-57
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links'
|
||||
import {
|
||||
DISCORD_INVITE,
|
||||
DOWNLOAD_URL,
|
||||
LICENSE_URL,
|
||||
QUEST_DOWNLOAD_URL,
|
||||
SOURCE_REPO,
|
||||
} from '../links'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
@@ -14,6 +20,16 @@ interface SelfAccount {
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Site config from the BFF (`/api/config`). `signupEnabled` is false when the operator
|
||||
* has no Turnstile keypair configured — web signup runs behind that bot check, so
|
||||
* without it the endpoint is closed and the UI must not offer the form.
|
||||
*/
|
||||
interface SiteConfig {
|
||||
signupEnabled: boolean
|
||||
turnstileSiteKey: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with
|
||||
* the upstream error message (auth uses `error`/`error_description`, the account
|
||||
@@ -85,12 +101,18 @@ function Link({
|
||||
export function App() {
|
||||
// undefined = still checking the session; null = signed out.
|
||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
||||
// undefined until the config lands. Signup is treated as closed until told otherwise,
|
||||
// so a slow (or failed) config fetch can't flash a form the server would refuse.
|
||||
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
|
||||
const { path, navigate } = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
api<SelfAccount>('/api/me')
|
||||
.then((me) => setAccount(me))
|
||||
.catch(() => setAccount(null))
|
||||
api<SiteConfig>('/api/config')
|
||||
.then((c) => setConfig(c))
|
||||
.catch(() => setConfig({ signupEnabled: false, turnstileSiteKey: null }))
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
@@ -102,12 +124,22 @@ export function App() {
|
||||
return (
|
||||
<>
|
||||
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||
{path === '/login' ? (
|
||||
<LoginPage account={account} navigate={navigate} onAuthed={setAccount} />
|
||||
{path === '/login' || path === '/signup' ? (
|
||||
// One page, two doors. `/signup` exists so the homepage's create-account link
|
||||
// lands on that tab instead of dropping people on sign-in to find it — and so
|
||||
// the URL is linkable. Unknown paths fall back to index.html (see the assets
|
||||
// config in wrangler.jsonc), so a cold load of /signup reaches the SPA.
|
||||
<LoginPage
|
||||
account={account}
|
||||
config={config}
|
||||
initialTab={path === '/signup' ? 'signup' : 'login'}
|
||||
navigate={navigate}
|
||||
onAuthed={setAccount}
|
||||
/>
|
||||
) : path === '/account' ? (
|
||||
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
||||
) : (
|
||||
<HomePage />
|
||||
<HomePage account={account} config={config} navigate={navigate} />
|
||||
)}
|
||||
<SiteFooter />
|
||||
</>
|
||||
@@ -205,12 +237,25 @@ function useSlideshow() {
|
||||
* on top of them. Everything about how the thing is built sits below, for whoever
|
||||
* scrolls looking for it.
|
||||
*/
|
||||
function HomePage() {
|
||||
function HomePage({
|
||||
account,
|
||||
config,
|
||||
navigate,
|
||||
}: {
|
||||
account: SelfAccount | null | undefined
|
||||
config: SiteConfig | undefined
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const feed = useSlideshow()
|
||||
|
||||
// The signup offer only makes sense to a signed-out visitor when the server would
|
||||
// actually take one. `account === undefined` is still-checking, so it shows nothing
|
||||
// rather than offering an account to someone who already has one.
|
||||
const offerSignup = account === null && config?.signupEnabled === true
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Stage slides={feed.slides} />
|
||||
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
|
||||
<div className="shell home">
|
||||
<About slides={feed.slides} error={feed.error} />
|
||||
</div>
|
||||
@@ -219,31 +264,36 @@ function HomePage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero: a rotating in-game photo with the headline and the way in over it. The
|
||||
* photo is the backdrop, never the payload — when the feed is slow or down the stage
|
||||
* still renders, so "Play now!" is reachable either way.
|
||||
* The hero: the headline and the way in on the left, a rotating in-game photo on the
|
||||
* right. The photo is proof, never the payload — when the feed is slow or down the
|
||||
* frame holds its space and the left half reads the same, so "Play now!" is reachable
|
||||
* either way.
|
||||
*/
|
||||
function Stage({ slides }: { slides: Slide[] | null }) {
|
||||
function Stage({
|
||||
slides,
|
||||
offerSignup,
|
||||
navigate,
|
||||
}: {
|
||||
slides: Slide[] | null
|
||||
offerSignup: boolean
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0)
|
||||
const count = slides?.length ?? 0
|
||||
|
||||
// A timeout keyed on the current slide rather than one long-lived interval: steering
|
||||
// by hand re-arms it, so a photo you just picked gets its full six seconds.
|
||||
useEffect(() => {
|
||||
if (!slides || slides.length < 2) return
|
||||
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 6000)
|
||||
return () => clearInterval(t)
|
||||
}, [slides])
|
||||
if (count < 2) return
|
||||
const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000)
|
||||
return () => clearTimeout(t)
|
||||
}, [count, idx])
|
||||
|
||||
const slide = slides && slides.length > 0 ? slides[idx] : null
|
||||
const step = (by: number) => setIdx((i) => (i + by + count) % count)
|
||||
|
||||
return (
|
||||
<section className="stage">
|
||||
{slide && (
|
||||
<img
|
||||
className="stage-photo"
|
||||
key={slide.url}
|
||||
src={slide.url}
|
||||
alt={`Photo taken in game by ${slide.username}`}
|
||||
/>
|
||||
)}
|
||||
<div className="stage-body">
|
||||
{/* Deliberately doesn't name the game: this is a fan project, so the
|
||||
trademark stays out of the headline and appears lower down, in
|
||||
@@ -251,40 +301,90 @@ function Stage({ slides }: { slides: Slide[] | null }) {
|
||||
<h1 className="stage-title">
|
||||
Play like it's <em>2023</em>.
|
||||
</h1>
|
||||
<p className="stage-lede">
|
||||
The servers you remember, rebuilt and running — free, open source, and up right now.
|
||||
</p>
|
||||
<div className="stage-actions">
|
||||
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
Download for PC
|
||||
</a>
|
||||
<a className="cta" href={QUEST_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
Download for Quest
|
||||
</a>
|
||||
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
||||
Join the Discord
|
||||
</a>
|
||||
</div>
|
||||
{/* A line rather than a fourth button: the download is the point of this page,
|
||||
and launching the game makes an account by itself — signing up here is the
|
||||
way in for someone who wants one first. Hidden entirely when signup is
|
||||
closed, matching /login, which hides its create-account tab the same way. */}
|
||||
{offerSignup && (
|
||||
<p className="stage-alt">
|
||||
New here?{' '}
|
||||
<Link to="/signup" navigate={navigate}>
|
||||
Create an account
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{slide && (
|
||||
<div className="stage-show">
|
||||
<div className="stage-frame">
|
||||
{slide && (
|
||||
<img
|
||||
className="stage-photo"
|
||||
key={slide.url}
|
||||
src={slide.url}
|
||||
alt={`Photo taken in game by ${slide.username}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
|
||||
<div className="stage-foot">
|
||||
<span className="credit">
|
||||
Photo by @{slide.username}
|
||||
{slide.roomName && ` in ${slide.roomName}`}
|
||||
</span>
|
||||
{slides && slides.length > 1 && (
|
||||
<span className="dots">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
key={s.url}
|
||||
className={i === idx ? 'on' : ''}
|
||||
onClick={() => setIdx(i)}
|
||||
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
||||
aria-current={i === idx}
|
||||
/>
|
||||
))}
|
||||
{slide && (
|
||||
<span className="credit">
|
||||
Photo by @{slide.username}
|
||||
{slide.roomName && ` in ${slide.roomName}`}
|
||||
</span>
|
||||
)}
|
||||
{/* Arrows and a count, not a dot per photo: the feed runs to SLIDESHOW_LIMIT
|
||||
(130) images, and a dot each is both unusable and wide enough to shove
|
||||
the headline's half of the split off the page. */}
|
||||
{count > 1 && (
|
||||
<span className="steer">
|
||||
<button onClick={() => step(-1)} aria-label="Previous photo">
|
||||
<Chevron />
|
||||
</button>
|
||||
<span className="count">
|
||||
{idx + 1} / {count}
|
||||
</span>
|
||||
<button onClick={() => step(1)} aria-label="Next photo">
|
||||
<Chevron next />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The slideshow's back/forward mark. Decorative — the buttons carry the label. */
|
||||
function Chevron({ next }: { next?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d={next ? 'M9 5l7 7-7 7' : 'M15 5l-7 7 7 7'}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** What RecFlare is, under the fold, for whoever wants it. */
|
||||
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
// The feed answering is proof the server replied, so the indicator can't claim
|
||||
@@ -297,8 +397,8 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
|
||||
<p className="about-lede">
|
||||
A free fan project, made by players who missed it. Aiming to be{' '}
|
||||
<strong>feature-complete</strong> and infinitely scalable — no gatekeeping, no basement
|
||||
server.
|
||||
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
||||
<strong>architected for the cloud</strong>, no gatekeeping, no basement server.
|
||||
</p>
|
||||
</div>
|
||||
<div className="about-side">
|
||||
@@ -324,34 +424,85 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The sign-in page. Redirects to the account page once a session exists. */
|
||||
/**
|
||||
* The sign-in page — sign in, plus create-account when the server says signup is open
|
||||
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
|
||||
* session exists, however it was obtained.
|
||||
*/
|
||||
function LoginPage({
|
||||
account,
|
||||
config,
|
||||
initialTab,
|
||||
navigate,
|
||||
onAuthed,
|
||||
}: {
|
||||
account: SelfAccount | null | undefined
|
||||
config: SiteConfig | undefined
|
||||
initialTab: 'signup' | 'login'
|
||||
navigate: Navigate
|
||||
onAuthed: (a: SelfAccount) => void
|
||||
}) {
|
||||
// The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can
|
||||
// never disagree — switching tabs pushes history, and back goes back to the other one.
|
||||
const tab = initialTab
|
||||
|
||||
useEffect(() => {
|
||||
if (account) navigate('/account')
|
||||
}, [account, navigate])
|
||||
|
||||
const authed = (a: SelfAccount) => {
|
||||
onAuthed(a)
|
||||
navigate('/account')
|
||||
}
|
||||
|
||||
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="card">
|
||||
<h2>Sign in</h2>
|
||||
<p className="muted">
|
||||
Launch the game first — that creates an account linked to your Steam ID. Once you set a
|
||||
password, use your username and that password to sign in here.
|
||||
</p>
|
||||
<LoginForm
|
||||
onAuthed={(a) => {
|
||||
onAuthed(a)
|
||||
navigate('/account')
|
||||
}}
|
||||
/>
|
||||
{siteKey && (
|
||||
<div className="tabs">
|
||||
<button className={tab === 'login' ? 'active' : ''} onClick={() => navigate('/login')}>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
className={tab === 'signup' ? 'active' : ''}
|
||||
onClick={() => navigate('/signup')}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{siteKey && tab === 'signup' ? (
|
||||
<>
|
||||
<h2>Create account</h2>
|
||||
<p className="muted">
|
||||
A username is assigned for you — you'll see it on your account page. Choose a
|
||||
password, and the two together sign you in here and in the game.
|
||||
</p>
|
||||
<SignupForm siteKey={siteKey} onAuthed={authed} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>Sign in</h2>
|
||||
<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.
|
||||
</p>
|
||||
<LoginForm onAuthed={authed} />
|
||||
{/* The tabs above already offer this; the line under the button is where
|
||||
someone who just found out they have no account is actually looking.
|
||||
Gated on the same key, so it can't point at a door that isn't there. */}
|
||||
{siteKey && (
|
||||
<p className="muted swap">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/signup" navigate={navigate}>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
@@ -409,9 +560,180 @@ function useAction() {
|
||||
return { pending, error, done, run }
|
||||
}
|
||||
|
||||
// Manual web signups are disabled for now, so only sign-in is exposed (accounts are
|
||||
// created via the game/platform, not the website). To bring signups back, restore a
|
||||
// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts.
|
||||
/**
|
||||
* Turnstile's browser API, as much of it as the signup widget uses. Loaded from
|
||||
* Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in
|
||||
* node_modules and has no types of its own.
|
||||
*/
|
||||
interface TurnstileApi {
|
||||
render: (
|
||||
el: HTMLElement,
|
||||
opts: {
|
||||
sitekey: string
|
||||
action?: string
|
||||
callback?: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
}
|
||||
) => string | undefined
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId?: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Turnstile's script, once per page, resolving when `window.turnstile` is ready.
|
||||
* `render=explicit` stops it scanning the document for widgets: this is a SPA, so the
|
||||
* container mounts and unmounts with the form and we render into it ourselves.
|
||||
*
|
||||
* The promise is cached at module scope, so switching tabs back and forth reuses the
|
||||
* loaded script instead of appending another tag. A rejection is cached too — the retry
|
||||
* is a page reload, which is what the error message asks for.
|
||||
*/
|
||||
let turnstileScript: Promise<void> | null = null
|
||||
function loadTurnstile(): Promise<void> {
|
||||
turnstileScript ??= new Promise<void>((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
el.async = true
|
||||
el.defer = true
|
||||
el.onload = () => resolve()
|
||||
el.onerror = () => reject(new Error('load failed'))
|
||||
document.head.appendChild(el)
|
||||
})
|
||||
return turnstileScript
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a Turnstile widget and hand back the token it produces. No token means no
|
||||
* submit: the BFF refuses a signup without one, so the form gates its button on it
|
||||
* rather than letting the request fail.
|
||||
*
|
||||
* `reset` re-arms the widget for another attempt — a token is single-use, so a rejected
|
||||
* signup can't be retried with the same one.
|
||||
*/
|
||||
function useTurnstile(siteKey: string) {
|
||||
const container = useRef<HTMLDivElement | null>(null)
|
||||
const widgetId = useRef<string | undefined>(undefined)
|
||||
const [token, setToken] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
loadTurnstile()
|
||||
.then(() => {
|
||||
// StrictMode mounts twice, and the cleanup below removes the first widget; bail
|
||||
// if this effect is the stale one so we don't render into a detached container.
|
||||
if (!live || !container.current || !window.turnstile) return
|
||||
widgetId.current = window.turnstile.render(container.current, {
|
||||
sitekey: siteKey,
|
||||
// Marker Cloudflare uses to segment Turnstile integrations; carries no user data.
|
||||
action: 'turnstile-spin-v1',
|
||||
callback: (t) => setToken(t),
|
||||
// Tokens expire after a few minutes; drop ours so the button locks again and
|
||||
// Turnstile can hand us a fresh one.
|
||||
'expired-callback': () => setToken(''),
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setError("Couldn't load the bot check — reload the page to try again.")
|
||||
})
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
if (widgetId.current) window.turnstile?.remove(widgetId.current)
|
||||
widgetId.current = undefined
|
||||
}
|
||||
}, [siteKey])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setToken('')
|
||||
if (widgetId.current) window.turnstile?.reset(widgetId.current)
|
||||
}, [])
|
||||
|
||||
return { container, token, error, reset }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an account from the website: a password, plus a Turnstile token proving a human
|
||||
* filled the form. The username comes back auto-assigned from `auth` (players don't pick
|
||||
* one), and the session is live on success — so this lands on the account page, where the
|
||||
* username is shown.
|
||||
*/
|
||||
function SignupForm({
|
||||
siteKey,
|
||||
onAuthed,
|
||||
}: {
|
||||
siteKey: string
|
||||
onAuthed: (a: SelfAccount) => void
|
||||
}) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const { container, token, error: widgetError, reset } = useTurnstile(siteKey)
|
||||
const { pending, error, run } = useAction()
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
try {
|
||||
const { account } = await api<{ account: SelfAccount }>('/api/signup', {
|
||||
password,
|
||||
email,
|
||||
turnstileToken: token,
|
||||
})
|
||||
onAuthed(account)
|
||||
return ''
|
||||
} catch (err) {
|
||||
// The token is spent either way, so re-arm the widget before they retry.
|
||||
reset()
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{/* Optional, and the button doesn't wait on it — but it's the only contact detail
|
||||
an account has, so the hint says plainly what it's for rather than leaving it
|
||||
to be guessed. `type="email"` gets the right keyboard on mobile and a free
|
||||
format check; the worker re-checks it before the account is created. */}
|
||||
<label>
|
||||
Email <span className="optional">optional</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<span className="hint">
|
||||
How you get back in if you forget your password — there's no other way to reach you.
|
||||
You can add it later on your account page.
|
||||
</span>
|
||||
</label>
|
||||
<div className="turnstile" ref={container} />
|
||||
{widgetError && <p className="error">{widgetError}</p>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={pending || token === ''}>
|
||||
{pending ? 'Creating…' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
+186
-70
@@ -84,7 +84,7 @@ body {
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
/* The homepage: the stage runs full-bleed above this, so it brings its own top space. */
|
||||
/* The homepage: the stage above brings its own top space and shares this width. */
|
||||
.shell.home {
|
||||
max-width: 1040px;
|
||||
padding-top: 0;
|
||||
@@ -153,19 +153,42 @@ body {
|
||||
/* ---- The stage (hero) --------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Full-bleed, edge to edge under the nav: a photo somebody actually took in game,
|
||||
* with the headline and the way in over it. The photo is the backdrop and never the
|
||||
* payload — with no photo the stage is still a solid panel carrying the same words.
|
||||
* Split down the middle: what this is and the way in on the left, a photo somebody
|
||||
* actually took in game on the right. The photo is proof and never the payload — with
|
||||
* no photo the frame holds its space and the left half carries the same words.
|
||||
*/
|
||||
.stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 20px 16px;
|
||||
}
|
||||
|
||||
/* min-width: 0 on both halves, or the split isn't one: a `1fr` track's automatic
|
||||
minimum is its content's min-content width, so a wide child (the slideshow controls,
|
||||
a long unbroken credit) grows its column past 50% and takes the space out of the
|
||||
other one — which is how this last read as 30/70 with the buttons crushed. */
|
||||
.stage-show {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: min(40vh, 320px);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Fixed shape, filled by whatever lands: screenshots arrive at any aspect ratio, and a
|
||||
frame that resized per photo would jog the headline beside it on every rotation. The
|
||||
ratio is landscape rather than 4:3 so the photo doesn't tower over the column beside
|
||||
it — equal columns still read unequal when one is half again as tall. */
|
||||
.stage-frame {
|
||||
position: relative;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-hi);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.stage-photo {
|
||||
@@ -174,11 +197,6 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -2;
|
||||
/* Softened so the headline reads over any screenshot. Scaled up past the frame
|
||||
because blur samples past the edges and would otherwise feather them. */
|
||||
filter: blur(5px) saturate(1.08);
|
||||
transform: scale(1.06);
|
||||
animation: photo-in 0.7s ease;
|
||||
}
|
||||
|
||||
@@ -188,40 +206,20 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrim: enough weight at the bottom to hold white text over any screenshot. */
|
||||
.stage::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(10 7 4 / 90%) 0%,
|
||||
rgb(10 7 4 / 74%) 40%,
|
||||
rgb(10 7 4 / 44%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.stage-body {
|
||||
max-width: 1040px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* No width cap here, unlike the headings below: this one shares a row with the photo,
|
||||
and a headline that stops short of its column makes the split read as 30/70. */
|
||||
.stage-title {
|
||||
font-family: var(--display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
margin: 0 0 20px;
|
||||
max-width: 15ch;
|
||||
margin: 0 0 16px;
|
||||
text-wrap: balance;
|
||||
/* Bloom: a wide, soft shadow rather than a hard one, so it separates the type from
|
||||
a bright screenshot without reading as a drop shadow. */
|
||||
text-shadow: 0 2px 30px rgb(8 5 2 / 55%);
|
||||
}
|
||||
|
||||
/* The one place the orange carries meaning in the headline: the year it restores. */
|
||||
@@ -230,55 +228,84 @@ body {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Credit line and slide dots, sitting under the headline on the photo itself. */
|
||||
.stage-lede {
|
||||
font-size: 1.05rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
/* The signup offer under the hero buttons. Deliberately quieter than a CTA — it sits
|
||||
below the downloads without competing with them — but the link itself carries the
|
||||
accent so it reads as the action it is. */
|
||||
.stage-alt {
|
||||
font-size: 0.925rem;
|
||||
color: var(--muted);
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
.stage-alt a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.stage-alt a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Credit line and slideshow controls, under the photo rather than on it. */
|
||||
.stage-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
max-width: 1040px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 22px;
|
||||
gap: 8px 20px;
|
||||
/* Reserved even while the feed is in flight, so nothing shifts when it lands. */
|
||||
min-height: 28px;
|
||||
font-size: 0.8rem;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
text-shadow: 0 1px 12px rgb(8 5 2 / 60%);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The dots are the only way to steer the stage, so each one gets a 24px target
|
||||
even though the mark itself is 7px. */
|
||||
.dots {
|
||||
/* Long usernames and room names wrap instead of widening the column. */
|
||||
.credit {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Back / forward, with the position between them. Fixed width whatever the feed
|
||||
length is — see the note on .stage-show for what a per-photo control did here. */
|
||||
.steer {
|
||||
display: flex;
|
||||
margin: -8px -6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dots button {
|
||||
.steer button {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dots button::after {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 34%);
|
||||
transition: background 0.2s ease;
|
||||
.steer button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.dots button:hover::after {
|
||||
background: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.dots button.on::after {
|
||||
background: var(--accent);
|
||||
/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */
|
||||
.count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ---- What it is (below the stage) --------------------------------------- */
|
||||
@@ -544,6 +571,40 @@ h2 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Sign in / create account, at the top of the auth card. Two of a kind, so they read as
|
||||
one control rather than as two buttons competing with the orange submit below. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: var(--surface-hi);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Forms -------------------------------------------------------------- */
|
||||
|
||||
label {
|
||||
@@ -572,6 +633,27 @@ textarea {
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
/* Marks a field the form will submit without. Quiet, but next to the label rather than
|
||||
inside the input, so it survives the field being filled in. */
|
||||
.optional {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Why a field is worth filling in, under the input it belongs to. Sits inside the label,
|
||||
so it's read out with the field rather than as loose text after it. */
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -579,6 +661,14 @@ textarea:focus {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Where the Turnstile iframe mounts. It brings its own chrome, so this only reserves the
|
||||
space (the widget is 300×65 at normal size) — otherwise the submit button jumps down
|
||||
the moment the check finishes loading. */
|
||||
.turnstile {
|
||||
min-height: 65px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -602,6 +692,22 @@ button[type='submit']:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The cross-link under an auth form ("Don't have an account? Create one"). Sits under
|
||||
the submit button, which hugs its label, so it needs its own separation from it. */
|
||||
.swap {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.swap a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.swap a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Utilities ---------------------------------------------------------- */
|
||||
|
||||
.big {
|
||||
@@ -633,6 +739,15 @@ button[type='submit']:disabled {
|
||||
|
||||
/* ---- Responsive --------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 860px) {
|
||||
/* One column: the words lead, the photo follows. */
|
||||
.stage {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
padding: 40px 20px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
/* One column: the copy first, then the links and the status under it. */
|
||||
.about {
|
||||
@@ -643,8 +758,9 @@ button[type='submit']:disabled {
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.stage {
|
||||
min-height: min(38vh, 300px);
|
||||
.stage-actions .cta {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-links .cta {
|
||||
|
||||
@@ -6,6 +6,23 @@ export type Env = SharedHonoEnv & {
|
||||
DOMAIN: string
|
||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||
ASSETS: Fetcher
|
||||
/**
|
||||
* The Turnstile widget's public site key. Public by design — it ships to the browser so
|
||||
* the widget can render — but it lives in the Secrets Store beside its secret, so one
|
||||
* place configures signup and there's a single place to look.
|
||||
*
|
||||
* Resolve the value with `await env.TURNSTILE_SITE_KEY.get()`.
|
||||
*/
|
||||
TURNSTILE_SITE_KEY: SecretsStoreSecret
|
||||
/**
|
||||
* The Turnstile widget's secret key — the one that turns a widget token into a verdict.
|
||||
* Same shared account-level store as JWT_SECRET; the store id is spliced into
|
||||
* wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||
*
|
||||
* Store values survive a deploy, so both are created once and left alone. Either one
|
||||
* failing to resolve closes web signup — see src/turnstile.ts.
|
||||
*/
|
||||
TURNSTILE_SECRET_KEY: SecretsStoreSecret
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -13,6 +13,9 @@ export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
|
||||
/** Where the stage's "Download for PC" button goes: the client's release listing. */
|
||||
export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
|
||||
|
||||
/** The stage's "Download for Quest" button: the build's listing on the Meta store. */
|
||||
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/6w1HPL3j2'
|
||||
|
||||
/** The public source repo, linked from the homepage and footer. */
|
||||
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { expect, it } from 'vitest'
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, expect, it } from 'vitest'
|
||||
|
||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||
import { turnstileKeys } from '../../turnstile'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
// Turnstile's documented always-passes test keypair, seeded into the LOCAL Secrets Store
|
||||
// so the bindings resolve — the same way every other worker's tests seed JWT_SECRET. It
|
||||
// stands in for the two account-level secrets a deployed www reads, and it's what OPENS
|
||||
// signup (see src/turnstile.ts): without it every signup test would test the closed door.
|
||||
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
||||
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
||||
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
|
||||
})
|
||||
|
||||
it('rejects unauthenticated account reads', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/me')
|
||||
@@ -10,14 +29,85 @@ it('rejects unauthenticated account reads', async () => {
|
||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
||||
})
|
||||
|
||||
it('refuses manual signups (disabled)', async () => {
|
||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
|
||||
it('advertises signup with the Turnstile site key the widget needs', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/config')
|
||||
expect(res.status).toBe(200)
|
||||
// Read through the Secrets Store binding, from the value seeded above.
|
||||
expect(await res.json()).toEqual({
|
||||
signupEnabled: true,
|
||||
turnstileSiteKey: TEST_SITE_KEY,
|
||||
})
|
||||
})
|
||||
|
||||
// The keypair is the on/off switch for signup, so a www whose keys don't resolve must
|
||||
// report it closed — that's the state a fresh deploy starts in, before the operator
|
||||
// creates the two secrets. Checked directly because the real bindings are seeded for the
|
||||
// fetch tests above.
|
||||
//
|
||||
// A store read that THROWS (secret absent, store unreachable) has to close the door the
|
||||
// same way rather than surface as an error: /api/config is on the homepage's critical
|
||||
// path, and a 500 there costs the whole page, not just the signup form.
|
||||
it('treats an unresolvable or half-configured keypair as signup being off', async () => {
|
||||
const stub = (value: string | null): SecretsStoreSecret =>
|
||||
({ get: async () => value ?? '' }) as SecretsStoreSecret
|
||||
const throws = (): SecretsStoreSecret =>
|
||||
({
|
||||
get: async () => {
|
||||
throw new Error('secret not found')
|
||||
},
|
||||
}) as unknown as SecretsStoreSecret
|
||||
|
||||
const withKeys = (site: SecretsStoreSecret, secret: SecretsStoreSecret) =>
|
||||
({
|
||||
ENVIRONMENT: 'development',
|
||||
TURNSTILE_SITE_KEY: site,
|
||||
TURNSTILE_SECRET_KEY: secret,
|
||||
}) as Env
|
||||
|
||||
await expect(turnstileKeys(withKeys(throws(), throws()))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(stub('0xsite'), throws()))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(throws(), stub('0xsecret')))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(stub(''), stub('0xsecret')))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(stub('0xsite'), stub('0xsecret')))).resolves.toEqual({
|
||||
siteKey: '0xsite',
|
||||
secretKey: '0xsecret',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a signup with no Turnstile token', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'whatever' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
|
||||
// Rejected before any upstream call, so a bot can't reach create_account by omitting it.
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'Please complete the bot check.' })
|
||||
})
|
||||
|
||||
// The email is optional, but a malformed one is rejected BEFORE the account is created —
|
||||
// the accounts worker would refuse to store it, and by then the account exists and the
|
||||
// player would be left with an account whose email silently didn't save.
|
||||
it('refuses a signup whose email could not be stored', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'whatever', email: 'not-an-address', turnstileToken: 'x' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'That email address looks wrong.' })
|
||||
})
|
||||
|
||||
it('refuses a signup with no password', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ turnstileToken: 'dummy' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
||||
})
|
||||
|
||||
it('requires credentials to log in', async () => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
import type { Env } from './context'
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile, the bot check in front of web signup. Two keys make a widget:
|
||||
* the SITE key, which is public (it ships in the page markup so the browser can render
|
||||
* the widget), and the SECRET key, which stays on the worker and is the only thing that
|
||||
* can turn a widget token into a verdict. Both are held in the shared Secrets Store.
|
||||
*
|
||||
* The verdict is fetched server-side, here in the BFF — never from the browser, which
|
||||
* would hand the secret to anyone who viewed source. The browser's only job is to carry
|
||||
* the widget's token to `POST /api/signup`.
|
||||
*
|
||||
* Turnstile is what makes web signup safe to leave open: `auth`'s per-IP cap is the only
|
||||
* other thing standing in front of the password/anonymous account path (it has no
|
||||
* platform identity to count), and that cap is coarse enough that it can't be the whole
|
||||
* defence. So the keypair IS the switch — no keypair, no signup (see `turnstileKeys`).
|
||||
* Nothing is ever inferred from the environment, so a worker can't end up with signup
|
||||
* open and no bot check behind it.
|
||||
*/
|
||||
|
||||
/** Turnstile's verdict endpoint. Called from the worker only; the secret never leaves it. */
|
||||
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
|
||||
/**
|
||||
* The keypair web signup runs on, or null when there isn't one — which is what closes
|
||||
* signup (`/api/config` reports it, `/api/signup` refuses). Both keys come from the
|
||||
* account-level Secrets Store the whole monorepo shares (see context.ts), so they're
|
||||
* resolved per request rather than read off `env` as strings.
|
||||
*
|
||||
* Both must resolve. Half a configuration (a site key whose secret is missing from the
|
||||
* store) counts as unconfigured rather than as a widget whose token nobody can check, and
|
||||
* says so in the log — it's otherwise indistinguishable from signup being deliberately
|
||||
* off. A `.get()` that throws (secret absent from the store, binding not deployed, store
|
||||
* unreachable) is treated the same way, so a Worker that can't read its keys closes the
|
||||
* door instead of 500ing on the homepage.
|
||||
*
|
||||
* `.get()` caches per isolate, so changing a value in the store needs a `www` redeploy to
|
||||
* take effect on a warm worker — the same caveat the shared JWT_SECRET carries.
|
||||
*
|
||||
* For local dev, seed the two names into the LOCAL store (miniflare's, not your account's)
|
||||
* with Turnstile's documented always-passes test keypair — see apps/www/README.md. That
|
||||
* pair belongs to no account and passes without a human. Deliberately not a built-in
|
||||
* fallback: the same code path then runs everywhere.
|
||||
*/
|
||||
export async function turnstileKeys(
|
||||
env: Env
|
||||
): Promise<{ siteKey: string; secretKey: string } | null> {
|
||||
const [siteKey, secretKey] = await Promise.all([
|
||||
readSecret(env.TURNSTILE_SITE_KEY, 'TURNSTILE_SITE_KEY'),
|
||||
readSecret(env.TURNSTILE_SECRET_KEY, 'TURNSTILE_SECRET_KEY'),
|
||||
])
|
||||
if (siteKey !== '' && secretKey !== '') return { siteKey, secretKey }
|
||||
if (siteKey !== '' || secretKey !== '') {
|
||||
logger.error('turnstile is half-configured, so web signup is closed', {
|
||||
hasSiteKey: siteKey !== '',
|
||||
hasSecretKey: secretKey !== '',
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* One Secrets Store value as a string, or '' when it can't be read. The binding is
|
||||
* declared in wrangler.jsonc, so it's always present on `env`; what varies is whether the
|
||||
* store actually holds the secret — a missing one throws here rather than resolving empty.
|
||||
*/
|
||||
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
|
||||
try {
|
||||
return (await secret.get()) ?? ''
|
||||
} catch (err) {
|
||||
logger.error('failed to read a turnstile key from the secrets store', {
|
||||
secret: name,
|
||||
error: String(err),
|
||||
})
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Turnstile's siteverify response, narrowed to the fields we act on. */
|
||||
interface SiteVerifyResponse {
|
||||
success?: boolean
|
||||
'error-codes'?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Turnstile whether a widget token is good. `remoteIp` is the client's real IP per
|
||||
* Cloudflare (`CF-Connecting-IP`), which Turnstile cross-checks against the one that
|
||||
* solved the challenge; it's omitted when absent rather than sent empty.
|
||||
*
|
||||
* A token is single-use, so a failed verdict means the widget has to be reset before the
|
||||
* player can retry — the client does that (see the signup form).
|
||||
*
|
||||
* Any failure to reach Turnstile is a rejection, not a pass: this is the only bot check
|
||||
* in front of signup, so a broken verdict path must not open the door.
|
||||
*/
|
||||
export async function verifyTurnstile(
|
||||
secretKey: string,
|
||||
token: string,
|
||||
remoteIp?: string
|
||||
): Promise<boolean> {
|
||||
const fields: Record<string, string> = { secret: secretKey, response: token }
|
||||
if (remoteIp) fields.remoteip = remoteIp
|
||||
|
||||
try {
|
||||
const res = await fetch(SITEVERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.error('turnstile siteverify failed', { status: res.status })
|
||||
return false
|
||||
}
|
||||
const verdict = (await res.json()) as SiteVerifyResponse
|
||||
if (verdict.success !== true) {
|
||||
// The codes name the reason (`invalid-input-response`, `timeout-or-duplicate`,
|
||||
// `invalid-input-secret`, …) — the last of those is a misconfiguration, not a bot,
|
||||
// and this log line is the only place it shows up.
|
||||
logger.info('turnstile rejected a signup', { codes: verdict['error-codes'] ?? [] })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.error('turnstile siteverify threw', { error: String(err) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
+83
-8
@@ -2,11 +2,12 @@ import { Hono } from 'hono'
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import { docsPage, fetchSpec } from './docs'
|
||||
import { privacyPage } from './privacy'
|
||||
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -87,8 +88,12 @@ async function relay(c: Context<App>, res: Response) {
|
||||
* Exchange an auth `/connect/token` response for a session: persist the returned
|
||||
* access token in the httpOnly cookie, then return the caller's self account
|
||||
* (fetched from the accounts worker with the fresh token).
|
||||
*
|
||||
* `email`, when given, is saved onto the new account before that fetch, so the account
|
||||
* comes back already carrying it. `create_account` takes no email — the accounts worker
|
||||
* owns that field — which is why this is a second call rather than another grant field.
|
||||
*/
|
||||
async function establishSession(c: Context<App>, tokenResponse: Response) {
|
||||
async function establishSession(c: Context<App>, tokenResponse: Response, email?: string) {
|
||||
if (!tokenResponse.ok) return relay(c, tokenResponse)
|
||||
|
||||
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
||||
@@ -103,6 +108,25 @@ async function establishSession(c: Context<App>, tokenResponse: Response) {
|
||||
sessionCookieOptions(c, token.expires_in ?? 3600)
|
||||
)
|
||||
|
||||
// Deliberately not fatal: the account exists and the session is live by now, so failing
|
||||
// the request would leave the player holding an account they think they don't have —
|
||||
// and a retry would burn another slot against auth's per-IP signup cap. They land on
|
||||
// the account page instead, where the email field is the same one call away. The
|
||||
// address is validated before signup starts, so reaching here means something upstream
|
||||
// went wrong, not that the input was bad.
|
||||
if (email) {
|
||||
const res = await postForm(
|
||||
`${accountsBase(c.env)}/account/me/email`,
|
||||
{ email },
|
||||
token.access_token
|
||||
)
|
||||
if (!res.ok) {
|
||||
logger.error('failed to save the signup email; the account was still created', {
|
||||
status: res.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
|
||||
headers: { authorization: `Bearer ${token.access_token}` },
|
||||
})
|
||||
@@ -126,12 +150,63 @@ const app = new Hono<App>()
|
||||
|
||||
// ---- BFF API ------------------------------------------------------------
|
||||
|
||||
// Manual web signups are disabled for now — accounts are created via the game /
|
||||
// platform, not the website. Kept as an explicit closed endpoint (rather than
|
||||
// removed) so a direct POST is refused too, not just hidden in the UI. To reopen,
|
||||
// forward a platform-less `grant_type=create_account` to auth and start a session
|
||||
// (see git history), and restore the SignupForm in the client.
|
||||
.post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403))
|
||||
// What the SPA has to know before it can render the sign-in page: whether web signup
|
||||
// is open, and the Turnstile site key to mount its widget with. The site key is public
|
||||
// (it ships in the widget markup either way); the secret never leaves the worker.
|
||||
// Served rather than baked into the client build so one build works for any operator.
|
||||
.get('/api/config', async (c) => {
|
||||
const keys = await turnstileKeys(c.env)
|
||||
return c.json({ signupEnabled: keys !== null, turnstileSiteKey: keys?.siteKey ?? null })
|
||||
})
|
||||
|
||||
// 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 is the only other thing in front of this path.
|
||||
//
|
||||
// Deliberately passes NO `platform`: create_account treats an asserted platform as one
|
||||
// to verify against Steam and would reject RecNet (see WEB_PLATFORM), so this is the
|
||||
// platform-less password-account path. The username is auto-assigned by auth — players
|
||||
// don't pick one — and the new session is established from the token response.
|
||||
.post('/api/signup', async (c) => {
|
||||
// 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)
|
||||
|
||||
type SignupBody = { password?: string; email?: string; turnstileToken?: string }
|
||||
const { password, email, turnstileToken } = await c.req
|
||||
.json<SignupBody>()
|
||||
.catch(() => ({}) as SignupBody)
|
||||
if (!password) return c.json({ error: 'A password is required.' }, 400)
|
||||
if (!turnstileToken) return c.json({ error: 'Please complete the bot check.' }, 400)
|
||||
|
||||
// Optional — an account works without one; it's the address a locked-out player
|
||||
// would be reached at. Checked HERE, before anything is created, because the
|
||||
// accounts worker rejects an address with no `@` and by then the account exists:
|
||||
// better to fail the form than to hand back an account whose email silently didn't
|
||||
// save. Same rule the accounts worker applies, deliberately no stricter — this is
|
||||
// a contact address, not an identity, and nothing is sent to it to prove it.
|
||||
const signupEmail = typeof email === 'string' ? email.trim() : ''
|
||||
if (signupEmail !== '' && !signupEmail.includes('@')) {
|
||||
return c.json({ error: 'That email address looks wrong.' }, 400)
|
||||
}
|
||||
|
||||
// The IP Turnstile cross-checks the token against — set by the edge, so the client
|
||||
// can't spoof it (unlike X-Forwarded-For). `auth` records the same header as the
|
||||
// account's signup IP.
|
||||
const verified = await verifyTurnstile(
|
||||
keys.secretKey,
|
||||
turnstileToken,
|
||||
c.req.header('cf-connecting-ip')
|
||||
)
|
||||
// A token is single-use, so the client resets its widget before letting them retry.
|
||||
if (!verified) return c.json({ error: 'Bot check failed. Please try again.' }, 403)
|
||||
|
||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
||||
grant_type: 'create_account',
|
||||
password,
|
||||
})
|
||||
return establishSession(c, res, signupEmail || undefined)
|
||||
})
|
||||
|
||||
// Log in with a username + password, then start a session. The auth password grant
|
||||
// resolves the account by `username` (case-insensitive) — web players sign in with
|
||||
|
||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
||||
miniflare: {
|
||||
bindings: {
|
||||
ENVIRONMENT: 'VITEST',
|
||||
// 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
|
||||
// shadow the store binding with a string.
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -28,6 +28,32 @@
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
|
||||
},
|
||||
// The Turnstile keypair guarding web signup, out of the same account-level Secrets
|
||||
// Store every other worker binds for JWT_SECRET — values live there, never in this
|
||||
// file. The "local" store_id placeholder is replaced with RECFLARE_SECRETS_STORE at
|
||||
// deploy time, exactly as it is for the other workers.
|
||||
//
|
||||
// wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
|
||||
// --scopes workers --remote
|
||||
// wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
|
||||
// --scopes workers --remote
|
||||
//
|
||||
// Creating both is what OPENS signup; if either can't be resolved it stays closed, so
|
||||
// an operator who skips this gets no signup rather than an unprotected one. The SITE
|
||||
// key is public (it ships to the browser to render the widget) and is kept here beside
|
||||
// its secret so one place configures signup. See src/turnstile.ts.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "TURNSTILE_SITE_KEY",
|
||||
"store_id": "local",
|
||||
"secret_name": "TURNSTILE_SITE_KEY"
|
||||
},
|
||||
{
|
||||
"binding": "TURNSTILE_SECRET_KEY",
|
||||
"store_id": "local",
|
||||
"secret_name": "TURNSTILE_SECRET_KEY"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
|
||||
Reference in New Issue
Block a user