From 8d1539de03840b61a499e9028f2a2461761d735a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 11:42:53 -0400 Subject: [PATCH] add player reports --- apps/api/migrations/0004_report.sql | 23 +++++ apps/api/src/openapi.ts | 33 +++++++ apps/api/src/reports-db.ts | 103 ++++++++++++++++++++++ apps/api/src/routes/moderation.ts | 90 +++++++++++++++++++ apps/api/src/test/integration/api.test.ts | 87 ++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 apps/api/migrations/0004_report.sql create mode 100644 apps/api/src/reports-db.ts diff --git a/apps/api/migrations/0004_report.sql b/apps/api/migrations/0004_report.sql new file mode 100644 index 0000000..bcc5a6c --- /dev/null +++ b/apps/api/migrations/0004_report.sql @@ -0,0 +1,23 @@ +-- Player-report storage. Like the relationship table (and unlike the JSON-blob +-- tables in this shared database), a report is genuinely columnar, so it gets a +-- normal relational table. Owned by the `api` worker; generated from +-- src/reports-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- One row per submitted report; nothing updates or dedupes them, so the table is +-- an append-only log of what players sent. `reporter_player_id` comes from the +-- caller's bearer token, everything else from the form body. + +CREATE TABLE IF NOT EXISTS report ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reporter_player_id INTEGER NOT NULL, + reported_player_id INTEGER NOT NULL, + report_category INTEGER NOT NULL DEFAULT 0, + details TEXT, + height_reporter REAL, + height_reported REAL, + room_id INTEGER, + room_instance_type TEXT, + created_at TEXT NOT NULL + ); +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); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 436219a..887bac2 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -411,6 +411,39 @@ export const ModerationBlockDetails = z.object({ TimeoutStartedAt: z.string().nullable(), }) +/** + * `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a + * string on the wire (it's form-encoded); only `PlayerIdReported` is required. The + * reporter is NOT in the body — it's taken from the bearer token. + */ +export const CreateReportRequest = z.object({ + PlayerIdReported: z.string().describe('Account id of the player being reported'), + ReportCategory: z + .string() + .optional() + .describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'), + Details: z.string().optional().describe('The free-text description the reporter typed'), + HeightReporter: z + .string() + .optional() + .describe('Reporter’s player height in metres at report time, e.g. `1.64`'), + HeightReported: z.string().optional().describe('Reported player’s height in metres'), + RoomId: z.string().optional().describe('Room the report was raised in, if any'), + RoomInstanceType: z + .string() + .optional() + .describe('Instance type name, e.g. `Public`. Stored verbatim'), +}) + +/** + * The `{ success, error }` envelope `POST /api/PlayerReporting/v3/create` answers with — + * `error` is an empty string on success, never null. + */ +export const ReportCreateResponse = z.object({ + success: z.boolean(), + error: z.string().describe('Empty string when the report was recorded'), +}) + /** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */ export const DeviceIdRequest = z.object({ oldDeviceId: z.string().optional().describe('The id the client thinks we hold'), diff --git a/apps/api/src/reports-db.ts b/apps/api/src/reports-db.ts new file mode 100644 index 0000000..2bff269 --- /dev/null +++ b/apps/api/src/reports-db.ts @@ -0,0 +1,103 @@ +/** + * Player-report storage on the shared `recflare` D1 database. + * + * Like the relationship table (and unlike the JSON-blob tables here — rooms / + * accounts / image / invention), a report is genuinely columnar, so it gets a + * normal relational table. Rows are append-only: nothing updates or dedupes a + * report, so the table is a log of exactly what players submitted. + * + * The `api` worker owns this schema/migration (migrations/0004_report.sql, + * applied under its own `migrations_table` so it doesn't clash with the other + * workers' migrations that share the database). + * + * Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails` + * still answers "not blocked" unconditionally; this is the record that a future + * moderation flow would read. + */ + +/** Schema DDL (mirror of migrations/0004_report.sql, sans seed rows). */ +export const SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS report ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reporter_player_id INTEGER NOT NULL, + reported_player_id INTEGER NOT NULL, + report_category INTEGER NOT NULL DEFAULT 0, + details TEXT, + height_reporter REAL, + height_reported REAL, + room_id INTEGER, + room_instance_type TEXT, + created_at TEXT NOT NULL + )`, + `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)`, +] + +/** A stored report row (snake_case columns, one row per submission). */ +export interface ReportRow { + id: number + reporter_player_id: number + reported_player_id: number + report_category: number + details: string | null + /** Player height in metres, as the client measured it at report time. */ + height_reporter: number | null + height_reported: number | null + room_id: number | null + /** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */ + room_instance_type: string | null + created_at: string +} + +/** + * A report as submitted — everything but the reporter (which comes from the bearer + * token) and the timestamp. Only the reported player is required; the client omits + * fields it has no value for (a report raised outside a room carries no `RoomId`), + * so the rest are optional and stored as NULL when absent. + */ +export interface NewReport { + reporterPlayerId: number + reportedPlayerId: number + reportCategory?: number + details?: string | null + heightReporter?: number | null + heightReported?: number | null + roomId?: number | null + roomInstanceType?: string | null +} + +/** Record a submitted report, returning the stored row (with its assigned id). */ +export async function createReport(db: D1Database, input: NewReport): Promise { + const row = await db + .prepare( + `INSERT INTO report ( + reporter_player_id, reported_player_id, report_category, details, + height_reporter, height_reported, room_id, room_instance_type, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + RETURNING *` + ) + .bind( + input.reporterPlayerId, + input.reportedPlayerId, + input.reportCategory ?? 0, + input.details ?? null, + input.heightReporter ?? null, + input.heightReported ?? null, + input.roomId ?? null, + input.roomInstanceType ?? null, + new Date().toISOString() + ) + .first() + // RETURNING always yields the inserted row; the non-null assert keeps the caller + // from having to handle an impossible null. + return row! +} + +/** Every report filed against a player, newest first. Backs a future moderation view. */ +export async function getReportsAgainst(db: D1Database, playerId: number): Promise { + const { results } = await db + .prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC') + .bind(playerId) + .all() + return results +} diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 1756c80..6c0feb8 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -1,17 +1,52 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' +import { authedId, unauthorized } from '../http' import { + AUTHED, BareBoolean, + CreateReportRequest, DeviceIdRequest, form, json, JsonArray, ModerationBlockDetails, + ReportCreateResponse, + UNAUTHORIZED_RESPONSE, } from '../openapi' +import { createReport } from '../reports-db' +import type { Context } from 'hono' import type { App } from '../context' +/** + * Read one field of the report submission. The client posts it form-encoded, but the + * same names also arrive as a query string on some builds, so both are accepted. + */ +function reportField( + body: Record, + c: Context, + name: string +): string | undefined { + const raw = body[name] + if (typeof raw === 'string' && raw !== '') return raw + return c.req.query(name) || undefined +} + +/** Parse a field as an integer, or null when absent / not a number. */ +const asInt = (v: string | undefined): number | null => { + if (v === undefined) return null + const n = Number.parseInt(v, 10) + return Number.isNaN(n) ? null : n +} + +/** Parse a field as a float (the reported heights), or null when absent / not a number. */ +const asFloat = (v: string | undefined): number | null => { + if (v === undefined) return null + const n = Number.parseFloat(v) + return Number.isNaN(n) ? null : n +} + // ---- Player reporting ------------------------------------------------------ export const moderationRoutes = new Hono({ strict: false }) // Whether the caller is currently blocked (banned / timed out / host-kicked). No @@ -69,6 +104,61 @@ export const moderationRoutes = new Hono({ strict: false }) (c) => c.json(false) ) + // The report the client actually submits. Auth-gated: the reporter is taken from + // the bearer token rather than the body, so a report can't be filed as someone else. + .post( + '/api/PlayerReporting/v3/create', + describeRoute({ + tags: ['Moderation'], + summary: 'Submit a player report', + description: + 'Records a player report in the `report` table — an append-only log; nothing ' + + 'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' + + '“not blocked” unconditionally.\n\n' + + 'The reporter is the caller (from the bearer token), NOT a body field. Only ' + + '`PlayerIdReported` is required; the client omits whatever it has no value for ' + + '(a report raised outside a room carries no `RoomId`), and those are stored as ' + + 'NULL. `ReportCategory` and `RoomInstanceType` are stored verbatim — neither ' + + 'enum is mapped here. A `RoomId` of 0 or below means “no room”.\n\n' + + 'Answers the real service’s `{ success, error }` envelope, where `error` is an ' + + 'empty string rather than null. The rejected branch uses the same envelope so ' + + 'the client only ever parses one shape.', + security: AUTHED, + requestBody: form(CreateReportRequest, 'The report'), + responses: { + 200: json(ReportCreateResponse, '`{ success: true, error: "" }`'), + 400: json(ReportCreateResponse, 'No `PlayerIdReported` in the request'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const reporterId = await authedId(c) + if (reporterId === null) return unauthorized(c) + + const body = await c.req.parseBody().catch(() => ({}) as Record) + const reportedPlayerId = asInt(reportField(body, c, 'PlayerIdReported')) + if (reportedPlayerId === null) { + return c.json({ success: false, error: 'PlayerIdReported is required' }, 400) + } + + // 0 / -1 are the client's "no room" values — store null rather than a bogus id. + const roomId = asInt(reportField(body, c, 'RoomId')) + + await createReport(c.env.DB, { + reporterPlayerId: reporterId, + reportedPlayerId, + reportCategory: asInt(reportField(body, c, 'ReportCategory')) ?? 0, + details: reportField(body, c, 'Details') ?? null, + heightReporter: asFloat(reportField(body, c, 'HeightReporter')), + heightReported: asFloat(reportField(body, c, 'HeightReported')), + roomId: roomId !== null && roomId > 0 ? roomId : null, + roomInstanceType: reportField(body, c, 'RoomInstanceType') ?? null, + }) + + return c.json({ success: true, error: '' }) + } + ) + // The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`, // `platform`), rotating from the id it thinks we hold to the current one. Carries no // bearer token and fires before account creation, so there is no caller to attribute diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index dd4bfe8..f571afa 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -14,6 +14,7 @@ import '../../api.app' import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db' +import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db' import type { Env } from '../../context' import type { SavedImage } from '../../images-db' @@ -81,6 +82,9 @@ beforeAll(async () => { // Inventions table (owned by the api worker) — invention save/mine use it. for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // Reports table (owned by the api worker) — player reports are recorded here. + for (const stmt of REPORTS_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 @@ -1099,6 +1103,88 @@ describe('auth-gated endpoints', () => { }) }) +describe('player reports', () => { + const submit = async (fields: Record, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body: new URLSearchParams(fields), + }) + + test('POST /api/PlayerReporting/v3/create records the report', async () => { + const res = await submit( + { + PlayerIdReported: '205', + ReportCategory: '100', + Details: 'ya know', + HeightReporter: '1.64', + HeightReported: '1.65', + RoomId: '58', + RoomInstanceType: 'Public', + }, + await bearer() + ) + expect(res.status).toBe(200) + // `error` is an empty string, not null — the real service's envelope. + expect(await res.json()).toEqual({ success: true, error: '' }) + + const [row] = await getReportsAgainst(env.DB, 205) + expect(row).toMatchObject({ + // The reporter is the token's subject, not a body field. + reporter_player_id: 42, + reported_player_id: 205, + report_category: 100, + details: 'ya know', + height_reporter: 1.64, + height_reported: 1.65, + room_id: 58, + room_instance_type: 'Public', + }) + expect(row?.created_at).toBeTruthy() + }) + + // Everything but the reported player is optional — a report raised outside a room + // carries no RoomId, and 0 means "no room" rather than room zero. + test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => { + const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer()) + expect(res.status).toBe(200) + + const [row] = await getReportsAgainst(env.DB, 206) + expect(row).toMatchObject({ + reporter_player_id: 42, + reported_player_id: 206, + report_category: 0, + details: null, + height_reporter: null, + height_reported: null, + room_id: null, + room_instance_type: null, + }) + }) + + // Append-only: a second report against the same player is a second row. + test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => { + await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer()) + await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer()) + const rows = await getReportsAgainst(env.DB, 207) + expect(rows).toHaveLength(2) + // Newest first. + expect(rows.map((r) => r.details)).toEqual(['second', 'first']) + }) + + test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => { + const res = await submit({ PlayerIdReported: '205' }) + expect(res.status).toBe(401) + }) + + test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => { + const res = await submit({ Details: 'ya know' }, await bearer()) + expect(res.status).toBe(400) + // Same envelope as the success branch — the client parses only one shape. + expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' }) + }) +}) + describe('rooms', () => { test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => { const verify = async (fields: Record, sub?: string): Promise => { @@ -2016,6 +2102,7 @@ describe('openapi', () => { 'POST /api/CampusCard/v1/UpdateAndGetSubscription', 'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/hile', + 'POST /api/PlayerReporting/v3/create', 'POST /api/avatar/v2/gifts/generate', 'POST /api/gamesight/event', 'POST /api/images/v1/cheer',