import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod'

import {
	countAccountsBySignupIp,
	createAccount,
	GAME_VERSION,
	getAccount,
	getAccountByUsername,
	getAccountsByIds,
	getPasswordHash,
	getRoomById,
	hashPassword,
	RoomInstanceType,
	setLastLoginTime,
	setLoginContext,
	setPasswordHash,
	setPresence,
	subRoomDataBlob,
	updateAccount,
	verifyPassword,
} from '@repo/domain'
import {
	intVar,
	logger,
	withCleanSpec,
	withDefaultCors,
	withNotFound,
	withOnError,
} from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'

// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
import { verifyMetaNonce } from './meta-nonce'
import {
	CachedLogin,
	ChangePasswordRequest,
	ChangePasswordResponse,
	FakeCachedLogin,
	form,
	json,
	OAuthError,
	PlatformIdsRequest,
	PlatformType,
	RestrictionDto,
	roleLookup,
	TokenRequest,
	TokenResponse,
} from './openapi'
import {
	countAccountsForPlatformIdentity,
	getLinksForPlatformId,
	getLinksForPlatformIdentity,
	isPlatformIdentityLinked,
	linkPlatformIdentity,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'

import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
import type { PlatformLink } from './platform-db'

/** OAuth scopes granted by `/connect/token`. */
const TOKEN_SCOPE =
	'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'

/**
 * The `error_description` a banned account's grant is refused with. A fixed sentence,
 * never interpolated with the expiry, because `www`'s shared auth-messages table keys on
 * this exact string to put a real sentence in front of a player — anything varying would
 * fall through to the generic "you could not be signed in". Keep the two in sync.
 */
const BANNED_DESCRIPTION = 'this account is banned'

/**
 * The refusal when it is not THIS account that is banned but one it shares an identity
 * with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the
 * account being refused may be an innocent housemate of a banned player, so telling them
 * "this account is banned" would be a lie, and naming the account we matched them to
 * would hand out somebody else's moderation record.
 */
const BLOCKED_DESCRIPTION = 'this device or network is blocked'

/**
 * The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
 * build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
 * headset reports this same value. Two things follow, and both are enforced below:
 *  - it is never verifiable (`verifyPlatformProof` refuses it outright), and
 *  - it is therefore never LINKED to an account. A link is a password-free way in, so
 *    one link on a shared id would open that account to every sideloaded build.
 * It exists only to get such a client onto the username/password login screen.
 */
const SIDELOAD_PLATFORM_ID = '1'

/**
 * The canned entry served for the one Oculus cached-login lookup below — the sideloaded
 * APK's way onto the password login screen. Not backed by a link, an account or a
 * platform proof, hence `requirePassword: true`.
 */
const FAKE_OCULUS_CACHED_LOGIN = {
	platform: PlatformType.Oculus,
	platformId: SIDELOAD_PLATFORM_ID,
	accountId: 1,
	lastLoginTime: '2026-07-19T17:13:29.225Z',
	requirePassword: true,
} as const

/**
 * Signup caps, enforced on create_account only (never on login — an existing account
 * always stays reachable, however many accounts its owner has since accumulated).
 *
 * Two independent arms, because they fail in opposite ways:
 *  - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be
 *    spoofed and can't be reset by changing networks. Only binds on a platform
 *    create_account — the password/anonymous path has no platform identity to count,
 *    so the IP arm is the only thing standing between it and bulk signup.
 *  - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many
 *    legitimate players behind one address, so this WILL be the arm that produces false
 *    positives. It counts `signupIp` (immutable), so an abuser can't reset their own
 *    count by hopping networks.
 *
 * These are the defaults. An operator overrides either arm with the matching worker var
 * (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
 * setting one to 0 disables that arm entirely — which a small private server that trusts
 * everyone it invites will want, and which the IP arm in particular is worth reaching for
 * if a shared network is being locked out.
 */
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3

/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
/**
 * The client loads Orientation locally (no matchmake) and tags its instance with
 * the sentinel id -2. The heartbeat must echo that exact `roomInstanceId` or the
 * client treats presence as out-of-sync and bounces the player to the dorm.
 */
const ORIENTATION_INSTANCE_ID = -2

/**
 * Seed a freshly created account's match presence to the Orientation room. The
 * client is placed into Orientation by its new-user flow without a matchmake
 * call, so the match heartbeat would otherwise report no/stale (dorm) presence
 * and bounce the player out. We write the Orientation instance (built from the
 * shared rooms D1, matching the match worker's `roomInstanceFromRoom` shape) into
 * the shared `presence` table (see @repo/domain) so the heartbeat keeps them there.
 */
async function placeNewPlayerInOrientation(
	env: App['Bindings'],
	accountId: number,
	deviceClass: number
): Promise<void> {
	// getRoomById hydrates the room's SubRooms from the subroom table (they no longer
	// live in the room blob), so the Orientation scene resolves the same way match does.
	const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID)
	if (!room) return

	const subRooms = room.SubRooms
	const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
		Record<string, unknown> | undefined
	const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
	const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)

	const roomInstance = {
		roomInstanceId: ORIENTATION_INSTANCE_ID,
		roomId: ORIENTATION_ROOM_ID,
		subRoomId: num(sub?.SubRoomId, 1),
		roomInstanceType: RoomInstanceType.Public,
		location: str(sub?.UnitySceneId),
		dataBlob: subRoomDataBlob(sub),
		eventId: 0,
		clubId: 0,
		roomCode: '',
		photonRegion: 'us',
		photonRegionId: 'us',
		photonRoomId: `rec.${ORIENTATION_ROOM_ID}`,
		name: `^${str(room.Name, 'Orientation')}`,
		maxCapacity: num(sub?.MaxPlayers, 4),
		isFull: false,
		isPrivate: false,
		isInProgress: false,
		EncryptVoiceChat: false,
	}
	await setPresence(env.DB, {
		accountId,
		roomInstance,
		statusVisibility: 0,
		deviceClass,
		vrMovementMode: 1,
		platform: 0,
		appVersion: GAME_VERSION,
	})
}

