[match] fix 2025 game invites

This commit is contained in:
Devin Zuczek
2026-08-26 17:37:09 -04:00
parent e2ced3e797
commit 062ed452a5
5 changed files with 540 additions and 23 deletions
+302 -22
View File
@@ -25,8 +25,10 @@ import {
getRoomByName,
getRoomInstance,
getRoomInstancesByRoom,
getRoomInvite,
getRoomInstanceSummariesByRoom,
getStoredRoomInstance,
InviteMode,
isClubMember,
isPlayerBannedFromRoom,
MatchmakingErrorCode,
@@ -616,11 +618,105 @@ function nextLiveMessageId(): number {
return Date.now()
}
/**
* The first build that gets a game invite as {@link MessageType.GameInviteV2} rather than
* the original {@link MessageType.GameInvite}. Inclusive — a client reporting exactly this
* build is on v2; only OLDER builds get v1.
*/
const GAME_INVITE_V2_MIN_BUILD = 20230414
/**
* The date portion of a client build (`20250718.01` → `20250718`) as a number, or null when
* there's nothing parseable to compare. Builds are `<yyyymmdd>[.<n>]`, so the leading date
* orders them; the point release after it never decides this gate.
*/
function buildNumber(version: string | null): number | null {
const date = Number.parseInt(version?.split('.')[0] ?? '', 10)
return Number.isNaN(date) ? null : date
}
/**
* Which game-invite message type this caller's client gets, from the `rn.ver` claim on
* their token — v2 from {@link GAME_INVITE_V2_MIN_BUILD} onward, the original below it.
*
* A token that names no build (an older one issued before the claim carried it, or no
* token at all) falls back to v1: that is what every client got before this gate existed,
* so an unknown build is left where it was known to work rather than moved onto a message
* type it may not have.
*
* The claim is the INVITER's, since theirs is the only token in the request — while the
* message is parsed by the INVITEE. The two agree whenever a party is on one build, which
* is the normal case; the invitee's own build is on their presence row (`appVersion`) if
* this ever needs to key off the client that actually reads the frame.
*/
async function gameInviteType(c: Context<App>): Promise<MessageType> {
const build = buildNumber(await callerVersion(c))
return build !== null && build >= GAME_INVITE_V2_MIN_BUILD
? MessageType.GameInviteV2
: MessageType.GameInvite
}
/**
* The {@link InviteMode} a v2 game invite carries — what the invite is ASKING FOR, as
* distinct from the instance it names.
*
* `PlayTogether` (22) is the plain "come join me", which is what both paths here send:
* `POST /invite` is exactly that, and the party fan-out is the same ask aimed at several
* people at once. `InviteParty` (2) is the other reading of a join-me, and the fan-out is
* where it would go if the two turn out to be distinguishable — they have not been told
* apart on the wire yet.
*/
const GAME_INVITE_V2_INVITE_MODE = InviteMode.PlayTogether
/**
* The placeholder `InviteId` on a v2 invite that has no `room_invite` row behind it — the
* party fan-out, which invites without recording anything. What the client does with the
* field isn't known yet; `POST /invite` passes the real row id (see {@link RoomInvite}),
* which is the obvious candidate, and this stands in where there is no row to name.
*/
const UNKNOWN_INVITE_ID = 0
/** What an invite points at, in the shape both message versions need to describe it. */
type GameInviteTarget = {
/** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */
instanceId: string
/** The instance's room, or null when it didn't resolve. Rides on the message itself. */
roomId: number | null
/** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */
name: string
/** The `room_invite` row this came from, or {@link UNKNOWN_INVITE_ID}. */
inviteId: number
}
/**
* The `Data` a game invite carries, which is a different thing per message version:
*
* - v1 ({@link MessageType.GameInvite}) is the bare roomInstanceId as a string.
* - v2 ({@link MessageType.GameInviteV2}) is an escaped JSON object,
* `{"InviteId":…,"Name":"^GoldenTrophy","InviteMode":22}` — a STRING holding JSON, not a
* nested object, since `Data` is a string field on the Message either way.
*
* `Name` is the instance's wire name, the same `^`/`@`-prefixed string presence carries.
*/
function gameInviteData(type: MessageType, target: GameInviteTarget): string {
if (type !== MessageType.GameInviteV2) return target.instanceId
return JSON.stringify({
InviteId: target.inviteId,
Name: target.name,
InviteMode: GAME_INVITE_V2_INVITE_MODE,
})
}
/**
* Deliver a game invite from `fromId` to `toId` for a room instance — a `MessageReceived`
* frame carrying a game-invite `Message` the client renders the join prompt from. `data`
* is the raw roomInstanceId string the message carries; `roomId` (nullable) tells the
* client which room it points at. Best-effort: a hub failure is logged and swallowed.
* frame carrying a game-invite `Message` the client renders the join prompt from. `target`
* describes the instance being invited into; what of it reaches the wire depends on the
* message version (see {@link gameInviteData}), except `RoomId`, which rides on the message
* itself in both. Best-effort: a hub failure is logged and swallowed.
*
* `type` is the caller's game-invite message type from {@link gameInviteType}, resolved by
* the call site rather than here so the party fan-out settles it once for the whole party
* instead of re-reading the same token per member.
*
* Shared by `POST /invite` (a single explicit invite) and the party fan-out on a room
* matchmake (one per `AdditionalPlayerIds` entry), so the two can't drift.
@@ -629,17 +725,17 @@ async function sendGameInvite(
c: Context<App>,
fromId: number,
toId: number,
data: string,
roomId: number | null
target: GameInviteTarget,
type: MessageType
): Promise<void> {
const message = {
Id: nextLiveMessageId(),
FromPlayerId: fromId,
ToPlayerId: toId,
Type: MessageType.GameInvite,
Data: data,
Type: type,
Data: gameInviteData(type, target),
SentTime: new Date().toISOString(),
RoomId: roomId,
RoomId: target.roomId,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
@@ -868,6 +964,16 @@ function toV2RoomInstance(instance: RoomInstance) {
}
}
/**
* The matchmake paths whose responses are the PascalCase envelope (`ErrorCode`,
* `CorrelationId`, `RoomInstance`) rather than the lowercase one with its `result` twin.
*
* `/matchmake/v2/` is the 2025 client's rewrite of the older routes. `/matchmake/invite/`
* has no older spelling — it only ever existed on the 2025 client, and so only ever spoke
* this shape.
*/
const PASCAL_CASE_MATCHMAKE_PATHS = ['/matchmake/v2/', '/matchmake/invite/']
/**
* A matchmake response: the join-result code, the instance (null on a refusal), and the
* request's correlation id echoed back. Every matchmake route answers through here, so
@@ -878,11 +984,13 @@ function toV2RoomInstance(instance: RoomInstance) {
* every other consumer (and this worker's own tests) reads. They must never disagree —
* that's why nothing builds this envelope by hand.
*
* The `/matchmake/v2/*` routes answer a different envelope — PascalCase `ErrorCode`,
* `CorrelationId`, `RoomInstance`, no `result` twin — so the shape is chosen HERE, off
* the request path, rather than in the handlers. That keeps the two spellings from
* drifting and, more importantly, means everything that answers on a v2 path answers in
* v2: the ban gate, a refusal, and the success all go through this one function.
* Some routes answer a different envelope — PascalCase `ErrorCode`, `CorrelationId`,
* `RoomInstance`, no `result` twin — so the shape is chosen HERE, off the request path,
* rather than in the handlers. That keeps the two spellings from drifting and, more
* importantly, means everything answering on such a path answers in that shape: the ban
* gate, a refusal, and the success all go through this one function.
*
* See {@link PASCAL_CASE_MATCHMAKE_PATHS} for which paths those are.
*/
async function matchmakeResult(
c: Context<App>,
@@ -890,7 +998,7 @@ async function matchmakeResult(
roomInstance: RoomInstance | null
) {
const correlationId = await readCorrelationId(c)
if (c.req.path.startsWith('/matchmake/v2/')) {
if (PASCAL_CASE_MATCHMAKE_PATHS.some((prefix) => c.req.path.startsWith(prefix))) {
return c.json({
ErrorCode: errorCode,
CorrelationId: correlationId,
@@ -963,11 +1071,21 @@ async function inviteParty(
playerIds: number[],
instance: RoomInstance
): Promise<void> {
const data = String(instance.roomInstanceId)
// The leader's own instance, which every member is being pulled into. Nothing records
// a `room_invite` row on this path, so a v2 invite has no real id to name.
const target: GameInviteTarget = {
instanceId: String(instance.roomInstanceId),
roomId: instance.roomId,
name: instance.name,
inviteId: UNKNOWN_INVITE_ID,
}
// One read of the leader's token for the whole party — every member gets the same
// message, so the type can't differ between them.
const type = await gameInviteType(c)
await Promise.all(
playerIds
.filter((pid) => pid !== leaderId)
.map((pid) => sendGameInvite(c, leaderId, pid, data, instance.roomId))
.map((pid) => sendGameInvite(c, leaderId, pid, target, type))
)
}
@@ -1922,6 +2040,153 @@ const app = new Hono<App>()
}
)
// Accept a game invite and land in the inviter's instance
// (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it
// carries the `RoomInviteId` minted by `POST /invite`, and this resolves that row to the
// instance the INVITER is standing in right now.
//
// The row is the authorization. Only the player the invite was addressed to
// (`ToPlayerId`) may redeem it, checked against the caller's own token — an invite id is
// a small integer handed out to somebody else, so an ungated version of this would let
// anyone walk into any private instance by counting upward. It is also single-target:
// the inviter can't redeem their own invite, since they aren't its `ToPlayerId`.
//
// Where the caller goes is read from the inviter's LIVE presence, not from the invite's
// `RoomId`: the row records which room was named when the invite was sent, while the
// invitee needs the instance the inviter is in when they actually click, which may be a
// different one (or none). Registered before `/matchmake/:room` so `invite` isn't read
// as a room name.
.post(
'/matchmake/invite/:inviteId{[0-9]+}',
describeRoute({
tags: ['Navigation', '2025'],
summary: 'Accept a game invite',
description: [
'Places the caller into the room instance the INVITER is currently in, resolved from',
'the `room_invite` row named by `roomInviteId` and the inviters live presence (not the',
'invites stored `RoomId`, which records where they were when they sent it).',
'',
'ADDRESSEE ONLY: the caller must be the invites `ToPlayerId`, or it answers 76',
'(InstanceJoinNotPermitted) — the id is a small integer held by another player, so an',
'ungated form of this would be a way into any private instance. Answers 40',
'(RoomInviteExpired) for an invite that isnt there any more, 2 (PlayerNotOnline) when',
'the inviter isnt in a room, 17 (AlreadyInTargetInstance) when the caller is already',
'standing in it, 3 (InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when',
'the caller is banned from the room theyd be joining.',
'',
'2025-client route: it answers the PascalCase `ErrorCode`/`RoomInstance` envelope, as',
'the `/matchmake/v2/*` routes do, and has no older lowercase spelling.',
].join(' '),
security: AUTHED,
requestBody: form(CorrelationIdRequest, 'The attempts CorrelationId'),
parameters: [
{
name: 'inviteId',
in: 'path',
required: true,
description: 'The `RoomInviteId` from `POST /invite` (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeV2Response,
'The inviters instance, or a null instance with the refusal code'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const inviteId = Number.parseInt(c.req.param('inviteId'), 10)
const invite = await getRoomInvite(c.env.DB, inviteId)
// No row means no longer good — expiry deletes rows rather than flagging them, and
// ids are never reused, so "never existed" and "expired" are the same answer here.
// (78 ChatPartyInviteNotFound is for a CHAT party invite, a different object.)
if (invite === null) {
logger.info('invite matchmake refused: no such invite', { inviteId, id })
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null)
}
// The gate: an invite is redeemable only by the player it was addressed to. Told
// as "not permitted" rather than "no such invite" — the caller is holding an id
// that is real, and pretending otherwise doesn't hide anything they don't have.
if (invite.ToPlayerId !== id) {
logger.info('invite matchmake refused: caller is not the invitee', {
inviteId,
toPlayerId: invite.ToPlayerId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.InstanceJoinNotPermitted, null)
}
// Where the inviter is NOW. An inviter who has left (or whose presence lapsed) has
// nothing to join, which is what PlayerNotOnline says.
const inviterPresence = await getPresence<RoomInstance>(c.env.DB, invite.FromPlayerId)
const instance = inviterPresence?.roomInstance ?? null
if (!instance) {
logger.info('invite matchmake refused: inviter is not in a room', {
inviteId,
fromPlayerId: invite.FromPlayerId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null)
}
// Already standing there: nothing to do, and re-entering would churn presence and
// re-fire the friend fan-out for a move that didn't happen.
const own = await getPresence<RoomInstance>(c.env.DB, id)
if (own?.roomInstance?.roomInstanceId === instance.roomInstanceId) {
return matchmakeResult(c, MatchmakingErrorCode.AlreadyInTargetInstance, null)
}
// Like the follow-a-friend path, this hands out real Photon coordinates without
// going through resolveRoomInstance, so the room's bans have to be checked here —
// otherwise an invite is a way around one.
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
logger.info('invite matchmake refused: player banned from room', {
roomId: instance.roomId,
id,
})
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
// Nor does it get the build scoping a room matchmake has by construction. Compared
// against the INVITER's presence — they're the person actually standing in there.
const refusal = crossBuildRefusal(
await callerGameVersion(c),
inviterPresence?.appVersion ?? GAME_VERSION
)
if (refusal !== null) {
logger.info('invite matchmake refused: inviter is on another client build', {
roomInstanceId: instance.roomInstanceId,
fromPlayerId: invite.FromPlayerId,
id,
})
return matchmakeResult(c, refusal, null)
}
// Fullness read fresh rather than off the stored flag, which is only as current as
// the last person to move: an invite is usually redeemed seconds after it's sent,
// which is exactly when a nearly-full instance is still filling. Null means a
// synthetic instance with no row (a dorm), which has no head-count to check.
if ((await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)) === true) {
logger.info('invite matchmake refused: instance is full', {
roomInstanceId: instance.roomInstanceId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.InsufficientSpace, null)
}
// Same instance, same Photon room, stored as the caller's presence so their
// heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance)
return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
}
)
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
// where they pick a session of their room and drop into it. Unlike every other
@@ -2399,11 +2664,13 @@ const app = new Hono<App>()
// Resolve the instance to stamp the invite's RoomId — the client reads it to know
// which room the invite points at. A missing/unknown instance just leaves RoomId
// null (buildNotificationPayload drops it from the frame), as the reference does.
let roomId: number | null = null
if (!Number.isNaN(roomInstanceId) && roomInstanceId > 0) {
const instance = await getRoomInstance(c.env.DB, roomInstanceId)
if (instance) roomId = instance.roomId
}
// The whole instance, not just its room: a v2 invite names it by `Name` too, and an
// unresolved one leaves that empty rather than guessing at a name.
const instance =
!Number.isNaN(roomInstanceId) && roomInstanceId > 0
? await getRoomInstance(c.env.DB, roomInstanceId)
: null
const roomId: number | null = instance?.roomId ?? null
// Record the invite before delivering it: the row is what mints the `RoomInviteId`
// the response carries, while the frame itself is fire-and-forget.
@@ -2416,7 +2683,20 @@ const app = new Hono<App>()
return c.body(null, 500)
}
await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId)
// The `room_invite` row just written is what a v2 invite names as its `InviteId` —
// the only real invite id this server has.
await sendGameInvite(
c,
id,
toPlayerId,
{
instanceId: roomInstanceIdStr,
roomId,
name: instance?.name ?? '',
inviteId: invite.RoomInviteId,
},
await gameInviteType(c)
)
return c.json(invite)
}
)
+183
View File
@@ -19,6 +19,7 @@ import {
ROOM_INVITE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
setPresence,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
@@ -2426,6 +2427,79 @@ describe('auth-gated endpoints', () => {
expect(after[0].data.Data).toBe('999999')
})
test('POST /invite picks the invite message type off the tokens build', async () => {
type Sent = { data: { Type: number; Data: string; RoomId: number | null } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 42,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
})
// The one frame an invite from a client on `version` pushes, with the RoomInviteId
// the call answered — a v2 invite names it in its Data.
const inviteFrom = async (
version?: string
): Promise<{ frame: Sent; roomInviteId: number }> => {
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('42', version)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=153&roomInstanceId=${instance.roomInstanceId}`,
})
expect(res.status).toBe(200)
const { RoomInviteId } = (await res.json()) as { RoomInviteId: number }
const notes = await sent()
expect(notes).toHaveLength(1)
return { frame: notes[0], roomInviteId: RoomInviteId }
}
const typeFor = async (version?: string): Promise<number> =>
(await inviteFrom(version)).frame.data.Type
// The cutoff build and anything newer → GameInviteV2 (6). The cutoff is INCLUSIVE, and
// the point release after the date is not part of the comparison.
expect(await typeFor('20250718.01')).toBe(6)
expect(await typeFor('20230415')).toBe(6)
expect(await typeFor('20230414')).toBe(6)
expect(await typeFor('20230414.99')).toBe(6)
// Only builds OLDER than the cutoff get the original (0) — including the day before.
expect(await typeFor('20230413')).toBe(0)
expect(await typeFor('20230413.99')).toBe(0)
expect(await typeFor('20220101')).toBe(0)
// No `rn.ver` on the token, or one that isn't a build at all: fall back to the
// original rather than move an unknown client onto v2.
expect(await typeFor()).toBe(0)
expect(await typeFor('not-a-build')).toBe(0)
// v2 carries an escaped JSON object as its Data — a STRING holding JSON, not a
// nested object — naming the instance and the invite row behind it.
const v2 = await inviteFrom('20250718.01')
expect(typeof v2.frame.data.Data).toBe('string')
expect(JSON.parse(v2.frame.data.Data)).toEqual({
InviteId: v2.roomInviteId, // the row POST /invite just answered with
Name: '^RecCenter', // the instance's `^`-prefixed wire name
InviteMode: 22, // verbatim, as observed off the client
})
// RoomId still rides on the message itself, in both versions.
expect(v2.frame.data.RoomId).toBe(2)
// v1 is unchanged: the bare roomInstanceId as a string, no JSON.
const v1 = await inviteFrom('20220101')
expect(v1.frame.data.Data).toBe(String(instance.roomInstanceId))
expect(v1.frame.data.RoomId).toBe(2)
})
test('matchmake pushes SubscriptionUpdatePresence to the players friends', async () => {
// The notify DO is stubbed to record every send (see vitest.config). The friend
// fan-out is a single batch call carrying the friend ids.
@@ -2751,6 +2825,114 @@ describe('auth-gated endpoints', () => {
expect(await sent()).toEqual([])
})
test('POST /matchmake/invite/:id lands the invitee in the inviters instance', async () => {
// 8801 invites 8802. The invite row is what POST /invite answers with.
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 8801,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
})
const stand = async (accountId: number, roomInstance: unknown) =>
setPresence(env.DB, {
accountId,
roomInstance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
await stand(8801, instance)
const invite = async (sub: string) =>
exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8802&roomInstanceId=${instance.roomInstanceId}`,
})
const accept = async (inviteId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/matchmake/invite/${inviteId}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'CorrelationId=acc97dc2-be74-4722-99c3-36530491f5ff',
})
const { RoomInviteId } = (await (await invite('8801')).json()) as { RoomInviteId: number }
// Anyone who isn't the addressee is refused, the INVITER included — the row is the
// authorization, and an invite id is a small integer somebody else is holding.
for (const stranger of ['8801', '8803']) {
const res = await accept(RoomInviteId, stranger)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ ErrorCode: 76, RoomInstance: null })
}
// The addressee lands in the instance the inviter is standing in, answered in the
// PascalCase envelope with the CorrelationId echoed back.
const ok = await accept(RoomInviteId, '8802')
expect(ok.status).toBe(200)
expect(await ok.json()).toMatchObject({
ErrorCode: 0,
CorrelationId: 'acc97dc2-be74-4722-99c3-36530491f5ff',
RoomInstance: {
RoomInstanceId: instance.roomInstanceId,
RoomId: 2,
Name: '^RecCenter',
MatchmakingPolicy: 0,
},
})
// ...and it really moved them: presence now names that instance.
const presence = await exports.default.fetch(`${ORIGIN}/player?id=8802`, {
headers: await bearer('8802'),
})
expect(
((await presence.json()) as Array<{ roomInstance: { roomInstanceId: number } | null }>)[0]
?.roomInstance?.roomInstanceId
).toBe(instance.roomInstanceId)
// Standing there already is 17, not a second join.
const again = await accept(RoomInviteId, '8802')
expect(await again.json()).toMatchObject({ ErrorCode: 17, RoomInstance: null })
// An invite id that isn't there is 40 — expiry deletes rows, so "gone" and "expired"
// are one answer.
expect(await (await accept(99_999_999, '8802')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
// The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee is
// moved out of the instance first so the AlreadyIn check doesn't answer ahead of it.)
await stand(8802, null)
await env.DB.prepare('DELETE FROM presence WHERE account_id = 8801').run()
const { RoomInviteId: staleId } = (await (await invite('8801')).json()) as {
RoomInviteId: number
}
expect(await (await accept(staleId, '8802')).json()).toMatchObject({
ErrorCode: 2,
RoomInstance: null,
})
// Unauthenticated is a 401, not a refusal code.
expect(
(
await exports.default.fetch(`${ORIGIN}/matchmake/invite/${RoomInviteId}`, {
method: 'POST',
})
).status
).toBe(401)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -2786,6 +2968,7 @@ describe('auth-gated endpoints', () => {
'POST /matchmake/dorm',
'POST /matchmake/event/{eventId}',
'POST /matchmake/instance/{instanceId}',
'POST /matchmake/invite/{inviteId}',
'POST /matchmake/none',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',