diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 93ada2b..5a93563 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -145,7 +145,13 @@ export const ApiConfigV2 = JsonObject.describe( '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({ VersionStatus: z.int().describe('0 = current, 1 = client on a different build'), UpdateNotificationStage: z.int(), diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index 1aef822..668a500 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' 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 gameConfigsV1All from '../../static/gameconfigs-v1-all.json' @@ -10,6 +10,7 @@ import { ApiConfigV2, AzureSpeechConfig, BacktraceConfig, + IslandedVersions, json, JsonObject, VersionCheck, @@ -100,18 +101,33 @@ export const configRoutes = new Hono({ strict: false }) summary: 'Client version check', description: '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.', + 'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' + + 'client is on one of them, 1 when it is on some other build.', responses: { 200: json(VersionCheck, 'Version status') }, }), (c) => c.json({ - VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1, + VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1, UpdateNotificationStage: 0, IsVersionIslanded: 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( '/api/gameconfigs/v1/all', describeRoute({ diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 8b6b8c8..595080b 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -19,6 +19,7 @@ import { ROOM_SCHEMA_DDL, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL, + SUPPORTED_GAME_VERSIONS, } from '@repo/domain' import '../../api.app' @@ -197,11 +198,29 @@ describe('public endpoints', () => { 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 () => { const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`) 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 () => { const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, { headers: await bearer('99999'), @@ -3646,6 +3665,7 @@ describe('openapi', () => { 'GET /api/roomkeys/v1/mine', 'GET /api/roomkeys/v1/room', 'GET /api/rooms/v1/filters', + 'GET /api/versioncheck/islandedversions', 'GET /api/versioncheck/v4', 'GET /voice/config', 'POST /api/PlayerReporting/v1/deviceId', diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index dc587ea..31afee7 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -434,7 +434,14 @@ const app = new Hono() // 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 // 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', describeRoute({ tags: ['Cached login'], @@ -449,6 +456,10 @@ const app = new Hono() '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', '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(' '), parameters: [ { diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index c313736..b1ac519 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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 () => { // 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 @@ -1122,6 +1175,7 @@ describe('auth worker routes', () => { 'GET /role/developer/{id}', 'GET /role/moderator/{id}', 'POST /account/me/changepassword', + 'POST /cachedlogin/forplatformid/{platform}/{id}', 'POST /cachedlogin/forplatformids', 'POST /connect/token', ]) diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index f712073..3ff1a3b 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -32,6 +32,18 @@ export const PRESENCE_TTL_SECONDS = 900 */ 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). */ export const PRESENCE_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS presence (