From fef6754aad436029a1e29cf8b948345bbbc6f38d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 2 Sep 2026 14:54:14 -0400 Subject: [PATCH] [api] add non-working v3/votekick --- CLAUDE.md | 7 + apps/api/src/openapi.ts | 16 ++ apps/api/src/routes/moderation.ts | 173 ++++++++++++++++++++++ apps/api/src/test/integration/api.test.ts | 158 ++++++++++++++++++++ apps/api/vitest.config.ts | 14 +- 5 files changed, 364 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a1798f..f7407fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,13 @@ inconsistency here without checking the client first. - The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success. That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use; the two live side by side in the same worker and must not be unified. +- A Message's `Data` (every `MessageReceived` frame) is a STRING on the wire, so a payload + with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested + object. An object there does not degrade: the client's decoder rejects it outright + (`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just + the field. Bites the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`, + whose `Data` carries `{ PlayerId, Response, GameSessionId }` — `PlayerId` a string inside + it, as the reference relays it) and, the same way, a chat message's `Contents`. - Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is 0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second place and the top of a board must be 0. Its own slice says the same: it asks for the first diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 5ed1b16..70c9d7c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1304,6 +1304,22 @@ export const CreateWarningRequest = z.object({ ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'), }) +/** + * `POST /api/PlayerReporting/v3/voteToKick` form body — a player calling a vote on + * another. Everything is a string on the wire (it's form-encoded). `Reason` is one of the + * labels `GET /api/PlayerReporting/v1/voteToKickReasons` serves; the voter is NOT in the + * body — it's the bearer token's subject. + */ +export const VoteToKickRequest = z.object({ + PlayerId: z.string().describe('Account id of the player being voted on'), + Response: z.string().describe('The caller’s own vote, e.g. `True`'), + Reason: z + .string() + .optional() + .describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'), + GameSessionId: z.string().describe('The room instance both players are standing in'), +}) + /** * `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are * ejecting from one live instance. JSON, not a form, unlike its neighbours in this diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 4db4673..64fe006 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -4,9 +4,11 @@ import { describeRoute } from 'hono-openapi' import { canModerateRoom, deletePresence, + getPlayerIdsInInstance, getPresences, getRoomById, getStoredRoomInstance, + MessageType, refreshInstanceFullness, } from '@repo/domain' import { logger } from '@repo/hono-helpers' @@ -31,6 +33,7 @@ import { SuccessErrorEnvelope, UNAUTHORIZED_RESPONSE, VoteToKickReason, + VoteToKickRequest, } from '../openapi' import { createReport } from '../reports-db' import { createWarning } from '../warnings-db' @@ -157,6 +160,69 @@ async function pushInstantKick( } } +/** + * What a vote-to-kick Message's `Data` says, BEFORE it is serialized. It reaches the + * client as an escaped JSON string, never as a nested object — a Message's `Data` is a + * string on the wire like every other Message's, and the client's decoder rejects an + * object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole + * notification rather than dropping the field. Serialize it with {@link voteToKickData}. + * + * `PlayerId` is the account id as a STRING — the reference passes the posted form field + * straight through, and this mirrors it verbatim. + * + * `Response` is the empty string even though the caller posted their own vote: the frame + * is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response` + * is theirs alone and is not relayed. + */ +interface VoteToKickData { + PlayerId: string + Response: string + GameSessionId: number +} + +/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */ +const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data) + +/** + * The Message a vote-to-kick frame carries — the same four fields as every other Message + * this server sends (see the `social` routes' `Message`), `Data` string included. A type + * alias rather than an interface: the hub's send takes an index-signature record, which + * only aliases satisfy implicitly. + */ +type VoteToKickMessage = { + FromPlayerId: number + ToPlayerId: number + Type: number + Data: string +} + +/** + * Put a vote-to-kick to one player — a `MessageReceived` frame carrying a Message of type + * 5 (`VoteToKick`), the frame their client raises the vote prompt from. Resolves false when + * the hub could not be reached, which the caller reports honestly: nothing stores a vote, + * so the notification is the whole delivery. + * + * EPHEMERAL, unlike the messages the social routes send. A vote belongs to the moment it + * was called: queued for an offline player, it would raise a prompt on their next connect + * about a session that ended hours ago, and there would be nothing left to vote on. + */ +async function pushVoteToKick(c: Context, message: VoteToKickMessage): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral( + message.ToPlayerId, + NotificationType.MessageReceived, + message + ) + return true + } catch (err) { + logger.error('failed to push VoteToKick MessageReceived notification', { + toPlayerId: message.ToPlayerId, + error: err instanceof Error ? err.message : String(err), + }) + return false + } +} + // ---- Player reporting ------------------------------------------------------ export const moderationRoutes = new Hono({ strict: false }) // Whether the caller is currently blocked (banned / timed out / host-kicked). Bans @@ -332,6 +398,113 @@ export const moderationRoutes = new Hono({ strict: false }) } ) + // A player calling a vote to kick another. Ungated by role — anyone may start one — + // but both players have to be standing in the session the vote is called in, which is + // what stops a client putting a vote to a room it isn't in, about someone who isn't + // there. Nothing tallies the votes yet: this relays the prompt and no more. + .post( + '/api/PlayerReporting/v3/voteToKick', + describeRoute({ + tags: ['Moderation'], + summary: 'Call a vote to kick a player', + description: + 'Puts a vote-to-kick to the room instance. Open to any player — no role is ' + + 'required — but BOTH the caller and `PlayerId` must have a live `presence` row in ' + + 'the instance `GameSessionId` names, or the call is refused with a 403. That is ' + + 'the whole gate: without it a client could raise a vote in a session it is not ' + + 'in, or against a player who is not there.\n\n' + + 'Everyone else in that instance — the player being voted on included, since a ' + + 'vote is called in front of them — gets a `MessageReceived` frame carrying a ' + + 'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' + + 'already, and their own `Response` is what they posted.\n\n' + + '`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' + + 'object. A Message’s `Data` is a string on the wire, and an object there fails the ' + + "client’s decoder outright (`expected:'String Begin Token', actual:'{'`), " + + 'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' + + 'is the account id as a STRING, as the reference relays it, and `Response` is ' + + 'empty — the frame is the question, not an answer.\n\n' + + 'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' + + 'offline player gets nothing rather than a prompt about a dead session on their ' + + 'next connect.\n\n' + + 'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' + + 'unused. Answers the same lowercase `{ success, error }` envelope as the report ' + + 'write; a hub failure for any recipient is reported honestly as a 500, since ' + + 'with nothing behind it the frame is the whole delivery.', + security: AUTHED, + requestBody: form(VoteToKickRequest, 'The vote'), + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(SuccessErrorEnvelope, 'Either player is not in that game session'), + 500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'), + }, + }), + async (c) => { + const voterId = await authedId(c) + if (voterId === null) return unauthorized(c) + + const body = await c.req.parseBody().catch(() => ({}) as Record) + // Kept as posted for the frame — `Data.PlayerId` goes out as the string the + // reference relays — but parsed here to check it against presence. + const playerIdField = formField(body, c, 'PlayerId') + const playerId = asInt(playerIdField) + if (playerIdField === undefined || playerId === null) { + return c.json({ success: false, error: 'PlayerId is required' }, 400) + } + const gameSessionId = asInt(formField(body, c, 'GameSessionId')) + if (gameSessionId === null) { + return c.json({ success: false, error: 'GameSessionId is required' }, 400) + } + + // One read for both players. A vote may only be called by someone standing in the + // session, about someone standing in the same one — the session is read from live + // presence, never from the body, so neither side can be asserted by the client. + const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, [ + voterId, + playerId, + ]) + const isHere = (id: number) => + presences.get(id)?.roomInstance?.roomInstanceId === gameSessionId + if (!isHere(voterId)) { + return c.json({ success: false, error: 'You are not in that game session!' }, 403) + } + if (!isHere(playerId)) { + return c.json({ success: false, error: 'That player is not in that game session!' }, 403) + } + + // The room votes, so the audience is everyone standing there — the player being + // voted on included; a vote is called in front of them. The caller is dropped: + // their vote is the one they just posted. + const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter( + (id) => id !== voterId + ) + + // Every recipient is attempted even if an earlier one fails, so the reachable + // players still get the vote. + const results = await Promise.all( + audience.map((toPlayerId) => + pushVoteToKick(c, { + FromPlayerId: voterId, + ToPlayerId: toPlayerId, + Type: MessageType.VoteToKick, + // An escaped JSON STRING, not a nested object — see VoteToKickData. + Data: voteToKickData({ + PlayerId: playerIdField, + Response: '', + GameSessionId: gameSessionId, + }), + }) + ) + ) + if (results.includes(false)) { + return c.json({ success: false, error: 'Failed to deliver vote' }, 500) + } + + return c.json({ success: true, error: '' }) + } + ) + // The kick a room's own staff hand out from the moderation menu: eject named players // from ONE live instance. Two gates, and both matter — the caller must be able to // moderate the room the instance belongs to, and each named player must actually be diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 184efb4..10bc18b 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4216,6 +4216,163 @@ describe('instant kick', () => { }) }) +describe('vote to kick', () => { + // Two live sessions, so a vote called in one can be checked against a player in the + // other. Nothing reads `room_instance` here — the gate is presence alone. + const SESSION = 1014079 + const OTHER_SESSION = 1014080 + + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + + const standIn = async (accountId: number, roomInstanceId: number) => + env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: { roomInstanceId, roomId: 4 }, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + platform: 0, + appVersion: GAME_VERSION, + expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS, + }) + ) + .run() + + // The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`. + const vote = async (fields: Record, sub = '42') => + exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(fields), + }) + + const frames = async () => + (await (await hub().fetch('http://do/all')).json()) as Array<{ + playerId?: number + ephemeral?: boolean + notificationType: number + data: Record + }> + + const FIELDS = { + PlayerId: '205', + Response: 'True', + Reason: 'Inactive in games (AFK)', + GameSessionId: String(SESSION), + } + + test('the vote goes to everyone in the session except the caller', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + // 42 calls the vote, 205 is voted on, 206 is a bystander; 207 stands elsewhere. + await standIn(42, SESSION) + await standIn(205, SESSION) + await standIn(206, SESSION) + await standIn(207, OTHER_SESSION) + + const res = await vote(FIELDS) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + + // One frame each for 205 and 206 — the player voted on gets it too (the vote is + // called in front of them), the caller does not, and 207 is in another session. + // `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the + // client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole + // notification with it. `PlayerId` inside it is a STRING, as the reference relays it, + // and `Response` is empty because the frame is the question, not an answer. + const message = { + ephemeral: true, + notificationType: 2, // NotificationType.MessageReceived + data: { + FromPlayerId: 42, + Type: MessageType.VoteToKick, + Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`, + }, + } + const sent = await frames() + expect(sent).toHaveLength(2) + expect(sent).toContainEqual({ + ...message, + playerId: 205, + data: { ...message.data, ToPlayerId: 205 }, + }) + expect(sent).toContainEqual({ + ...message, + playerId: 206, + data: { ...message.data, ToPlayerId: 206 }, + }) + }) + + test('both players have to be standing in the session', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + await standIn(42, OTHER_SESSION) + await standIn(205, SESSION) + + // The caller is somewhere else — a vote can't be called into a session you're not in. + const away = await vote(FIELDS) + expect(away.status).toBe(403) + expect(await away.json()).toEqual({ + success: false, + error: 'You are not in that game session!', + }) + + // And with the caller present, the player voted on has to be there too — offline, + // or standing elsewhere, both refuse. + await standIn(42, SESSION) + await standIn(205, OTHER_SESSION) + const elsewhere = await vote(FIELDS) + expect(elsewhere.status).toBe(403) + expect(await elsewhere.json()).toEqual({ + success: false, + error: 'That player is not in that game session!', + }) + expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(403) + + // Nothing was put to the room on any of those. + expect(await frames()).toEqual([]) + }) + + test('the body must name a player and a session, and the call needs a token', async () => { + await standIn(42, SESSION) + await standIn(205, SESSION) + + for (const [fields, error] of [ + [{ Response: 'True', GameSessionId: String(SESSION) }, 'PlayerId is required'], + [{ PlayerId: 'nope', GameSessionId: String(SESSION) }, 'PlayerId is required'], + [{ PlayerId: '205', Response: 'True' }, 'GameSessionId is required'], + [{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'], + ] as Array<[Record, string]>) { + const res = await vote(fields) + expect(res.status, error).toBe(400) + expect(await res.json()).toEqual({ success: false, error }) + } + + const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(FIELDS), + }) + expect(anon.status).toBe(401) + }) + + // A vote called with nobody else there is a no-op rather than an error: the caller and + // the player voted on are both here, so the gate passes, and there is simply no room + // to put it to. + test('a session holding only the two of them sends nothing', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + await standIn(42, SESSION) + await standIn(205, SESSION) + await env.DB.prepare('DELETE FROM presence WHERE account_id NOT IN (42, 205)').run() + + const res = await vote(FIELDS) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + // 205 is still in the session, so they still hear it — only the caller is dropped. + expect((await frames()).map((f) => f.playerId)).toEqual([205]) + }) +}) + describe('player reports', () => { const submit = async (fields: Record, headers?: Record) => exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, { @@ -7234,6 +7391,7 @@ describe('openapi', () => { 'POST /api/PlayerReporting/v1/moderationBlockDetails', 'POST /api/PlayerReporting/v1/referee', 'POST /api/PlayerReporting/v3/create', + 'POST /api/PlayerReporting/v3/voteToKick', 'POST /api/avatar/v1/lockeditems/bulk', 'POST /api/avatar/v2/gifts/generate', 'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 81a7cf9..def2e31 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -26,10 +26,12 @@ export default defineConfig({ // GET /all for the whole list (friend-graph changes notify both players), // DELETE to reset it between assertions. // - // notifyPlayersEphemeral lands in the same list, tagged `ephemeral` and - // carrying `playerIds` rather than `playerId` — the two sends differ in - // whether an offline recipient gets the frame later, which is a thing worth - // asserting (a cheer's effect is broadcast to a room this way). + // The ephemeral sends land in the same list, tagged `ephemeral`: + // notifyPlayerEphemeral carries `playerId` like the durable send, + // notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable + // and ephemeral differ in whether an offline recipient gets the frame + // later, which is a thing worth asserting (a cheer's effect is broadcast to + // a room this way, a vote-to-kick is put to each player in it). script: ` import { DurableObject } from 'cloudflare:workers' export class NotificationsHub extends DurableObject { @@ -38,6 +40,10 @@ export default defineConfig({ this.sent.push({ playerId, notificationType, data }) return { delivered: 0, queued: true } } + async notifyPlayerEphemeral(playerId, notificationType, data) { + this.sent.push({ playerId, ephemeral: true, notificationType, data }) + return { delivered: 0 } + } async notifyPlayersEphemeral(playerIds, notificationType, data) { this.sent.push({ playerIds, ephemeral: true, notificationType, data }) return { delivered: 0 }