[match] allow friends to join friends in public

This commit is contained in:
Devin Zuczek
2026-09-30 11:18:37 -04:00
parent fec3fc5258
commit d543b301e2
2 changed files with 147 additions and 24 deletions
+55 -24
View File
@@ -2281,16 +2281,23 @@ const app = new Hono<App>()
)
// The newer client's join-by-player (`/matchmake/v2/player/{playerId}`). Same move as
// the v1 follow above — land in the instance the target is standing in — but gated on
// the `room_invite` table rather than friendship: the caller must hold a standing
// invite FROM the target (the newer client's invite frame doesn't always carry a
// redeemable `InviteId` — the party fan-out sends 0 — so it redeems by player instead
// 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 v1 follow above — land in the instance the target is standing in — and the newer
// client sends it for two different buttons, so it admits the caller two ways:
//
// 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.
// - An INVITE: the caller holds a standing `room_invite` row FROM the target (the newer
// client's invite frame doesn't always carry a redeemable `InviteId` — the party
// fan-out sends 0 — so it redeems by player instead 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.
// - A FRIEND in a PUBLIC instance: "Join" on a friend's profile or the friends list,
// which sends no invite at all. Gated on mutual friendship like the v1 follow, and —
// unlike it — on the instance being public: a private session admits people through
// the invite branch, which is what an invite is for. Nothing is consumed.
//
// Every refusal is checked with the invite (if any) still standing, so a retry works.
//
// 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.
@@ -2303,14 +2310,18 @@ const app = new Hono<App>()
summary: 'Join the player who invited you (v2)',
description: [
'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`. 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).',
'the target’s stored presence. Two ways in. INVITE: the caller holds 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`. 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). FRIEND: with no invite, a mutual friend may join the target’s PUBLIC',
'instance (the profile/friends-list “Join”); a private instance still takes an invite.',
'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',
'(RoomInviteExpired) when neither holds — not a friend, or a friend whose instance is',
'private — so nothing about a stranger’s whereabouts leaks (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',
'(InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when the caller is',
'banned from that room.',
@@ -2342,16 +2353,23 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c)
const targetId = Number.parseInt(c.req.param('playerId'), 10)
// The gate: a standing invite from the target to the caller. No row means never
// invited or already swept — the same answer either way, since expiry deletes
// rows. This also refuses joining yourself: nobody holds a self-invite.
// The first key: a standing invite from the target to the caller. No row means
// never invited or already swept — the same thing, since expiry deletes rows.
// Nobody holds a self-invite, and nobody is their own friend, so joining yourself
// is refused by both keys.
const invite = await getLatestRoomInviteBetween(c.env.DB, targetId, id)
if (invite === null) {
logger.info('v2 player matchmake refused: no invite from target', { targetId, id })
// The second: mutual friendship, which admits the caller only to a PUBLIC instance
// (checked below, once the instance is known). Not read when an invite stands.
const friend = invite === null && (await areFriends(c.env.DB, id, targetId))
if (invite === null && !friend) {
logger.info('v2 player matchmake refused: no invite from target, not a friend', {
targetId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null)
}
// Where the inviter is NOW, straight off their presence row — not the invite's
// Where the target is NOW, straight off their presence row — not an invite's
// stored RoomId, which records where they were when they sent it.
const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId)
const instance = targetPresence?.roomInstance ?? null
@@ -2360,6 +2378,18 @@ const app = new Hono<App>()
return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null)
}
// Friendship alone doesn't open a private session: that's what an invite is for.
// The same opaque 40 as a stranger gets — a distinct code would tell a friend the
// target is in a private room, which the target's presence settings may hide.
if (invite === null && instance.isPrivate) {
logger.info('v2 player matchmake refused: friend’s instance is private, no invite', {
roomInstanceId: instance.roomInstanceId,
targetId,
id,
})
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, 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)
@@ -2407,12 +2437,13 @@ const app = new Hono<App>()
// 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
// An 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)
// A friend's join consumed nothing; there's nothing to drop.
if (invite !== null) await deleteRoomInvite(c.env.DB, invite.RoomInviteId)
return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
}
)
@@ -3342,6 +3342,98 @@ describe('auth-gated endpoints', () => {
).toBe(401)
})
test('POST /matchmake/v2/player/:id lets a friend join a public instance without an invite', async () => {
// 8821 stands in a PUBLIC instance; 8822 is a mutual friend (8821 requested), 8823
// has a pending request only, 8824 is a stranger.
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([insertRel.bind(8821, 8822, 3), insertRel.bind(8823, 8821, 1)])
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 8821,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
isPrivate: false,
})
const stand = (accountId: number, roomInstance: typeof instance | null) =>
setPresence(env.DB, {
accountId,
roomInstance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
await stand(8821, instance)
const join = async (targetId: number, sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/${targetId}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'CorrelationId=5d1f0a4e-9a5b-4a3e-8d2b-6f1c2e3a4b5c',
})
).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null }
// Not a friend, no invite: 40, whichever way round the non-friendship is.
expect(await join(8821, '8824')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
expect(await join(8821, '8823')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
// A friend lands in the public instance with no invite row at all…
expect(
await env.DB.prepare('SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821')
.first<{ n: number }>()
).toMatchObject({ n: 0 })
expect(await join(8821, '8822')).toMatchObject({
ErrorCode: 0,
RoomInstance: { RoomInstanceId: instance.roomInstanceId, IsPrivate: false },
})
// …and is standing there afterwards, so the same call now says so (17).
expect(await join(8821, '8822')).toMatchObject({ ErrorCode: 17, RoomInstance: null })
// A friend's PRIVATE instance still takes an invite: the same opaque 40 a stranger
// gets, so the code doesn't confirm where the friend is.
const closed = await createRoomInstance(env.DB, {
ownerAccountId: 8821,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
isPrivate: true,
})
await stand(8821, closed)
await stand(8822, null)
expect(await join(8821, '8822')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
// With an invite the private instance opens, and the invite (not the friendship)
// is what's spent: the row is gone after the join.
await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('8821')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8822&roomInstanceId=${closed.roomInstanceId}`,
})
expect(await join(8821, '8822')).toMatchObject({
ErrorCode: 0,
RoomInstance: { RoomInstanceId: closed.roomInstanceId, IsPrivate: true },
})
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821 AND to_player_id = 8822'
).first<{ n: number }>()
).toMatchObject({ n: 0 })
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)