From 2f6a40279d9eda803a717f18454ac45f22d91eba Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 23 Jul 2026 12:23:30 -0400 Subject: [PATCH] add version check for 20230414 client --- apps/api/src/openapi.ts | 4 ++-- apps/api/src/routes/config.ts | 11 +++++++---- apps/api/src/test/integration/api.test.ts | 11 +++++++++-- apps/auth/src/auth.app.ts | 3 ++- apps/match/src/match.app.ts | 8 +------- apps/match/src/test/integration/api.test.ts | 11 ++++++----- packages/domain/src/presence-db.ts | 8 ++++++++ packages/jwt/package.json | 1 + packages/jwt/src/jwt.ts | 5 +++-- pnpm-lock.yaml | 3 +++ 10 files changed, 42 insertions(+), 23 deletions(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 2d64b79..8f1a275 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -135,9 +135,9 @@ export const ApiConfigV2 = JsonObject.describe( '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({ - VersionStatus: z.int().describe('0 = current'), + VersionStatus: z.int().describe('0 = current, 1 = client on a different build'), UpdateNotificationStage: z.int(), IsVersionIslanded: z.boolean(), IsCrossPlayDisabled: z.boolean(), diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index 6ad61a9..1aef822 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' +import { GAME_VERSION } from '@repo/domain' + import apiConfigV2 from '../../static/api-config-v2.json' import gameConfigsV1All from '../../static/gameconfigs-v1-all.json' import { @@ -97,13 +99,14 @@ export const configRoutes = new Hono({ strict: false }) tags: ['Config'], summary: 'Client version check', description: - 'Whether the client build is current. Always the “up to date, nothing islanded” ' + - 'answer — this server does not gate on client version.', - responses: { 200: json(VersionCheck, 'Always current') }, + '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 ' + + 'client is on a different build.', + responses: { 200: json(VersionCheck, 'Version status') }, }), (c) => c.json({ - VersionStatus: 0, + VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1, UpdateNotificationStage: 0, IsVersionIslanded: false, IsCrossPlayDisabled: false, diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 6aab923..602bfe4 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -2,6 +2,8 @@ import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' +import { GAME_VERSION } from '@repo/domain' + import '../../api.app' 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: '.*' }) }) - test('GET /api/versioncheck/v4', async () => { - const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`) + test('GET /api/versioncheck/v4 reports current for the matching build', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${GAME_VERSION}`) 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 () => { const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, { headers: await bearer('99999'), diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 224b5cc..339aa54 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -6,6 +6,7 @@ import { countAccountsByPlatformId, countAccountsBySignupIp, createAccount, + GAME_VERSION, getAccount, getAccountByUsername, getAccountsByPlatformId, @@ -139,7 +140,7 @@ async function placeNewPlayerInOrientation( deviceClass, vrMovementMode: 1, platform: 0, - appVersion: '20230302', + appVersion: GAME_VERSION, }) } diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 1939bcb..89a135d 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -7,6 +7,7 @@ import { createRoomInstance, deleteExpiredPresence, deletePresence, + GAME_VERSION, getAccount, getClubSummary, getExpiredPresenceInstanceIds, @@ -140,13 +141,6 @@ type Presence = StoredPresence */ 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 * account isn't found. Inlined here (Workers have no filesystem). The stub player diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 1581057..a641183 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -10,6 +10,7 @@ import { beforeAll, describe, expect, test } from 'vitest' import { countPlayersInInstance, + GAME_VERSION, getRoomInstance, PRESENCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL, @@ -200,7 +201,7 @@ describe('public endpoints', () => { // fields, which only ever carry values in a matchmaking response. expect(await res.json()).toEqual([ { - appVersion: '20230302', + appVersion: GAME_VERSION, deviceClass: 0, errorCode: 0, isOnline: false, @@ -234,7 +235,7 @@ describe('public endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/player`) expect(res.status).toBe(200) 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 () => { @@ -702,7 +703,7 @@ describe('auth-gated endpoints', () => { deviceClass: 0, vrMovementMode: 1, platform: 0, - appVersion: '20230302', + appVersion: GAME_VERSION, expiresAt, }) ) @@ -761,7 +762,7 @@ describe('auth-gated endpoints', () => { deviceClass: 0, vrMovementMode: 1, platform: 0, - appVersion: '20230302', + appVersion: GAME_VERSION, expiresAt, }) ) @@ -879,7 +880,7 @@ describe('auth-gated endpoints', () => { deviceClass: 0, vrMovementMode: 1, platform: 0, - appVersion: '20230302', + appVersion: GAME_VERSION, expiresAt: nowSeconds() + 800, }) ) diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 3c652bb..a80b7a5 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -24,6 +24,14 @@ /** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */ 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). */ export const PRESENCE_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS presence ( diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 7ff85c6..7d262c4 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -10,6 +10,7 @@ "check:types": "run-tsc" }, "dependencies": { + "@repo/domain": "workspace:*", "hono": "4.12.27" }, "devDependencies": { diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index e8405fa..6e5e2af 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -10,6 +10,8 @@ import { sign, verify } from 'hono/jwt' +import { GAME_VERSION } from '@repo/domain' + /** Token lifetime in seconds (mirrored in the `expires_in` response field). */ export const TOKEN_TTL_SECONDS = 3600 @@ -127,8 +129,7 @@ export async function generateToken( idp: 'local', platform, platform_id: platformId, - 'rn.ver': '20230302', - // Same PlatformType int as the `platform` claim — it was pinned to Steam. + 'rn.ver': GAME_VERSION, 'rn.plat': platform, role: [...BASE_ROLES, ...extraRoles], scope: TOKEN_SCOPES, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d04dc0..f87251c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -881,6 +881,9 @@ importers: packages/jwt: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../domain hono: specifier: 4.12.27 version: 4.12.27