import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { createAccount, getAccountByUsername, getPasswordHash, RoomInstanceType, setPasswordHash, } from '@repo/domain' import { logger, withNotFound, withOnError } from '@repo/hono-helpers' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' import { hashPassword, verifyPassword } from './password' import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import type { Context } from 'hono' import type { App } from './context' /** 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' /** Platform-type enum names by value, used for the token's `platform` claim. */ const PLATFORM_TYPES: Record = { [-1]: 'All', 0: 'Steam', 1: 'Oculus', 2: 'PlayStation', 3: 'Xbox', 4: 'RecNet', 5: 'IOS', 6: 'GooglePlay', 7: 'Standalone', 8: 'Pico', } /** 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 /** Presence TTL (s) — matches the match worker; refreshed by each heartbeat. */ const PRESENCE_TTL = 900 /** * 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) so * the heartbeat keeps them there. */ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: number): Promise { const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1') .bind(ORIENTATION_ROOM_ID) .first<{ data: string }>() if (!row) return const room = JSON.parse(row.data) as Record const subRooms = room.SubRooms const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as Record | 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: str(sub?.DataBlob), 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, } const presence = { roomInstance, statusVisibility: 0, deviceClass: 0, vrMovementMode: 1, platform: 0, appVersion: '20230302', } await env.RECFLARE_MATCH_PRESENCE.put(`presence:${accountId}`, JSON.stringify(presence), { expirationTtl: PRESENCE_TTL, }) } /** The Bearer token's account id (`sub`), or null when there's no valid token. */ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } const app = new Hono() .use( '*', // middleware (c, next) => useWorkersLogger(c.env.NAME, { environment: c.env.ENVIRONMENT, release: c.env.SENTRY_RELEASE, })(c, next) ) .onError(withOnError()) .notFound(withNotFound()) // EAC challenge — a fresh GUID, JSON-quoted, served as plain text. .get('/eac/challenge', (c) => c.text(`"AA=="`)) // Cached logins for a platform id. No CachedLogins storage yet, so there's never // a cached account — return []. The client then goes through a fresh login / // create_account instead of auto-logging into a stub account. .get('/cachedlogin/forplatformid/:platform/:id', (c) => { const { platform, id } = c.req.param() logger.info('cached login lookup', { platform, id }) // TODO: query CachedLogins once they're persisted. return c.json([]) }) // Bulk cached-login lookup by platform id (friends resolution). The client // POSTs repeated `id=` params on the auth host; no DB → no matches → []. .post('/cachedlogin/forplatformids', (c) => c.json([])) // OAuth token endpoint — accepts a form-urlencoded body and issues a JWT. .post('/connect/token', async (c) => { // Reads `grant_type`, `account_id`, `platform_id` and `platform` from the // form body. const body = await c.req.parseBody().catch(() => ({}) as Record) 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 : '' // `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam"). const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '') // 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') { const account = await createAccount(c.env.DB, { platforms: platformInt || 0 }) accountId = String(account.accountId) // 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) } 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 ) } accountId = String(refreshed.accountId) platform = refreshed.platform platformId = refreshed.platformId } 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) } const accessToken = await generateToken( accountId, platformId, platform, await c.env.JWT_SECRET.get() ) // 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, { accountId: Number(accountId), platform, platformId, }) 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', async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) const body = await c.req.parseBody().catch(() => ({}) as Record) 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 }) }) // Developer role lookup. No developer role granted by default. .get('/role/developer/:id', (c) => { const { id } = c.req.param() logger.info('developer role lookup', { id }) return c.json({ success: false }) }) export default app