support for 202507 endpoints (#37)

* [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>

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by GitHub
parent 66c09806f9
commit 34171417e3
162 changed files with 114930 additions and 469 deletions
+499 -37
View File
@@ -38,6 +38,24 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/** What a matchmake answers with when the request named no `CorrelationId`. */
const EMPTY_CORRELATION_ID = '00000000-0000-0000-0000-000000000000'
/**
* A refused matchmake, whole: the code under both names the client reads (`result` and
* the legacy `errorCode`, always equal), a null instance, and the correlation echo — an
* empty GUID here, since these requests carry no `CorrelationId`. A refusal has to
* correlate too, or the client goes on waiting for a response it never matches up.
*/
function refused(code: number) {
return {
errorCode: code,
result: code,
roomInstance: null,
correlationId: EMPTY_CORRELATION_ID,
}
}
// Matchmaking into a room resolves its real scene from the shared recflare D1.
// Seed the schema + a couple of rooms (matching the rooms worker's migration).
const RECCENTER_SCENE = 'cbad71af-0831-44d8-b8ef-69edafa841f6'
@@ -242,10 +260,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
// `version` mints the `rn.ver` claim auth stamps from the client's posted `ver`; left
// off, the token carries none — which is what a token issued before the claim carried the
// client's own build looks like to presence.
async function bearer(sub = '42', version?: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(version && { 'rn.ver': version }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -592,6 +613,136 @@ describe('public endpoints', () => {
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
})
test('the /matchmake/v2 routes take a JSON body and answer the PascalCase envelope', async () => {
// The newer client posts JSON with real types (JoinMode a number, AdditionalPlayerIds
// null when alone) and reads back `ErrorCode`/`CorrelationId`/`RoomInstance`.
type V2Instance = {
RoomInstanceId: number
RoomId: number
SubRoomId: number
Location: string
MaxCapacity: number
IsPrivate: boolean
RoomInstanceType: number
MatchmakingPolicy: number
}
type V2Body = { ErrorCode: number; CorrelationId: string; RoomInstance: V2Instance | null }
const correlationId = 'e3f1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b'
const matchmake = async (
path: string,
player: string,
body: Record<string, unknown> = {}
): Promise<V2Body> => {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(player)), 'Content-Type': 'application/json' },
// The client's real body, verbatim.
body: JSON.stringify({
AdditionalPlayerIds: null,
BypassMovementModeRestriction: false,
MaxPersistenceVersion: 12,
Ugc1SubVersion: 0,
Ugc2SubVersion: 0,
VoiceServerVersion: '1.0',
LoginLock: EMPTY_CORRELATION_ID,
ClientJoinData: null,
CorrelationId: correlationId,
JoinMode: 0,
InviteMode: 0,
ShouldKeepPlayerWithParty: true,
PlayerScores: null,
...body,
}),
})
expect(res.status).toBe(200)
return (await res.json()) as V2Body
}
const room = await matchmake('/matchmake/v2/room/2', '8901')
expect(room.ErrorCode).toBe(0)
// The JSON body's CorrelationId is read and echoed — a form-only body read would
// have lost it here and the client would never match the response to its attempt.
expect(room.CorrelationId).toBe(correlationId)
expect(room.RoomInstance).toMatchObject({
RoomId: 2,
Location: RECCENTER_SCENE,
IsPrivate: false,
MatchmakingPolicy: 0,
})
// The v2 instance is a strict field set: no camelCase twins, no DataBlob, no Photon
// coordinates (the reference server sends none).
expect(Object.keys(room.RoomInstance!).sort()).toEqual(
[
'ClubId',
'EncryptVoiceChat',
'EventId',
'IsFull',
'IsInProgress',
'IsPrivate',
'Location',
'MaxCapacity',
'MatchmakingPolicy',
'Name',
'RoomCode',
'RoomId',
'RoomInstanceId',
'RoomInstanceType',
'SubRoomId',
].sort()
)
// ...and the envelope has no `result`/`roomInstance` camelCase twins either.
expect(Object.keys(room).sort()).toEqual(['CorrelationId', 'ErrorCode', 'RoomInstance'])
// By name, and the subroom form carries the subroom through.
expect((await matchmake('/matchmake/v2/room/RecCenter', '8902')).RoomInstance).toMatchObject({
RoomId: 2,
})
expect((await matchmake('/matchmake/v2/room/77/35', '8903')).RoomInstance).toMatchObject({
RoomId: 77,
SubRoomId: 35,
Location: SECOND_SUBROOM_SCENE,
})
// JoinMode is a NUMBER here: 2 still means a private instance.
const priv = await matchmake('/matchmake/v2/room/2', '8905', { JoinMode: 2 })
expect(priv.RoomInstance).toMatchObject({ IsPrivate: true })
// A v2 and a v1 player asking for the same public room land in the SAME instance —
// only the wire shape differs. Its own room, so the two players it seats don't count
// against another test's capacity.
await seedRoomWithSubRooms(env.DB, {
RoomId: 79,
Name: 'V2Room',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 8907,
SubRooms: [{ SubRoomId: 37, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 }],
} as unknown as Record<string, unknown>)
const v1 = await exports.default.fetch(`${ORIGIN}/matchmake/room/79`, {
method: 'POST',
headers: {
...(await bearer('8906')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ JoinMode: '0' }).toString(),
})
const v1Instance = (
(await v1.json()) as { roomInstance: { roomInstanceId: number; roomId: number } }
).roomInstance
expect(v1Instance.roomId).toBe(79)
const v2 = await matchmake('/matchmake/v2/room/79', '8907')
expect(v2.RoomInstance!.RoomInstanceId).toBe(v1Instance.roomInstanceId)
// Refusals answer in v2 too, correlation id echoed — including the ban gate, which
// answers before any route runs.
const unknown = await matchmake('/matchmake/v2/room/99999', '8904')
expect(unknown).toEqual({ ErrorCode: 20, CorrelationId: correlationId, RoomInstance: null })
// Unauthenticated is a 401, not a matchmake refusal.
const anon = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, { method: 'POST' })
expect(anon.status).toBe(401)
})
test('matchmaking serves the PUBLISHED save to everyone, creator included', async () => {
// The client offers the owner "latest or published" itself, from the
// `/subrooms/{id}/saves` list — matchmaking never picks. Serving a staged blob to
@@ -687,10 +838,7 @@ describe('public endpoints', () => {
['/matchmake/club/5', '120'],
['/matchmake/club/9999', '120'],
] as const) {
expect(await (await matchmake(path, sub)).json()).toEqual({
errorCode: 20,
roomInstance: null,
})
expect(await (await matchmake(path, sub)).json()).toEqual(refused(20))
}
// Signed out is a 401, not a matchmaking error.
@@ -732,10 +880,7 @@ describe('public endpoints', () => {
expect((await join('/matchmake/event/8', '302')).errorCode).toBe(0)
// A stranger doesn't — and is told why (35 EventIsPrivate), not fobbed off with 20.
expect(await join('/matchmake/event/8', '399')).toEqual({
errorCode: 35,
roomInstance: null,
})
expect(await join('/matchmake/event/8', '399')).toEqual(refused(35))
// Public and unlisted are open to anyone: unlisted only keeps an event out of the
// listings, it doesn't close it.
@@ -743,10 +888,7 @@ describe('public endpoints', () => {
expect((await join('/matchmake/event/10', '399')).errorCode).toBe(0)
// An unknown event is the opaque NoSuchRoom, so ids can't be probed.
expect(await join('/matchmake/event/9999', '399')).toEqual({
errorCode: 20,
roomInstance: null,
})
expect(await join('/matchmake/event/9999', '399')).toEqual(refused(20))
// Signed out is a 401, not a matchmaking error.
expect((await matchmake('/matchmake/event/9')).status).toBe(401)
@@ -777,7 +919,7 @@ describe('public endpoints', () => {
headers: await bearer('88'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await res.json()).toEqual(refused(20))
})
test('ROOM_REDIRECTS switches a matchmake out to another room', async () => {
@@ -858,6 +1000,25 @@ describe('public endpoints', () => {
expect(res.status).toBe(200)
})
test('GET /player/connection-info 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`)
expect(res.status).toBe(401)
})
test('GET /player/qos returns the probe targets', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/qos`)
expect(res.status).toBe(200)
// A bare array, not the { success, value, error } envelope connection-info uses.
expect(await res.json()).toEqual([
{ id: 'us-west1', address: '34.169.254.144:50000' },
{ id: 'europe-west1', address: '35.205.141.119:50000' },
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
{ id: 'us-east1', address: '34.73.244.122:50000' },
{ id: 'us-central1', address: '34.69.179.51:50000' },
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
])
})
test('PUT /player/photonregionpings returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
expect(res.status).toBe(200)
@@ -899,6 +1060,164 @@ describe('auth-gated endpoints', () => {
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
})
test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => {
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('960'),
})
).json()) as { roomInstance: { photonRoomId: string } }
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('960'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: true,
value: {
// A signed JWT, not an opaque id — three base64url segments.
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
// Empty until the operator names their own Photon apps — this server ships none,
// so there is no id to hand out.
photonRealtimeAppId: '',
photonVoiceAppId: '',
photonChatAppId: '',
// Matches the region every room instance is stamped with. This one DOES have a
// default: an instance stamped with an empty region can't be connected to.
photonRegion: 'us',
// The room the client is told to join has to be the one matchmaking placed
// them in, or they end up alone in a room of their own.
photonRoomId: matchmaked.roomInstance.photonRoomId,
// Empty strings, not nulls — unlike the presence payload's connection fields,
// which stay null (they never carry credentials).
voiceConnectionInfo: '',
voiceServerId: '',
experiments: {
networkTransformSyncInterval: 10,
shouldUseUnreliableOnChange: false,
shouldAvoidDiscontinuityRPCs: true,
shouldAvoidRedundantDiscontinuity: false,
r2RuntimeStaticBaking: true,
r2AutoEmbodiment: true,
r2RuntimeStaticBakingMinShapeThreshold: 1,
r2UseCheapReplicas: true,
// true would send the client to a local game server instead of Photon.
shouldUseGameServerNetworking: false,
},
},
error: null,
})
})
test('the Photon apps and region come from the operators vars', async () => {
// Unset, every value is the shipped default — asserted by the test above. Set, the
// vars win, and the region has to reach BOTH the connection info and the instance:
// the client authenticates against the app named here and connects to the region on
// its instance, so a mismatch is a session it can't join.
const original = {
realtime: env.PHOTON_REALTIME_APP_ID,
voice: env.PHOTON_VOICE_APP_ID,
chat: env.PHOTON_CHAT_APP_ID,
region: env.PHOTON_REGION,
}
try {
env.PHOTON_REALTIME_APP_ID = '11111111-1111-4111-8111-111111111111'
env.PHOTON_VOICE_APP_ID = '22222222-2222-4222-8222-222222222222'
env.PHOTON_CHAT_APP_ID = '33333333-3333-4333-8333-333333333333'
env.PHOTON_REGION = 'eu'
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('961'),
})
).json()) as { roomInstance: { photonRegion: string; photonRegionId: string } }
expect(matchmaked.roomInstance).toMatchObject({ photonRegion: 'eu', photonRegionId: 'eu' })
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
expect((await res.json()) as { value: Record<string, unknown> }).toMatchObject({
value: {
photonRealtimeAppId: '11111111-1111-4111-8111-111111111111',
photonVoiceAppId: '22222222-2222-4222-8222-222222222222',
photonChatAppId: '33333333-3333-4333-8333-333333333333',
photonRegion: 'eu',
},
})
// A whitespace-only var is not a value: the app id reads as unset (empty) rather
// than as a blank-but-present id, and the region falls back to its default.
env.PHOTON_REALTIME_APP_ID = ' '
env.PHOTON_REGION = ' '
const blank = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
expect((await blank.json()) as { value: Record<string, unknown> }).toMatchObject({
value: { photonRealtimeAppId: '', photonRegion: 'us' },
})
} finally {
env.PHOTON_REALTIME_APP_ID = original.realtime
env.PHOTON_VOICE_APP_ID = original.voice
env.PHOTON_CHAT_APP_ID = original.chat
env.PHOTON_REGION = original.region
}
})
test('GET /player/connection-info mints a token carrying the callers id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
const body = (await res.json()) as {
value: { photonAuthToken: string; photonRealtimeAppId: string }
}
const claims = JSON.parse(atob(body.value.photonAuthToken.split('.')[1]!)) as {
sub: string
aud: string
exp: number
'rn.env': string
}
expect(claims.sub).toBe('961')
// Scoped to the realtime app the same response hands out. Asserted as agreement
// rather than a pinned literal: PHOTON_APPS is hardcoded until it moves to wrangler
// vars, and a token minted for a different app than the client is handed is the bug
// worth catching here.
expect(claims.aud).toBe(body.value.photonRealtimeAppId)
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
// The client is built against prod regardless of which environment we run in.
expect(claims['rn.env']).toBe('prod')
})
test('GET /player/connection-info falls back to ?roomInstanceId when presence has no room', async () => {
// Player 962 never matchmade, so there's no presence to read the room from; the
// param names the instance they're trying to connect to.
const instance = await createRoomInstance(env.DB, {
roomId: 2,
subRoomId: 2,
roomInstanceType: 0,
photonRoomId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
maxCapacity: 12,
isPrivate: false,
ownerAccountId: 962,
})
const res = await exports.default.fetch(
`${ORIGIN}/player/connection-info?roomInstanceId=${instance.roomInstanceId}`,
{ headers: await bearer('962') }
)
expect(res.status).toBe(200)
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
})
test('GET /player/connection-info serves an empty photonRoomId when nothing resolves', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('963'),
})
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('')
})
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
// The client keys the room transition off a changing roomInstanceId; handing back
// the instance the player is already in hangs their join. RecCenter (cap 12) so
@@ -952,6 +1271,97 @@ describe('auth-gated endpoints', () => {
})
})
test('a matchmake echoes the requests CorrelationId (and mirrors errorCode as result)', async () => {
// The client tags each attempt with a GUID and won't accept a session whose
// response doesn't carry the same one back ("Unable to connect to game session").
const correlationId = 'b71abbbb-93e1-4d67-94da-64e6f554863a'
const dorm = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: {
...(await bearer('43')),
'content-type': 'application/x-www-form-urlencoded',
},
// Verbatim from the client, unread fields included.
body: `BypassMovementModeRestriction=False&LoginLock=40bacd8f-7c60-4d49-93f9-462b096602de&VoiceServerVersion=gameserver-2&CorrelationId=${correlationId}&MaxPersistenceVersion=227`,
})
).json()) as { errorCode: number; result: number; correlationId: string }
expect(dorm.correlationId).toBe(correlationId)
// Both names for the one code, always in agreement.
expect(dorm.result).toBe(0)
expect(dorm.errorCode).toBe(0)
// A room matchmake echoes it too, and so does a refusal — a refused attempt the
// client can't correlate is one it goes on waiting for.
const room = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/999999`, {
method: 'POST',
headers: {
...(await bearer('43')),
'content-type': 'application/x-www-form-urlencoded',
},
body: `JoinMode=0&CorrelationId=${correlationId}`,
})
).json()) as { errorCode: number; result: number; roomInstance: null; correlationId: string }
expect(room).toEqual({
errorCode: 20,
result: 20,
roomInstance: null,
correlationId,
})
})
test('a matchmake with no CorrelationId answers the empty GUID, not null', async () => {
// The client reads correlationId as a Guid, not a nullable one — an older client
// that sends none still has to get a parseable value back.
const body = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43'),
})
).json()) as { correlationId: string }
expect(body.correlationId).toBe(EMPTY_CORRELATION_ID)
})
test('POST /matchmake/none 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
expect(res.status).toBe(401)
})
test('POST /matchmake/none keeps the caller where they are, else falls back to the dorm', async () => {
const none = async (sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { errorCode: number; roomInstance: { roomId: number; roomInstanceId: number } }
// Account 44 has never entered a room → their personal dorm, and a second call is
// idempotent now that presence holds it.
const fresh = await none('44')
expect(fresh.errorCode).toBe(0)
expect(fresh.roomInstance.roomId).toBeGreaterThan(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: fresh.roomInstance.roomId,
roomInstanceId: fresh.roomInstance.roomInstanceId,
})
// Once in a real room, `none` must NOT warp them out of it — that is the whole
// point of the endpoint, since the client posts it while sitting in Orientation.
const entered = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('44'),
})
).json()) as { roomInstance: { roomId: number; roomInstanceId: number } }
expect(entered.roomInstance.roomId).toBe(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: 2,
roomInstanceId: entered.roomInstance.roomInstanceId,
})
})
test('each players dorm gets a distinct global subroom id', async () => {
// Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1.
// With subrooms minted from the global sequence, each dorm gets its own unique id.
@@ -1051,6 +1461,59 @@ describe('auth-gated endpoints', () => {
})
})
// The build a player reports is the one their TOKEN carries (`rn.ver`, from the `ver`
// they posted to /connect/token) — not this server's GAME_VERSION, which is only the
// fallback for a token that names none.
test('presence reports the build from the callers token', async () => {
const headers = await bearer('9710', '20250718.01')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { appVersion: string }
expect(hb.appVersion).toBe('20250718.01')
// And it is what everyone else sees of them, since it was written to the row.
const [player] = (await (
await exports.default.fetch(`${ORIGIN}/player?id=9710`)
).json()) as Array<{ appVersion: string }>
expect(player.appVersion).toBe('20250718.01')
})
// A player who quit and relaunched on a new build heartbeats with a NEW token against
// the row the old session left behind; the heartbeat adopts it rather than waiting for
// a re-matchmake.
test('a heartbeat on a new build updates the stored version', async () => {
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('9711', '20250424.01'),
})
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('9711', '20250718.01'),
})
).json()) as { appVersion: string }
expect(hb.appVersion).toBe('20250718.01')
const [player] = (await (
await exports.default.fetch(`${ORIGIN}/player?id=9711`)
).json()) as Array<{ appVersion: string }>
expect(player.appVersion).toBe('20250718.01')
})
// A token issued before the claim carried the client's build still has to produce a
// usable version — an empty one breaks the client's presence handling.
test('a token with no rn.ver falls back to GAME_VERSION', async () => {
const headers = await bearer('9712')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { appVersion: string }
expect(hb.appVersion).toBe(GAME_VERSION)
})
test('heartbeat pushes no websocket frame', async () => {
// The notify DO is stubbed to record every send (see vitest.config).
type Sent = { playerId: number; notificationType: number; data: Record<string, unknown> }
@@ -1511,14 +1974,14 @@ describe('auth-gated endpoints', () => {
headers: await bearer('999'),
})
expect(stranger.status).toBe(200)
expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await stranger.json()).toEqual(refused(20))
// Unknown instance → same refusal.
const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, {
method: 'POST',
headers: await bearer('42'),
})
expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await unknown.json()).toEqual(refused(20))
// Park the owner somewhere else first, so this is a real transition.
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
@@ -1858,11 +2321,11 @@ describe('auth-gated endpoints', () => {
expect(hb.roomInstance?.roomInstanceId).toBe(friendMM.roomInstance?.roomInstanceId)
// A non-friend can't be followed → NoSuchRoom, null instance (no leak of their state).
expect(await (await follow(9802, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await (await follow(9802, '9800')).json()).toEqual(refused(20))
// You can't follow yourself.
expect(await (await follow(9800, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await (await follow(9800, '9800')).json()).toEqual(refused(20))
// A friend who isn't in any room → nothing to join.
expect(await (await follow(9803, '9800')).json()).toEqual({ errorCode: 20, roomInstance: null })
expect(await (await follow(9803, '9800')).json()).toEqual(refused(20))
// No token → 401.
expect((await follow(9801)).status).toBe(401)
@@ -1875,10 +2338,7 @@ describe('auth-gated endpoints', () => {
VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')`
).run()
try {
expect(await (await follow(9801, '9800')).json()).toEqual({
errorCode: 55,
roomInstance: null,
})
expect(await (await follow(9801, '9800')).json()).toEqual(refused(55))
} finally {
await env.DB.prepare(
'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800'
@@ -1907,12 +2367,12 @@ describe('auth-gated endpoints', () => {
// is nothing for the banned player to join. errorCode 55 rather than the opaque
// NoSuchRoom every other refusal answers — a banned player already knows the room
// exists, so the client can say why. Applies to the subroom path as well.
expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('9701')).toEqual(refused(55))
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
method: 'POST',
headers: await bearer('9701'),
})
expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null })
expect(await sub.json()).toEqual(refused(55))
// Refused before any instance is created, and no presence was recorded for them.
expect(
@@ -2019,6 +2479,8 @@ describe('auth-gated endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /player',
'GET /player/avoidjuniors',
'GET /player/connection-info',
'GET /player/qos',
'GET /room/{roomId}/instances',
'GET /rooms/requiring/developer',
'GET /rooms/requiring/rrplus',
@@ -2027,9 +2489,12 @@ describe('auth-gated endpoints', () => {
'POST /matchmake/dorm',
'POST /matchmake/event/{eventId}',
'POST /matchmake/instance/{instanceId}',
'POST /matchmake/none',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
'POST /matchmake/v2/room/{roomId}',
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
'POST /player/exclusivelogin',
'POST /player/heartbeat',
'POST /player/login',
@@ -2077,7 +2542,7 @@ describe('account bans', () => {
]) {
const res = await matchmake(path, '6001')
expect(res.status, path).toBe(200)
expect(await res.json(), path).toEqual({ errorCode: 55, roomInstance: null })
expect(await res.json(), path).toEqual(refused(55))
}
})
@@ -2104,10 +2569,7 @@ describe('account bans', () => {
test('a ban that has not expired yet blocks a matchmake', async () => {
await banAccount(6004, new Date(Date.now() + 3_600_000).toISOString())
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual({
errorCode: 55,
roomInstance: null,
})
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual(refused(55))
})
// A report on its own is not a ban — only a moderator converting it is.
@@ -2180,7 +2642,7 @@ describe('ban evasion at matchmake', () => {
await account(6202)
await link(6202, 0, 'steam-evader')
expect(await matchmake('6202')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6202')).toEqual(refused(55))
})
test('a new account sharing a banned accounts signup IP is refused', async () => {
@@ -2188,7 +2650,7 @@ describe('ban evasion at matchmake', () => {
await banAccount(6203)
await account(6204, { signupIp: '203.0.113.203' })
expect(await matchmake('6204')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6204')).toEqual(refused(55))
})
// The address the request arrives from counts too, so an account that has never
@@ -2198,7 +2660,7 @@ describe('ban evasion at matchmake', () => {
await banAccount(6205)
await account(6206)
expect(await matchmake('6206', '203.0.113.205')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6206', '203.0.113.205')).toEqual(refused(55))
// From anywhere else, that same account plays.
expect((await matchmake('6206', '198.51.100.50')).errorCode).toBe(0)
})
@@ -2226,14 +2688,14 @@ describe('ban evasion at matchmake', () => {
try {
env.BAN_EVASION_MATCH = 'platform'
expect((await matchmake('6211')).errorCode).toBe(0)
expect(await matchmake('6212')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6212')).toEqual(refused(55))
// The banned account itself is still refused, whatever the knob says.
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6210')).toEqual(refused(55))
env.BAN_EVASION_MATCH = 'off'
expect((await matchmake('6211')).errorCode).toBe(0)
expect((await matchmake('6212')).errorCode).toBe(0)
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
expect(await matchmake('6210')).toEqual(refused(55))
} finally {
env.BAN_EVASION_MATCH = original
}