From d543b301e24978465dd8bc1f8060a96e034b72c1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 30 Sep 2026 11:18:37 -0400 Subject: [PATCH] [match] allow friends to join friends in public --- apps/match/src/match.app.ts | 79 ++++++++++++------ apps/match/src/test/integration/api.test.ts | 92 +++++++++++++++++++++ 2 files changed, 147 insertions(+), 24 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index c6ad910..86d40e3 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -2281,16 +2281,23 @@ const app = new Hono() ) // 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() 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() 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(c.env.DB, targetId) const instance = targetPresence?.roomInstance ?? null @@ -2360,6 +2378,18 @@ const app = new Hono() 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(c.env.DB, id) @@ -2407,12 +2437,13 @@ const app = new Hono() // 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) } ) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index bec0aaa..307c72f 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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)