mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add warning table (maybe this is just a notification, later)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
|
||||
-- reports are what players submit, warnings are what a moderator hands down. Also
|
||||
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
|
||||
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
|
||||
-- token (the endpoint is gated on the `moderator` role); everything else comes
|
||||
-- from the form body. `display_reason` is what the warned player is shown,
|
||||
-- `moderator_note` is internal.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
|
||||
* stamps from the account's flags (a plain player's token is just `['gameClient']`).
|
||||
* `null` when the request carries no valid token, which callers treat as a 401; an
|
||||
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
|
||||
*/
|
||||
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
|
||||
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||
export function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
|
||||
+21
-3
@@ -436,12 +436,30 @@ export const CreateReportRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* The `{ success, error }` envelope `POST /api/PlayerReporting/v3/create` answers with —
|
||||
* `error` is an empty string on success, never null.
|
||||
* The `{ success, error }` envelope the report / warning writes answer 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'),
|
||||
error: z.string().describe('Empty string when the record was written'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `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
|
||||
* moderator is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateWarningRequest = z.object({
|
||||
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
|
||||
DisplayReason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
|
||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import { authedId, authedRoles, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CreateReportRequest,
|
||||
CreateWarningRequest,
|
||||
DeviceIdRequest,
|
||||
form,
|
||||
json,
|
||||
@@ -15,15 +16,24 @@ import {
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
import { createWarning } from '../warnings-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
|
||||
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
||||
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
|
||||
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
|
||||
* their admin surfaces on: a warning is a moderation action, but staff hold both.
|
||||
*/
|
||||
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
|
||||
|
||||
/**
|
||||
* Read one field of a submitted form. The client posts these form-encoded, but the
|
||||
* same names also arrive as a query string on some builds, so both are accepted.
|
||||
*/
|
||||
function reportField(
|
||||
function formField(
|
||||
body: Record<string, unknown>,
|
||||
c: Context<App>,
|
||||
name: string
|
||||
@@ -136,23 +146,81 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const reportedPlayerId = asInt(reportField(body, c, 'PlayerIdReported'))
|
||||
const reportedPlayerId = asInt(formField(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'))
|
||||
const roomId = asInt(formField(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')),
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
details: formField(body, c, 'Details') ?? null,
|
||||
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
|
||||
heightReported: asFloat(formField(body, c, 'HeightReported')),
|
||||
roomId: roomId !== null && roomId > 0 ? roomId : null,
|
||||
roomInstanceType: reportField(body, c, 'RoomInstanceType') ?? null,
|
||||
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A warning handed down by a moderator — the staff-side counterpart to a report.
|
||||
// Gated on the `moderator` role in the token, not just a valid one.
|
||||
.post(
|
||||
'/api/playerwarnings',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Issue a player warning',
|
||||
description:
|
||||
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
|
||||
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
|
||||
'yet.\n\n' +
|
||||
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
|
||||
'per account by the operator, see the admin CLI’s `grant-moderator` / ' +
|
||||
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
|
||||
'is the caller, NOT a body field.\n\n' +
|
||||
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
|
||||
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
|
||||
'internal and never surfaced to them.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
|
||||
'an empty string rather than null — including on the rejected branches, so there ' +
|
||||
'is only one shape to parse.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateWarningRequest, 'The warning'),
|
||||
responses: {
|
||||
200: json(ReportCreateResponse, '`{ success: true, error: "" }`'),
|
||||
400: json(ReportCreateResponse, 'No `WarnedPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ReportCreateResponse, 'A valid token with neither staff role'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
|
||||
if (warnedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
await createWarning(c.env.DB, {
|
||||
moderatorPlayerId: moderatorId,
|
||||
warnedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
displayReason: formField(body, c, 'DisplayReason') ?? null,
|
||||
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '..
|
||||
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 { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
import type { SavedImage } from '../../images-db'
|
||||
@@ -85,6 +86,9 @@ beforeAll(async () => {
|
||||
|
||||
// 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()
|
||||
|
||||
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
|
||||
for (const stmt of WARNINGS_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
|
||||
@@ -98,10 +102,13 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
|
||||
// off, the token carries none, which is what a plain player's looks like to the
|
||||
// role-gated routes.
|
||||
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600 })
|
||||
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -1185,6 +1192,97 @@ describe('player reports', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('player warnings', () => {
|
||||
const MOD = ['gameClient', 'moderator']
|
||||
|
||||
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings records the warning', async () => {
|
||||
const res = await issue(
|
||||
{
|
||||
WarnedPlayerId: '205',
|
||||
ReportCategory: '101',
|
||||
DisplayReason: 'Sexual gestures',
|
||||
ModeratorNote: 'dfg',
|
||||
},
|
||||
await bearer('42', MOD)
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const [row] = await getWarningsAgainst(env.DB, 205)
|
||||
expect(row).toMatchObject({
|
||||
// The moderator is the token's subject, not a body field.
|
||||
moderator_player_id: 42,
|
||||
warned_player_id: 205,
|
||||
report_category: 101,
|
||||
display_reason: 'Sexual gestures',
|
||||
moderator_note: 'dfg',
|
||||
})
|
||||
expect(row?.created_at).toBeTruthy()
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings stores absent fields as null', async () => {
|
||||
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const [row] = await getWarningsAgainst(env.DB, 206)
|
||||
expect(row).toMatchObject({
|
||||
warned_player_id: 206,
|
||||
report_category: 0,
|
||||
display_reason: null,
|
||||
moderator_note: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Append-only, like reports: warning the same player twice is two rows.
|
||||
test('POST /api/playerwarnings appends rather than dedupes', async () => {
|
||||
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
|
||||
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
|
||||
const rows = await getWarningsAgainst(env.DB, 207)
|
||||
expect(rows).toHaveLength(2)
|
||||
// Newest first.
|
||||
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings 401s without a bearer token', async () => {
|
||||
const res = await issue({ WarnedPlayerId: '205' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// A valid token is not enough — a plain player's carries neither staff role.
|
||||
// Nothing is written on the rejected branch.
|
||||
test('POST /api/playerwarnings 403s without a staff role', async () => {
|
||||
for (const roles of [undefined, ['gameClient']]) {
|
||||
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
|
||||
})
|
||||
|
||||
// `developer` gets in as well as `moderator` — staff hold both.
|
||||
test('POST /api/playerwarnings accepts the developer role', async () => {
|
||||
const res = await issue(
|
||||
{ WarnedPlayerId: '209' },
|
||||
await bearer('42', ['gameClient', 'developer'])
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings 400s without a warned player', async () => {
|
||||
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId 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> => {
|
||||
@@ -2114,6 +2212,7 @@ describe('openapi', () => {
|
||||
'POST /api/playerReputation/v2/bulk',
|
||||
'POST /api/players/v1/progression/bulk',
|
||||
'POST /api/players/v2/progression/bulk',
|
||||
'POST /api/playerwarnings',
|
||||
'POST /api/relationships/v1/favorite',
|
||||
'POST /api/relationships/v1/ignore',
|
||||
'POST /api/relationships/v1/mute',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Moderator-issued player warnings on the shared `recflare` D1 database.
|
||||
*
|
||||
* The counterpart to the `report` table (see reports-db.ts): a report is what a
|
||||
* player submits, a warning is what a moderator hands down. Same shape of storage —
|
||||
* columnar rather than a JSON blob, append-only, nothing dedupes or acts on the
|
||||
* rows yet.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0005_warning.sql,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0005_warning.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id)`,
|
||||
]
|
||||
|
||||
/** A stored warning row (snake_case columns, one row per warning issued). */
|
||||
export interface WarningRow {
|
||||
id: number
|
||||
/** The moderator who issued it, from their bearer token. */
|
||||
moderator_player_id: number
|
||||
warned_player_id: number
|
||||
report_category: number
|
||||
/** What the warned player is shown, e.g. `Sexual gestures`. */
|
||||
display_reason: string | null
|
||||
/** Internal note — never surfaced to the warned player. */
|
||||
moderator_note: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning as issued — everything but the moderator (which comes from the bearer
|
||||
* token) and the timestamp. Only the warned player is required; the rest are
|
||||
* optional and stored as NULL when absent.
|
||||
*/
|
||||
export interface NewWarning {
|
||||
moderatorPlayerId: number
|
||||
warnedPlayerId: number
|
||||
reportCategory?: number
|
||||
displayReason?: string | null
|
||||
moderatorNote?: string | null
|
||||
}
|
||||
|
||||
/** Record an issued warning, returning the stored row (with its assigned id). */
|
||||
export async function createWarning(db: D1Database, input: NewWarning): Promise<WarningRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO warning (
|
||||
moderator_player_id, warned_player_id, report_category,
|
||||
display_reason, moderator_note, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.moderatorPlayerId,
|
||||
input.warnedPlayerId,
|
||||
input.reportCategory ?? 0,
|
||||
input.displayReason ?? null,
|
||||
input.moderatorNote ?? null,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<WarningRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
// from having to handle an impossible null.
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Every warning issued against a player, newest first. Backs a future moderation view. */
|
||||
export async function getWarningsAgainst(db: D1Database, playerId: number): Promise<WarningRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC')
|
||||
.bind(playerId)
|
||||
.all<WarningRow>()
|
||||
return results
|
||||
}
|
||||
Reference in New Issue
Block a user