mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
record deviceclass
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
getAccountsByPlatformId,
|
getAccountsByPlatformId,
|
||||||
getPasswordHash,
|
getPasswordHash,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
|
setDeviceInfo,
|
||||||
setLastLoginTime,
|
setLastLoginTime,
|
||||||
setPasswordHash,
|
setPasswordHash,
|
||||||
setPresence,
|
setPresence,
|
||||||
@@ -58,7 +59,11 @@ const ORIENTATION_INSTANCE_ID = -2
|
|||||||
* shared rooms D1, matching the match worker's `roomInstanceFromRoom` shape) into
|
* shared rooms D1, matching the match worker's `roomInstanceFromRoom` shape) into
|
||||||
* the shared `presence` table (see @repo/domain) so the heartbeat keeps them there.
|
* the shared `presence` table (see @repo/domain) so the heartbeat keeps them there.
|
||||||
*/
|
*/
|
||||||
async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: number): Promise<void> {
|
async function placeNewPlayerInOrientation(
|
||||||
|
env: App['Bindings'],
|
||||||
|
accountId: number,
|
||||||
|
deviceClass: number
|
||||||
|
): Promise<void> {
|
||||||
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
||||||
.bind(ORIENTATION_ROOM_ID)
|
.bind(ORIENTATION_ROOM_ID)
|
||||||
.first<{ data: string }>()
|
.first<{ data: string }>()
|
||||||
@@ -95,7 +100,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
|
|||||||
accountId,
|
accountId,
|
||||||
roomInstance,
|
roomInstance,
|
||||||
statusVisibility: 0,
|
statusVisibility: 0,
|
||||||
deviceClass: 0,
|
deviceClass,
|
||||||
vrMovementMode: 1,
|
vrMovementMode: 1,
|
||||||
platform: 0,
|
platform: 0,
|
||||||
appVersion: '20230302',
|
appVersion: '20230302',
|
||||||
@@ -212,6 +217,16 @@ const app = new Hono<App>()
|
|||||||
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
||||||
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
|
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,
|
// 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
|
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
||||||
// ticket. So those logins must be Steam:
|
// ticket. So those logins must be Steam:
|
||||||
@@ -269,6 +284,8 @@ const app = new Hono<App>()
|
|||||||
platform: verifiedSteamId !== null ? 0 : undefined,
|
platform: verifiedSteamId !== null ? 0 : undefined,
|
||||||
platformId: verifiedSteamId ?? undefined,
|
platformId: verifiedSteamId ?? undefined,
|
||||||
lastLoginTime: new Date().toISOString(),
|
lastLoginTime: new Date().toISOString(),
|
||||||
|
deviceId: deviceId || undefined,
|
||||||
|
deviceClass: deviceId ? deviceClass : undefined,
|
||||||
})
|
})
|
||||||
accountId = String(account.accountId)
|
accountId = String(account.accountId)
|
||||||
// Establish the login password when one is posted (raw password never stored).
|
// Establish the login password when one is posted (raw password never stored).
|
||||||
@@ -277,7 +294,7 @@ const app = new Hono<App>()
|
|||||||
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
|
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
|
||||||
}
|
}
|
||||||
// Place the new player in Orientation (they don't explicitly matchmake into it).
|
// 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') {
|
} else if (grantType === 'refresh_token') {
|
||||||
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
|
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
|
||||||
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
|
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
|
||||||
@@ -315,6 +332,7 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
accountId = String(account.accountId)
|
accountId = String(account.accountId)
|
||||||
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
|
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
|
||||||
|
await setDeviceInfo(c.env.DB, account.accountId, deviceId, deviceClass)
|
||||||
} else {
|
} else {
|
||||||
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
|
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
|
||||||
// password grant sends, a `username` (case-insensitive; trailing whitespace
|
// password grant sends, a `username` (case-insensitive; trailing whitespace
|
||||||
@@ -346,6 +364,7 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
accountId = String(resolvedId)
|
accountId = String(resolvedId)
|
||||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||||
|
await setDeviceInfo(c.env.DB, resolvedId, deviceId, deviceClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await generateToken(
|
const accessToken = await generateToken(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import '../../auth.app'
|
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 { isLinkedToPlatformIdentity } from '../../auth.app'
|
||||||
import { hashPassword } from '../../password'
|
import { hashPassword } from '../../password'
|
||||||
@@ -344,6 +344,38 @@ describe('auth worker routes', () => {
|
|||||||
expect(account.username).not.toMatch(/^Player\d+$/)
|
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 () => {
|
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 payload = await tokenFor('grant_type=create_account&platform_id=steam-456')
|
||||||
const sub = payload.sub as string
|
const sub = payload.sub as string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
import {
|
import {
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
deleteExpiredPresence,
|
deleteExpiredPresence,
|
||||||
|
getAccount,
|
||||||
getExpiredPresenceInstanceIds,
|
getExpiredPresenceInstanceIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
getOrCreateDormRoom,
|
getOrCreateDormRoom,
|
||||||
@@ -131,16 +132,27 @@ const GAME_VERSION = '20230302'
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
|
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<App>, id: number, roomInstance: RoomInstance): Promise<void> {
|
async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance): Promise<void> {
|
||||||
const prev = await getPresence<RoomInstance>(c.env.DB, id)
|
const prev = await getPresence<RoomInstance>(c.env.DB, id)
|
||||||
|
const account = prev ? null : await getAccount(c.env.DB, id)
|
||||||
await setPresence(c.env.DB, {
|
await setPresence(c.env.DB, {
|
||||||
accountId: id,
|
accountId: id,
|
||||||
roomInstance,
|
roomInstance,
|
||||||
statusVisibility: prev?.statusVisibility ?? 0,
|
statusVisibility: prev?.statusVisibility ?? 0,
|
||||||
deviceClass: prev?.deviceClass ?? 0,
|
deviceClass: prev?.deviceClass ?? account?.deviceClass ?? 0,
|
||||||
vrMovementMode: prev?.vrMovementMode ?? 1,
|
vrMovementMode: prev?.vrMovementMode ?? 1,
|
||||||
platform: prev?.platform ?? 0,
|
platform: prev?.platform ?? account?.platform ?? 0,
|
||||||
appVersion: prev?.appVersion || GAME_VERSION,
|
appVersion: prev?.appVersion || GAME_VERSION,
|
||||||
})
|
})
|
||||||
// Keep the destination instance's is_full flag in sync with live presence (the
|
// Keep the destination instance's is_full flag in sync with live presence (the
|
||||||
|
|||||||
@@ -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 () => {
|
test('POST /matchmake/room/:roomId/:subRoomId enters that subroom', async () => {
|
||||||
type Instance = {
|
type Instance = {
|
||||||
roomId: number
|
roomId: number
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ export interface Account {
|
|||||||
platform?: number
|
platform?: number
|
||||||
/** ISO-8601 time of the account's most recent successful login. */
|
/** ISO-8601 time of the account's most recent successful login. */
|
||||||
lastLoginTime?: string
|
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. */
|
/** Set via POST /account/me/email; absent until the player provides one. */
|
||||||
email?: string
|
email?: string
|
||||||
/** Set via POST /account/me/phone; absent until the player provides one. */
|
/** Set via POST /account/me/phone; absent until the player provides one. */
|
||||||
@@ -203,6 +212,28 @@ export async function getAccountsByPlatformId(
|
|||||||
return parseAll(results)
|
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<Account[]> {
|
||||||
|
if (deviceId === '') return []
|
||||||
|
const { results } = await db
|
||||||
|
.prepare("SELECT data FROM account WHERE json_extract(data, '$.deviceId') = ?1")
|
||||||
|
.bind(deviceId)
|
||||||
|
.all<AccountRow>()
|
||||||
|
return parseAll(results)
|
||||||
|
}
|
||||||
|
|
||||||
/** Record the account's most recent successful login time (ISO-8601). */
|
/** Record the account's most recent successful login time (ISO-8601). */
|
||||||
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
@@ -211,6 +242,26 @@ export async function setLastLoginTime(db: D1Database, id: number, time: string)
|
|||||||
.run()
|
.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<void> {
|
||||||
|
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). */
|
/** Look up multiple accounts by AccountId (order not guaranteed). */
|
||||||
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
||||||
if (ids.length === 0) return []
|
if (ids.length === 0) return []
|
||||||
|
|||||||
Reference in New Issue
Block a user