mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
remove proxies and use native services w/cors
This commit is contained in:
@@ -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())
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('*')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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.'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user