diff --git a/apps/api/migrations/0005_warning.sql b/apps/api/migrations/0005_warning.sql new file mode 100644 index 0000000..4aaa42c --- /dev/null +++ b/apps/api/migrations/0005_warning.sql @@ -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); diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index f548fae..8c9ac24 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -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): Promise { 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): Promise { + return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get()) +} + /** Results.Unauthorized() equivalent — 401 with empty body. */ export function unauthorized(c: Context) { return c.body(null, 401) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 887bac2..6c8555f 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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. */ diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 6c0feb8..f166cde 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -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, c: Context, name: string @@ -136,23 +146,81 @@ export const moderationRoutes = new Hono({ strict: false }) if (reporterId === null) return unauthorized(c) const body = await c.req.parseBody().catch(() => ({}) as Record) - 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) + 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: '' }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index f571afa..09b15a7 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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> { +// `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> { 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, headers?: Record) => + 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, sub?: string): Promise => { @@ -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', diff --git a/apps/api/src/warnings-db.ts b/apps/api/src/warnings-db.ts new file mode 100644 index 0000000..32410ee --- /dev/null +++ b/apps/api/src/warnings-db.ts @@ -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 { + 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() + // 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 { + const { results } = await db + .prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC') + .bind(playerId) + .all() + return results +}