From 9c63f077b88cd1d17ba2e090d4002d216d17c10c Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 1 Sep 2026 14:47:49 -0400 Subject: [PATCH] [match] fix play-and-invite endpoint --- apps/match/src/match.app.ts | 46 +++++++++------- apps/match/src/test/integration/api.test.ts | 60 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index e5bdf94..8590553 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -788,14 +788,6 @@ async function gameInviteType(c: Context): Promise { */ 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. */ @@ -804,7 +796,7 @@ type GameInviteTarget = { 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}. */ + /** The id of the `room_invite` row this came from — what the invitee redeems. */ inviteId: number } @@ -1184,6 +1176,12 @@ async function readMatchmakeBody( * sends, pointing at this instance, so a party matchmake pulls the whole party along. The * leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own * failures), and never blocks the matchmake beyond the sends themselves. + * + * Each member's invite is RECORDED, exactly as `POST /invite` records one, because the row + * is what the member redeems the frame against: the 2025 client joins off a party invite + * through `/matchmake/invite/{InviteId}` or `/matchmake/v2/player/{leaderId}`, and both + * resolve a `room_invite` row. A fan-out that only pushed the frame minted no row, so every + * party invite read as expired the moment it arrived while a manual `POST /invite` worked. */ async function inviteParty( c: Context, @@ -1191,21 +1189,33 @@ async function inviteParty( playerIds: number[], instance: RoomInstance ): Promise { - // 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, target, type)) + .map(async (pid) => { + // The row before the frame, as `POST /invite` does: the frame names the row's id, + // so an invite that couldn't be recorded has nothing to redeem and isn't sent. + const invite = await createRoomInvite(c.env.DB, leaderId, pid, instance.roomId) + if (invite === null) { + logger.error('failed to record party room invite', { + fromPlayerId: leaderId, + toPlayerId: pid, + roomId: instance.roomId, + }) + return + } + // The leader's own instance, which every member is being pulled into. + const target: GameInviteTarget = { + instanceId: String(instance.roomInstanceId), + roomId: instance.roomId, + name: instance.name, + inviteId: invite.RoomInviteId, + } + await sendGameInvite(c, leaderId, pid, target, type) + }) ) } diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 0677334..08b771a 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -2914,6 +2914,14 @@ describe('auth-gated endpoints', () => { RoomId: 2, }) + // The fan-out RECORDS each invite, like POST /invite: the row is what the invitee + // redeems the frame against, so a frame without one is expired on arrival. + const row = await env.DB.prepare( + 'SELECT room_invite_id, room_id FROM room_invite WHERE from_player_id = 9850 AND to_player_id = 153' + ).first<{ room_invite_id: number; room_id: number }>() + expect(row).not.toBeNull() + expect(row?.room_id).toBe(2) + // Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader // themselves is skipped. await reset() @@ -2929,6 +2937,58 @@ describe('auth-gated endpoints', () => { expect(await sent()).toEqual([]) }) + test('a v2 party matchmake mints invites the members can actually redeem', async () => { + // The reported bug: /matchmake/v2/room/:id fanned the party out as frames with no + // `room_invite` row behind them, so the member's join answered 40 (RoomInviteExpired) + // while a manual POST /invite worked. + type Sent = { playerId: number; data: { Type: number; Data: string } } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + + const res = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, { + method: 'POST', + headers: { + ...(await bearer('9860', '20250718.01')), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + AdditionalPlayerIds: [9861], + CorrelationId: '3c60e657-21c4-46be-815c-57ee51add506', + JoinMode: 2, + InviteMode: 20, + }), + }) + expect(res.status).toBe(200) + const leader = (await res.json()) as { RoomInstance: { RoomInstanceId: number } } + + // The party member's frame is a v2 invite naming a REAL row id. + const frames = (await (await hub().fetch('http://do/all')).json()) as Sent[] + const invite = frames.find((f) => f.playerId === 9861) + expect(invite?.data.Type).toBe(6) // MessageType.GameInviteV2 + const { InviteId } = JSON.parse(invite?.data.Data ?? '{}') as { InviteId: number } + expect(InviteId).toBeGreaterThan(0) + + // Redeeming it puts the member in the leader's instance rather than answering 40. + const joined = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/invite/${InviteId}`, { + method: 'POST', + headers: { ...(await bearer('9861', '20250718.01')) }, + }) + ).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null } + expect(joined.ErrorCode).toBe(0) + expect(joined.RoomInstance?.RoomInstanceId).toBe(leader.RoomInstance.RoomInstanceId) + + // The by-sender redemption the newer client falls back to works off the same row. + await env.DB.prepare('DELETE FROM presence WHERE account_id = 9861').run() + const bySender = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/9860`, { + method: 'POST', + headers: { ...(await bearer('9861', '20250718.01')) }, + }) + ).json()) as { ErrorCode: number } + expect(bySender.ErrorCode).toBe(0) + }) + test('POST /matchmake/invite/:id lands the invitee in the inviter’s instance', async () => { // 8801 invites 8802. The invite row is what POST /invite answers with. const instance = await createRoomInstance(env.DB, {