diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index f64b29d..aed16ba 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -750,6 +750,17 @@ function persistenceVersionRefusal( */ const ROOM_IS_PRIVATE = MatchmakingErrorCode.RoomIsPrivate +/** + * Whether a room is PUBLISHED — Public, or Unlisted (reachable by whoever holds the id, + * just not listed). Every other accessibility (Private, and the two Dev_ ones) is a room + * its owner hasn't released. + */ +function isPublished(room: Room): boolean { + return ( + room.Accessibility === Accessibility.Public || room.Accessibility === Accessibility.Unlisted + ) +} + /** * Whether a room MATCHMAKE may enter this room at all, before any instance is found or * made. A PUBLISHED room — Public, or Unlisted (reachable by whoever holds the id, just @@ -768,12 +779,7 @@ const ROOM_IS_PRIVATE = MatchmakingErrorCode.RoomIsPrivate * their guests through the invite routes, not this one. */ async function canEnterRoom(db: D1Database, room: Room, accountId: number): Promise { - if ( - room.Accessibility === Accessibility.Public || - room.Accessibility === Accessibility.Unlisted - ) { - return true - } + if (isPublished(room)) return true if (room.CreatorAccountId === accountId) return true if (roomRoles(room).some((r) => r.AccountId === accountId && r.Role !== Role.None)) return true return hasRoomInviteTo(db, accountId, Number(room.RoomId)) @@ -1468,6 +1474,16 @@ async function resolveRoomInstance( return { instance: null, errorCode: ROOM_IS_PRIVATE } } + // An unpublished room has no PUBLIC instances, whatever `JoinMode` asked for. The gate + // above only decides who may START a session; a public one is then a door for everyone + // it never admitted — a friend follows the owner in, and `IsPrivate: false` tells every + // client the session is open. The owner walking through their own room's subroom door + // posts `JoinMode` 0, so this has to be decided here rather than trusted from the body. + // Private means a fresh instance each time, as `JoinMode` 2 always has: the room's + // people reach each other by invite. Room routes only, like the gate — an event or a + // clubhouse held in a private room is a session its own guest list shares. + const privateInstance = isPrivate || (gateOnAccessibility && !isPublished(room)) + // The build this player is on, from their token. A 2023 client can't load a scene // saved at a newer persistence version, so it is refused the room outright (see // persistenceVersionRefusal) before any instance is created or reused. @@ -1489,7 +1505,7 @@ async function resolveRoomInstance( // search, which pushes them to another live instance if one exists or forces a // fresh one below. (Only the public path reuses instances, so only it needs the // read; a private matchmake always gets a fresh instance.) - const currentInstanceId = isPrivate + const currentInstanceId = privateInstance ? undefined : (await getPresence(c.env.DB, ownerId))?.roomInstance?.roomInstanceId // The same build, with GAME_VERSION standing in for a token that names none. It scopes @@ -1501,7 +1517,7 @@ async function resolveRoomInstance( // another, and neither must a session running a different version of the room. // Private matchmakes always get a fresh instance. Create one when there's nothing // to join. - let instance = isPrivate + let instance = privateInstance ? null : await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId) if (!instance) { @@ -1514,7 +1530,7 @@ async function resolveRoomInstance( photonRoomId: crypto.randomUUID(), name: f.name, maxCapacity: f.maxCapacity, - isPrivate: isPrivate || f.isDorm, + isPrivate: privateInstance || f.isDorm, roomInstanceType: f.roomInstanceType, gameVersion, }) @@ -1523,7 +1539,7 @@ async function resolveRoomInstance( instance: roomInstanceFromRoom( c.env, room, - isPrivate, + privateInstance, instance.roomInstanceId, instance.photonRoomId, f.subRoomId @@ -1542,7 +1558,8 @@ async function resolveRoomInstance( * The one gate that is these routes' alone is the room's ACCESSIBILITY (see * {@link canEnterRoom}): a room that isn't published answers `RoomIsPrivate` (25) to anyone * but its creator, its role holders and its invitees, in either join mode — a private - * instance of an unpublished room is still a way into it. + * instance of an unpublished room is still a way into it. Those it does admit always get + * a PRIVATE instance of it, whatever `JoinMode` they posted. * * `subRoomId` is optional: absent, `resolveRoomInstance` falls back to the room's first * subroom (its default entrance). @@ -2317,6 +2334,21 @@ const app = new Hono() return matchmakeResult(c, BANNED_FROM_ROOM, null) } + // Nor through the room's accessibility gate: friendship is not a key to a room its + // owner hasn't published, so the friend must be someone that room admits on their + // own account (canEnterRoom). The same opaque refusal as "not in a room" — a + // distinct code would tell the caller where their friend is. An instance naming no + // stored room (a dorm's synthetic one) has nothing to gate on. + const followedRoom = await getRoomById(c.env.DB, instance.roomId) + if (followedRoom && !(await canEnterRoom(c.env.DB, followedRoom, id))) { + logger.info('follow refused: friend is in an unpublished room', { + roomId: instance.roomId, + targetId, + id, + }) + return matchmakeResult(c, NO_SUCH_ROOM, null) + } + // Nor does it go through the build scoping the room matchmakes get by // construction, so a player on another build could otherwise follow their way // into a session that can't render them. Compared against the FRIEND's presence diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 2ed8798..14ac1c7 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -3032,6 +3032,105 @@ describe('auth-gated endpoints', () => { expect((await v2('/matchmake/v2/room/2', '9880', 2)).RoomInstance?.RoomId).toBe(2) }) + test('an unpublished room’s instances are always PRIVATE, whatever JoinMode asked for', async () => { + type V2 = { + ErrorCode: number + RoomInstance: { RoomInstanceId: number; RoomId: number; IsPrivate: boolean } | null + } + const v2 = async (path: string, sub: string, joinMode: number) => + (await ( + await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ JoinMode: joinMode }), + }) + ).json()) as V2 + const stored = async (instanceId: number | undefined) => + ( + await env.DB.prepare('SELECT is_private FROM room_instance WHERE id = ?1') + .bind(instanceId ?? 0) + .first<{ is_private: number }>() + )?.is_private + + // The creator walking through their own room's subroom door posts JoinMode 0 — the + // body the client was seen sending. Room 6 is Private, so the session is too: in the + // answer, and in the row every other route reads. + const own = await v2('/matchmake/v2/room/6/6', '42', 0) + expect(own.ErrorCode).toBe(0) + expect(own.RoomInstance).toMatchObject({ RoomId: 6, IsPrivate: true }) + expect(await stored(own.RoomInstance?.RoomInstanceId)).toBe(1) + // The 2023 spelling, and JoinMode 1, are the same. + const v1 = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/6`, { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'JoinMode=1', + }) + ).json()) as { roomInstance: { roomInstanceId: number; isPrivate: boolean } | null } + expect(v1.roomInstance?.isPrivate).toBe(true) + expect(await stored(v1.roomInstance?.roomInstanceId)).toBe(1) + + // Nothing public to reuse: a role holder asking for a public session gets a private + // one of their own rather than joining the creator's. + const host = await v2('/matchmake/v2/room/6', '44', 0) + expect(host.RoomInstance?.IsPrivate).toBe(true) + expect(host.RoomInstance?.RoomInstanceId).not.toBe(v1.roomInstance?.roomInstanceId) + expect( + await env.DB.prepare( + 'SELECT COUNT(*) AS n FROM room_instance WHERE room_id = 6 AND is_private = 0' + ).first<{ n: number }>() + ).toEqual({ n: 0 }) + + // A FRIEND of the creator is not one of the room's people: neither follow gets them + // in — the 2023 route by the room's gate, the v2 route because the session is private. + await env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (42, 9885, 3)' + ).run() + try { + const follow = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/player/42`, { + method: 'POST', + headers: await bearer('9885'), + }) + ).json()) as { errorCode: number; roomInstance: unknown } + expect(follow).toEqual(refused(20)) + const followV2 = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/42`, { + method: 'POST', + headers: { ...(await bearer('9885')), 'Content-Type': 'application/json' }, + body: '{}', + }) + ).json()) as V2 + expect(followV2.ErrorCode).not.toBe(0) + expect(followV2.RoomInstance).toBeNull() + expect( + await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9885').first() + ).toBeNull() + + // One of the room's own people who is also a friend may follow: the room admits + // them itself. Here the creator follows the Host into the Host's session. + await env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (42, 44, 3)' + ).run() + const ownFollow = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/player/44`, { + method: 'POST', + headers: await bearer('42'), + }) + ).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null } + expect(ownFollow.errorCode).toBe(0) + expect(ownFollow.roomInstance?.roomInstanceId).toBe(host.RoomInstance?.RoomInstanceId) + } finally { + await env.DB.prepare( + 'DELETE FROM relationship WHERE requester_id = 42 AND target_id IN (9885, 44)' + ).run() + } + + // A published room still hands out a public session for the same request. + const open = await v2('/matchmake/v2/room/2', '9885', 0) + expect(open.RoomInstance?.IsPrivate).toBe(false) + }) + test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => { const matchmake = async (sub: string) => (await (