[api] chat msg report

This commit is contained in:
Devin Zuczek
2026-09-19 11:21:07 -04:00
parent 69dda96739
commit 5e2fad31df
6 changed files with 304 additions and 14 deletions
+21
View File
@@ -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
+28 -13
View File
@@ -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<Re
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id, invention_id, custom_avatar_item_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
event_id, invention_id, custom_avatar_item_id, chat_message_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
RETURNING *`
)
.bind(
@@ -173,7 +187,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
new Date().toISOString(),
input.eventId ?? null,
input.inventionId ?? null,
input.customAvatarItemId ?? null
input.customAvatarItemId ?? null,
input.chatMessageId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
+118
View File
@@ -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<string, KickReportCategory> = {
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<App>({ 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<string, unknown>)
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
+110
View File
@@ -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<string, string>) =>
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<Record<string, unknown>>()
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',