[match] consume invite on join

This commit is contained in:
Devin Zuczek
2026-09-02 11:21:24 -04:00
parent bc12bb07d4
commit 20196361c8
3 changed files with 56 additions and 2 deletions
+16 -1
View File
@@ -18,6 +18,7 @@ import {
getExpiredPresenceInstanceIds,
getFriendIds,
getJoinableInstance,
deleteRoomInvite,
getLatestRoomInviteBetween,
getMostActiveClubhouses,
getOrCreateDormRoom,
@@ -2192,6 +2193,10 @@ const app = new Hono<App>()
// 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<App>()
'Places the caller into the room instance the target player is currently in, read from',
'the targets 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 cant 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 isnt in a room, 17',
'(AlreadyInTargetInstance) when the caller is already standing there, 3',
@@ -2303,6 +2311,13 @@ const app = new Hono<App>()
// 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)
}
)
+22 -1
View File
@@ -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,