From d6a0e3e6a663ec91617711dd7ab6ce17ad5fe426 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 17:49:53 -0400 Subject: [PATCH] add a few admin endpoints for matching into instances --- apps/match/src/match.app.ts | 150 +++++++++++++++++++- apps/match/src/test/integration/api.test.ts | 137 ++++++++++++++++++ 2 files changed, 283 insertions(+), 4 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 33b0471..069d326 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -29,6 +29,7 @@ import { RoomInstanceType, setPresence, setRoomInstanceInProgress, + setRoomInstancePrivate, subRoomDataBlob, } from '@repo/domain' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' @@ -1013,6 +1014,93 @@ const app = new Hono() } ) + // Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) — + // the action behind the owner's instance listing (`GET /room/{roomId}/instances`), + // where they pick a session of their room and drop into it. Unlike every other + // matchmake this targets a fixed instance: nothing is reused, nothing is created, + // and a full or in-progress instance is still entered (moderating a full instance + // is the point). OWNER-ONLY, gated with the same creator-or-co-owner check as the + // listing — the Photon room id is the join coordinate, so an open version of this + // would let anyone warp into any private session by guessing an id. Registered + // before the `/matchmake/room/…` routes so `instance` isn't read as a room name. + .post( + '/matchmake/instance/:instanceId{[0-9]+}', + describeRoute({ + tags: ['Navigation'], + summary: 'Join a specific instance (owner only)', + description: [ + 'Places the caller into one specific live instance of their own room, picked by id', + 'from the owner’s instance listing. Gated to the room’s creator or a co-owner.', + 'Unlike the other matchmakes this never reuses or creates an instance, and enters', + 'even a full or in-progress one. Returns errorCode 20 with a null instance when the', + 'instance or its room is gone, or the caller doesn’t manage that room; errorCode 55', + 'when banned.', + ].join(' '), + security: AUTHED, + parameters: [ + { + name: 'instanceId', + in: 'path', + required: true, + description: 'Room instance id (digits only)', + schema: { type: 'string', pattern: '^[0-9]+$' }, + }, + ], + responses: { + 200: json( + MatchmakeResponse, + 'The instance (or a null instance with errorCode 20 / 55 when it can’t be joined)' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const instanceId = Number.parseInt(c.req.param('instanceId'), 10) + const stored = await getRoomInstance(c.env.DB, instanceId) + // One opaque refusal for "no such instance", "no such room" and "not yours": + // a distinct code for the last would confirm which instance ids are live. + if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + const room = await getRoomById(c.env.DB, stored.roomId) + if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + if (!canManageRoom(room, id)) { + logger.info('instance matchmake refused: not the room’s owner', { + roomInstanceId: instanceId, + roomId: stored.roomId, + accountId: id, + }) + return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + } + + // Like the follow-a-friend path, this hands out a Photon room id without going + // through resolveRoomInstance, so the room's bans are checked here too. An owner + // can't ban themselves out of their own room in practice, but a co-owner can be + // banned, and a ban must beat every route that yields join coordinates. + if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) { + logger.info('instance matchmake refused: player banned from room', { + roomId: stored.roomId, + id, + }) + return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null }) + } + + // Rebuild the wire instance from the room (fresh scene + published save) keyed to + // this instance's own id and Photon room, so the owner lands in exactly the + // session they picked rather than a new one alongside it. + const instance = roomInstanceFromRoom( + room, + stored.isPrivate, + stored.roomInstanceId, + stored.photonRoomId, + stored.subRoomId + ) + await enterRoom(c, id, instance) + return c.json({ errorCode: 0, roomInstance: instance }) + } + ) + // Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}` // — the client uses this to enter a room's other scenes). The subroom decides the // scene the client loads and which instances are joinable, so it must be carried @@ -1221,16 +1309,20 @@ const app = new Hono() (c) => c.body(null, 200) ) - // The room owner flips the instance's in-progress flag once the session starts - // (e.g. a game round begins). Body is a form post: `inProgress=True|False`. + // The instance's in-progress flag, flipped when a session starts (e.g. a game round + // begins). Deliberately NOT owner-gated, unlike the other room-instance mutations: + // this is set by whoever in the room starts the game, not by the room's owner — a + // gate here would break game starts for everyone else. Body is a form post: + // `inProgress=True|False`. .put( '/roominstance/:id/inprogress', describeRoute({ tags: ['Room instance'], summary: 'Set instance in-progress flag', description: [ - 'The room owner flips the instance’s in-progress flag when a session starts (e.g. a', - 'round begins). Body is `inProgress=True|False`.', + 'Flips the instance’s in-progress flag when a session starts (e.g. a round begins).', + 'Set by whoever in the room starts the game — any authenticated player, not just the', + 'room’s owner. Body is `inProgress=True|False`.', ].join(' '), security: AUTHED, requestBody: form(InProgressRequest, 'The inProgress flag'), @@ -1258,6 +1350,56 @@ const app = new Hono() } ) + // Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner + // makes the session they're running private, so public matchmaking stops feeding new + // players into it (getJoinableInstance only reuses non-private instances). Everyone + // already inside stays put; this shuts the door rather than clearing the room. + // OWNER-ONLY (same creator-or-co-owner gate as the instance listing): whether a + // session is open is the room owner's call, not a passer-by's. Generic empty ack. + .post( + '/roominstance/:id/markprivate', + describeRoute({ + tags: ['Room instance'], + summary: 'Mark an instance private (owner only)', + description: [ + 'Marks a live instance private, so public matchmaking stops placing new players', + 'into it. Players already inside are unaffected. Auth-gated and gated to the', + 'instance’s room’s creator or a co-owner (403 otherwise). Empty ack.', + ].join(' '), + security: AUTHED, + parameters: [ + { + name: 'id', + in: 'path', + required: true, + description: 'Room instance id', + schema: { type: 'string' }, + }, + ], + responses: { + 200: EMPTY_OK, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the room’s creator or a co-owner (empty body)' }, + 404: { description: 'Non-numeric id or no such instance (empty body)' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const instanceId = Number.parseInt(c.req.param('id'), 10) + if (Number.isNaN(instanceId)) return c.body(null, 404) + + const stored = await getRoomInstance(c.env.DB, instanceId) + if (!stored) return c.body(null, 404) + const room = await getRoomById(c.env.DB, stored.roomId) + if (!room || !canManageRoom(room, id)) return c.body(null, 403) + + await setRoomInstancePrivate(c.env.DB, instanceId, true) + return c.body(null, 200) + } + ) + // The room's live instances — the owner's view of active sessions of their room. // Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's // creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 56cc61e..ee875f7 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1018,6 +1018,141 @@ describe('auth-gated endpoints', () => { expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length) }) + test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => { + // A player with no role on room 3 spins up an instance of it, which the room's + // owner should then be able to drop into by id. + const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, { + method: 'POST', + headers: await bearer('43'), + }) + const spawned = (await spawn.json()) as { + roomInstance: { roomInstanceId: number; photonRoomId: string } + } + const instanceId = spawned.roomInstance.roomInstanceId + + // No token → 401. + expect( + (await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { method: 'POST' })) + .status + ).toBe(401) + + // Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal, + // so instance ids can't be probed for live private sessions. + const stranger = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { + method: 'POST', + headers: await bearer('999'), + }) + expect(stranger.status).toBe(200) + expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null }) + + // Unknown instance → same refusal. + const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, { + method: 'POST', + headers: await bearer('42'), + }) + expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null }) + + // Park the owner somewhere else first, so this is a real transition. + await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { + method: 'POST', + headers: await bearer('42'), + }) + + // The owner lands in that exact instance — same id AND same Photon room as the + // player already in it, which is what makes it the same session. + const joined = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { + method: 'POST', + headers: await bearer('42'), + }) + expect(joined.status).toBe(200) + const body = (await joined.json()) as { + errorCode: number + roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number } + } + expect(body.errorCode).toBe(0) + expect(body.roomInstance.roomInstanceId).toBe(instanceId) + expect(body.roomInstance.photonRoomId).toBe(spawned.roomInstance.photonRoomId) + expect(body.roomInstance.roomId).toBe(3) + + // It's now the owner's presence, and the listing shows both of them in there. + const listed = (await ( + await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') }) + ).json()) as Array<{ roomInstanceId: number; playerIds: number[] }> + const target = listed.find((i) => i.roomInstanceId === instanceId) + expect(target?.playerIds).toEqual([42, 43]) + }) + + test('POST /roominstance/:id/markprivate closes the instance, owner-only', async () => { + // Room 77 subroom 34 — its own instance, so marking it private can't affect the + // instances the other tests matchmake into. + const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/77/34`, { + method: 'POST', + headers: await bearer('42'), + }) + const { roomInstance } = (await spawn.json()) as { roomInstance: { roomInstanceId: number } } + const instanceId = roomInstance.roomInstanceId + + // No token → 401. + expect( + ( + await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, { + method: 'POST', + }) + ).status + ).toBe(401) + + // Unknown instance → 404. + expect( + ( + await exports.default.fetch(`${ORIGIN}/roominstance/9999999/markprivate`, { + method: 'POST', + headers: await bearer('42'), + }) + ).status + ).toBe(404) + + // Room 77 has no creator and no roles, so nobody manages it → 403 even for 42. + expect( + ( + await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, { + method: 'POST', + headers: await bearer('42'), + }) + ).status + ).toBe(403) + + // Room 3 is account 42's, so its instances are theirs to close. + const owned = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, { + method: 'POST', + headers: await bearer('43'), + }) + const ownedId = ((await owned.json()) as { roomInstance: { roomInstanceId: number } }) + .roomInstance.roomInstanceId + const marked = await exports.default.fetch(`${ORIGIN}/roominstance/${ownedId}/markprivate`, { + method: 'POST', + headers: await bearer('42'), + }) + expect(marked.status).toBe(200) + expect(await marked.text()).toBe('') + + // Closed to strangers: a public matchmake into room 3 no longer reuses it, so a + // new player lands in a different instance. + const after = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, { + method: 'POST', + headers: await bearer('999'), + }) + const afterId = ((await after.json()) as { roomInstance: { roomInstanceId: number } }) + .roomInstance.roomInstanceId + expect(afterId).not.toBe(ownedId) + + // The player already inside is untouched — this shuts the door, it doesn't clear + // the room. + const listed = (await ( + await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') }) + ).json()) as Array<{ roomInstanceId: number; playerIds: number[] }> + expect(listed.find((i) => i.roomInstanceId === ownedId)?.playerIds).toContain(43) + }) + test('POST /invite pushes a game-invite MessageReceived to the target', async () => { // The notify DO is stubbed to record every notifyPlayer call (see vitest.config). type Sent = { @@ -1420,6 +1555,7 @@ describe('auth-gated endpoints', () => { 'POST /invite', 'POST /matchmake/club/{clubId}', 'POST /matchmake/dorm', + 'POST /matchmake/instance/{instanceId}', 'POST /matchmake/player/{playerId}', 'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}/{subRoomId}', @@ -1428,6 +1564,7 @@ describe('auth-gated endpoints', () => { 'POST /player/login', 'POST /player/logout', 'POST /player/notifydisconnect', + 'POST /roominstance/{id}/markprivate', 'POST /roominstance/{id}/reportjoinresult', 'PUT /player/gameserverregionpings', 'PUT /player/photonregionpings',