mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[auth][api] accept the 20250424.01 client
Version check now answers "current" for a set of builds rather than one: SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is unchanged and still what the server reports for itself (presence, rn.ver). Adds GET /api/versioncheck/islandedversions, always [] — we never island a build off into its own matchmaking pool. The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a deviceId/platformAuth/time form body where the 2023 build GETs it, so that route now takes both methods. The body is accepted and ignored for now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -145,7 +145,13 @@ export const ApiConfigV2 = JsonObject.describe(
|
|||||||
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
||||||
)
|
)
|
||||||
|
|
||||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
|
/**
|
||||||
|
* `GET /api/versioncheck/islandedversions` — builds islanded onto their own matchmaking
|
||||||
|
* pool. Always empty here.
|
||||||
|
*/
|
||||||
|
export const IslandedVersions = z.array(z.string())
|
||||||
|
|
||||||
|
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we serve. */
|
||||||
export const VersionCheck = z.object({
|
export const VersionCheck = z.object({
|
||||||
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
||||||
UpdateNotificationStage: z.int(),
|
UpdateNotificationStage: z.int(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
import { GAME_VERSION } from '@repo/domain'
|
import { isSupportedGameVersion } from '@repo/domain'
|
||||||
|
|
||||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||||
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ApiConfigV2,
|
ApiConfigV2,
|
||||||
AzureSpeechConfig,
|
AzureSpeechConfig,
|
||||||
BacktraceConfig,
|
BacktraceConfig,
|
||||||
|
IslandedVersions,
|
||||||
json,
|
json,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
VersionCheck,
|
VersionCheck,
|
||||||
@@ -100,18 +101,33 @@ export const configRoutes = new Hono<App>({ strict: false })
|
|||||||
summary: 'Client version check',
|
summary: 'Client version check',
|
||||||
description:
|
description:
|
||||||
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
||||||
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
|
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' +
|
||||||
'client is on a different build.',
|
'client is on one of them, 1 when it is on some other build.',
|
||||||
responses: { 200: json(VersionCheck, 'Version status') },
|
responses: { 200: json(VersionCheck, 'Version status') },
|
||||||
}),
|
}),
|
||||||
(c) =>
|
(c) =>
|
||||||
c.json({
|
c.json({
|
||||||
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
|
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
|
||||||
UpdateNotificationStage: 0,
|
UpdateNotificationStage: 0,
|
||||||
IsVersionIslanded: false,
|
IsVersionIslanded: false,
|
||||||
IsCrossPlayDisabled: false,
|
IsCrossPlayDisabled: false,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
// Islanding splits players onto version-specific matchmaking pools. We serve every
|
||||||
|
// supported build from one pool, so the list is empty — the client reads it as
|
||||||
|
// "nobody is islanded" and matchmakes normally.
|
||||||
|
.get(
|
||||||
|
'/api/versioncheck/islandedversions',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Config'],
|
||||||
|
summary: 'Islanded client builds',
|
||||||
|
description:
|
||||||
|
'The builds that are islanded off into their own matchmaking pool. This server ' +
|
||||||
|
'never islands a build, so the list is always empty.',
|
||||||
|
responses: { 200: json(IslandedVersions, 'Always an empty list') },
|
||||||
|
}),
|
||||||
|
(c) => c.json([])
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
'/api/gameconfigs/v1/all',
|
'/api/gameconfigs/v1/all',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
|
SUPPORTED_GAME_VERSIONS,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import '../../api.app'
|
import '../../api.app'
|
||||||
@@ -197,11 +198,29 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/versioncheck/v4 reports current for every supported build', async () => {
|
||||||
|
for (const version of SUPPORTED_GAME_VERSIONS) {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
|
||||||
|
expect(await res.json(), version).toMatchObject({ VersionStatus: 0 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/versioncheck/v4 flags a client that sends no build', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`)
|
||||||
|
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/versioncheck/islandedversions is empty', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/islandedversions`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
|
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
|
||||||
headers: await bearer('99999'),
|
headers: await bearer('99999'),
|
||||||
@@ -3646,6 +3665,7 @@ describe('openapi', () => {
|
|||||||
'GET /api/roomkeys/v1/mine',
|
'GET /api/roomkeys/v1/mine',
|
||||||
'GET /api/roomkeys/v1/room',
|
'GET /api/roomkeys/v1/room',
|
||||||
'GET /api/rooms/v1/filters',
|
'GET /api/rooms/v1/filters',
|
||||||
|
'GET /api/versioncheck/islandedversions',
|
||||||
'GET /api/versioncheck/v4',
|
'GET /api/versioncheck/v4',
|
||||||
'GET /voice/config',
|
'GET /voice/config',
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
|
|||||||
@@ -434,7 +434,14 @@ const app = new Hono<App>()
|
|||||||
// id, so the client can offer them on the login screen (and post one back as a
|
// 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
|
// cached_login grant). No linked account → [], and the client falls back to a
|
||||||
// fresh login / create_account.
|
// fresh login / create_account.
|
||||||
.get(
|
//
|
||||||
|
// 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',
|
'/cachedlogin/forplatformid/:platform/:id',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Cached login'],
|
tags: ['Cached login'],
|
||||||
@@ -449,6 +456,10 @@ const app = new Hono<App>()
|
|||||||
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
|
'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',
|
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
|
||||||
'with `requirePassword: true`, sending the build to username/password login.',
|
'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(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -420,6 +420,59 @@ describe('auth worker routes', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
|
||||||
|
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
|
||||||
|
// same list — otherwise the newer client's login screen comes up empty.
|
||||||
|
test('POST /cachedlogin/forplatformid answers exactly what the GET answers', async () => {
|
||||||
|
const steamId = '76561197962463211'
|
||||||
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId: 31381,
|
||||||
|
username: 'SteamPlayer2025',
|
||||||
|
platform: 0,
|
||||||
|
platformId: steamId,
|
||||||
|
lastLoginTime: '2026-08-13T04:21:34.768Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await linkPlatformIdentity(env.DB, 31381, 0, steamId)
|
||||||
|
|
||||||
|
// The body as the live client sends it: device id, the platform session ticket, a
|
||||||
|
// timestamp. All ignored for now.
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
deviceId: '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88',
|
||||||
|
platformAuth: JSON.stringify({ Ticket: '140000009C5F501B447424FF', AppId: '471710' }),
|
||||||
|
time: '2026-08-13T04:21:34.7684754Z',
|
||||||
|
}).toString()
|
||||||
|
const posted = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
expect(posted.status).toBe(200)
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
platform: 0,
|
||||||
|
platformId: steamId,
|
||||||
|
accountId: 31381,
|
||||||
|
lastLoginTime: '2026-08-13T04:21:34.768Z',
|
||||||
|
requirePassword: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(await posted.json()).toEqual(expected)
|
||||||
|
expect(await cachedLogins(0, steamId)).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A POST with no body at all still resolves — the client's body is never consulted.
|
||||||
|
test('POST /cachedlogin/forplatformid/1/1 still returns the canned Oculus entry', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toMatchObject([{ accountId: 1, requirePassword: true }])
|
||||||
|
})
|
||||||
|
|
||||||
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
|
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
|
||||||
// The point of the link table. The same account is reachable from the PC and from
|
// The point of the link table. The same account is reachable from the PC and from
|
||||||
// the headset, and each picker reports the identity IT was asked about — that's
|
// the headset, and each picker reports the identity IT was asked about — that's
|
||||||
@@ -1122,6 +1175,7 @@ describe('auth worker routes', () => {
|
|||||||
'GET /role/developer/{id}',
|
'GET /role/developer/{id}',
|
||||||
'GET /role/moderator/{id}',
|
'GET /role/moderator/{id}',
|
||||||
'POST /account/me/changepassword',
|
'POST /account/me/changepassword',
|
||||||
|
'POST /cachedlogin/forplatformid/{platform}/{id}',
|
||||||
'POST /cachedlogin/forplatformids',
|
'POST /cachedlogin/forplatformids',
|
||||||
'POST /connect/token',
|
'POST /connect/token',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ export const PRESENCE_TTL_SECONDS = 900
|
|||||||
*/
|
*/
|
||||||
export const GAME_VERSION = '20230414'
|
export const GAME_VERSION = '20230414'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client builds this server treats as current. `GAME_VERSION` is the one we report for
|
||||||
|
* ourselves; the rest are additional builds `/api/versioncheck/v4` answers "current"
|
||||||
|
* for, so a player on one of them isn't pushed into an update loop.
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_GAME_VERSIONS: string[] = [GAME_VERSION, '20250424.01']
|
||||||
|
|
||||||
|
/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */
|
||||||
|
export function isSupportedGameVersion(version: string | null | undefined): boolean {
|
||||||
|
return version != null && SUPPORTED_GAME_VERSIONS.includes(version)
|
||||||
|
}
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0006_presence.sql). */
|
/** Schema DDL (mirror of migrations/0006_presence.sql). */
|
||||||
export const PRESENCE_SCHEMA_DDL: string[] = [
|
export const PRESENCE_SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS presence (
|
`CREATE TABLE IF NOT EXISTS presence (
|
||||||
|
|||||||
Reference in New Issue
Block a user