[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, getExpiredPresenceInstanceIds,
getFriendIds, getFriendIds,
getJoinableInstance, getJoinableInstance,
deleteRoomInvite,
getLatestRoomInviteBetween, getLatestRoomInviteBetween,
getMostActiveClubhouses, getMostActiveClubhouses,
getOrCreateDormRoom, 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: // 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 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 // 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. // going through resolveRoomInstance, so it carries its own ban and build checks.
// `/matchmake/v2/` answers the PascalCase envelope via `matchmakeResult`, as the v2 // `/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', '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', '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', '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', '(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', '“expired” are one answer), 2 (PlayerNotOnline) when the target isnt in a room, 17',
'(AlreadyInTargetInstance) when the caller is already standing there, 3', '(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 // Same instance, same Photon room, stored as the caller's presence so their
// heartbeat replays it and their own friend fan-out fires. // heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance) 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) return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
} }
) )
+22 -1
View File
@@ -3187,7 +3187,28 @@ describe('auth-gated endpoints', () => {
].sort() ].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({ expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 17, ErrorCode: 17,
RoomInstance: null, RoomInstance: null,
+18
View File
@@ -147,3 +147,21 @@ export async function createRoomInvite(
RoomId: row.room_id, 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<boolean> {
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
}