From 5e2fad31dfb9b82f1f27360cc0940a69064e57b8 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 19 Sep 2026 11:21:07 -0400 Subject: [PATCH] [api] chat msg report --- .../migrations/0025_report_chat_message.sql | 24 ++++ apps/api/src/openapi.ts | 21 ++++ apps/api/src/reports-db.ts | 41 ++++-- apps/api/src/routes/moderation.ts | 118 ++++++++++++++++++ apps/api/src/test/integration/api.test.ts | 110 ++++++++++++++++ apps/www/src/client/Moderation.tsx | 4 +- 6 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 apps/api/migrations/0025_report_chat_message.sql diff --git a/apps/api/migrations/0025_report_chat_message.sql b/apps/api/migrations/0025_report_chat_message.sql new file mode 100644 index 0000000..21305d6 --- /dev/null +++ b/apps/api/migrations/0025_report_chat_message.sql @@ -0,0 +1,24 @@ +-- Reporting a CHAT MESSAGE (`POST /api/chatreport/createChatReport`) reuses the report +-- table, as the event, invention and custom-avatar-item reports before it do: same fields, +-- same moderation life — a moderator acting on one sets `banned` on the row exactly as they +-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- `chat_message_id` names the reported message; NULL on every other kind of report. It +-- joins `event_id`, `invention_id` and `custom_avatar_item_id`, and all four are mutually +-- exclusive: a row names one of those things or none of them (an ordinary player report), +-- which is what tells the kinds apart. +-- +-- The message id is the WHOLE reference. The client also posts the `ChatThreadId`, which is +-- not stored: `message.chat_message_id` is unique across every thread (see the `chat` +-- worker's 0001_message.sql), so the thread is one lookup away and a stored copy could only +-- ever disagree with it. +-- +-- The row's `reported_player_id` is the message's SENDER (`message.sender_player_id`), read +-- from the message rather than sent by the client — the body names no player at all, and +-- the column is NOT NULL. No `room_id`: a chat thread is not in a room. +-- +-- NOT indexed, for the reason 0016 and 0017 give: it is written on every report of this kind +-- and read by nothing. Every moderation read goes by player (`idx_report_reported`) or by +-- the ban flag. Add an index with the query that needs it. + +ALTER TABLE report ADD COLUMN chat_message_id INTEGER; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index a85cacf..292a0a4 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1432,6 +1432,27 @@ export const CreateReportRequest = z.object({ .describe('Instance type name, e.g. `Public`. Stored verbatim'), }) +/** + * `POST /api/chatreport/createChatReport` form body — a report against one chat message. + * Form-encoded, so everything is a string; only `ChatMessageId` is required. Neither the + * reporter (the bearer token) nor the reported player (the message's sender) is in the body. + */ +export const CreateChatReportRequest = z.object({ + ChatMessageId: z.string().describe('Id of the message being reported, e.g. `72`'), + ChatThreadId: z + .string() + .optional() + .describe('The thread the message is in. Accepted and unused — the message names its own'), + ReportCategory: z + .string() + .optional() + .describe( + 'The reason picked in the report UI, sent as a NAME (`Discriminatory`) where the ' + + 'player report sends a number. Mapped onto the numeric category the table stores' + ), + ReportDescription: z.string().optional().describe('The free-text description the reporter typed'), +}) + /** * `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything * is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The diff --git a/apps/api/src/reports-db.ts b/apps/api/src/reports-db.ts index 087abc6..c992203 100644 --- a/apps/api/src/reports-db.ts +++ b/apps/api/src/reports-db.ts @@ -8,9 +8,9 @@ * * The `api` worker owns this schema/migration (migrations/0004_report.sql, * 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql, - * 0017_report_custom_avatar_item.sql and 0020_report_ban_audit.sql, applied under its own - * `migrations_table` so it doesn't clash with the other workers' migrations that share the - * database). + * 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and + * 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash + * with the other workers' migrations that share the database). * * The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` — * are not called by any endpoint on this worker. They back the staff panel served by @@ -22,9 +22,11 @@ * table of its own: same fields, same moderation life. Such a row carries `event_id`, * `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's * CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and - * `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive; - * a row with none of them is an ordinary player report. They are three columns rather than - * one polymorphic id because the keys differ in TYPE: two numbers and a guid. + * `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind: + * `chat_message_id`, with the message's SENDER as the reported player — see + * `POST /api/chatreport/createChatReport`. The four id columns are mutually exclusive; a row + * with none of them is an ordinary player report. They are separate columns rather than one + * polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key. * * A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned` * on that same row (see `banFromReport`), so the ban carries the evidence for it. It is @@ -40,10 +42,10 @@ /** * Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql + * 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql + - * 0020_report_ban_audit.sql). + * 0020_report_ban_audit.sql + 0025_report_chat_message.sql). * - * None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written - * on every report of its kind and read by nothing — no query here filters on any of them, + * None of `event_id`, `invention_id`, `custom_avatar_item_id` or `chat_message_id` is + * indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them, * and the reads that do exist go by player or by the ban flag. 0011's partial index over * `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that * needs it. @@ -66,7 +68,8 @@ export const SCHEMA_DDL: string[] = [ invention_id INTEGER, custom_avatar_item_id TEXT, banned_by_player_id INTEGER, - banned_at TEXT + banned_at TEXT, + chat_message_id INTEGER )`, `CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`, `CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`, @@ -125,6 +128,15 @@ export interface ReportRow { * 0020_report_ban_audit.sql carry NULL and fall back to `created_at`. */ banned_at: string | null + /** + * The chat message this report is against, or NULL for any other kind — mutually + * exclusive with `event_id`, `invention_id` and `custom_avatar_item_id`. See + * `POST /api/chatreport/createChatReport`: `reported_player_id` is the message's SENDER, + * read from the `message` table, because the body names no player. The thread the client + * also posts is not kept — a message id is unique across threads, so it is the whole + * reference. + */ + chat_message_id: number | null } /** @@ -148,6 +160,8 @@ export interface NewReport { inventionId?: number | null /** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */ customAvatarItemId?: string | null + /** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */ + chatMessageId?: number | null } /** Record a submitted report, returning the stored row (with its assigned id). */ @@ -157,8 +171,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise() // RETURNING always yields the inserted row; the non-null assert keeps the caller diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index a3674d7..2b2695c 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -15,6 +15,10 @@ import { } from '@repo/domain' import { logger } from '@repo/hono-helpers' +// The message being reported, and whether the reporter can see it — `chat` owns both tables +// on the shared database, so its reads are used rather than restated here. +import { getMessage } from '../../../chat/src/message-db' +import { isThreadMember } from '../../../chat/src/thread-db' // The notification-type ids the hub carries (owned by the `notify` worker). Imported as a // value — the enum has no runtime dependencies. import { KickReportCategory } from '../../../notify/src/notification-payloads' @@ -23,6 +27,7 @@ import { authedId, authedRoles, unauthorized } from '../http' import { AUTHED, BareBoolean, + CreateChatReportRequest, CreateReportRequest, CreateWarningRequest, DeviceIdRequest, @@ -91,6 +96,39 @@ const asFloat = (v: string | undefined): number | null => { return Number.isNaN(n) ? null : n } +/** + * Chat-report category names that are not a `KickReportCategory` member's own name. Only + * `Discriminatory` has been seen on the wire; add the others as the client is seen to send + * them, rather than guessing at an enum nobody here has a copy of. + */ +const CHAT_REPORT_CATEGORY_ALIASES: Record = { + discriminatory: KickReportCategory.CoCDiscrimination, +} + +/** + * The numeric category a chat report is filed under. The chat report sends its category as + * an enum NAME (`ReportCategory=Discriminatory`) where every other report sends a number, + * and `report.report_category` is numeric — it is what the staff panel labels rows from and + * what a ban's block screen names as the reason — so the name is mapped onto the same + * `KickReportCategory` codes the other reports carry. A number is taken as it comes. + * + * Null for a name that maps to nothing, which the caller files as `Unknown` with the name + * kept in the details: losing which box the reporter ticked would be worse than an odd-looking + * description. + */ +function chatReportCategory(raw: string | undefined): number | null { + if (raw === undefined) return KickReportCategory.Unknown + const name = raw.trim() + if (/^-?\d+$/.test(name)) return Number.parseInt(name, 10) + const key = name.toLowerCase() + const alias = CHAT_REPORT_CATEGORY_ALIASES[key] + if (alias !== undefined) return alias + for (const [member, value] of Object.entries(KickReportCategory)) { + if (typeof value === 'number' && member.toLowerCase() === key) return value + } + return null +} + /** * The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the * category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing, @@ -488,6 +526,86 @@ export const moderationRoutes = new Hono({ strict: false }) } ) + // Report one chat message. Stored in the `report` table every other report uses — same + // fields, same moderation life — with `chat_message_id` set. See + // migrations/0025_report_chat_message.sql. + .post( + '/api/chatreport/createChatReport', + describeRoute({ + tags: ['Moderation'], + summary: 'Report a chat message', + description: + 'Files a report against one chat message. Stored as a row in the same `report` table ' + + 'a player report goes to (`POST /api/PlayerReporting/v3/create`), beside the event, ' + + 'invention and custom-avatar-item reports — the same submission with the same ' + + 'moderation life, and a moderator converts any of them into a ban the same way. What ' + + 'marks it as a chat report is `chat_message_id`; the row’s `reported_player_id` is ' + + 'the message’s SENDER, read from the `message` table, since the body names no ' + + 'player. `ChatThreadId` is accepted and unused: a message id is unique across ' + + 'threads, so the message names its own thread and nothing stores a second copy.\n\n' + + 'The reporter is the caller (from the bearer token) and must be a MEMBER of the ' + + 'thread the message is in — the same gate reading the message sits behind. A ' + + 'message the caller cannot see answers exactly as one that does not exist, so the ' + + 'route cannot be used to probe message ids or to file reports citing conversations ' + + 'the reporter was never in. A system notice (a join/leave line) has no sender to ' + + 'report and is refused.\n\n' + + '`ReportCategory` arrives as an enum NAME (`Discriminatory`), unlike the numeric ' + + 'one a player report sends, and is mapped onto the numeric category the table ' + + 'stores (`Discriminatory` → 102). A number is stored as sent; a name that maps to ' + + 'nothing is filed as 0 with the name kept at the front of the details. ' + + '`ReportDescription` is the details. Nothing dedupes the rows.\n\n' + + 'Answers the `{ success, error }` envelope the other reports use, `error` an empty ' + + 'string rather than null, on the rejected branches too.', + security: AUTHED, + requestBody: form(CreateChatReportRequest, 'The report'), + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'No usable `ChatMessageId`, or a system message'), + 401: UNAUTHORIZED_RESPONSE, + 404: json(SuccessErrorEnvelope, 'No such message, or not one the caller can see'), + }, + }), + async (c) => { + const reporterId = await authedId(c) + if (reporterId === null) return unauthorized(c) + + const body = await c.req.parseBody().catch(() => ({}) as Record) + const chatMessageId = asInt(formField(body, c, 'ChatMessageId')) + if (chatMessageId === null) { + return c.json({ success: false, error: 'ChatMessageId is required' }, 400) + } + + // The message supplies the reported player, and its OWN thread is the one the + // membership check runs against — not the body's `ChatThreadId`, which would let a + // caller vouch for a message by naming a thread they happen to be in. + const message = await getMessage(c.env.DB, chatMessageId) + if (message === null || !(await isThreadMember(c.env.DB, message.chatThreadId, reporterId))) { + return c.json({ success: false, error: 'No such message' }, 404) + } + // System notices are sent as a pseudo-player with a negative id (see `chat`'s + // SYSTEM_SENDER_ID); a report against one would be a report against nobody. + if (message.senderPlayerId <= 0) { + return c.json({ success: false, error: 'That message has no sender to report' }, 400) + } + + const rawCategory = formField(body, c, 'ReportCategory') + const category = chatReportCategory(rawCategory) + const description = formField(body, c, 'ReportDescription') ?? null + await createReport(c.env.DB, { + reporterPlayerId: reporterId, + reportedPlayerId: message.senderPlayerId, + reportCategory: category ?? KickReportCategory.Unknown, + details: + category === null + ? [`[${rawCategory?.trim()}]`, description].filter(Boolean).join(' ') + : description, + chatMessageId, + }) + + return c.json({ success: true, error: '' }) + } + ) + // 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 diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b8231a5..c9e7322 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -31,6 +31,13 @@ import { import '../../api.app' import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db' +import { SCHEMA_DDL as MESSAGE_SCHEMA_DDL } from '../../../../chat/src/message-db' +import { + createThread, + postMessage, + SYSTEM_SENDER_ID, + THREAD_SCHEMA_DDL, +} from '../../../../chat/src/thread-db' import { banEvasionMatch, resolveBan } from '../../bans-db' import { createCustomAvatarItem, @@ -192,6 +199,11 @@ beforeAll(async () => { // The message store (owned by the api worker) — the inbox reads it and every send // writes to it. for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // Chat messages and thread membership (owned by the chat worker) — a chat report reads + // the reported player off the message, and gates on the reporter being in its thread. + for (const stmt of MESSAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -3081,6 +3093,103 @@ describe('public endpoints', () => { expect(anon.status).toBe(401) }) + test('POST /api/chatreport/createChatReport files a report row against the message sender', async () => { + const url = `${ORIGIN}/api/chatreport/createChatReport` + const post = async (sub: string, fields: Record) => + exports.default.fetch(url, { + method: 'POST', + headers: await bearer(sub), + body: new URLSearchParams(fields), + }) + + // 5150 says something in a DM with 42; 42 reports it. The body names no player — the + // reported one is the message's sender. + const threadId = await createThread(env.DB, [42, 5150]) + const message = await postMessage(env.DB, { + chatThreadId: threadId, + senderPlayerId: 5150, + contents: '{"Type":0,"Version":1,"Data":"something awful"}', + }) + const id = String(message.chatMessageId) + + // The body exactly as the client posts it: the category is a NAME, not a number. + const res = await post('42', { + ChatThreadId: String(threadId), + ChatMessageId: id, + ReportCategory: 'Discriminatory', + ReportDescription: 'details', + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + + const row = await env.DB.prepare('SELECT * FROM report WHERE chat_message_id = ?1') + .bind(message.chatMessageId) + .first>() + expect(row).toMatchObject({ + reporter_player_id: 42, + reported_player_id: 5150, // the message's sender + report_category: 102, // `Discriminatory` → CoCDiscrimination + details: 'details', + chat_message_id: message.chatMessageId, + event_id: null, // the id columns are mutually exclusive + invention_id: null, + custom_avatar_item_id: null, + room_id: null, + banned: 0, // filed unbanned, like any report + }) + + // The thread in the body is not what vouches for the message: a wrong one changes + // nothing, and a category the server can't place is filed as 0 with its name kept. + const odd = await post('42', { + ChatThreadId: '999999', + ChatMessageId: id, + ReportCategory: 'SomethingNew', + ReportDescription: 'why', + }) + expect(odd.status).toBe(200) + const oddRow = await env.DB.prepare( + 'SELECT report_category, details FROM report WHERE chat_message_id = ?1 ORDER BY id DESC' + ) + .bind(message.chatMessageId) + .first() + expect(oddRow).toEqual({ report_category: 0, details: '[SomethingNew] why' }) + + // Someone outside the thread can't report a message they can't read — and is told + // exactly what they'd be told about a message that doesn't exist. + const outsider = await post('777', { ChatMessageId: id, ReportCategory: 'Discriminatory' }) + expect(outsider.status).toBe(404) + expect(await outsider.json()).toEqual({ success: false, error: 'No such message' }) + const unknown = await post('42', { ChatMessageId: '999999' }) + expect(unknown.status).toBe(404) + expect(await unknown.json()).toEqual({ success: false, error: 'No such message' }) + + // A system notice has no sender to file a report against. + const notice = await postMessage(env.DB, { + chatThreadId: threadId, + senderPlayerId: SYSTEM_SENDER_ID, + contents: '{"Type":1,"Version":1,"Data":"5150"}', + }) + const system = await post('42', { ChatMessageId: String(notice.chatMessageId) }) + expect(system.status).toBe(400) + + const noId = await post('42', { ReportDescription: 'x' }) + expect(noId.status).toBe(400) + expect(await noId.json()).toEqual({ success: false, error: 'ChatMessageId is required' }) + + // Auth-gated: the reporter comes from the token, so there's no filing one signed out. + const anon = await exports.default.fetch(url, { + method: 'POST', + body: new URLSearchParams({ ChatMessageId: id }), + }) + expect(anon.status).toBe(401) + + // Only the two accepted reports were filed. + const count = await env.DB.prepare( + 'SELECT COUNT(*) AS n FROM report WHERE chat_message_id IS NOT NULL' + ).first<{ n: number }>() + expect(count?.n).toBe(2) + }) + test('POST /api/inventions/v6/save 401s without a bearer token', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', @@ -8878,6 +8987,7 @@ describe('openapi', () => { 'POST /api/PlayerReporting/v3/voteToKick', 'POST /api/avatar/v1/lockeditems/bulk', 'POST /api/avatar/v2/gifts/generate', + 'POST /api/chatreport/createChatReport', 'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', 'POST /api/customAvatarItems/v1', 'POST /api/customAvatarItems/v1/bulk', diff --git a/apps/www/src/client/Moderation.tsx b/apps/www/src/client/Moderation.tsx index 61dbfd0..2ae47e1 100644 --- a/apps/www/src/client/Moderation.tsx +++ b/apps/www/src/client/Moderation.tsx @@ -93,6 +93,7 @@ interface ReportRow { custom_avatar_item_id: string | null banned_by_player_id: number | null banned_at: string | null + chat_message_id: number | null } /** A moderator-issued warning, as the `warning` table stores it. */ @@ -193,7 +194,7 @@ const expiryLabel = (report: ReportRow): string => report.banned !== 1 ? '—' : report.ban_expires === null ? 'Permanent' : when(report.ban_expires) /** - * What kind of thing a report is against. The three id columns are mutually exclusive and + * What kind of thing a report is against. The four id columns are mutually exclusive and * a row with none of them is an ordinary player report (see reports-db) — which is the * only way to tell the kinds apart, so it is worth a column of its own. */ @@ -201,6 +202,7 @@ function reportKind(report: ReportRow): string { if (report.event_id !== null) return 'Event' if (report.invention_id !== null) return 'Invention' if (report.custom_avatar_item_id !== null) return 'Avatar item' + if (report.chat_message_id !== null) return 'Chat message' return 'Player' }