remove proxies and use native services w/cors

This commit is contained in:
Devin Zuczek
2026-08-05 11:13:15 -04:00
parent 9f4ce07aca
commit 079c889ccb
12 changed files with 702 additions and 684 deletions
+9 -1
View File
@@ -11,7 +11,7 @@ import {
searchAccounts,
updateAccount,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
@@ -161,6 +161,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
+9 -1
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
@@ -40,6 +40,14 @@ const app = new Hono<App>({ strict: false })
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
+9 -1
View File
@@ -21,7 +21,7 @@ import {
updateAccount,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { verifyMetaNonce } from './meta-nonce'
@@ -364,6 +364,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -1084,3 +1084,67 @@ describe('auth worker routes', () => {
}
})
})
// The website is a browser origin calling these endpoints directly — the same ones the
// game calls — instead of proxying them through `www`. That only works if the responses
// carry CORS headers: without them the browser discards a perfectly good token response
// and sign-in fails with nothing in any server log to explain it.
describe('CORS', () => {
test('answers the preflight the browser sends before a token grant', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'content-type'
)
})
// The header has to be on the REAL response too, not just the preflight — and on a
// refusal as much as a success, or a rejected sign-in reaches the page as an opaque
// network error rather than "that password is incorrect".
test('allows the origin on the response itself, refusals included', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: {
origin: 'https://www.example.com',
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'password', username: 'nobody' }).toString(),
}),
env
)
expect(res.status).toBe(400)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
// The bearer header is what the SPA authenticates with, so it must be allowed by name
// — a preflight that omits it makes every signed-in call fail.
test('allows the Authorization header the SPA signs its calls with', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/account/me/changepassword`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'authorization'
)
})
})
+9 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { NotificationsHub, OWNER_HEADER } from './notifications-hub'
@@ -80,6 +80,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -581,3 +581,44 @@ describe('clearing pending notifications', () => {
expect((await clear('all=true', await bearer('3', ['gameClient']))).status).toBe(403)
})
})
// The website's admin controls (maintenance countdown, coach broadcast) are a browser
// calling `/internal/*` directly rather than through a `www` proxy, so these need CORS.
describe('CORS', () => {
// The catch: `/internal/*` is behind `requireAdmin`, and a browser preflight carries
// NO Authorization header — it can't, that's the header it's asking permission to
// send. So the CORS middleware has to answer it before the admin gate sees it,
// otherwise every admin action fails the preflight with a 401 and never gets sent.
test('answers the preflight on an admin endpoint without a token', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/internal/broadcast`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization, content-type',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
const allowed = res.headers.get('access-control-allow-headers')?.toLowerCase() ?? ''
expect(allowed).toContain('authorization')
expect(allowed).toContain('content-type')
})
// The gate itself is untouched: the preflight passing is not the request passing.
test('still rejects the actual call without an admin token', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/internal/broadcast`, {
method: 'POST',
headers: { origin: 'https://www.example.com', 'content-type': 'application/json' },
body: JSON.stringify({ notificationType: 25, data: {} }),
}),
env
)
expect(res.status).toBe(401)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
})
+92
View File
@@ -0,0 +1,92 @@
/**
* What a refused `auth` `/connect/token` grant means to somebody filling in a form.
*
* Shared by the worker and the browser, because the two halves of the site refuse in
* different places and have to say the same thing. Signup is refused SERVER-side (it
* goes through www for the Turnstile check — see www.app.ts), while sign-in is refused
* by `auth` directly, which the SPA calls itself. Without this shared table the second
* one would put a bare OAuth code on screen.
*
* No runtime dependencies, so it's safe to pull into the client bundle.
*/
/** Which grant was being made, so a shared refusal reads right on either form. */
export type AuthAction = 'signup' | 'login'
/**
* Keyed on the exact `error_description` auth sends (see its `/connect/token` handler).
* The platform arms aren't reachable from the web today — signup posts `create_account`
* 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.
*/
const AUTH_MESSAGES: Record<string, string> = {
'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':
'This platform account has already created as many accounts as it is allowed.',
'invalid account_id or password': 'That username or password is incorrect.',
'account_id or username is required': 'Username and password are required.',
'invalid or missing platform_auth': 'Your platform sign-in could not be verified.',
'unsupported platform; only Steam and Meta can be verified':
'That platform cannot be verified — only Steam and Meta are supported.',
'no linked account for this platform identity':
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
}
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
const GENERIC_MESSAGES: Record<AuthAction, { rejected: string; broken: string }> = {
signup: {
rejected: 'Your account could not be created. Please check your details and try again.',
broken:
'Accounts cannot be created right now. This is a problem on our end — please try again later.',
},
login: {
rejected: 'You could not be signed in. Please check your details and try again.',
broken:
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
},
}
/** Message for an `auth` that couldn't be reached at all (the request itself threw). */
export const authUnreachable = (action: AuthAction): string => GENERIC_MESSAGES[action].broken
/** A rejected `/connect/token` grant, translated. */
export interface AuthFailure {
/** The sentence to put in front of the player. */
message: string
/** 400 when the grant was refused, 502 when `auth` itself couldn't proceed. */
status: 400 | 502
/** The raw `error`/`error_description` pair, for the operator's log line only. */
upstream: string
}
/**
* Translate a refusal auth has already answered.
*
* auth answers the OAuth shape — `{ error: 'invalid_grant', error_description: … }` —
* where `error` is one of three machine codes and the DESCRIPTION carries the actual
* reason. Showing that body verbatim put "invalid_grant" on screen for every failure,
* including the ones a player can act on (the per-network signup cap). An unrecognised
* description falls back to the generic line for the action rather than leaking whatever
* it did say — those are written for an operator.
*/
export function authFailure(
action: AuthAction,
status: number,
code: string,
description: string
): AuthFailure {
// A 5xx (or a `server_error`) is an operator misconfiguration — an unset JWT_SECRET,
// an unset META_APP_SECRET — not something the player got wrong. Don't send them back
// to re-check a form that was fine; the real reason is in auth's log, not theirs.
const broken = status >= 500 || code === 'server_error'
const generic = GENERIC_MESSAGES[action]
return {
message:
(!broken && AUTH_MESSAGES[description]) || (broken ? generic.broken : generic.rejected),
status: broken ? 502 : 400,
upstream: description ? `${code || 'unknown'}: ${description}` : code || `HTTP ${status}`,
}
}
+338 -72
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { NotificationType } from '../../../notify/src/notification-types'
import { authFailure, authUnreachable } from '../auth-messages'
import {
DISCORD_INVITE,
DOWNLOAD_URL,
@@ -10,7 +12,37 @@ import {
import type { ReactNode } from 'react'
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
/**
* The SPA calls the SAME endpoints the game does — `auth` for tokens and the password
* change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin
* broadcasts — rather than proxying each one through `www`, exactly as rec.net's own
* site did. Those workers answer CORS for it (see their `withDefaultCors()`), and the
* access token lives here in the browser.
*
* `www` serves only two things of its own (see www.app.ts): the config below, and
* signup, which is Turnstile-gated and so cannot leave the server.
*/
/** Where each worker lives. From `/api/config`, never baked into this build. */
interface Hosts {
auth: string
accounts: string
api: string
img: string
notify: string
}
/**
* Site config from `www`. `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
}
/** The private self DTO from `accounts` (`GET /account/me`). */
interface SelfAccount {
accountId: number
username: string
@@ -22,57 +54,239 @@ interface SelfAccount {
* stays usable and lets the server be the one to refuse.
*/
availableUsernameChanges?: number
/** Whether this session may use admin controls (from the token's role claim). */
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.
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
* NOT passed on signup: create_account treats an asserted platform as one to verify
* against Steam and rejects RecNet — the web signup is the (platform-less) password
* account path.
*/
interface SiteConfig {
signupEnabled: boolean
turnstileSiteKey: string | null
const WEB_PLATFORM = '4'
/**
* The session's access token, in localStorage so a reload stays signed in.
*
* Readable by page JS, which the httpOnly cookie this replaced was not — that is the
* tradeoff that comes with the browser calling the workers itself, and it's the same
* posture the game client has. Nothing third-party runs on this origin except the
* Turnstile widget, which is Cloudflare's own.
*/
const TOKEN_KEY = 'rf_token'
let token: string | null = localStorage.getItem(TOKEN_KEY)
function setToken(next: string | null) {
token = next
if (next === null) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, next)
}
/**
* Filled in once `/api/config` lands, before any worker call is made — a module value
* rather than a prop threaded through every form, since the components that call a
* worker only render after the config resolves.
*/
let hosts: Hosts | null = null
/** The hostnames, once known. Throws rather than guessing a domain. */
function where(): Hosts {
if (hosts === null) throw new Error('Still starting up — please reload the page.')
return hosts
}
/**
* Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate —
* this only decides whether to SHOW them; notify verifies the token on every call.
*/
const ADMIN_ROLES = new Set(['developer', 'moderator'])
/**
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
* verifying it — a page holds no signing key, and faking one here only reveals buttons
* whose endpoints reject the same token. A malformed token reads as "not admin".
*/
function isAdmin(): boolean {
const payload = token?.split('.')[1]
if (!payload) return false
try {
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
const claims = JSON.parse(atob(padded)) as { role?: unknown }
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
} catch {
return false
}
}
/**
* An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a
* lower_snake_case word with no spaces. Bodies relayed from a worker that speaks OAuth
* put one of these in `error`, where the readable reason is in `error_description`.
* lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in
* `error`, where the readable reason is in `error_description`.
*/
const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s)
/**
* 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
* mutations use `error`) so callers can surface it.
*
* `error` wins, since that's where www puts the message it wrote for the player — but
* NOT when it's a bare OAuth code: showing "invalid_grant" tells nobody anything, so a
* relayed OAuth body falls through to its description. www translates the signup/login
* grants itself (see `readAuthError`); this covers the endpoints that still relay.
* The message worth showing for a refusal. `error` wins, since that's where a worker
* puts a sentence it wrote for the player — but NOT when it's a bare OAuth code, which
* tells nobody anything. Some refusals carry no body at all (accounts answers a
* malformed email with an empty 400), hence the last-resort line.
*/
async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method: body === undefined ? 'GET' : 'POST',
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
function errorMessage(data: Record<string, unknown>, status: number): string {
const error = typeof data.error === 'string' ? data.error : ''
const description = typeof data.error_description === 'string' ? data.error_description : ''
return (
(error && !(isErrorCode(error) && description) && error) ||
description ||
error ||
`Request failed (${status})`
)
}
interface CallOptions {
method?: 'GET' | 'POST' | 'PUT'
/** Form fields — auth and accounts read their input with Hono's `parseBody()`. */
form?: Record<string, string>
/** A JSON body — what notify's internal endpoints take instead. */
json?: unknown
/** Send the session token. */
authed?: boolean
}
/** Call a worker. Returns the parsed body, or throws with something worth showing. */
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (opts.authed && token) headers.authorization = `Bearer ${token}`
let body: string | undefined
if (opts.form) {
headers['content-type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(opts.form).toString()
} else if (opts.json !== undefined) {
headers['content-type'] = 'application/json'
body = JSON.stringify(opts.json)
}
const res = await fetch(url, {
method: opts.method ?? (body === undefined ? 'GET' : 'POST'),
headers,
body,
})
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const error = typeof data.error === 'string' ? data.error : ''
const description = typeof data.error_description === 'string' ? data.error_description : ''
const message =
(error && !(isErrorCode(error) && description) && error) ||
description ||
error ||
`Request failed (${res.status})`
throw new Error(message)
// Expired or revoked. Cleared here so no caller has to remember to.
if (res.status === 401 && opts.authed) {
setToken(null)
throw new Error('Your session has expired. Please sign in again.')
}
throw new Error(errorMessage(data, res.status))
}
return data as T
}
/** The signed-in account, straight from `accounts`. */
const fetchMe = (): Promise<SelfAccount> =>
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
/**
* Sign in with auth's password grant, posted directly the way the game posts it. The
* account is resolved by `username` (case-insensitive) — web players sign in with their
* username, not the numeric account id.
*
* A refusal is translated through the table shared with the worker (see
* `auth-messages.ts`): auth's `error` is always a machine code, and the reason in
* `error_description` is written for an operator, not a player.
*/
async function signIn(username: string, password: string): Promise<void> {
const res = await fetch(`${where().auth}/connect/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password',
username,
platform: WEB_PLATFORM,
password,
}).toString(),
}).catch(() => null)
if (res === null) throw new Error(authUnreachable('login'))
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const code = typeof data.error === 'string' ? data.error : ''
const description = typeof data.error_description === 'string' ? data.error_description : ''
throw new Error(authFailure('login', res.status, code, description).message)
}
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
setToken(data.access_token)
}
/**
* Create an account — the one flow that goes through `www`, because it's gated by
* Turnstile and that check needs a secret key a page can't hold. www hands back auth's
* token response unchanged, so the session is established just as sign-in establishes it.
*/
async function signUp(password: string, turnstileToken: string): Promise<void> {
const data = await call<{ access_token?: string }>('/api/signup', {
json: { password, turnstileToken },
})
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('signup'))
setToken(data.access_token)
}
/**
* Change the username.
*
* `accounts` answers this one in its own envelope — `{ success, error, value }` at HTTP
* 200 even when it refused (taken name, no changes left) — so a 200 is not enough to
* call it done. The sentences it writes are already player-facing, so they're shown as-is.
*
* On success the SELF account is re-read rather than using the envelope's `value`: that
* is the PUBLIC DTO, and it carries no `availableUsernameChanges` — the very field this
* form needs to know whether another change is left.
*/
async function changeUsername(username: string): Promise<SelfAccount> {
const result = await call<{ error?: unknown }>(`${where().accounts}/account/me/username`, {
method: 'PUT',
form: { username },
authed: true,
})
const refusal = typeof result.error === 'string' ? result.error : ''
if (refusal !== '') throw new Error(refusal)
return fetchMe()
}
/** Set the account's email. `accounts` refuses an address with no `@` — with an empty
* 400 body, so the check is made here too rather than showing a bare status. */
const saveEmail = (email: string): Promise<unknown> =>
call(`${where().accounts}/account/me/email`, { form: { email }, authed: true })
/** Change the account's password. Lives on `auth`, not `accounts`. */
const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> =>
call(`${where().auth}/account/me/changepassword`, {
form: { oldPassword, newPassword },
authed: true,
})
/**
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
* — so a session without the role is rejected there (403) even though the UI shows no
* button. The maintenance frame carries `Msg: { StartsInMinutes }`, matching the game
* client's ServerMaintenance handler.
*/
const broadcastMaintenance = (startsInMinutes: number): Promise<{ delivered?: number }> =>
call<{ delivered?: number }>(`${where().notify}/internal/broadcast`, {
json: {
notificationType: NotificationType.ServerMaintenance,
data: { StartsInMinutes: startsInMinutes },
},
authed: true,
})
const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> =>
call<{ sent?: number }>(`${where().notify}/internal/coach-message-all`, {
json: { messageContent },
authed: true,
})
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
function useRouter() {
const [path, setPath] = useState(() => window.location.pathname)
@@ -128,16 +342,31 @@ export function App() {
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 }))
// Config first, and everything else after it: it carries the hostnames every other
// call needs. A config that doesn't land leaves the page signed out with signup
// closed rather than guessing where the workers are.
call<SiteConfig & { hosts: Hosts }>('/api/config')
.then(async ({ hosts: resolved, ...site }) => {
hosts = resolved
setConfig(site)
if (token === null) return setAccount(null)
// A stored token that `accounts` rejects is stale — `call` has already dropped
// it, so this just falls back to signed-out rather than surfacing an error.
await fetchMe()
.then(setAccount)
.catch(() => setAccount(null))
})
.catch(() => {
setConfig({ signupEnabled: false, turnstileSiteKey: null })
setAccount(null)
})
}, [])
const logout = useCallback(async () => {
await api('/api/logout', {})
// Nothing to tell a server: the access token is a stateless JWT, so dropping it here
// IS the sign-out. (The refresh token auth issues alongside it is never stored, so a
// closed session leaves nothing behind to redeem.)
const logout = useCallback(() => {
setToken(null)
setAccount(null)
navigate('/')
}, [navigate])
@@ -239,16 +468,35 @@ interface Slide {
roomName: string | null
}
/** Loads the public photo feed once. `slides === null` means still in flight. */
function useSlideshow() {
/**
* Loads the public photo feed once. `slides === null` means still in flight.
*
* Waits for the config, since the feed is served by the `api` worker — the same public
* endpoint the game reads it from — and its hostname arrives with the config. Each entry
* names an image; the browsable URL for it is on the `img` worker.
*/
function useSlideshow(config: SiteConfig | undefined) {
const [slides, setSlides] = useState<Slide[] | null>(null)
const [error, setError] = useState('')
useEffect(() => {
api<{ images: Slide[] }>('/api/slideshow')
.then((d) => setSlides(d.images))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
if (config === undefined) return
type Feed = { Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }> }
// Wrapped in an async call rather than started directly, because `where()` THROWS
// when the config didn't land — synchronously, which straight out of an effect
// would take the page down instead of leaving an empty stage behind the fold.
void (async () => {
const h = where()
const d = await call<Feed>(`${h.api}/api/images/v1/slideshow`)
setSlides(
(d.Images ?? []).map((i) => ({
url: `${h.img}/${i.ImageName}`,
username: i.Username,
roomName: i.RoomName,
}))
)
})().catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [config])
return { slides, error }
}
@@ -267,7 +515,7 @@ function HomePage({
config: SiteConfig | undefined
navigate: Navigate
}) {
const feed = useSlideshow()
const feed = useSlideshow(config)
// 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
@@ -694,7 +942,7 @@ function SignupForm({
}) {
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const { container, token, error: widgetError, reset } = useTurnstile(siteKey)
const { container, token: widgetToken, error: widgetError, reset } = useTurnstile(siteKey)
const { pending, error, run } = useAction()
return (
@@ -702,19 +950,41 @@ function SignupForm({
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
// Checked before anything is created, because `accounts` rejects an address
// with no `@` and by then the account would exist: better to fail the form
// than to hand back an account whose email silently didn't save. Same rule
// accounts applies, deliberately no stricter — this is a contact address, not
// an identity, and nothing is sent to it to prove it.
const wanted = email.trim()
if (wanted !== '' && !wanted.includes('@')) {
throw new Error('That email address looks wrong.')
}
try {
const { account } = await api<{ account: SelfAccount }>('/api/signup', {
password,
email,
turnstileToken: token,
})
onAuthed(account)
return ''
await signUp(password, widgetToken)
} catch (err) {
// The token is spent either way, so re-arm the widget before they retry.
// The widget token is spent either way, so re-arm before they retry. Only
// a failed signup gets here — past this point the account exists, and a
// retry would spend another slot against auth's per-IP cap.
reset()
throw err
}
// Saved with the new session's own token: `create_account` takes no email,
// `accounts` owns the field. Deliberately not fatal — the account exists and
// the session is live, and the same field is one call away on the account
// page.
if (wanted !== '') await saveEmail(wanted).catch(() => {})
// The session is already stored, so a failure here isn't one they can act on
// by retrying: a reload finds them signed in.
const me = await fetchMe().catch(() => {
throw new Error(
'Your account was created, but loading it failed. Reload the page — you are already signed in.'
)
})
onAuthed(me)
return ''
})
}}
>
@@ -748,7 +1018,7 @@ function SignupForm({
<div className="turnstile" ref={container} />
{widgetError && <p className="error">{widgetError}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={pending || token === ''}>
<button type="submit" disabled={pending || widgetToken === ''}>
{pending ? 'Creating…' : 'Create account'}
</button>
</form>
@@ -765,11 +1035,8 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { account } = await api<{ account: SelfAccount }>('/api/login', {
username,
password,
})
onAuthed(account)
await signIn(username, password)
onAuthed(await fetchMe())
return ''
})
}}
@@ -823,7 +1090,7 @@ function Dashboard({
render: () => <EmailForm account={account} onChange={onChange} />,
},
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
...(account.isAdmin
...(isAdmin()
? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
@@ -876,9 +1143,7 @@ function CoachMessageForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { sent } = await api<{ sent?: number }>('/api/coach-message', {
messageContent: message,
})
const { sent } = await coachMessageAll(message.trim())
setMessage('')
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
})
@@ -919,9 +1184,10 @@ function MaintenanceForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { connections } = await api<{ connections?: number }>('/api/maintenance', {
startsInMinutes: Number(minutes),
})
// Coerced the way the worker used to: a blank or negative box means "now".
const asked = Number(minutes)
const startsIn = Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 0
const { delivered: connections } = await broadcastMaintenance(startsIn)
return `Notified ${connections ?? 0} connected client${connections === 1 ? '' : 's'}.`
})
}}
@@ -984,7 +1250,7 @@ function UsernameForm({
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const updated = await api<SelfAccount>('/api/username', { username })
const updated = await changeUsername(username.trim())
onChange(updated)
setUsername(updated.username)
return `You are now @${updated.username}.`
@@ -1036,7 +1302,7 @@ function EmailForm({
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/email', { email })
await saveEmail(email.trim())
onChange({ ...account, email })
return 'Email saved.'
})
@@ -1074,7 +1340,7 @@ function PasswordForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/password', { oldPassword, newPassword })
await changePassword(oldPassword, newPassword)
setOldPassword('')
setNewPassword('')
return 'Password changed.'
+36 -79
View File
@@ -24,24 +24,51 @@ beforeAll(async () => {
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')
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
// 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 () => {
//
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify
// DIRECTLY (as rec.net's site did), and this is the only place it learns where they are.
// A build with them missing can't sign anyone in.
it('advertises signup and where the other workers live', 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,
hosts: {
auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.rec.example.com',
api: 'https://api.rec.example.com',
img: 'https://img.rec.example.com',
notify: 'https://notify.rec.example.com',
},
})
})
// The BFF proxies are gone: the browser calls those workers itself. Pinned because
// nothing else would fail if one were left behind — a stale proxy keeps working, it just
// re-creates the maintenance burden (and the shared-IP bug) this removed. `/api/signup`
// is the deliberate exception, and it's covered below.
it('no longer proxies the endpoints the game already serves', async () => {
for (const path of [
'/api/me',
'/api/login',
'/api/logout',
'/api/username',
'/api/email',
'/api/password',
'/api/maintenance',
'/api/coach-message',
'/api/slideshow',
]) {
const res = await SELF.fetch(`https://example.com${path}`, { method: 'POST' })
// Falls through to the SPA catch-all, which has no ASSETS binding under test.
expect(res.status, path).toBe(404)
}
})
// 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
@@ -88,19 +115,6 @@ it('refuses a signup with no Turnstile token', async () => {
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',
@@ -204,66 +218,9 @@ it('carries the browser IP across to auth instead of losing it to the edge', asy
// A call with no IP to forward must not invent one: an absent header leaves auth's
// own `clientIp` empty, which SKIPS the cap, rather than counting everyone together.
await postAuthForm(withAuth(capture), '/account/me/changepassword', {}, { bearer: 'tok' })
// Reachable in local dev, where the edge sets no `cf-connecting-ip` to pass on.
await postAuthForm(withAuth(capture), '/connect/token', { grant_type: 'create_account' })
expect(seen[1]!.headers.get('cf-connecting-ip')).toBeNull()
expect(seen[1]!.headers.get('authorization')).toBe('Bearer tok')
})
it('requires credentials to log in', async () => {
const res = await SELF.fetch('https://example.com/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'alice' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'Username and password are required.' })
})
it('rejects an unauthenticated username change', async () => {
const res = await SELF.fetch('https://example.com/api/username', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'newname' }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
// The accounts worker answers a refused username change at HTTP 200, in its own
// `{ success, error, value }` envelope — so an empty name reaching it would come back
// looking like a success to a browser that keys off the status. Rejected here instead,
// before the upstream call, and in the `{ error }` + 4xx shape every other endpoint uses.
it('refuses a username change with no username', async () => {
const res = await SELF.fetch('https://example.com/api/username', {
method: 'POST',
// Any cookie value gets past the session check — www holds no signing key and
// only forwards the token, so this stops at the empty-name check without ever
// reaching accounts.
headers: { 'content-type': 'application/json', cookie: 'rf_token=stub' },
body: JSON.stringify({ username: ' ' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'A username is required.' })
})
it('rejects an unauthenticated maintenance broadcast', async () => {
const res = await SELF.fetch('https://example.com/api/maintenance', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ startsInMinutes: 15 }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
it('rejects an unauthenticated coach message', async () => {
const res = await SELF.fetch('https://example.com/api/coach-message', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messageContent: 'hello all' }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
it('serves the aggregated docs page with a source per documented service', async () => {
+24 -129
View File
@@ -1,11 +1,13 @@
import { authFailure } from './auth-messages'
import type { AuthAction, AuthFailure } from './auth-messages'
import type { Env } from './context'
/**
* The www worker is a backend-for-frontend (BFF): the browser only ever talks to
* www, and www forwards to the `auth` and `accounts` workers server-side. That
* keeps the JWT off other origins and sidesteps CORS (those workers set no CORS
* headers). Hosts are derived from the shared base domain (`auth.<DOMAIN>`,
* `accounts.<DOMAIN>`), matching how the workers are deployed.
* Where the other workers live. Derived from the shared base domain, matching how they
* are deployed. www serves these to the SPA (`/api/config`), which calls them DIRECTLY —
* the same endpoints the game uses, as rec.net's own site did. The only one www still
* calls itself is `auth`, for the Turnstile-gated signup grant (see `postAuthForm`).
*/
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
@@ -14,64 +16,26 @@ export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
/**
* Send a form-urlencoded body to an upstream worker. The auth/accounts endpoints
* read their inputs via Hono's `parseBody()`, so they expect form fields (not
* JSON). `bearer`, when given, authenticates the caller.
*/
async function sendForm(
method: 'POST' | 'PUT',
url: string,
fields: Record<string, string>,
bearer?: string
): Promise<Response> {
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded',
}
if (bearer) headers.authorization = `Bearer ${bearer}`
return fetch(url, {
method,
headers,
body: new URLSearchParams(fields).toString(),
})
}
/** POST a form-urlencoded body to an upstream worker (see `sendForm`). */
export const postForm = (
url: string,
fields: Record<string, string>,
bearer?: string
): Promise<Response> => sendForm('POST', url, fields, bearer)
/**
* PUT a form-urlencoded body to an upstream worker (see `sendForm`). The accounts
* worker's profile mutations are split by verb — the username change is a PUT, and
* posting to it 404s rather than failing loudly.
*/
export const putForm = (
url: string,
fields: Record<string, string>,
bearer?: string
): Promise<Response> => sendForm('PUT', url, fields, bearer)
/**
* POST a form body to the `auth` worker, carrying the browser's real IP across.
*
* Every other upstream is reached over its public hostname, but auth can't be: it reads
* the caller's address from `CF-Connecting-IP` and counts it as the account's immutable
* `signupIp`, and a Worker subrequest to https://auth.<DOMAIN> re-enters the Cloudflare
* edge, which REPLACES that header with Cloudflare's own address. Every web signup
* therefore recorded one shared IP, and auth's per-IP cap — 3 accounts, never decaying —
* refused the fourth web account ever created, for everybody. The web path asserts no
* platform, so that arm was also the only cap actually in front of it.
* The browser could post `/connect/token` itself — it does exactly that to sign in — but
* not to SIGN UP: that grant is gated by Turnstile, whose secret key can't ship to a
* page. So signup goes through www, and www has to solve a problem the browser doesn't
* have: `auth` reads the caller's address from `CF-Connecting-IP` and records it as the
* account's immutable `signupIp`, and a Worker subrequest to https://auth.<DOMAIN>
* re-enters the Cloudflare edge, which REPLACES that header with Cloudflare's own
* address. Every web signup therefore recorded one shared IP, and auth's per-IP cap —
* 3 accounts, never decaying — refused the fourth web account ever created, for everybody.
*
* Going through the service binding skips the edge, so the header set here is the one
* auth reads. That is safe precisely because the edge does overwrite it on the public
* route: a game client posting `/connect/token` directly still cannot spoof its own IP,
* so no shared secret is needed to tell the two callers apart.
* route: a game client (or the SPA signing in) posting `/connect/token` directly still
* cannot spoof its own IP, so no shared secret is needed to tell the callers apart.
*
* `clientIp` is the caller's own edge-set `cf-connecting-ip`, and must never be anything
* a browser supplied. Omit it on calls that aren't IP-sensitive.
* a browser supplied. Absent, no header is sent at all — auth's `clientIp` then reads
* empty, which SKIPS the cap rather than counting every such signup together.
*
* Falls back to the public hostname when the binding is absent (local `vite dev` — see
* `Env.AUTH`); the edge then overwrites the header again, which is the old behaviour.
@@ -96,69 +60,11 @@ export async function postAuthForm(
return env.AUTH ? env.AUTH.fetch(request) : fetch(request)
}
/** Which grant www was making, so a shared refusal reads right on either form. */
export type AuthAction = 'signup' | 'login'
/** A rejected `/connect/token` grant, translated for the browser. */
export interface AuthFailure {
/** The sentence to put in front of the player. */
message: string
/** 400 when the grant was refused, 502 when `auth` itself couldn't proceed. */
status: 400 | 502
/** The raw `error`/`error_description` pair, for the operator's log line only. */
upstream: string
}
/**
* What each of auth's `error_description`s means to somebody filling in a form.
*
* Keyed on the exact string auth sends (see its `/connect/token` handler). Only a few
* are reachable from the web — signup posts `create_account` with no platform, login
* posts `password` with no `platform_auth` — but the platform arms are mapped anyway so
* a future web flow that does assert one can't regress to a bare code.
*/
const AUTH_MESSAGES: Record<string, string> = {
'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':
'This platform account has already created as many accounts as it is allowed.',
'invalid account_id or password': 'That username or password is incorrect.',
'account_id or username is required': 'Username and password are required.',
'invalid or missing platform_auth': 'Your platform sign-in could not be verified.',
'unsupported platform; only Steam and Meta can be verified':
'That platform cannot be verified — only Steam and Meta are supported.',
'no linked account for this platform identity':
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
}
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
const GENERIC_MESSAGES: Record<AuthAction, { rejected: string; broken: string }> = {
signup: {
rejected: 'Your account could not be created. Please check your details and try again.',
broken:
'Accounts cannot be created right now. This is a problem on our end — please try again later.',
},
login: {
rejected: 'You could not be signed in. Please check your details and try again.',
broken:
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
},
}
/** Message for an `auth` that couldn't be reached at all (the fetch itself threw). */
export const authUnreachable = (action: AuthAction): string => GENERIC_MESSAGES[action].broken
/**
* Read a failed `auth` `/connect/token` response into something worth showing.
*
* auth answers the OAuth shape — `{ error: 'invalid_grant', error_description: … }` —
* where `error` is one of three machine codes and the DESCRIPTION carries the actual
* reason. Relaying that body verbatim put "invalid_grant" on screen for every failure,
* including the ones a player can act on (the per-network signup cap), so the
* description is matched to a sentence here instead. An unrecognised body — or a
* non-JSON one from something in front of auth — falls back to the generic line for
* the action rather than leaking whatever it did say.
* Read a failed `auth` response into something worth showing. The translation itself is
* shared with the browser (see `auth-messages.ts`); this only unpacks the body. A
* non-JSON one — from something in front of auth, like an edge error page — falls
* through to the generic line for the action.
*/
export async function readAuthError(res: Response, action: AuthAction): Promise<AuthFailure> {
const parsed = (await res.json().catch(() => null)) as {
@@ -169,16 +75,5 @@ export async function readAuthError(res: Response, action: AuthAction): Promise<
const code = typeof body.error === 'string' ? body.error : ''
const description = typeof body.error_description === 'string' ? body.error_description : ''
// A 5xx (or a `server_error`) is an operator misconfiguration — an unset JWT_SECRET,
// an unset META_APP_SECRET — not something the player got wrong. Don't send them back
// to re-check a form that was fine; the real reason is in auth's log, not theirs.
const broken = res.status >= 500 || code === 'server_error'
const generic = GENERIC_MESSAGES[action]
return {
message:
(!broken && AUTH_MESSAGES[description]) || (broken ? generic.broken : generic.rejected),
status: broken ? 502 : 400,
upstream: description ? `${code || 'unknown'}: ${description}` : code || `HTTP ${res.status}`,
}
return authFailure(action, res.status, code, description)
}
+59 -393
View File
@@ -1,183 +1,39 @@
import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withOnError } from '@repo/hono-helpers'
import { NotificationType } from '../../notify/src/notification-types'
import { authUnreachable } from './auth-messages'
import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy'
import { turnstileKeys, verifyTurnstile } from './turnstile'
import {
accountsBase,
apiBase,
authUnreachable,
authBase,
imgBase,
notifyBase,
postAuthForm,
postForm,
putForm,
readAuthError,
} from './upstream'
import type { Context } from 'hono'
import type { CookieOptions } from 'hono/utils/cookie'
import type { App } from './context'
import type { AuthAction } from './upstream'
/**
* www — the first frontend worker. It serves the React SPA (create account, set
* email, change password) and acts as a backend-for-frontend: the browser talks
* only to www, and www forwards to the `auth`/`accounts` workers server-side (see
* `upstream.ts`). The account's JWT lives in an httpOnly cookie set here, so it's
* never exposed to page JS.
*/
/** Name of the httpOnly session cookie holding the account's access token. */
const SESSION_COOKIE = 'rf_token'
/**
* RecNet (4) is the web platform, stamped as the token's `platform` claim on login.
* NOT passed on signup: create_account treats an asserted platform as one to verify
* against Steam and rejects RecNet — the web signup is the (platform-less) password
* account path.
*/
const WEB_PLATFORM = '4'
/**
* Roles that unlock the admin controls in the UI. Mirrors the notify worker's
* `ADMIN_ROLES` gate — www only decides whether to *show* the controls; notify does
* the real enforcement (it verifies the token) on every call.
*/
const ADMIN_ROLES = new Set(['developer', 'moderator'])
/** Cookie flags for the session token. `secure` is dropped for local http dev. */
function sessionCookieOptions(c: Context<App>, maxAge: number): CookieOptions {
const local = c.env.ENVIRONMENT === 'development' || c.env.ENVIRONMENT === 'VITEST'
return {
httpOnly: true,
secure: !local,
sameSite: 'Lax',
path: '/',
maxAge,
}
}
/** Pull the session token out of the request cookie, or null when absent. */
function sessionToken(c: Context<App>): string | null {
return getCookie(c, SESSION_COOKIE) ?? null
}
/**
* Whether the session token carries an admin role. Decodes the JWT's `role` claim
* WITHOUT verifying — www holds no signing key, and this only gates whether admin UI
* is shown; the notify worker verifies the token before acting on it. A malformed
* token simply reads as "not admin".
*/
function isAdminToken(token: string): boolean {
const payload = token.split('.')[1]
if (!payload) return false
try {
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
const claims = JSON.parse(atob(padded)) as { role?: unknown }
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
} catch {
return false
}
}
/** Relay an upstream worker's JSON response back to the browser unchanged. */
async function relay(c: Context<App>, res: Response) {
const body = await res.text()
return c.body(body, res.status as never, {
'content-type': res.headers.get('content-type') ?? 'application/json',
})
}
/**
* 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).
* www — the website worker. It serves the React SPA (create account, sign in, change
* username/email/password) and almost nothing else: the SPA calls the SAME endpoints
* the game does, on `auth`/`accounts`/`api`/`notify` directly, exactly as rec.net's own
* site did. Those workers answer CORS for it, and the browser holds the access 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.
* Two things stay server-side here, both because they can't work any other way:
*
* A refused grant is translated (see `readAuthError`) rather than relayed: auth answers
* the OAuth shape, whose `error` is always a code like `invalid_grant`, and that code is
* what the form used to show for every failure — including the per-network signup cap,
* which the player could otherwise understand. The raw pair is logged for the operator.
* - `/api/signup`, because it's gated by Turnstile and the secret key that turns a
* widget token into a verdict cannot ship to a browser. It's also the one account
* endpoint with no game equivalent — the game never creates password accounts — so
* there's no client contract being duplicated.
* - `/api/config`, which tells the SPA the Turnstile site key and where the other
* workers live, so one client build works for any operator's domain.
*/
async function establishSession(
c: Context<App>,
action: AuthAction,
tokenResponse: Response,
email?: string
) {
if (!tokenResponse.ok) {
const failure = await readAuthError(tokenResponse, action)
logger.info('auth refused a token grant', {
action,
status: tokenResponse.status,
upstream: failure.upstream,
})
return c.json({ error: failure.message }, failure.status)
}
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
if (!token.access_token) {
logger.error('auth answered a token grant with no access_token', { action })
return c.json({ error: authUnreachable(action) }, 502)
}
setCookie(
c,
SESSION_COOKIE,
token.access_token,
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}` },
})
// The session cookie is already set, so this is the one failure where telling them to
// retry would be wrong: on signup the account exists (and a second attempt spends
// another slot against auth's per-IP cap), and either way a reload finds them signed in.
if (!me.ok) {
logger.error('failed to load the account after a token grant', { action, status: me.status })
return c.json(
{
error:
action === 'signup'
? 'Your account was created, but loading it failed. Reload the page — you are already signed in.'
: 'You are signed in, but loading your account failed. Please reload the page.',
},
502
)
}
const account = (await me.json()) as Record<string, unknown>
return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } })
}
const app = new Hono<App>()
.use(
@@ -192,48 +48,56 @@ const app = new Hono<App>()
.onError(withOnError())
// ---- BFF API ------------------------------------------------------------
// ---- Site config --------------------------------------------------------
// 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.
// What the SPA has to know before it can do anything: whether web signup is open,
// the Turnstile site key to mount its widget with, and the hostnames of the workers
// 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.
.get('/api/config', async (c) => {
const keys = await turnstileKeys(c.env)
return c.json({ signupEnabled: keys !== null, turnstileSiteKey: keys?.siteKey ?? null })
return c.json({
signupEnabled: keys !== null,
turnstileSiteKey: keys?.siteKey ?? null,
hosts: {
auth: authBase(c.env),
accounts: accountsBase(c.env),
api: apiBase(c.env),
img: imgBase(c.env),
notify: notifyBase(c.env),
},
})
})
// ---- 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 is the only other thing in front of this path.
// 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.
//
// 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.
// to verify against Steam and would reject RecNet, so this is the platform-less
// password-account path. The username is auto-assigned by auth — players don't pick one.
//
// On success auth's token response is returned VERBATIM, so the SPA stores it the same
// way it stores the one it gets from calling `/connect/token` itself to sign in. The
// 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) => {
// 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
type SignupBody = { password?: string; turnstileToken?: string }
const { password, 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, which is why it's forwarded to the grant below rather than
@@ -257,223 +121,25 @@ const app = new Hono<App>()
logger.error('could not reach auth to create an account')
return c.json({ error: authUnreachable('signup') }, 502)
}
return establishSession(c, 'signup', 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
// their username, not the numeric account id.
.post('/api/login', async (c) => {
const { username, password } = await c.req
.json<{ username?: string; password?: string }>()
.catch(() => ({}) as { username?: string; password?: string })
if (!username || !password) {
return c.json({ error: 'Username and password are required.' }, 400)
// A refused grant is translated (see `readAuthError`) rather than relayed: auth
// answers the OAuth shape, whose `error` is always a code like `invalid_grant`, and
// that code is what the form used to show for every failure — including the
// per-network cap, which the player could otherwise understand. Sign-in doesn't need
// this (the browser calls `/connect/token` itself and reads `error_description`), but
// the cap is reachable only from signup, so the sentences live on this path.
if (!res.ok) {
const failure = await readAuthError(res, 'signup')
logger.info('auth refused a signup', { status: res.status, upstream: failure.upstream })
return c.json({ error: failure.message }, failure.status)
}
// The IP goes along here too: auth refreshes `lastLoginIp` on every successful
// grant, and without it every web login would stamp the same Cloudflare address.
const res = await postAuthForm(
c.env,
'/connect/token',
{ grant_type: 'password', username, platform: WEB_PLATFORM, password },
{ clientIp: c.req.header('cf-connecting-ip') }
).catch(() => null)
if (res === null) {
logger.error('could not reach auth to sign in')
return c.json({ error: authUnreachable('login') }, 502)
const token = (await res.json().catch(() => null)) as { access_token?: string } | null
if (!token?.access_token) {
logger.error('auth answered a signup with no access_token')
return c.json({ error: authUnreachable('signup') }, 502)
}
return establishSession(c, 'login', res)
})
// Clear the session cookie.
.post('/api/logout', (c) => {
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({ success: true })
})
// Public homepage slideshow. Proxies the api worker's (public) slideshow feed and
// projects each image to a full img.<domain> URL the browser can load directly, so
// the page JS never has to know the upstream hosts. No session required.
.get('/api/slideshow', async (c) => {
const res = await fetch(`${apiBase(c.env)}/api/images/v1/slideshow`)
if (!res.ok) return relay(c, res)
const data = (await res.json()) as {
Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }>
ValidTill?: string
}
const images = (data.Images ?? []).map((i) => ({
url: `${imgBase(c.env)}/${i.ImageName}`,
username: i.Username,
roomName: i.RoomName,
}))
return c.json({ images, validTill: data.ValidTill ?? null })
})
// Current session's self account (used to restore UI state on page load).
.get('/api/me', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const res = await fetch(`${accountsBase(c.env)}/account/me`, {
headers: { authorization: `Bearer ${token}` },
})
// Token expired/invalid — drop the stale cookie so the client shows sign-in.
if (res.status === 401) {
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({ error: 'session expired' }, 401)
}
if (!res.ok) return relay(c, res)
// Augment the self account with whether this session may use admin controls,
// read from the token's role claim (see isAdminToken).
const account = (await res.json()) as Record<string, unknown>
return c.json({ ...account, isAdmin: isAdminToken(token) })
})
// Change the signed-in account's username.
//
// The accounts worker answers this one in its own envelope — `{ success, error, value }`
// at HTTP 200 even when it refused (taken name, no changes left) — so relaying it
// verbatim would read as a success to the browser, which keys off the status. It's
// translated to the same `{ error }` + 4xx shape as every other endpoint here instead;
// the sentences accounts writes are already player-facing, so they pass through as-is.
//
// On success the caller's SELF account is re-fetched rather than returning the
// envelope's `value`: that's the PUBLIC DTO, and it carries no
// `availableUsernameChanges` — the very field the form needs to know whether another
// change is left. (An account starts with one; it's spent by this call.)
.post('/api/username', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { username } = await c.req
.json<{ username?: string }>()
.catch(() => ({}) as { username?: string })
const wanted = typeof username === 'string' ? username.trim() : ''
if (wanted === '') return c.json({ error: 'A username is required.' }, 400)
const res = await putForm(
`${accountsBase(c.env)}/account/me/username`,
{ username: wanted },
token
)
if (!res.ok) return relay(c, res)
const result = (await res.json().catch(() => null)) as {
success?: boolean
error?: unknown
} | null
if (!result) {
logger.error('accounts answered a username change with a body that was not JSON')
return c.json({ error: 'Your username could not be changed. Please try again later.' }, 502)
}
const refusal = typeof result.error === 'string' ? result.error : ''
if (refusal !== '') return c.json({ error: refusal }, 400)
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
headers: { authorization: `Bearer ${token}` },
})
// The name has already changed by now, so this can't be reported as a failure —
// telling them to retry would spend the change they no longer have. A reload shows
// the new name either way.
if (!me.ok) {
logger.error('failed to reload the account after a username change', { status: me.status })
return c.json(
{ error: 'Your username was changed, but reloading your account failed. Reload the page.' },
502
)
}
const account = (await me.json()) as Record<string, unknown>
return c.json({ ...account, isAdmin: isAdminToken(token) })
})
// Set the signed-in account's email.
.post('/api/email', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { email } = await c.req.json<{ email?: string }>().catch(() => ({}) as { email?: string })
if (!email) return c.json({ error: 'An email is required.' }, 400)
const res = await postForm(`${accountsBase(c.env)}/account/me/email`, { email }, token)
return relay(c, res)
})
// Change the signed-in account's password (current password required).
.post('/api/password', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { oldPassword, newPassword } = await c.req
.json<{ oldPassword?: string; newPassword?: string }>()
.catch(() => ({}) as { oldPassword?: string; newPassword?: string })
if (!newPassword) return c.json({ error: 'A new password is required.' }, 400)
// No `clientIp`: auth reads no IP on this path — it's routed through the binding
// only so every auth call goes one way.
const res = await postAuthForm(
c.env,
'/account/me/changepassword',
{ oldPassword: oldPassword ?? '', newPassword },
{ bearer: token }
)
return relay(c, res)
})
// Broadcast a ServerMaintenance countdown to every connected client. Forwards the
// session token to the notify worker, which enforces the admin-role gate — so a
// non-admin session is rejected upstream (403) even though www shows no button.
// The notification frame carries `Msg: { StartsInMinutes }`, matching the client's
// ServerMaintenance handler; the response mirrors the reference maintenance API.
.post('/api/maintenance', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { startsInMinutes } = await c.req
.json<{ startsInMinutes?: number }>()
.catch(() => ({}) as { startsInMinutes?: number })
const minutes = Number(startsInMinutes)
const startsIn = Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : 0
const res = await fetch(`${notifyBase(c.env)}/internal/broadcast`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
notificationType: NotificationType.ServerMaintenance,
data: { StartsInMinutes: startsIn },
}),
})
if (!res.ok) return relay(c, res)
const result = (await res.json()) as { delivered?: number }
return c.json({
success: true,
starts_in_minutes: startsIn,
connections: result.delivered ?? 0,
})
})
// Send a coach/system message to every online player. Like maintenance, this
// forwards the session token to notify, which enforces the admin-role gate.
.post('/api/coach-message', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { messageContent } = await c.req
.json<{ messageContent?: string }>()
.catch(() => ({}) as { messageContent?: string })
const content = typeof messageContent === 'string' ? messageContent.trim() : ''
if (content === '') return c.json({ error: 'A message is required.' }, 400)
const res = await fetch(`${notifyBase(c.env)}/internal/coach-message-all`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ messageContent: content }),
})
if (!res.ok) return relay(c, res)
const result = (await res.json()) as { sent?: number }
return c.json({ success: true, sent: result.sent ?? 0 })
return c.json(token)
})
// ---- Privacy policy -----------------------------------------------------
+12 -7
View File
@@ -13,7 +13,8 @@
// routing entirely, so the Worker runs ONLY for the listed patterns and every other
// path is served assets-first (with the SPA fallback → index.html). It must therefore
// list EVERY route the Worker handles, not just the new ones — otherwise `/api/*`
// falls through to index.html and the whole BFF breaks. Why it's needed at all: with
// falls through to index.html and both signup and the site config break (the SPA
// reads the other workers' hostnames from `/api/config`). Why it's needed at all: with
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
@@ -62,8 +63,11 @@
// created. A service binding skips the edge, so the real browser IP www forwards on
// that header survives (see src/upstream.ts `postAuthForm`).
//
// Only auth is bound: it's the only upstream whose behaviour depends on the caller's
// IP. accounts/api/img/notify still go over their public hostnames.
// Only auth is bound, and only for SIGNUP — the one call this worker still makes on
// the browser's behalf, because Turnstile's secret key can't ship to a page. Sign-in,
// the profile mutations and the photo feed are posted by the browser straight to
// auth/accounts/api/notify (as rec.net's own site did), where the edge sets the real
// client IP for free.
"services": [{ "binding": "AUTH", "service": "auth" }],
"upload_source_maps": true,
"observability": {
@@ -75,10 +79,11 @@
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Base domain the auth/accounts hosts are derived from (auth.<DOMAIN>,
// accounts.<DOMAIN>). Overridden at deploy time with the real RECFLARE_DOMAIN
// (see run-wrangler-deploy). For local dev, point this at a deployed domain so
// the BFF proxy can reach the auth/accounts workers.
// Base domain every worker hostname is derived from (auth.<DOMAIN>,
// accounts.<DOMAIN>, …). Overridden at deploy time with the real RECFLARE_DOMAIN
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
// is how one client build works for any operator. For local dev, point it at a
// deployed domain so the page has real workers to call.
"DOMAIN": "rec.example.com"
}
}