/** The Bearer token's account id (`sub`), or null when there's no valid token. */
async function authedId(c: Context<App>): Promise<number | null> {
	return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}

/**
 * The role names beyond `gameClient` for an account's token `role` claim. Base roles
 * (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
 * client gates the screen-share feature on it and nothing grants it per-account, so it
 * is unconditional (even with no account resolved). The rest are the operator-granted
 * extras, plus `junior` off the account's own `isJunior` flag. Order is stable so
 * tokens are deterministic.
 */
function accountRoles(
	account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
): string[] {
	const roles = ['screenshare']
	if (!account) return roles
	if (account.isDeveloper) roles.push('developer')
	if (account.isModerator) roles.push('moderator')
	if (account.isJunior) roles.push('junior')
	return roles
}

/**
 * The account's token `rn.privilege` claim. Despite the scope-shaped name it is a CLAIM,
 * read out of the same claims dictionary as `role` — it never belongs in `scope`. The
 * client knows exactly two values, both chat restrictions, and both ride on a junior
 * account: `BanVChat` (voice) and `BanRmChat` (room chat). Empty for everyone else, which
 * drops the claim rather than sending a blank one.
 */
function accountPrivileges(account: Pick<Account, 'isJunior'> | null): string[] {
	return account?.isJunior ? ['BanVChat', 'BanRmChat'] : []
}

/**
 * The platform an account's `platformId` belongs to. Nothing defaults the `platform`
 * field (see defaultAccount), so an account can carry a platform identity with no
 * platform recorded — and until Meta verification landed Steam was the only identity
 * we could prove, so an unset one *is* Steam. Every account bound since records its
 * platform explicitly; this default only covers those older rows.
 */
function accountPlatform(account: Pick<Account, 'platform'>): number {
	return account.platform ?? 0
}

/**
 * Project a linked account into the client's CachedLogin DTO — the account-picker
 * entry on the login screen. The client posts the chosen `accountId` back as a
 * `grant_type=cached_login`. `requirePassword` is false because platform ownership
 * (the verified `platform_auth`) is the credential for a cached login — no prompt.
 *
 * The platform and id come from the LINK, not from the account: an account linked to
 * both a Steam and a Meta identity appears in both pickers, and each has to report the
 * identity that picker was asked about — that's what the client posts back, and what
 * the grant then checks the link against.
 */
function toCachedLogin(account: Account, link: PlatformLink) {
	return {
		platform: link.platform,
		platformId: link.platformId,
		accountId: account.accountId,
		lastLoginTime: account.lastLoginTime ?? account.createdAt,
		requirePassword: false,
	}
}

/**
 * Project a set of links into picker entries, dropping any whose account no longer
 * exists. One batched account read rather than one per link.
 *
 * Order follows the links (oldest first), so the picker is stable between launches.
 */
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
	if (links.length === 0) return []
	const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
	const byId = new Map(accounts.map((a) => [a.accountId, a]))
	return links.flatMap((link) => {
		const account = byId.get(link.accountId)
		return account ? [toCachedLogin(account, link)] : []
	})
}

/**
 * Link the platform identity a password login proved to the account it logged into,
 * so the next launch on that device is a cached login. Called only with a VERIFIED
 * identity — a link is a password-free way into the account.
 *
 * Already linked is the common case (every subsequent login on that device) and costs
 * one read and nothing else.
 *
 * The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
 * an identity could otherwise sit at the limit, have accounts created for it with a
 * password, and link its way into all of them. Reaching it does NOT fail the login —
 * the password was valid — it just leaves the account without a cached login, so the
 * player types their password each time rather than being locked out.
 *
 * The first identity linked also becomes the account's primary (the blob's
 * `platform`/`platformId`), which is what the account DTO and the refresh grant's
 * claims report. Later platforms link without disturbing it.
 */
