From 2e057e93b7cfb37493c4986f83a346cc92007d75 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 14 Jul 2026 11:26:11 -0400 Subject: [PATCH] record deviceclass --- apps/auth/src/auth.app.ts | 25 ++++++++-- apps/auth/src/test/integration/api.test.ts | 34 +++++++++++++- apps/match/src/match.app.ts | 18 ++++++-- apps/match/src/test/integration/api.test.ts | 22 +++++++++ packages/domain/src/accounts-db.ts | 51 +++++++++++++++++++++ 5 files changed, 143 insertions(+), 7 deletions(-) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 860d0d1..7c934fe 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -8,6 +8,7 @@ import { getAccountsByPlatformId, getPasswordHash, RoomInstanceType, + setDeviceInfo, setLastLoginTime, setPasswordHash, setPresence, @@ -58,7 +59,11 @@ const ORIENTATION_INSTANCE_ID = -2 * 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): Promise { +async function placeNewPlayerInOrientation( + env: App['Bindings'], + accountId: number, + deviceClass: number +): Promise { const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1') .bind(ORIENTATION_ROOM_ID) .first<{ data: string }>() @@ -95,7 +100,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb accountId, roomInstance, statusVisibility: 0, - deviceClass: 0, + deviceClass, vrMovementMode: 1, platform: 0, appVersion: '20230302', @@ -212,6 +217,16 @@ const app = new Hono() const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[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 + // A platform-authenticated login proves who you are with the platform itself, // and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth // ticket. So those logins must be Steam: @@ -269,6 +284,8 @@ const app = new Hono() platform: verifiedSteamId !== null ? 0 : undefined, platformId: verifiedSteamId ?? undefined, lastLoginTime: new Date().toISOString(), + deviceId: deviceId || undefined, + deviceClass: deviceId ? deviceClass : undefined, }) accountId = String(account.accountId) // Establish the login password when one is posted (raw password never stored). @@ -277,7 +294,7 @@ const app = new Hono() 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) + 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 @@ -315,6 +332,7 @@ const app = new Hono() } accountId = String(account.accountId) await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString()) + await setDeviceInfo(c.env.DB, account.accountId, deviceId, deviceClass) } else { // Resolve the account from a posted numeric `account_id` or, as RecRoom's // password grant sends, a `username` (case-insensitive; trailing whitespace @@ -346,6 +364,7 @@ const app = new Hono() } accountId = String(resolvedId) await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString()) + await setDeviceInfo(c.env.DB, resolvedId, deviceId, deviceClass) } const accessToken = await generateToken( diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index d56f069..7e3c8fa 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../auth.app' -import { PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain' +import { getAccountsByDeviceId, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain' import { isLinkedToPlatformIdentity } from '../../auth.app' import { hashPassword } from '../../password' @@ -344,6 +344,38 @@ describe('auth worker routes', () => { expect(account.username).not.toMatch(/^Player\d+$/) }) + test('POST /connect/token create_account stores the login device on the account', async () => { + const deviceId = '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88' + const payload = await tokenFor( + `grant_type=create_account&platform_id=steam-dev1&device_id=${deviceId}&device_class=2` + ) + const sub = Number.parseInt(payload.sub as string, 10) + const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1') + .bind(sub) + .first<{ data: string }>() + const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number } + expect(account.deviceId).toBe(deviceId) + expect(account.deviceClass).toBe(2) + + // Accounts sharing a device can be found later (account linkup). + const shared = await getAccountsByDeviceId(env.DB, deviceId) + expect(shared.map((a) => a.accountId)).toContain(sub) + }) + + test('POST /connect/token refreshes the stored device on a credential login', async () => { + // Account 42 was seeded with no device; a later login records the one it came from. + const res = await postToken( + `grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=dev-42-new&device_class=3` + ) + expect(res.status).toBe(200) + const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1') + .bind(42) + .first<{ data: string }>() + const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number } + expect(account.deviceId).toBe('dev-42-new') + expect(account.deviceClass).toBe(3) + }) + test('POST /connect/token create_account seeds the new player into Orientation', async () => { const payload = await tokenFor('grant_type=create_account&platform_id=steam-456') const sub = payload.sub as string diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index a65e861..21af501 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { createRoomInstance, deleteExpiredPresence, + getAccount, getExpiredPresenceInstanceIds, getJoinableInstance, getOrCreateDormRoom, @@ -131,16 +132,27 @@ const GAME_VERSION = '20230302' */ const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }] -/** Store the room instance the player just matchmade into, preserving status. */ +/** + * Store the room instance the player just matchmade into, preserving status. + * + * With no live presence to carry forward (the player's first matchmake after login, + * or one after their presence lapsed) the device fields would otherwise default — + * writing a screen player into the instance as deviceClass 0 until their next + * heartbeat corrects it. Everyone already in the room sees that stale class in the + * meantime, so fall back to what the account reported at login (auth stores + * `deviceClass`/`platform` from the token request) instead of to 0. The account read + * only happens on that no-presence path; a normal matchmake carries `prev` forward. + */ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance): Promise { const prev = await getPresence(c.env.DB, id) + const account = prev ? null : await getAccount(c.env.DB, id) await setPresence(c.env.DB, { accountId: id, roomInstance, statusVisibility: prev?.statusVisibility ?? 0, - deviceClass: prev?.deviceClass ?? 0, + deviceClass: prev?.deviceClass ?? account?.deviceClass ?? 0, vrMovementMode: prev?.vrMovementMode ?? 1, - platform: prev?.platform ?? 0, + platform: prev?.platform ?? account?.platform ?? 0, appVersion: prev?.appVersion || GAME_VERSION, }) // Keep the destination instance's is_full flag in sync with live presence (the diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index cabccd0..4e26f30 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -229,6 +229,28 @@ describe('public endpoints', () => { }) }) + test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => { + // A screen player (deviceClass 2, recorded by auth at login) matchmaking with no + // live presence: without the account fallback they'd enter the room as deviceClass + // 0 (VR) until their next heartbeat, and everyone in the room would see that. + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 55, username: 'Screenie', deviceClass: 2, platform: 0 })) + .run() + const headers = await bearer('55') + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ JoinMode: '2' }).toString(), + }) + expect(res.status).toBe(200) + + const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1') + .bind(55) + .first<{ data: string }>() + const presence = JSON.parse(row!.data) as { deviceClass: number } + expect(presence.deviceClass).toBe(2) + }) + test('POST /matchmake/room/:roomId/:subRoomId enters that subroom', async () => { type Instance = { roomId: number diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index c65310c..71be201 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -49,6 +49,15 @@ export interface Account { platform?: number /** ISO-8601 time of the account's most recent successful login. */ lastLoginTime?: string + /** + * The client's `device_id` from its most recent login (a stable per-install hash + * the client sends on every /connect/token). Not a credential — the client picks + * it and nothing verifies it — so never authorize on it alone. Kept (and indexed) + * so accounts sharing a device can be found later, e.g. for account linkup. + */ + deviceId?: string + /** DeviceClass int (2 = PC/standalone) that `deviceId` was last seen on. */ + deviceClass?: number /** Set via POST /account/me/email; absent until the player provides one. */ email?: string /** Set via POST /account/me/phone; absent until the player provides one. */ @@ -203,6 +212,28 @@ export async function getAccountsByPlatformId( return parseAll(results) } +/** + * Accounts last seen on a given device (the client-supplied `device_id` auth records + * at login). An empty id yields no matches (avoids matching every account with no + * device recorded). + * + * Reads `deviceId` straight out of the JSON blob, so this is a table scan — no + * generated column, no migration. Fine at our account count and for the occasional + * linkup lookup this exists for; if it ever gets hot, promote `deviceId` to an + * indexed generated column the way `platformId` is (see the 0004 migration). + * + * The device id is unverified client input, so treat a match as a *hint* (these + * accounts share a device) and never as proof of identity. + */ +export async function getAccountsByDeviceId(db: D1Database, deviceId: string): Promise { + if (deviceId === '') return [] + const { results } = await db + .prepare("SELECT data FROM account WHERE json_extract(data, '$.deviceId') = ?1") + .bind(deviceId) + .all() + return parseAll(results) +} + /** Record the account's most recent successful login time (ISO-8601). */ export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise { await db @@ -211,6 +242,26 @@ export async function setLastLoginTime(db: D1Database, id: number, time: string) .run() } +/** + * Record the device the account most recently logged in from. Called on every + * successful login (not just account creation) so the stored device tracks the + * player as they move between devices. + */ +export async function setDeviceInfo( + db: D1Database, + id: number, + deviceId: string, + deviceClass: number +): Promise { + if (deviceId === '') return + await db + .prepare( + "UPDATE account SET data = json_set(data, '$.deviceId', ?2, '$.deviceClass', ?3) WHERE account_id = ?1" + ) + .bind(id, deviceId, deviceClass) + .run() +} + /** Look up multiple accounts by AccountId (order not guaranteed). */ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return []