add player reports

This commit is contained in:
Devin Zuczek
2026-08-04 11:42:53 -04:00
parent 7e0c26a100
commit 8d1539de03
5 changed files with 336 additions and 0 deletions
+23
View File
@@ -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);
+33
View File
@@ -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('Reporters player height in metres at report time, e.g. `1.64`'),
HeightReported: z.string().optional().describe('Reported players 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'),
+103
View File
@@ -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<ReportRow> {
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<ReportRow>()
// 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<ReportRow[]> {
const { results } = await db
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<ReportRow>()
return results
}
+90
View File
@@ -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<string, unknown>,
c: Context<App>,
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<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
@@ -69,6 +104,61 @@ export const moderationRoutes = new Hono<App>({ 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 services `{ 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<string, unknown>)
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
+87
View File
@@ -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<string, string>, headers?: Record<string, string>) =>
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<string, string>, sub?: string): Promise<boolean> => {
@@ -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',