async function linkLoginIdentity(
	db: D1Database,
	accountId: number,
	platform: number,
	platformId: string,
	maxAccountsPerIdentity: number
): Promise<void> {
	if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return

	if (
		maxAccountsPerIdentity > 0 &&
		(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
	) {
		logger.info('platform link refused: account limit reached for this platform identity', {
			accountId,
			platform,
			platformId,
		})
		return
	}

	if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
	logger.info('linked platform identity to account', { accountId, platform, platformId })

	const account = await getAccount(db, accountId)
	if (account && !account.platformId) {
		await updateAccount(db, accountId, { platform, platformId })
	}
}

/**
 * What a login's `platform_auth` proved, if anything. Failures are split because the
 * callers act on them differently: a grant that authenticates BY platform identity has
 * to refuse, while a password grant — which has already proven who it is — carries on
 * and just doesn't link.
 *
 * `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
 * and is the one case that warrants a 5xx.
 */
type PlatformProof =
	/** Nothing was checked — the login offered no proof, so there is nothing to report. */
	| { status: 'none' }
	| { status: 'verified'; platform: number; platformId: string }
	| { status: 'unsupported' }
	| { status: 'unconfigured' }
	| { status: 'rejected'; reason: string }

/**
 * Verify a login's `platform_auth` and return the identity it proves.
 *
 * The two verifiable platforms prove the id in opposite directions, which is why they
 * can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
 * so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
 * validated *against* the posted `platform_id`, so that field is an input, and a
 * spoofed one fails validation rather than being ignored. Either way the id that comes
 * back is proven, never the raw client-supplied field, and only a proven id is ever
 * written to an account or linked to one.
 */
async function verifyPlatformProof(
	env: App['Bindings'],
	platform: number,
	platformAuth: string,
	postedPlatformId: string
): Promise<PlatformProof> {
	// A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it
	// has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no
	// caller downstream can treat it as an identity — above all `linkLoginIdentity` on the
	// password grant, which is the path such a client actually takes. Linking it would
	// hand every sideloaded headset a password-free login into that account, since they
	// all report this same id.
	//
	// Refusing costs a sideloaded player nothing: their password login still succeeds (a
	// password grant carries its own credential and only *links* on a verified proof), it
	// just never gets a cached login, so they type their password each launch. That is
	// the intended shape of the sideload flow.
	if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) {
		return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' }
	}
	if (platform === PlatformType.Steam) {
		const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
		if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
		return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
	}
	if (platform === PlatformType.Oculus) {
		// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
		// holding an empty/placeholder value) — the same misconfiguration from the player's
		// side, so it takes the same branch.
		const appSecret = await env.META_APP_SECRET.get().catch(() => '')
		if (appSecret === '') return { status: 'unconfigured' }
		const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
		if (!verified.ok) return { status: 'rejected', reason: verified.reason }
		return {
			status: 'verified',
			platform: PlatformType.Oculus,
			platformId: verified.identity.userId,
		}
	}
	return { status: 'unsupported' }
}

