add version check for 20230414 client

This commit is contained in:
Devin Zuczek
2026-07-23 12:23:30 -04:00
parent 7ab171b646
commit 2f6a40279d
10 changed files with 42 additions and 23 deletions
+2 -2
View File
@@ -135,9 +135,9 @@ 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` — always the "you are up to date" answer. */ /** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
export const VersionCheck = z.object({ export const VersionCheck = z.object({
VersionStatus: z.int().describe('0 = current'), VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
UpdateNotificationStage: z.int(), UpdateNotificationStage: z.int(),
IsVersionIslanded: z.boolean(), IsVersionIslanded: z.boolean(),
IsCrossPlayDisabled: z.boolean(), IsCrossPlayDisabled: z.boolean(),
+7 -4
View File
@@ -1,6 +1,8 @@
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 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'
import { import {
@@ -97,13 +99,14 @@ export const configRoutes = new Hono<App>({ strict: false })
tags: ['Config'], tags: ['Config'],
summary: 'Client version check', summary: 'Client version check',
description: description:
'Whether the client build is current. Always the “up to date, nothing islanded” ' + 'Whether the client build is current. Compares the clients `?v=` build against ' +
'answer — this server does not gate on client version.', 'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
responses: { 200: json(VersionCheck, 'Always current') }, 'client is on a different build.',
responses: { 200: json(VersionCheck, 'Version status') },
}), }),
(c) => (c) =>
c.json({ c.json({
VersionStatus: 0, VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
UpdateNotificationStage: 0, UpdateNotificationStage: 0,
IsVersionIslanded: false, IsVersionIslanded: false,
IsCrossPlayDisabled: false, IsCrossPlayDisabled: false,
+9 -2
View File
@@ -2,6 +2,8 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
import { GAME_VERSION } from '@repo/domain'
import '../../api.app' import '../../api.app'
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
@@ -135,11 +137,16 @@ describe('public endpoints', () => {
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' }) expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
}) })
test('GET /api/versioncheck/v4', async () => { test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`) const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${GAME_VERSION}`)
expect(await res.json()).toMatchObject({ VersionStatus: 0 }) expect(await res.json()).toMatchObject({ VersionStatus: 0 })
}) })
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
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'),
+2 -1
View File
@@ -6,6 +6,7 @@ import {
countAccountsByPlatformId, countAccountsByPlatformId,
countAccountsBySignupIp, countAccountsBySignupIp,
createAccount, createAccount,
GAME_VERSION,
getAccount, getAccount,
getAccountByUsername, getAccountByUsername,
getAccountsByPlatformId, getAccountsByPlatformId,
@@ -139,7 +140,7 @@ async function placeNewPlayerInOrientation(
deviceClass, deviceClass,
vrMovementMode: 1, vrMovementMode: 1,
platform: 0, platform: 0,
appVersion: '20230302', appVersion: GAME_VERSION,
}) })
} }
+1 -7
View File
@@ -7,6 +7,7 @@ import {
createRoomInstance, createRoomInstance,
deleteExpiredPresence, deleteExpiredPresence,
deletePresence, deletePresence,
GAME_VERSION,
getAccount, getAccount,
getClubSummary, getClubSummary,
getExpiredPresenceInstanceIds, getExpiredPresenceInstanceIds,
@@ -140,13 +141,6 @@ type Presence = StoredPresence<RoomInstance>
*/ */
const PRESENCE_REFRESH_THRESHOLD = 300 const PRESENCE_REFRESH_THRESHOLD = 300
/**
* Game build version reported in presence. This is a server-side constant — the
* client doesn't supply it, and an empty value breaks the client's
* presence/version handling. Matches our target 2023 client build.
*/
const GAME_VERSION = '20230302'
/** /**
* Default `/player` payload, served whenever the `id` is missing/invalid or the * Default `/player` payload, served whenever the `id` is missing/invalid or the
* account isn't found. Inlined here (Workers have no filesystem). The stub player * account isn't found. Inlined here (Workers have no filesystem). The stub player
+6 -5
View File
@@ -10,6 +10,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import { import {
countPlayersInInstance, countPlayersInInstance,
GAME_VERSION,
getRoomInstance, getRoomInstance,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL,
@@ -200,7 +201,7 @@ describe('public endpoints', () => {
// fields, which only ever carry values in a matchmaking response. // fields, which only ever carry values in a matchmaking response.
expect(await res.json()).toEqual([ expect(await res.json()).toEqual([
{ {
appVersion: '20230302', appVersion: GAME_VERSION,
deviceClass: 0, deviceClass: 0,
errorCode: 0, errorCode: 0,
isOnline: false, isOnline: false,
@@ -234,7 +235,7 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/player`) const res = await exports.default.fetch(`${ORIGIN}/player`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }> const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: '20230302' }) expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
}) })
test('POST /goto/none returns the offline dorm', async () => { test('POST /goto/none returns the offline dorm', async () => {
@@ -702,7 +703,7 @@ describe('auth-gated endpoints', () => {
deviceClass: 0, deviceClass: 0,
vrMovementMode: 1, vrMovementMode: 1,
platform: 0, platform: 0,
appVersion: '20230302', appVersion: GAME_VERSION,
expiresAt, expiresAt,
}) })
) )
@@ -761,7 +762,7 @@ describe('auth-gated endpoints', () => {
deviceClass: 0, deviceClass: 0,
vrMovementMode: 1, vrMovementMode: 1,
platform: 0, platform: 0,
appVersion: '20230302', appVersion: GAME_VERSION,
expiresAt, expiresAt,
}) })
) )
@@ -879,7 +880,7 @@ describe('auth-gated endpoints', () => {
deviceClass: 0, deviceClass: 0,
vrMovementMode: 1, vrMovementMode: 1,
platform: 0, platform: 0,
appVersion: '20230302', appVersion: GAME_VERSION,
expiresAt: nowSeconds() + 800, expiresAt: nowSeconds() + 800,
}) })
) )
+8
View File
@@ -24,6 +24,14 @@
/** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */ /** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */
export const PRESENCE_TTL_SECONDS = 900 export const PRESENCE_TTL_SECONDS = 900
/**
* Game build version reported in presence (and echoed in the auth token's `rn.ver`
* claim). This is a server-side constant — the client doesn't supply it, and an
* empty value breaks the client's presence/version handling. Matches our target
* 2023 client build.
*/
export const GAME_VERSION = '20230414'
/** 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 (
+1
View File
@@ -10,6 +10,7 @@
"check:types": "run-tsc" "check:types": "run-tsc"
}, },
"dependencies": { "dependencies": {
"@repo/domain": "workspace:*",
"hono": "4.12.27" "hono": "4.12.27"
}, },
"devDependencies": { "devDependencies": {
+3 -2
View File
@@ -10,6 +10,8 @@
import { sign, verify } from 'hono/jwt' import { sign, verify } from 'hono/jwt'
import { GAME_VERSION } from '@repo/domain'
/** Token lifetime in seconds (mirrored in the `expires_in` response field). */ /** Token lifetime in seconds (mirrored in the `expires_in` response field). */
export const TOKEN_TTL_SECONDS = 3600 export const TOKEN_TTL_SECONDS = 3600
@@ -127,8 +129,7 @@ export async function generateToken(
idp: 'local', idp: 'local',
platform, platform,
platform_id: platformId, platform_id: platformId,
'rn.ver': '20230302', 'rn.ver': GAME_VERSION,
// Same PlatformType int as the `platform` claim — it was pinned to Steam.
'rn.plat': platform, 'rn.plat': platform,
role: [...BASE_ROLES, ...extraRoles], role: [...BASE_ROLES, ...extraRoles],
scope: TOKEN_SCOPES, scope: TOKEN_SCOPES,
+3
View File
@@ -881,6 +881,9 @@ importers:
packages/jwt: packages/jwt:
dependencies: dependencies:
'@repo/domain':
specifier: workspace:*
version: link:../domain
hono: hono:
specifier: 4.12.27 specifier: 4.12.27
version: 4.12.27 version: 4.12.27