diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 2f55c71..53291b1 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -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() })(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()) diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 7ac786f..1c8c277 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -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({ 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()) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index cf348a7..7d499ba 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -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() })(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()) diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 4001487..538bd6d 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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' + ) + }) +}) diff --git a/apps/notify/src/notify.app.ts b/apps/notify/src/notify.app.ts index 9270bf7..6f43aa9 100644 --- a/apps/notify/src/notify.app.ts +++ b/apps/notify/src/notify.app.ts @@ -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() })(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()) diff --git a/apps/notify/src/test/integration/api.test.ts b/apps/notify/src/test/integration/api.test.ts index 32cd8af..8410ec6 100644 --- a/apps/notify/src/test/integration/api.test.ts +++ b/apps/notify/src/test/integration/api.test.ts @@ -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('*') + }) +}) diff --git a/apps/www/src/auth-messages.ts b/apps/www/src/auth-messages.ts new file mode 100644 index 0000000..754fdc3 --- /dev/null +++ b/apps/www/src/auth-messages.ts @@ -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 = { + '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 = { + 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}`, + } +} diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 48dc469..d14f844 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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(path: string, body?: unknown): Promise { - 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, 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 + /** 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>(url: string, opts: CallOptions = {}): Promise { + const headers: Record = {} + 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 + 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 => + call(`${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 { + 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 + 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 { + 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 { + 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 => + 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 => + 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('/api/me') - .then((me) => setAccount(me)) - .catch(() => setAccount(null)) - api('/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('/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(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(`${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({
{widgetError &&

{widgetError}

} {error &&

{error}

} - @@ -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: () => , }, { id: 'password', label: 'Password', render: () => }, - ...(account.isAdmin + ...(isAdmin() ? [ { id: 'maintenance', label: 'Server maintenance', render: () => }, { id: 'coach', label: 'Broadcast message', render: () => }, @@ -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('/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.' diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index f79d665..b8f6ff9 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -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 () => { diff --git a/apps/www/src/upstream.ts b/apps/www/src/upstream.ts index d0a23c8..430232f 100644 --- a/apps/www/src/upstream.ts +++ b/apps/www/src/upstream.ts @@ -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.`, - * `accounts.`), 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, - bearer?: string -): Promise { - const headers: Record = { - '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, - bearer?: string -): Promise => 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, - bearer?: string -): Promise => 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. 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. + * 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 = { - '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 = { - 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 { 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) } diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 6153bbf..e474cee 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -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, 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): 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, 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, - 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 - return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } }) -} const app = new Hono() .use( @@ -192,48 +48,56 @@ const app = new Hono() .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() .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() 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. 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 - 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 - 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 ----------------------------------------------------- diff --git a/apps/www/wrangler.jsonc b/apps/www/wrangler.jsonc index a6823ef..6c3a650 100644 --- a/apps/www/wrangler.jsonc +++ b/apps/www/wrangler.jsonc @@ -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., - // accounts.). 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., + // accounts., …). 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" } }