const app = new Hono<App>()
	.use(
		'*',
		// middleware
		(c, next) =>
			useWorkersLogger(c.env.NAME, {
				environment: c.env.ENVIRONMENT,
				release: c.env.SENTRY_RELEASE,
			})(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())

	// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
	.get(
		'/eac/challenge',
		describeRoute({
			tags: ['EAC'],
			summary: 'Easy Anti-Cheat challenge',
			description:
				'Returns a constant JSON-quoted string (`"AA=="`) as `text/plain`. Anti-cheat is not implemented; this exists so the client\'s EAC handshake succeeds.',
			responses: {
				200: {
					description: 'The challenge, JSON-quoted, as text/plain',
					content: { 'text/plain': { schema: { type: 'string', example: '"AA=="' } } },
				},
			},
		}),
		(c) => c.text(`"AA=="`)
	)

	// Cached logins for a platform id — the accounts linked to this platform-native
	// id, so the client can offer them on the login screen (and post one back as a
	// cached_login grant). No linked account → [], and the client falls back to a
	// fresh login / create_account.
	//
	// GET or POST: the 2023 build asks with a GET, the 2025 build (20250424.01) POSTs
	// the same path with a form body — `deviceId`, `platformAuth` (a JSON blob holding
	// the platform's session ticket and app id) and `time`. The body is READ BY NOTHING
	// here; both methods answer the same list off the path params, so a newer client
	// gets its picker. Verifying that ticket is the eventual point of the POST.
	.on(
		['GET', 'POST'],
		'/cachedlogin/forplatformid/:platform/:id',
		describeRoute({
			tags: ['Cached login'],
			summary: 'Accounts linked to a platform id',
			description: [
				'Accounts the client may offer on its login screen for this platform identity —',
				'the links this identity has, so an entry here is always redeemable by a',
				'`cached_login` grant (both read the same table). An account linked to several',
				'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
				'and the client falls back to a fresh login or create_account.',
				'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
				'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
				'empty picker. It consults nothing and returns one canned, non-redeemable entry',
				'with `requirePassword: true`, sending the build to username/password login.',
				'Older clients GET this; the 20250424.01 build POSTs it with a',
				'`deviceId` / `platformAuth` / `time` form body attesting the platform session.',
				'That body is accepted and ignored — both methods answer identically from the',
				'path params.',
			].join(' '),
			parameters: [
				{
					name: 'platform',
					in: 'path',
					required: true,
					description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
					schema: { type: 'string' },
				},
				{
					name: 'id',
					in: 'path',
					required: true,
					description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
					schema: { type: 'string' },
				},
			],
			responses: {
				200: json(
					CachedLogin.or(FakeCachedLogin).array(),
					'Matching accounts; `[]` if none. The canned entry for `1/1`.'
				),
			},
		}),
		async (c) => {
			const { platform, id } = c.req.param()
			logger.info('cached login lookup', { platform, id })
			const platformInt = Number.parseInt(platform, 10)
			// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
			// produce a real Meta identity or a nonce to prove one with — it asks about the
			// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
			// login screen with nothing to do. Hand back one canned entry to push it onto the
			// username/password login instead, which is the only flow such a build can finish.
			// `requirePassword` is true for exactly that reason: there's no platform proof here,
			// and the `cached_login` grant would (correctly) refuse this entry.
			//
			// Scoped to that ONE identity rather than to all of platform 1 — store builds do
			// real Meta logins, and shadowing the whole platform would hide genuine links from
			// their pickers.
			if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
				return c.json([FAKE_OCULUS_CACHED_LOGIN])
			}
			// Listed straight from the link table, which is also what the `cached_login`
			// grant authorizes against — so the picker can't offer an account the grant
			// then refuses.
			const links = Number.isNaN(platformInt)
				? await getLinksForPlatformId(c.env.DB, id)
				: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
			return c.json(await toCachedLogins(c.env.DB, links))
		}
	)

	// Bulk cached-login lookup by platform id (friends resolution). The client POSTs
	// repeated `id=` params on the auth host; resolve each to its linked accounts.
	.post(
		'/cachedlogin/forplatformids',
		describeRoute({
			tags: ['Cached login'],
			summary: 'Bulk cached-login lookup (friends resolution)',
			description: [
				'Resolves many platform ids at once. Results are flattened across all ids, so the',
				'response cannot be mapped back to a specific input id — the client uses each',
				'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
				'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
			].join(' '),
			requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
			responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
		}),
		async (c) => {
			const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
			const raw = body.id
			const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
			const out: Array<ReturnType<typeof toCachedLogin>> = []
			for (const pid of ids) {
				// No platform accompanies these ids, so they match on any platform.
				out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
			}
			return c.json(out)
		}
	)

	// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
	.post(
		'/connect/token',
		describeRoute({
			tags: ['Token'],
			summary: 'OAuth token endpoint — issues a JWT',
			description: [
				'Issues an access token (plus a single-use refresh token) for one of four grants,',
				'selected by `grant_type`. Every grant returns the same body on success.',
				'',
				'**`create_account`** — mints a new account with an auto-assigned random username',
				'(players do not pick one initially) and places it in the Orientation room. A posted',
				'`password` becomes the login credential. Subject to two independent signup caps,',
				'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
				'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
				'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
				'must verify.',
				'',
				'**`cached_login`** — logs into an already-linked account using platform ownership as',
				'the credential; no password. Requires a verifying `platform_auth`, and the posted',
				'`account_id` must be LINKED to exactly the identity it proves. An account with no',
				'link for that identity cannot be cached-logged-into.',
				'',
				'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
				'platform and platform id come from what was stored at issue time, not the body.',
				'',
				'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
				'identifies the account by `username` or numeric `account_id` and requires the',
				'matching `password`. An account with no stored hash cannot be logged into at all,',
				'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
				'that verifies, that identity is LINKED to the account — this is how a player who',
				'signed up on one platform gets a cached login on a second device. The login is',
				'never failed over the link: an unverifiable proof (or one over the per-identity',
				'cap) just leaves the account without a cached login there.',
				'',
				'**Platform identity.** An account can be reached from several platform identities;',
				'the links are the one thing both the picker and `cached_login` consult, and only a',
				'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
				'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
				'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
				'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
				'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
				'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
				'when it is unset. The first identity linked also becomes the account’s primary',
				'(what the account DTO and a refreshed token report); later ones only link.',
				'',
				'The one platform id that is never verified and never linked is `1` on platform `1`',
				'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
				'build reports it, so it identifies nobody. A password login that carries it still',
				'succeeds; it simply links nothing, and the player types their password each launch.',
				'',
				'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
				'powers refresh on every login and every refresh grant. `junior` rides along for an',
				'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
				'gate the client reads, not a privilege anyone is granted. A junior also carries',
				'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
				'client reads it as a claim beside `role`, and it is absent for everyone else.',
				'',
				'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
				'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
				'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
				'own when `ban_expires` passes, and never if that is null.',
				'',
				'The refusal follows the player, not just the account: it also catches an account',
				'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
				'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
				'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
				'Those two arms are the operator’s `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
				'`off`); the ban on the account itself is always enforced. A linked match answers a',
				'deliberately vaguer description than a direct one — the account refused may belong',
				'to a housemate of the banned player rather than to them.',
			].join('\n'),
			requestBody: form(
				TokenRequest,
				'Union of all grants; see the description for per-grant requirements'
			),
			responses: {
				200: json(TokenResponse, 'Access token, refresh token and granted scopes'),
				400: json(
					OAuthError,
					[
						'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
						'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
						'or a banned account',
					].join(' ')
				),
				500: json(
					OAuthError,
					[
						'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
						'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
						'(no nonce can be validated without it).',
					].join(' ')
				),
			},
		}),
		async (c) => {
			// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
			// form body.
			const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
			const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
			// `platform`/`platform_id` come from the body for a fresh login; a refresh
			// grant overrides them below with what was stored when the token was issued.
			let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
			const platformInt =
				typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
			// The token's `platform` claim is the PlatformType int. A grant that asserts no
			// platform (a password login) falls back to Steam/0, the same default the account
			// itself carries — see `accountPlatform`.
			let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt

			// The device this login came from. The client posts both on every grant; they're
			// unverified (client-picked) so they're recorded on the account, never trusted as
			// a credential. Stored on account creation AND refreshed on each successful login,
			// so the account's device tracks the player across devices — the raw material for
			// linking accounts that share a device later.
			const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
			const deviceClassInt =
				typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
			const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt

			// The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's
			// `rn.ver` claim so everything downstream reports the build the player is ACTUALLY
			// running rather than this server's GAME_VERSION — `match` reads it back off the
			// token when it writes presence. Unverified like the device fields, and only ever
			// echoed, never trusted for a decision (the version CHECK is `api`'s
			// `/api/versioncheck/v4`, against its own list).
			//
			// A grant that posts none — a refresh, or a caller that isn't the game — leaves it
			// undefined and generateToken falls back to GAME_VERSION. An empty string is
			// treated as absent for the same reason: presence must never carry an empty
			// version, which breaks the client's handling of it.
			const version = typeof body.ver === 'string' && body.ver !== '' ? body.ver : undefined

			// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
			// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
			// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
			// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
			const clientIp = c.req.header('cf-connecting-ip') ?? ''

			// A platform-authenticated login proves who you are with the platform itself, and
			// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
			// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
			// verifyPlatformProof). Only a verified identity is ever bound or linked.
			//
			// Two grants are GATED on it — they have no other credential, so an unverifiable
			// platform is fatal:
			//   - cached_login authenticates purely by platform identity.
			//   - create_account that asserts a platform: we won't bind an identity we can't
			//     prove. (create_account with NO platform is the password-account path —
			//     allowed, but binds no platformId.)
			//
			// A password grant is NOT gated: the password already proved who it is. It posts
			// its platform proof too, and if that verifies we LINK the identity to the account
			// (see below), which is how a player who created an account on Steam gets a cached
			// login on their headset. If it doesn't verify, the login still succeeds — it just
			// links nothing, because a link is a password-free way into the account and must
			// never rest on an unproven id.
			const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
			const platformAsserted = !Number.isNaN(platformInt)
			const gatedOnPlatform =
				grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
			// The password grant only spends a verification when the client actually offered
			// one; the rest of the time there is nothing to link.
			const proof: PlatformProof =
				gatedOnPlatform || (platformAsserted && platformAuth !== '')
					? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
					: { status: 'none' }

			let verifiedPlatformId: string | null = null
			let verifiedPlatform: number | null = null
			if (proof.status === 'verified') {
				verifiedPlatform = proof.platform
				verifiedPlatformId = proof.platformId
			} else if (proof.status !== 'none') {
				// Log every failure, including the ones a password grant shrugs off: a player
				// who silently never gets a cached login on their headset has no other symptom,
				// and this line is where "Meta rejected the nonce" becomes visible.
				logger.info('platform_auth not verified', {
					platform: platformInt,
					platformId,
					grantType,
					status: proof.status,
					reason: proof.status === 'rejected' ? proof.reason : undefined,
				})
			}

			if (gatedOnPlatform && proof.status !== 'verified') {
				if (proof.status === 'unsupported') {
					return c.json(
						{
							error: 'invalid_grant',
							error_description: 'unsupported platform; only Steam and Meta can be verified',
						},
						400
					)
				}
				if (proof.status === 'unconfigured') {
					// An operator misconfiguration, not the client's fault: without the app secret
					// every Meta player is locked out, so it answers 500 the way an unset
					// JWT_SECRET does below rather than blaming the credential. (We never fall
					// back to trusting the posted id — that would let anyone log into any
					// Meta-linked account by naming its user id.)
					logger.error('refusing a Meta login: META_APP_SECRET is empty')
					return c.json(
						{
							error: 'server_error',
							error_description: 'Meta platform verification is not configured',
						},
						500
					)
				}
				// The reason is for the operator; the client is told only that it was rejected.
				// A wrong app secret and a stale nonce look identical from the client side.
				return c.json(
					{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
					400
				)
			}

			// From here on `platformId` is the PROVEN identity wherever there is one — the
			// SteamID64 out of the ticket or the Meta user id the nonce validated against,
			// never the raw client-supplied field.
			if (verifiedPlatformId !== null) platformId = verifiedPlatformId

			// Resolve the account this token is for:
			//  - create_account: mint + persist a brand-new account (auto-assigned random
			//    username — players don't pick one initially); the token's `sub` is its id.
			//    A `password` may be posted to establish the account's login credential.
			//  - refresh_token: redeem a stored (single-use) refresh token for its account +
			//    platform, so an expiring session renews without re-login.
			//  - otherwise: a credential login. The request identifies the account by
			//    `username` (RecRoom's password grant posts the username, not the id) or a
			//    numeric `account_id`, and MUST post the account's correct `password`. An
			//    account with no password set can't be logged into (no credential to verify)
			//    — closing the id/username-only takeover. New accounts establish a password
			//    via create_account or /account/me/changepassword.
			let accountId: string
			if (grantType === 'create_account') {
				// A banned player's next move is a new account, so the ban is checked BEFORE
				// one is minted — against the only identity a signup has, the IP it came from
				// and the platform identity it just proved. Refusing after the fact (as the
				// shared check below would) still refuses the token, but leaves the account
				// row behind and burns a slot off both signup caps, so the evader gets to keep
				// making them.
				//
				// Nothing here can match the account arm (there is no account yet), so this is
				// purely the linked matching, and BAN_EVASION_MATCH=off leaves signup open —
				// which is the honest default position: a server that won't accept the IP arm's
				// false positives is choosing to let evaders re-register.
				const blocked = await resolveBan(c.env.DB, null, {
					identity: {
						ip: clientIp,
						platform: verifiedPlatform,
						platformId: verifiedPlatformId,
					},
					arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
				})
				if (blocked) {
					logger.info('signup refused: player banned', {
						via: blocked.via,
						bannedAccountId: blocked.bannedAccountId,
						ip: clientIp,
						platformId: verifiedPlatformId,
					})
					return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
				}

				// Signup caps. Checked before minting anything, so a rejected signup leaves no
				// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
				// identity is unknown (no verified platform id / no client IP) — an unattributable
				// signup can't be counted against anyone, and lumping them together would lock out
				// real players. The disabled check comes first so a disabled arm costs no D1 read.
				const maxPerPlatformId = intVar(
					c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
					DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
				)
				const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
				if (
					maxPerPlatformId > 0 &&
					verifiedPlatformId !== null &&
					(await countAccountsForPlatformIdentity(
						c.env.DB,
						verifiedPlatform ?? 0,
						verifiedPlatformId
					)) >= maxPerPlatformId
				) {
					logger.info('signup rejected: platform account limit', {
						platformId: verifiedPlatformId,
					})
					return c.json(
						{
							error: 'invalid_grant',
							error_description: 'account limit reached for this platform account',
						},
						400
					)
				}
				if (
					maxPerIp > 0 &&
					clientIp !== '' &&
					(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
				) {
					logger.info('signup rejected: per-IP account limit', { ip: clientIp })
					return c.json(
						{
							error: 'invalid_grant',
							error_description: 'too many accounts created from this network',
						},
						400
					)
				}

				// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
				// a Meta-validated nonce). A password/anonymous create_account (no platform)
				// binds nothing. The account blob keeps this first identity as its PRIMARY one
				// (for the account DTO and the refresh grant's claims); the link written just
				// below is what a later cached login is actually authorized against.
				const account = await createAccount(c.env.DB, {
					platforms: platformInt || 0,
					platform: verifiedPlatform ?? undefined,
					platformId: verifiedPlatformId ?? undefined,
					lastLoginTime: new Date().toISOString(),
					deviceId: deviceId || undefined,
					deviceClass: deviceId ? deviceClass : undefined,
					signupIp: clientIp || undefined,
					lastLoginIp: clientIp || undefined,
				})
				accountId = String(account.accountId)
				if (verifiedPlatformId !== null) {
					await linkPlatformIdentity(
						c.env.DB,
						account.accountId,
						verifiedPlatform ?? 0,
						verifiedPlatformId
					)
				}
				// Establish the login password when one is posted (raw password never stored).
				const password = typeof body.password === 'string' ? body.password : ''
				if (password !== '') {
					await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
				}
				// Place the new player in Orientation (they don't explicitly matchmake into it).
				await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
			} else if (grantType === 'refresh_token') {
				const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
				const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
				if (!refreshed) {
					return c.json(
						{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
						400
					)
				}
				// `platform`/`platform_id` aren't stored with the token — they're taken from
				// the account below, so a refreshed token always reflects the identity the
				// account is bound to now.
				accountId = String(refreshed)
			} else if (grantType === 'cached_login') {
				// Platform-authenticated login into an already-linked account. The client posts
				// the `account_id` it got from /cachedlogin/forplatformid together with the
				// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
				// table says that account is linked to exactly this platform identity — this is
				// the check that keeps anyone but that platform user out of the account
				// (platform ownership is the credential; no password needed). An account with no
				// link for the presented identity must use a password.
				//
				// The picker lists straight from the same table, so it can only offer accounts
				// this check accepts.
				//
				// NB: `platform_id` here is the verified identity set above — the SteamID64 from
				// the ticket, or the Meta user id the nonce validated against — never the raw
				// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
				//
				const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
				const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
				const linked =
					account !== null &&
					(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
				if (!account || !linked) {
					return c.json(
						{
							error: 'invalid_grant',
							error_description: 'no linked account for this platform identity',
						},
						400
					)
				}
				accountId = String(account.accountId)
				await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
				await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
			} else {
				// Resolve the account from a posted numeric `account_id` or, as RecRoom's
				// password grant sends, a `username` (case-insensitive; trailing whitespace
				// is trimmed off the posted value).
				const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
				const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
				let resolvedId: number | null = null
				if (/^\d+$/.test(postedId)) {
					resolvedId = Number(postedId)
				} else if (postedUsername !== '') {
					resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
				}
				if (resolvedId === null) {
					return c.json(
						{ error: 'invalid_request', error_description: 'account_id or username is required' },
						400
					)
				}
				// The account's password MUST be presented and match. An account with no
				// stored hash has no credential to authenticate against, so login is refused
				// — this closes the id/username-only takeover.
				const storedHash = await getPasswordHash(c.env.DB, resolvedId)
				const password = typeof body.password === 'string' ? body.password : ''
				if (!storedHash || !(await verifyPassword(password, storedHash))) {
					return c.json(
						{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
						400
					)
				}
				accountId = String(resolvedId)
				// The password proved the account; the platform proof (when the client sent one
				// and it verified) proves the device's platform identity. Linking the two is
				// what gives a player who signed up on Steam a cached login on their headset —
				// they type their password once there, and never again.
				if (verifiedPlatformId !== null) {
					await linkLoginIdentity(
						c.env.DB,
						resolvedId,
						verifiedPlatform ?? 0,
						verifiedPlatformId,
						intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
					)
				}
				await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
				await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
			}

			// A banned player gets no token — and with no token every other worker is shut to
			// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
			// one, which still has to exist because a token issued before the ban stays valid
			// until it expires.
			//
			// Checked once here, after the grant has resolved an account, so it covers every
			// grant: password, cached_login and a refresh_token redeemed by a client that has
			// been running since before the ban. Deliberately AFTER the credential checks —
			// a wrong password is still "invalid account_id or password", so this can't be
			// used to probe whether an account exists or is banned without knowing it.
			//
			// The request's own IP and proven identity are passed alongside the account, so a
			// ban also reaches an old, clean account logged into from the banned player's
			// device or network — the stored ips alone would only catch that on the SECOND
			// login. create_account was already refused before it minted anything (above);
			// this still runs for it, so a signup that raced one is refused too.
			const ban = await resolveBan(c.env.DB, Number(accountId), {
				identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
				arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
			})
			if (ban) {
				logger.info('token refused: player banned', {
					accountId,
					grantType,
					via: ban.via,
					bannedAccountId: ban.bannedAccountId,
					reportId: ban.ban.id,
					banExpires: ban.ban.ban_expires,
				})
				return c.json(
					{
						error: 'invalid_grant',
						error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
					},
					400
				)
			}

			// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
			// binding) would still yield a well-formed token — but one signed with an empty
			// key, which every worker validates against, so anyone could forge it. Refuse to
			// issue a token at all rather than complete the login with a forgeable credential.
			const jwtSecret = await c.env.JWT_SECRET.get()
			if (jwtSecret === '') {
				logger.error('refusing to issue token: JWT_SECRET is empty')
				return c.json(
					{ error: 'server_error', error_description: 'token signing is not configured' },
					500
				)
			}

			// Stamp the account's elevated roles into the token's `role` claim so the client
			// authorizes developer/moderator powers from the token itself (not just the
			// /role/* lookups). One read of the just-resolved account; roles thus refresh on
			// every login and every refresh_token grant.
			const roleAccount = await getAccount(c.env.DB, Number(accountId))
			// A refresh grant posts no platform of its own, so the identity comes off the
			// account — the same read, and the only place the bound identity is authoritative.
			if (grantType === 'refresh_token' && roleAccount) {
				platform = accountPlatform(roleAccount)
				platformId = roleAccount.platformId ?? ''
			}
			const accessToken = await generateToken(
				accountId,
				platformId,
				platform,
				jwtSecret,
				accountRoles(roleAccount),
				accountPrivileges(roleAccount),
				version
			)
			// Issue a fresh, persisted refresh token (single-use; the client redeems it via
			// grant_type=refresh_token). A refresh grant thus rotates its token.
			const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))

			return c.json({
				access_token: accessToken,
				expires_in: TOKEN_TTL_SECONDS,
				token_type: 'Bearer',
				refresh_token: refreshToken,
				scope: TOKEN_SCOPE,
				// @kludge Why is this necessary? Who knows.
				key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
			})
		}
	)

	// Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account
	// row (the raw password is never persisted). When the account already has a
	// password, `oldPassword` must match; the first time it's set, `oldPassword` is
	// empty (as the client sends).
	.post(
		'/account/me/changepassword',
		describeRoute({
			tags: ['Account'],
			summary: "Change the caller's password",
			description: [
				'Stores a PBKDF2 hash on the account row; the raw password is never persisted.',
				'When the account already has a password, `oldPassword` must match. The first time',
				'a password is set, `oldPassword` is empty — which is what the client sends.',
			].join(' '),
			security: [{ bearerAuth: [] }],
			requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'),
			responses: {
				200: json(ChangePasswordResponse, 'Password changed'),
				400: json(ChangePasswordResponse, '`newPassword` was empty, or `oldPassword` was wrong'),
				401: { description: 'Missing or invalid bearer token (empty body)' },
				404: { description: 'The account no longer exists (empty body)' },
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return c.body(null, 401)

			const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
			const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
			const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
			if (newPassword === '') {
				return c.json({ success: false, error: 'You must enter a new password.' }, 400)
			}

			const currentHash = await getPasswordHash(c.env.DB, id)
			if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
				return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
			}

			const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
			if (!ok) return c.body(null, 404)
			return c.json({ success: true })
		}
	)

	// The moderation restrictions on the caller's account — chat mutes and the like. STUB:
	// nothing here issues restrictions, so every caller is unrestricted and the list is
	// empty. An EMPTY ARRAY is the normal unrestricted answer, not null and not a 404: the
	// client clears and refills its list from this, and acts on a record simply being
	// present (plus its `EndDate`), so `[]` is a complete answer rather than a placeholder.
	//
	// When this is wired up, the display strings are free text — the client matches none of
	// them — so only `EndDate` and the record's presence need to be right. See
	// `RestrictionDto`.
	.get(
		'/privileges/me/restrictions',
		describeRoute({
			tags: ['Account'],
			summary: 'The caller’s moderation restrictions',
			description: [
				'The restrictions in force on the caller’s account (a chat mute, say), as a bare array.',
				'Always EMPTY here — nothing on this server issues restrictions — and an empty array is',
				'the normal unrestricted answer, not null. The client refills its list from this and',
				'acts on a record being present and its `EndDate`; the `Name`/`Description`/',
				'`DisplayReason` strings are display text it matches nothing against.',
			].join(' '),
			security: [{ bearerAuth: [] }],
			responses: {
				200: json(RestrictionDto.array(), 'The caller’s restrictions — always empty'),
				401: { description: 'Missing or invalid bearer token (empty body)' },
			},
		}),
		async (c) => {
			const id = await authedId(c)
			if (id === null) return c.body(null, 401)
			return c.json([])
		}
	)

	// Developer role lookup. Returns a bare JSON boolean (the client reads the body as
	// a bool), and 404s for an unknown player — mirroring the reference API. The role
	// is off by default and only an operator grants it (via `runx admin grant-developer`,
	// which sets the account's isDeveloper flag); it also rides in the token's `role`
	// claim (see accountRoles).
	.get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => {
		const { id } = c.req.param()
		logger.info('developer role lookup', { id })
		const accountId = Number.parseInt(id, 10)
		const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
		if (!account) return c.body(null, 404)
		return c.json(account.isDeveloper === true)
	})

	// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
	// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
	// the token's `role` claim.
	.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
		const { id } = c.req.param()
		logger.info('moderator role lookup', { id })
		const accountId = Number.parseInt(id, 10)
		const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
		if (!account) return c.body(null, 404)
		return c.json(account.isModerator === true)
	})

	// @guess Oculus nonce. The client asks for this before a Meta login; the exact shape
	// it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length
	// Meta's own `GetUserProof` nonces have) and answers it as a bare JSON string. Nothing
	// is stored — Meta's nonce validation (meta-nonce.ts) is what actually proves a login,
	// so this value is not security-relevant to the server. Revisit once the client's use
	// of it is seen.
	.get(
		'/oculus/nonce',
		describeRoute({
			tags: ['Account'],
			summary: 'A fresh nonce for the Oculus login flow',
			description: [
				'Mints a random 64-char hex nonce and returns it as a bare JSON string. Not stored',
				'and not verified later — a best guess at the shape the client wants.',
			].join(' '),
			responses: { 200: json(z.string(), 'The nonce') },
		}),
		(c) => {
			const bytes = crypto.getRandomValues(new Uint8Array(32))
			const nonce = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
			logger.info('oculus nonce issued')
			return c.json(nonce)
		}
	)

// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
	'/openapi.json',
	describeRoute({ hide: true }),
	withCleanSpec(
		openAPIRouteHandler(app, {
			documentation: {
				info: {
					title: 'recflare auth',
					version: '1.0.0',
					description: [
						'Authentication and token issuance for recflare, a private-server reimplementation',
						'of the Rec Room backend.',
					].join('\n'),
				},
				servers: [{ url: 'https://auth.recflare.net', description: 'Production' }],
				components: {
					securitySchemes: {
						bearerAuth: {
							type: 'http',
							scheme: 'bearer',
							bearerFormat: 'JWT',
							description: 'An `access_token` from `POST /connect/token`.',
						},
					},
				},
			},
		})
	)
)

export default app
