diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 86d40e3..cde14cf 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -31,6 +31,7 @@ import { getRoomInstanceSummariesByRoom, getRoomInvite, getStoredRoomInstance, + hasRoomInviteTo, InviteMode, isClubMember, isPlayerBannedFromRoom, @@ -40,7 +41,9 @@ import { recordRoomVisit, recordStat, refreshInstanceFullness, + Role, RoomInstanceType, + roomRoles, setPresence, setRoomInstanceInProgress, setRoomInstancePrivate, @@ -739,6 +742,43 @@ function persistenceVersionRefusal( return tooNew ? MatchmakingErrorCode.UpdateRequired : null } +/** + * "This room is private" — the refusal on a room that isn't published, asked for by id by + * someone it doesn't admit (see {@link canEnterRoom}). Told plainly, as the private-event + * refusal below is: whoever reaches this holds the room id already, and the only thing an + * opaque NoSuchRoom would buy is a room that fails to load for no visible reason. + */ +const ROOM_IS_PRIVATE = MatchmakingErrorCode.RoomIsPrivate + +/** + * 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 + * not listed) — admits anyone; every other accessibility (Private, and the two Dev_ ones) + * is a room its owner hasn't released, and admits only: + * + * - its creator, and anyone holding a role on it (Host and up: everyone named in `Roles` + * is working on the room, whether or not it is published), or + * - a player holding a live `room_invite` into it — an invite is how a private room's + * people bring someone in. + * + * Without this, a private INSTANCE was the only thing "private" meant to the room routes: + * `JoinMode` 2 spawned a fresh instance of any room whose id you had, published or not, + * and the unpublished builds a player's visit history named were open to anyone who + * could read it. Dorms are not gated here — they are Unlisted by construction and reach + * 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 (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)) +} + /** * "This event isn't open to you" — the refusal on a private event the caller wasn't * invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player @@ -1392,7 +1432,8 @@ async function resolveRoomInstance( roomKey: string, isPrivate: boolean, ownerId: number, - requestedSubRoomId?: number + requestedSubRoomId?: number, + gateOnAccessibility = false ): Promise { const id = Number.parseInt(roomKey, 10) const requested = Number.isNaN(id) @@ -1413,6 +1454,20 @@ async function resolveRoomInstance( return { instance: null, errorCode: BANNED_FROM_ROOM } } + // An unpublished room admits only its people (see canEnterRoom). Only the ROOM routes + // ask for this: an event or a clubhouse matchmake has already decided the caller + // belongs (invited to the event, a member of the club) and its room may well be private + // — that's what holding an event in your own room looks like. Checked against the room + // actually entered, after any substitution, and before an instance exists. + if (gateOnAccessibility && !(await canEnterRoom(c.env.DB, room, ownerId))) { + logger.info('matchmake refused: room is not published and player is not admitted', { + roomId: f.roomId, + ownerId, + accessibility: room.Accessibility, + }) + return { instance: null, errorCode: ROOM_IS_PRIVATE } + } + // 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. @@ -1484,6 +1539,11 @@ async function resolveRoomInstance( * reads the same envelope back — so they are the same handler under a second path rather * than a copy that can drift. * + * 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. + * * `subRoomId` is optional: absent, `resolveRoomInstance` falls back to the room's first * subroom (its default entrance). */ @@ -1498,7 +1558,10 @@ async function matchmakeIntoRoom(c: Context) { c.req.param('roomId') ?? '', joinMode === 2, id, - subRoomId + subRoomId, + // Named by id, so the room's own accessibility is the gate (canEnterRoom): an + // unpublished room refuses everyone but its people and its invitees with 25. + true ) if (!instance) return matchmakeResult(c, errorCode, null) await enterRoom(c, id, instance) @@ -2728,7 +2791,7 @@ const app = new Hono() responses: { 200: json( MatchmakeResponse, - 'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)' + 'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)' ), 401: UNAUTHORIZED_RESPONSE, }, @@ -2753,7 +2816,7 @@ const app = new Hono() responses: { 200: json( MatchmakeResponse, - 'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)' + 'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)' ), 401: UNAUTHORIZED_RESPONSE, }, @@ -2803,7 +2866,7 @@ const app = new Hono() responses: { 200: json( MatchmakeV2Response, - 'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned)' + 'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)' ), 401: UNAUTHORIZED_RESPONSE, }, @@ -2826,7 +2889,7 @@ const app = new Hono() responses: { 200: json( MatchmakeV2Response, - 'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned)' + 'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)' ), 401: UNAUTHORIZED_RESPONSE, }, diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 307c72f..4e77d86 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -88,6 +88,31 @@ const TEST_ROOMS = [ Roles: [{ AccountId: 43, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }], SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }], }, + { + // UNPUBLISHED (Private): admits its creator, its role holders and its invitees only. + RoomId: 6, + Name: 'UnpublishedRoom', + IsDorm: false, + Accessibility: 0, + CreatorAccountId: 42, + Roles: [ + { AccountId: 42, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }, + // A host — the lowest tier, and still somebody working on the room. + { AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 }, + // Invited to a role but holding none yet: not admitted by the role. + { AccountId: 46, Role: 0, LastChangedByAccountId: 42, InvitedRole: 30 }, + ], + SubRooms: [{ SubRoomId: 6, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }], + }, + { + // Unlisted: published, just not listed — anyone holding the id may enter. + RoomId: 7, + Name: 'UnlistedRoom', + IsDorm: false, + Accessibility: 2, + CreatorAccountId: 42, + SubRooms: [{ SubRoomId: 7, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }], + }, { // A single-seat room so one player fills its instance (fullness tests). RoomId: 5, @@ -2924,6 +2949,74 @@ describe('auth-gated endpoints', () => { } }) + test('POST /matchmake/room/:roomId refuses an unpublished room to anyone it does not admit', async () => { + // Every spelling of the room matchmake, in both join modes — a PRIVATE instance + // of an unpublished room is still a way into it, so JoinMode 2 is gated too. + const v1 = async (path: string, sub: string, joinMode: string) => + (await ( + await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ JoinMode: joinMode }).toString(), + }) + ).json()) as { errorCode: number; roomInstance: { roomId: number } | 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 { ErrorCode: number; RoomInstance: { RoomId: number } | null } + + // A stranger: RoomIsPrivate (25) — told plainly, since they hold the id already — + // with no instance, on every route and in either join mode. + for (const joinMode of ['1', '2']) { + expect(await v1('/matchmake/room/6', '9880', joinMode)).toEqual(refused(25)) + expect(await v1('/matchmake/room/6/6', '9880', joinMode)).toEqual(refused(25)) + } + for (const joinMode of [1, 2]) { + expect(await v2('/matchmake/v2/room/6', '9880', joinMode)).toMatchObject({ + ErrorCode: 25, + RoomInstance: null, + }) + expect(await v2('/matchmake/v2/room/6/6', '9880', joinMode)).toMatchObject({ + ErrorCode: 25, + RoomInstance: null, + }) + } + // Refused before any instance or presence exists for them. + expect( + await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9880').first() + ).toBeNull() + expect( + await env.DB.prepare('SELECT 1 AS hit FROM room_instance WHERE room_id = 6').first() + ).toBeNull() + + // The creator and a role holder (a Host, the lowest tier) get in; a pending role + // invite (Role 0) is not a role. + expect((await v1('/matchmake/room/6', '42', '2')).roomInstance?.roomId).toBe(6) + expect((await v2('/matchmake/v2/room/6', '44', 2)).RoomInstance?.RoomId).toBe(6) + expect(await v1('/matchmake/room/6', '46', '2')).toEqual(refused(25)) + + // A live room invite INTO THAT ROOM admits the invitee — and is not consumed, so a + // retry works. An invite to some other room is no key to this one. + await env.DB.prepare( + `INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at) + VALUES (42, 9881, 2, ?1), (42, 9880, 6, ?1)` + ) + .bind(Math.floor(Date.now() / 1000)) + .run() + expect(await v1('/matchmake/room/6', '9881', '2')).toEqual(refused(25)) + expect((await v1('/matchmake/room/6', '9880', '2')).roomInstance?.roomId).toBe(6) + expect((await v2('/matchmake/v2/room/6', '9880', 2)).RoomInstance?.RoomId).toBe(6) + await env.DB.prepare('DELETE FROM room_invite WHERE to_player_id IN (9880, 9881)').run() + + // Published rooms are untouched: Unlisted is reachable by whoever holds the id. + expect((await v1('/matchmake/room/7', '9880', '1')).roomInstance?.roomId).toBe(7) + expect((await v2('/matchmake/v2/room/2', '9880', 2)).RoomInstance?.RoomId).toBe(2) + }) + test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => { const matchmake = async (sub: string) => (await ( @@ -3388,8 +3481,9 @@ describe('auth-gated endpoints', () => { // 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 }>() + 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, diff --git a/packages/domain/src/room-invites-db.ts b/packages/domain/src/room-invites-db.ts index bf2c3f3..ae67544 100644 --- a/packages/domain/src/room-invites-db.ts +++ b/packages/domain/src/room-invites-db.ts @@ -127,6 +127,28 @@ export async function getLatestRoomInviteBetween( } } +/** + * Whether ANY live invite stands to `toPlayerId` for the room with this id, from anyone. + * + * This is the key that opens an unpublished room to a room MATCHMAKE + * (`POST /matchmake[/v2]/room/:roomId` on `match`): a room that isn't public admits its + * creator, its role holders, and whoever has been invited into it — and "invited" is a row + * here whose `room_id` is that room. Any row is enough and none is consumed: the caller may + * be retrying a join that failed downstream, and the row expires with the sweep anyway. + * A miss is, as everywhere in this module, "never invited" and "already swept" alike. + */ +export async function hasRoomInviteTo( + db: D1Database, + toPlayerId: number, + roomId: number +): Promise { + const row = await db + .prepare(`SELECT 1 AS hit FROM room_invite WHERE to_player_id = ?1 AND room_id = ?2 LIMIT 1`) + .bind(toPlayerId, roomId) + .first<{ hit: number }>() + return row !== null +} + /** * Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the * client reads it back. `roomId` is null when the caller's room instance didn't resolve.