From 20196361c809eb5ed5146a4da25be8d0d591f7fa Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 2 Sep 2026 11:21:24 -0400 Subject: [PATCH] [match] consume invite on join --- apps/match/src/match.app.ts | 17 ++++++++++++++- apps/match/src/test/integration/api.test.ts | 23 ++++++++++++++++++++- packages/domain/src/room-invites-db.ts | 18 ++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 8590553..487d327 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -18,6 +18,7 @@ import { getExpiredPresenceInstanceIds, getFriendIds, getJoinableInstance, + deleteRoomInvite, getLatestRoomInviteBetween, getMostActiveClubhouses, getOrCreateDormRoom, @@ -2192,6 +2193,10 @@ const app = new Hono() // of by row id, and this is that path). Everything the target sent stays checkable: // the newest row is enough, since any live row is authorization. // + // The row is consumed on a successful join: an invite authorizes one entry, and since + // this path follows the target's LIVE presence rather than the room the invite named, + // keeping it would leave a standing key into whatever instance they're in later. + // // Like the follow and invite paths, this hands out real Photon coordinates without // going through resolveRoomInstance, so it carries its own ban and build checks. // `/matchmake/v2/` answers the PascalCase envelope via `matchmakeResult`, as the v2 @@ -2205,7 +2210,10 @@ const app = new Hono() 'Places the caller into the room instance the target player is currently in, read from', 'the target’s stored presence. INVITEES ONLY: the caller must hold a `room_invite` row', 'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by', - 'its sender when the frame carries no usable `RoomInviteId`. Answers 40', + 'its sender when the frame carries no usable `RoomInviteId`. The invite is SINGLE-USE:', + 'a successful join deletes the row, so the same invite can’t be redeemed again into', + 'wherever that player goes next (a refusal leaves it standing, so a retry still works).', + 'Answers 40', '(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and', '“expired” are one answer), 2 (PlayerNotOnline) when the target isn’t in a room, 17', '(AlreadyInTargetInstance) when the caller is already standing there, 3', @@ -2303,6 +2311,13 @@ const app = new Hono() // 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) + + // The invite is spent: it was authorization for THIS join, and leaving the row + // standing would make it a permanent key into whatever instance the target is in + // later — this path reads their live presence, not the room the invite named. + // Dropped only once the caller is actually in, so every refusal above (target not + // in a room, full, banned, wrong build) leaves the invite redeemable for a retry. + await deleteRoomInvite(c.env.DB, invite.RoomInviteId) return matchmakeResult(c, MatchmakingErrorCode.Success, instance) } ) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 08b771a..ff304ca 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -3187,7 +3187,28 @@ describe('auth-gated endpoints', () => { ].sort() ) - // Standing there already is 17, not a second join. + // The invite was spent by that join: the row is gone, so the same call now reads as + // "no invite" (40) rather than authorizing a second entry off the same invite. + expect( + await env.DB.prepare( + 'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8811 AND to_player_id = 8812' + ).first<{ n: number }>() + ).toMatchObject({ n: 0 }) + expect(await (await join(8811, '8812')).json()).toMatchObject({ + ErrorCode: 40, + RoomInstance: null, + }) + + // With a fresh invite, standing there already is 17, not a second join — and a + // refusal leaves that invite standing, which the PlayerNotOnline case below redeems. + await exports.default.fetch(`${ORIGIN}/invite`, { + method: 'POST', + headers: { + ...(await bearer('8811')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`, + }) expect(await (await join(8811, '8812')).json()).toMatchObject({ ErrorCode: 17, RoomInstance: null, diff --git a/packages/domain/src/room-invites-db.ts b/packages/domain/src/room-invites-db.ts index b24e6f7..1fc11bc 100644 --- a/packages/domain/src/room-invites-db.ts +++ b/packages/domain/src/room-invites-db.ts @@ -147,3 +147,21 @@ export async function createRoomInvite( RoomId: row.room_id, } } + +/** + * Delete one invite by its id, answering whether a row was there to delete. + * + * An invite is single-use: `POST /matchmake/v2/player/:playerId` redeems the newest row + * from the target and drops it here once the caller is actually in the instance, so a + * standing invite doesn't stay a permanent key into whatever room that player is in + * later. Deleting rather than flagging matches the expiry sweep, which is why every + * lookup reads a miss as "no longer good" without a status column. + */ +export async function deleteRoomInvite(db: D1Database, roomInviteId: number): Promise { + const row = await db + .prepare(`DELETE FROM room_invite WHERE room_invite_id = ?1 RETURNING room_invite_id`) + .bind(roomInviteId) + .first<{ room_invite_id: number }>() + + return row !== null +}