From 658b81737b419adc16828f7ab1e4ac0d4830a73f Mon Sep 17 00:00:00 2001 From: Pushp Date: Tue, 27 Oct 2026 02:37:18 +0530 Subject: [PATCH 01/10] chore(telemetry): vendor catalog 1.6.0 (report_flow signal + report taxonomy) --- .../analytics/generated/telemetryCatalog.ts | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts index 2b36fa9..506d7a4 100644 --- a/src/lib/analytics/generated/telemetryCatalog.ts +++ b/src/lib/analytics/generated/telemetryCatalog.ts @@ -1,15 +1,15 @@ // GENERATED FILE - DO NOT EDIT. -// telemetry-integrity: sha256 d056181449f8f1338ba1f67fcca18da6873ef5d1ddecc63a861f39cdd2a8f286 +// telemetry-integrity: sha256 df6df589e003512e5d8bc9de08bf989cc28fe3bac66904d9ca22f6150de47642 // Source of truth: OneHookBackend/packages/telemetry/src/catalog.ts // Regenerate: (OneHookBackend) npm run telemetry:generate && npm run telemetry:sync -// Catalog version 1.5.0, hash 4ab81c4d5f20cdb7. +// Catalog version 1.6.0, hash 435bb38a1852d74c. // // HARD RULE: every user interaction MUST emit ui_interaction with an id from THIS file, and // ids are controlled by the backend. Read TELEMETRY_CONTRACT.txt (printed by every build). /* eslint-disable */ -export const TELEMETRY_CATALOG_VERSION = "1.5.0"; -export const TELEMETRY_CATALOG_HASH = "4ab81c4d5f20cdb7"; +export const TELEMETRY_CATALOG_VERSION = "1.6.0"; +export const TELEMETRY_CATALOG_HASH = "435bb38a1852d74c"; /** Every signal type the ingest accepts. */ export const SignalType = { @@ -31,6 +31,7 @@ export const SignalType = { HOOKED_STATE_VIEW: "hooked_state_view", LIKES_YOU_VIEW: "likes_you_view", SAFETY_UI_OPEN: "safety_ui_open", + REPORT_FLOW: "report_flow", } as const; export type SignalType = (typeof SignalType)[keyof typeof SignalType]; @@ -212,6 +213,48 @@ export const HookedStateAction = { } as const; export type HookedStateAction = (typeof HookedStateAction)[keyof typeof HookedStateAction]; +/** Member-report reason category the user chose. Mirrors the backend ReportCategory taxonomy (packages/matching ReportCategory / MODERATION.md section 1) so telemetry segments by the same codes the report API records. Carries no free text. */ +export const ReportCategory = { + HARASSMENT: "HARASSMENT", + THREATS_VIOLENCE: "THREATS_VIOLENCE", + SEXUAL_CONTENT_NONCONSENT: "SEXUAL_CONTENT_NONCONSENT", + MINOR_SAFETY: "MINOR_SAFETY", + SCAM_FRAUD: "SCAM_FRAUD", + FAKE_IMPERSONATION: "FAKE_IMPERSONATION", + SPAM_PROMOTION: "SPAM_PROMOTION", + HATE: "HATE", + SELF_HARM_CONCERN: "SELF_HARM_CONCERN", + OFF_PLATFORM_HARM: "OFF_PLATFORM_HARM", + OTHER: "OTHER", +} as const; +export type ReportCategory = (typeof ReportCategory)[keyof typeof ReportCategory]; + +/** Where in the app the member started the report flow from (not the reported content). */ +export const ReportSource = { + CHAT: "chat", + PROFILE: "profile", +} as const; +export type ReportSource = (typeof ReportSource)[keyof typeof ReportSource]; + +/** Step in the member-report flow a report_flow signal records. The flow mixes presses, "shown" impressions and upload outcomes, so it is a signal (not a ui_interaction). Carries only the ReportCategory / ReportSource / evidence dimensions — never message text, user ids or report free text. */ +export const ReportEvent = { + REPORT_OPEN: "report.open", + REPORT_CATEGORY_SELECTED: "report.category_selected", + REPORT_NOT_REPORTABLE_SHOWN: "report.not_reportable_shown", + REPORT_NOT_REPORTABLE_BLOCK: "report.not_reportable_block", + REPORT_SELF_HARM_RESOURCES_SHOWN: "report.self_harm_resources_shown", + REPORT_EVIDENCE_TOGGLE: "report.evidence_toggle", + REPORT_ATTACHMENTS_TOGGLE: "report.attachments_toggle", + REPORT_EVIDENCE_PREVIEW: "report.evidence_preview", + REPORT_NUDGE_SHOWN: "report.nudge_shown", + REPORT_SUBMIT_WITH_EVIDENCE: "report.submit_with_evidence", + REPORT_SUBMIT_WITHOUT_EVIDENCE: "report.submit_without_evidence", + REPORT_SUBMIT_FAILED: "report.submit_failed", + REPORT_EVIDENCE_UPLOAD_SUCCEEDED: "report.evidence_upload_succeeded", + REPORT_EVIDENCE_UPLOAD_FAILED: "report.evidence_upload_failed", +} as const; +export type ReportEvent = (typeof ReportEvent)[keyof typeof ReportEvent]; + /** Registered UI element ids (`.`). Use with trackUi / data-track-id. */ export const UiElement = { NAV_TAB_SELECT: "nav.tab_select", From 8e6ae58044fd357d9bcc9b1d3849a6539f809627 Mon Sep 17 00:00:00 2001 From: Pushp Date: Tue, 27 Oct 2026 02:47:06 +0530 Subject: [PATCH 02/10] feat(report): canonical evidence encoder, window selection, categories, report+evidence API, submit orchestration (+tests) --- src/api/rest.ts | 53 ++++ src/config/deployment.config.ts | 10 + src/features/moderation/report/categories.ts | 192 +++++++++++ .../moderation/report/evidence-api.ts | 201 ++++++++++++ .../moderation/report/evidence-bundle.ts | 205 ++++++++++++ src/features/moderation/report/member-auth.ts | 18 ++ src/features/moderation/report/strings.ts | 103 ++++++ src/features/moderation/report/submit.ts | 206 ++++++++++++ src/features/moderation/report/telemetry.ts | 47 +++ .../moderation/report/window-selection.ts | 82 +++++ .../fixtures/moderation/bundle-basic.json | 50 +++ .../fixtures/moderation/bundle-empty.json | 24 ++ .../fixtures/moderation/bundle-escaping.json | 38 +++ .../moderation/bundle-tiebreak-media.json | 57 ++++ src/tests/report-evidence-bundle.test.ts | 90 ++++++ src/tests/report-submit.test.ts | 298 ++++++++++++++++++ src/tests/report-window-categories.test.ts | 153 +++++++++ src/utils/env.config.ts | 7 + 18 files changed, 1834 insertions(+) create mode 100644 src/features/moderation/report/categories.ts create mode 100644 src/features/moderation/report/evidence-api.ts create mode 100644 src/features/moderation/report/evidence-bundle.ts create mode 100644 src/features/moderation/report/member-auth.ts create mode 100644 src/features/moderation/report/strings.ts create mode 100644 src/features/moderation/report/submit.ts create mode 100644 src/features/moderation/report/telemetry.ts create mode 100644 src/features/moderation/report/window-selection.ts create mode 100644 src/tests/fixtures/moderation/bundle-basic.json create mode 100644 src/tests/fixtures/moderation/bundle-empty.json create mode 100644 src/tests/fixtures/moderation/bundle-escaping.json create mode 100644 src/tests/fixtures/moderation/bundle-tiebreak-media.json create mode 100644 src/tests/report-evidence-bundle.test.ts create mode 100644 src/tests/report-submit.test.ts create mode 100644 src/tests/report-window-categories.test.ts diff --git a/src/api/rest.ts b/src/api/rest.ts index c85ce8c..1d952c2 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -319,3 +319,56 @@ export const ReceivedLikesApi = { list: (): Promise => request('GET', '/matching/likes'), }; + +// ── Member reports (POST /matching/reports) ─────────────────────────────────── + +/** + * Report category superset (OneHookBackend packages/matching `ReportCategory`). A new client sends + * these codes; the server maps them onto the legacy block `reason` allowlist for old-client compat. + */ +export type ReportCategoryCode = + | 'HARASSMENT' + | 'THREATS_VIOLENCE' + | 'SEXUAL_CONTENT_NONCONSENT' + | 'MINOR_SAFETY' + | 'SCAM_FRAUD' + | 'FAKE_IMPERSONATION' + | 'SPAM_PROMOTION' + | 'HATE' + | 'SELF_HARM_CONCERN' + | 'OFF_PLATFORM_HARM' + | 'OTHER'; + +/** Body of `POST /matching/reports` (OneHookBackend matching `CreateReportRequest`). */ +export interface CreateReportRequest { + reportedUserId: string; + category: ReportCategoryCode; + /** <= 2000 chars; REQUIRED for OTHER. */ + details?: string; + /** Defaults server-side to true, except false for SELF_HARM_CONCERN. */ + alsoBlock?: boolean; + conversationId?: string; + /** Whether the reporter opted to share a decrypted copy of the chat. */ + evidenceOffered: boolean; +} + +/** `201` response of `POST /matching/reports` (OneHookBackend matching `CreateReportResponse`). */ +export interface CreateReportResponse { + /** Backend report UUID — the id the evidence presign/complete calls reference. */ + reportId: string; + severity: string; + /** + * Whether the server will accept a decrypted-chat evidence upload for this report. False for + * MINOR_SAFETY (escalated immediately) and any `evidenceEligible:false` category. + */ + evidenceAllowed: boolean; +} + +export const ReportsApi = { + /** + * File a member report. Throws {@link ApiError} on failure; a `429` (rate limit) surfaces as + * `ApiError` with `status === 429` so the UI can show a friendly message. + */ + create: (body: CreateReportRequest): Promise => + request('POST', '/matching/reports', body), +}; diff --git a/src/config/deployment.config.ts b/src/config/deployment.config.ts index 951d3cd..66b98b5 100644 --- a/src/config/deployment.config.ts +++ b/src/config/deployment.config.ts @@ -27,6 +27,13 @@ export interface BackendDeploymentConfig { * pins the token's `aud` to that value, so a mismatch is rejected. */ readonly googleClientId: string; + /** + * Base URL of the admin portal's PUBLIC member-facing moderation surfaces (evidence presign / + * complete). Member-JWT authenticated via the portal gateway; see OneHookAdminPortal + * features/moderation README. The `{stage}` segment is baked per-stage here (same convention as + * `apiBaseUrl`) so it is never hardcoded in the report feature. + */ + readonly portalModerationBaseUrl: string; readonly enableAnalytics: boolean; readonly enableDebug: boolean; readonly requestTimeoutMs: number; @@ -50,6 +57,9 @@ export const BACKEND_DEPLOYMENTS = { cognitoDomain: '', // Mirrors OneHookBackend infra/config/auth.ts (gamma). googleClientId: '187849624492-jmmc973assosfgv3aaevh8fts2h0mtlr.apps.googleusercontent.com', + // Admin portal public moderation gateway for this stage (member-JWT): evidence presign/complete + // route under /api/p/{service}/{stage}/{feature}. {service}=onehook, {feature}=moderation. + portalModerationBaseUrl: 'https://admin.onehook.club/api/p/onehook/gamma/moderation', enableAnalytics: false, enableDebug: false, requestTimeoutMs: 30_000, diff --git a/src/features/moderation/report/categories.ts b/src/features/moderation/report/categories.ts new file mode 100644 index 0000000..6154a21 --- /dev/null +++ b/src/features/moderation/report/categories.ts @@ -0,0 +1,192 @@ +/** + * Report categories and their rules (OneHook moderation, docs/MODERATION.md §1 + §9). + * + * The 11 category codes mirror the backend `ReportCategory` taxonomy (also vendored into the + * telemetry catalog as `ReportCategory`). Titles and one-line descriptions are short, plain-language + * versions of the §1 definitions. Per-category rules captured here: + * + * - `alsoBlockDefault`: the default for the "Also block this person" toggle. True for every + * category EXCEPT SELF_HARM_CONCERN (a support route, not punishment — default OFF). + * - `evidenceEligible`: whether the decrypted-chat evidence option may be offered. False for + * MINOR_SAFETY, which is escalated immediately and never carries an evidence upload (§9.1). The + * server independently gates this via `CreateReportResponse.evidenceAllowed`; the client hides + * the option entirely for MINOR_SAFETY. + * - `requiresDetails`: whether the free-text details field is mandatory (OTHER only). + * - `selfHarm`: SELF_HARM_CONCERN shows support resources first and routes to a support team. + * - `minorSafety`: MINOR_SAFETY shows the "escalated immediately" note instead of evidence. + */ +import { ReportCategory } from '../../../lib/analytics/generated/telemetryCatalog'; + +export { ReportCategory }; + +export type ReportSeverityTier = 'critical' | 'high' | 'standard'; + +export interface CategoryMeta { + code: ReportCategory; + /** Short, plain-language title shown in the picker. */ + title: string; + /** One-line description shown under the title. */ + description: string; + tier: ReportSeverityTier; + /** Default for the "Also block this person" toggle. */ + alsoBlockDefault: boolean; + /** Whether the decrypted-chat evidence option may be offered for this category. */ + evidenceEligible: boolean; + /** OTHER requires a free-text description. */ + requiresDetails: boolean; + /** SELF_HARM_CONCERN: support route, resources shown first. */ + selfHarm: boolean; + /** MINOR_SAFETY: escalated immediately, evidence hidden entirely. */ + minorSafety: boolean; +} + +/** Ordered list of the 11 categories as shown in the picker. */ +export const REPORT_CATEGORIES: CategoryMeta[] = [ + { + code: ReportCategory.HARASSMENT, + title: 'Harassment', + description: 'Repeated unwanted contact, insults, or degradation.', + tier: 'high', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.THREATS_VIOLENCE, + title: 'Threats or violence', + description: 'Threats of physical harm, intimidation, or extortion.', + tier: 'critical', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.SEXUAL_CONTENT_NONCONSENT, + title: 'Unwanted sexual content', + description: 'Unsolicited sexual content, coercion, or sharing intimate images without consent.', + tier: 'critical', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.MINOR_SAFETY, + title: 'Minor safety', + description: 'A suspected underage user, or any sexualization of a minor.', + tier: 'critical', + alsoBlockDefault: true, + evidenceEligible: false, + requiresDetails: false, + selfHarm: false, + minorSafety: true, + }, + { + code: ReportCategory.SCAM_FRAUD, + title: 'Scam or fraud', + description: 'Money requests, investment or crypto pitches, phishing, or off-platform payment.', + tier: 'high', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.FAKE_IMPERSONATION, + title: 'Fake or impersonation', + description: 'A fake profile, stolen photos, or impersonating a real person or brand.', + tier: 'high', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.SPAM_PROMOTION, + title: 'Spam or promotion', + description: 'Bulk advertising, solicitation, link spam, or driving you to other platforms.', + tier: 'standard', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.HATE, + title: 'Hate', + description: + 'Attacks on protected characteristics such as race, religion, caste, gender, orientation, or disability.', + tier: 'high', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.SELF_HARM_CONCERN, + title: 'Self-harm concern', + description: 'Someone expressing suicidal thoughts or self-harm. This goes to a support team, not punishment.', + tier: 'critical', + alsoBlockDefault: false, + evidenceEligible: true, + requiresDetails: false, + selfHarm: true, + minorSafety: false, + }, + { + code: ReportCategory.OFF_PLATFORM_HARM, + title: 'Off-platform harm', + description: 'Harm that started here but continues elsewhere, like doxxing, stalking, or spreading private images.', + tier: 'high', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: false, + selfHarm: false, + minorSafety: false, + }, + { + code: ReportCategory.OTHER, + title: 'Something else', + description: 'Anything not covered above. Please add a short description.', + tier: 'standard', + alsoBlockDefault: true, + evidenceEligible: true, + requiresDetails: true, + selfHarm: false, + minorSafety: false, + }, +]; + +const BY_CODE: Record = Object.fromEntries( + REPORT_CATEGORIES.map((c) => [c.code, c]) +); + +export function getCategoryMeta(code: ReportCategory): CategoryMeta { + const meta = BY_CODE[code]; + if (!meta) throw new Error(`Unknown report category: ${code}`); + return meta; +} + +/** Max length of the details free-text field (backend CreateReportRequest cap). */ +export const DETAILS_MAX_CHARS = 2000; + +/** + * The cases that are explicitly NOT reportable (docs/MODERATION.md §1). Choosing "This isn't a + * safety issue" shows these and offers Block / Unmatch instead of creating a report. + */ +export const NOT_REPORTABLE_CASES: string[] = [ + 'Disagreeing with you or having different opinions.', + 'Not replying, replying slowly, or unmatching.', + "Someone's protected attributes themselves — race, religion, caste, gender, orientation, disability, body, or accent.", + "Their dating preferences, or who they are or aren't interested in.", + 'Being boring, unattractive, or a bad conversationalist.', +]; diff --git a/src/features/moderation/report/evidence-api.ts b/src/features/moderation/report/evidence-api.ts new file mode 100644 index 0000000..6a0cdbf --- /dev/null +++ b/src/features/moderation/report/evidence-api.ts @@ -0,0 +1,201 @@ +/** + * Evidence upload transport (OneHook moderation Phase 2 client contract). + * + * Implements the three HTTP steps against the admin portal's PUBLIC member gateway + * (features/moderation README "Evidence (decrypted-chat share) - client contract"): + * + * 1. POST {base}/evidence/presign (Bearer member ID token) -> { evidenceId, upload, expiresAt } + * 2. PUT upload.url (EXACTLY upload.headers, no auth) with the canonical bytes + * 3. POST {base}/evidence/complete (Bearer member ID token) -> { status } + * + * `complete` is retried on `503 EVIDENCE_FINALIZE_RETRY` with bounded backoff. Everything is + * dependency-injected (fetch, token provider, sleep) so the orchestration is unit-testable without a + * browser or the network. No message text or user ids are ever logged here. + */ +import { portalModerationBaseUrl } from '../../../utils/env.config'; + +export class EvidenceError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code?: string + ) { + super(message); + this.name = 'EvidenceError'; + } +} + +export interface EvidenceTransport { + /** Portal moderation base URL (…/moderation). Defaults to the app's env config. */ + baseUrl?: string; + fetchImpl?: typeof fetch; + /** Returns the member's Cognito ID token, or null when unauthenticated. */ + getToken: () => Promise; + /** Injected for tests; defaults to a real timed delay. */ + sleep?: (ms: number) => Promise; +} + +export interface PresignRequest { + reportId: string; + sizeBytes: number; + /** 64 lowercase hex. */ + sha256: string; + messageCount: number; + includesMedia: boolean; +} + +export interface PresignUpload { + url: string; + method: string; + headers: Record; +} + +export interface PresignResponse { + evidenceId: string; + upload: PresignUpload; + expiresAt: number; +} + +export interface CompleteRequest { + reportId: string; + evidenceId: string; + messageIds: string[]; + /** Epoch ms. */ + firstSentAt: number; + /** Epoch ms. */ + lastSentAt: number; +} + +export interface CompleteResponse { + evidenceId: string; + status: string; + idempotent: boolean; +} + +export interface RetryOptions { + /** Max additional attempts after the first on a retryable 503. Default 4. */ + maxRetries?: number; + /** Base backoff ms (doubled each attempt, capped). Default 400. */ + baseDelayMs?: number; + /** Cap on a single backoff delay. Default 4000. */ + maxDelayMs?: number; +} + +const FINALIZE_RETRY_CODE = 'EVIDENCE_FINALIZE_RETRY'; + +function resolveBaseUrl(t: EvidenceTransport): string { + return (t.baseUrl ?? portalModerationBaseUrl).replace(/\/+$/, ''); +} + +async function parseBody(res: Response): Promise<{ code?: string; message?: string }> { + const text = await res.text().catch(() => ''); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + } + const obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : undefined; + return { + code: typeof obj?.code === 'string' ? (obj.code as string) : undefined, + message: typeof obj?.message === 'string' ? (obj.message as string) : undefined, + }; +} + +async function postJson(t: EvidenceTransport, path: string, body: unknown): Promise { + const doFetch = t.fetchImpl ?? fetch; + const token = await t.getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + let res: Response; + try { + res = await doFetch(`${resolveBaseUrl(t)}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + } catch (err) { + throw new EvidenceError( + err instanceof Error ? err.message : 'Network request failed', + 0, + 'NETWORK_ERROR' + ); + } + if (!res.ok) { + const { code, message } = await parseBody(res); + throw new EvidenceError(message || `Evidence request failed (${res.status})`, res.status, code); + } + return (await res.json()) as T; +} + +/** Step 1: get a presigned PUT for the evidence object. */ +export function presignEvidence(t: EvidenceTransport, req: PresignRequest): Promise { + return postJson(t, '/evidence/presign', req); +} + +/** + * Step 2: PUT the exact canonical bytes to S3 using EXACTLY the returned headers (and no auth + * header — the presigned URL carries the signature). S3 enforces the SHA-256 checksum header. + */ +export async function putEvidenceBytes( + fetchImpl: typeof fetch | undefined, + upload: PresignUpload, + bytes: Uint8Array +): Promise { + const doFetch = fetchImpl ?? fetch; + let res: Response; + try { + res = await doFetch(upload.url, { + method: upload.method || 'PUT', + headers: { ...upload.headers }, + body: bytes as unknown as BodyInit, + }); + } catch (err) { + throw new EvidenceError( + err instanceof Error ? err.message : 'Evidence upload failed', + 0, + 'UPLOAD_NETWORK_ERROR' + ); + } + if (!res.ok) { + throw new EvidenceError(`Evidence upload rejected (${res.status})`, res.status, 'UPLOAD_FAILED'); + } +} + +const defaultSleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** + * Step 3: record the upload. Retries on `503 EVIDENCE_FINALIZE_RETRY` with bounded exponential + * backoff; any other error propagates immediately. + */ +export async function completeEvidence( + t: EvidenceTransport, + req: CompleteRequest, + opts: RetryOptions = {} +): Promise { + const maxRetries = opts.maxRetries ?? 4; + const baseDelayMs = opts.baseDelayMs ?? 400; + const maxDelayMs = opts.maxDelayMs ?? 4000; + const sleep = t.sleep ?? defaultSleep; + + let attempt = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await postJson(t, '/evidence/complete', req); + } catch (err) { + const retryable = + err instanceof EvidenceError && err.status === 503 && err.code === FINALIZE_RETRY_CODE; + if (!retryable || attempt >= maxRetries) throw err; + const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs); + attempt += 1; + await sleep(delay); + } + } +} diff --git a/src/features/moderation/report/evidence-bundle.ts b/src/features/moderation/report/evidence-bundle.ts new file mode 100644 index 0000000..c906367 --- /dev/null +++ b/src/features/moderation/report/evidence-bundle.ts @@ -0,0 +1,205 @@ +/** + * Canonical evidence-bundle format for the web client (OneHook moderation Phase 2). + * + * The reporter's device decrypts the last-N chat locally and uploads a copy (docs/MODERATION.md §2). + * iOS, Android and web MUST all produce BYTE-IDENTICAL canonical JSON so the SHA-256 the client + * declares at presign matches the hash the server recomputes on complete and the admin viewer + * recomputes on read. + * + * This is the web port of the portal reference implementation + * (OneHookAdminPortal/features/moderation/api/evidence-bundle.ts). The ONLY difference is hashing: + * the browser has no `node:crypto`, so SHA-256 is computed with the Web Crypto API (SubtleCrypto) + * and is therefore asynchronous. The canonical string / escaping / sort logic is identical, and the + * golden fixtures (features/moderation/contract-fixtures/*.json) are reused verbatim as test vectors. + * + * CANONICAL RULES (identical across platforms): + * - UTF-8 JSON, NO whitespace anywhere. + * - Object keys appear in a FIXED order (never alphabetical, never insertion order): + * top level : v, reportId, conversationId, window, messages, attachments + * window : maxMessages, maxDays + * message : messageId, sentAt, senderSide, text + * - `v` is the number 1; window.maxMessages / window.maxDays are JSON numbers (no quotes). + * - messages are sorted by sentAt (ISO-8601 UTC ms; lexicographic == chronological) then messageId + * (lexicographic by UTF-16 code unit). Sorting is applied canonically regardless of input order. + * - String escaping (STANDARD JSON, specified exactly so every platform agrees): + * escape only " and \ + * control chars < 0x20: use the short escapes \b \f \n \r \t; any OTHER control char is + * \u00XX with LOWERCASE hex (e.g. \u0000, \u001f). + * do NOT escape '/'; do NOT escape non-ASCII (emoji, accents travel as raw UTF-8); do NOT + * escape DEL (0x7f) or anything >= 0x20. + */ + +/** The evidence window (docs/MODERATION.md §9.3): last 200 messages OR 30 days, whichever is smaller. */ +export const EVIDENCE_WINDOW = { maxMessages: 200, maxDays: 30 } as const; + +/** Bundle format version. Bump only with a documented migration; clients pin on it. */ +export const BUNDLE_VERSION = 1 as const; + +export type SenderSide = 'reporter' | 'reported'; + +export interface BundleMessage { + messageId: string; + /** ISO-8601 UTC with millisecond precision, e.g. "2026-10-26T12:34:56.789Z". */ + sentAt: string; + senderSide: SenderSide; + text: string; +} + +export interface EvidenceWindow { + maxMessages: number; + maxDays: number; +} + +export interface EvidenceBundle { + v: typeof BUNDLE_VERSION; + reportId: string; + conversationId: string; + window: EvidenceWindow; + messages: BundleMessage[]; + attachments: string[]; +} + +export interface BundleInput { + reportId: string; + conversationId: string; + messages: BundleMessage[]; + /** Defaults to EVIDENCE_WINDOW. */ + window?: EvidenceWindow; + /** Opaque attachment refs (empty unless the reporter opted into media). Default []. */ + attachments?: string[]; +} + +const HEX = '0123456789abcdef'; + +/** + * Escape a string per the canonical rules and wrap it in double quotes. Deliberately hand-written + * (not JSON.stringify) so the contract is explicit and does not depend on any engine's escaping + * choices; iOS / Android / the portal implement the identical routine. + */ +export function escapeCanonicalString(s: string): string { + let out = '"'; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + const u = s.charCodeAt(i); + if (c === '"') out += '\\"'; + else if (c === '\\') out += '\\\\'; + else if (u === 0x08) out += '\\b'; + else if (u === 0x09) out += '\\t'; + else if (u === 0x0a) out += '\\n'; + else if (u === 0x0c) out += '\\f'; + else if (u === 0x0d) out += '\\r'; + else if (u < 0x20) out += '\\u00' + HEX[(u >> 4) & 0xf] + HEX[u & 0xf]; + else out += c; + } + return out + '"'; +} + +/** Canonical message order: sentAt then messageId, both lexicographic (UTF-16 code unit). */ +export function sortMessages(messages: readonly BundleMessage[]): BundleMessage[] { + return [...messages].sort((a, b) => { + if (a.sentAt < b.sentAt) return -1; + if (a.sentAt > b.sentAt) return 1; + if (a.messageId < b.messageId) return -1; + if (a.messageId > b.messageId) return 1; + return 0; + }); +} + +/** Build the fully-formed, canonically-ordered bundle object from loose input. */ +export function buildBundle(input: BundleInput): EvidenceBundle { + return { + v: BUNDLE_VERSION, + reportId: input.reportId, + conversationId: input.conversationId, + window: input.window + ? { maxMessages: input.window.maxMessages, maxDays: input.window.maxDays } + : { ...EVIDENCE_WINDOW }, + messages: sortMessages(input.messages), + attachments: input.attachments ? [...input.attachments] : [], + }; +} + +function isBundle(x: BundleInput | EvidenceBundle): x is EvidenceBundle { + return (x as EvidenceBundle).v === BUNDLE_VERSION && 'window' in x && !!(x as EvidenceBundle).window; +} + +/** The canonical JSON string (what gets hashed and uploaded). */ +export function canonicalString(input: BundleInput | EvidenceBundle): string { + const b = isBundle(input) ? input : buildBundle(input); + const esc = escapeCanonicalString; + const window = `{"maxMessages":${b.window.maxMessages},"maxDays":${b.window.maxDays}}`; + const messages = sortMessages(b.messages) + .map( + (m) => + `{"messageId":${esc(m.messageId)},"sentAt":${esc(m.sentAt)},"senderSide":${esc( + m.senderSide + )},"text":${esc(m.text)}}` + ) + .join(','); + const attachments = (b.attachments ?? []).map((a) => esc(a)).join(','); + return ( + `{"v":${BUNDLE_VERSION}` + + `,"reportId":${esc(b.reportId)}` + + `,"conversationId":${esc(b.conversationId)}` + + `,"window":${window}` + + `,"messages":[${messages}]` + + `,"attachments":[${attachments}]}` + ); +} + +/** Canonical bytes (UTF-8). This is exactly what the client PUTs to S3. */ +export function canonicalBytes(input: BundleInput | EvidenceBundle): Uint8Array { + return new TextEncoder().encode(canonicalString(input)); +} + +/** Byte length of the canonical bundle (the sizeBytes a client declares at presign). */ +export function canonicalByteLength(input: BundleInput | EvidenceBundle): number { + return canonicalBytes(input).length; +} + +function bytesToHex(bytes: Uint8Array): string { + let out = ''; + for (const b of bytes) out += HEX[(b >> 4) & 0xf] + HEX[b & 0xf]; + return out; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +/** Convert a 64-char lowercase hex SHA-256 to the base64 S3 checksum header value. */ +export function hexToBase64(hex: string): string { + if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error('sha256 must be 64 lowercase hex chars'); + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + return bytesToBase64(bytes); +} + +function subtle(): SubtleCrypto { + const api = globalThis.crypto?.subtle; + if (!api) { + throw new Error( + 'Evidence hashing is unavailable: this environment has no Web Crypto API (a secure HTTPS context is required).' + ); + } + return api; +} + +/** Raw SHA-256 digest of the canonical bytes. */ +export async function bundleSha256(input: BundleInput | EvidenceBundle): Promise { + const bytes = canonicalBytes(input); + const digest = await subtle().digest('SHA-256', bytes as unknown as ArrayBuffer); + return new Uint8Array(digest); +} + +/** SHA-256 of the canonical bytes, lowercase hex (the `sha256` field at presign). */ +export async function bundleSha256Hex(input: BundleInput | EvidenceBundle): Promise { + return bytesToHex(await bundleSha256(input)); +} + +/** SHA-256 of the canonical bytes, base64 (the x-amz-checksum-sha256 header value S3 enforces). */ +export async function bundleSha256Base64(input: BundleInput | EvidenceBundle): Promise { + return bytesToBase64(await bundleSha256(input)); +} diff --git a/src/features/moderation/report/member-auth.ts b/src/features/moderation/report/member-auth.ts new file mode 100644 index 0000000..3e79b6a --- /dev/null +++ b/src/features/moderation/report/member-auth.ts @@ -0,0 +1,18 @@ +/** + * Member auth token for portal evidence calls. + * + * The portal gateway verifies the member's Cognito USER POOL ID token (per the features/moderation + * client contract: the ID token — not the access token — is the correct bearer). Read the same way + * sdk-client.ts / rest.ts do: ID token first, access token as a fallback. Returns null when + * unauthenticated so the request is still attempted (and rejected) rather than throwing here. + */ +export async function getMemberIdToken(): Promise { + if (typeof window === 'undefined') return null; + try { + const { fetchAuthSession } = await import('aws-amplify/auth'); + const session = await fetchAuthSession(); + return session.tokens?.idToken?.toString() || session.tokens?.accessToken?.toString() || null; + } catch { + return null; + } +} diff --git a/src/features/moderation/report/strings.ts b/src/features/moderation/report/strings.ts new file mode 100644 index 0000000..928e7bd --- /dev/null +++ b/src/features/moderation/report/strings.ts @@ -0,0 +1,103 @@ +/** + * Member report flow copy (OneHook moderation Phase 2). + * + * The web app has no i18n framework; its existing mechanism is inline string constants co-located + * with the feature (see e.g. ChatView's BLOCK_REASONS, ADD_BY_USERNAME_CONFIRMATION). This module + * centralises the report flow copy the same way so the strings live in one reviewable place and the + * UI never hard-codes them inline. No message text, user ids or free text ever appears in telemetry + * or logs; these are static UI strings only. + */ + +/** A self-harm support resource (config so it can change per region — docs/MODERATION.md §9.5). */ +export interface SupportResource { + name: string; + /** Phone number as dialled (tel: link target). */ + phone: string; + region: string; + availability: string; +} + +/** India, 24x7 — the resource surfaced for SELF_HARM_CONCERN (docs/MODERATION.md §9.5). */ +export const SELF_HARM_RESOURCES: SupportResource[] = [ + { name: 'Tele-MANAS', phone: '14416', region: 'India', availability: '24x7' }, +]; + +export const REPORT_STRINGS = { + entryLabel: 'Report', + blockLabel: 'Block', + + dialogTitle: 'Report', + close: 'Close', + back: 'Back', + cancel: 'Cancel', + next: 'Continue', + + // ── Category picker ── + pickerHeading: 'What happened?', + pickerSubheading: 'Pick the closest reason. Your report goes to our safety team.', + notReportableRow: "This isn't a safety issue", + notReportableRowHint: 'Different opinions, slow replies, who they like, and similar', + + // ── Not-reportable explainer ── + notReportableHeading: "This isn't something we act on", + notReportableBody: + "These aren't safety issues, so reporting them won't create a case. If you'd rather not see or hear from this person, you can block or unmatch them instead.", + notReportableBlock: 'Block', + notReportableUnmatch: 'Unmatch', + + // ── Self-harm support ── + selfHarmHeading: "If someone's in crisis, help is available", + selfHarmBody: + 'Thank you for looking out for them. This report goes to a support team, not to punishment. If you or they are in immediate danger, please contact local emergency services.', + selfHarmCallPrefix: 'Call', + selfHarmContinue: 'Continue report', + + // ── Details ── + detailsLabel: 'Add details', + detailsLabelRequired: 'Add details (required)', + detailsPlaceholder: 'Share anything that helps our team understand what happened.', + detailsRequiredHint: 'Please add a short description so we can review this.', + + // ── Also block ── + alsoBlockLabel: 'Also block this person', + alsoBlockHint: 'They won’t be able to contact you or see you.', + + // ── Evidence ── + evidenceCheckboxLabel: 'Share a decrypted copy of this chat with OneHook Safety', + evidenceNudgeLine: 'Reports with the chat attached are resolved faster and more accurately.', + evidencePanelHeading: 'What gets shared', + evidencePanelWhat: + 'The last 200 messages or the last 30 days of this chat — whichever is smaller. Each message includes its time, who sent it (you or them), and the text.', + evidencePanelNoMedia: 'No photos or media are shared in this version.', + evidencePanelNotNotified: 'The other person is not told that you shared the chat.', + evidencePreviewButton: 'Preview what will be shared', + evidencePreviewHeading: 'Exactly what will be shared', + evidencePreviewEmpty: 'There are no messages in this chat to share.', + evidencePreviewYou: 'You', + evidencePreviewThem: 'Them', + + // ── Minor safety ── + minorSafetyEscalated: + 'This report is escalated to our safety team immediately. You don’t need to attach anything.', + + // ── Submit / confirmation ── + submit: 'Submit report', + submitting: 'Submitting…', + confirmationHeading: 'Your report was sent', + confirmationBody: + "Thanks for telling us. Our safety team reviews every report. We won't tell this person who reported them.", + confirmationBlocked: "You've also blocked this person, so they can no longer contact you or see you.", + confirmationDone: 'Done', + + // ── Evidence outcome (non-blocking) ── + evidenceAttached: 'The chat was attached to your report.', + evidenceFailed: "Your report was sent. We couldn't attach the chat.", + evidenceRetry: 'Retry attaching the chat', + evidenceRetrying: 'Attaching…', + + // ── Errors ── + rateLimited: "You've sent a lot of reports recently. Please try again in a little while.", + submitError: "We couldn't send your report just now. Please try again.", +} as const; + +export type ReportStrings = typeof REPORT_STRINGS; diff --git a/src/features/moderation/report/submit.ts b/src/features/moderation/report/submit.ts new file mode 100644 index 0000000..fb6ef98 --- /dev/null +++ b/src/features/moderation/report/submit.ts @@ -0,0 +1,206 @@ +/** + * Report submit orchestration (OneHook moderation Phase 2). + * + * Files the report, then — only if the reporter opted in AND the server allows it — decrypts the + * chat window locally, builds the canonical bundle, and uploads it. The two hard rules: + * + * 1. The report fields are IDENTICAL whether or not evidence is shared, except `evidenceOffered` + * (no dark patterns: declining is treated exactly like sharing). + * 2. Evidence failure must NEVER fail the report: the report is already filed, so an upload error + * is swallowed into a non-blocking outcome the UI surfaces with a Retry action. + * + * Fully dependency-injected (report API, evidence transport, telemetry, clock) so it is unit-testable + * without a browser or the network. + */ +import { + ReportsApi, + type CreateReportRequest, + type CreateReportResponse, + type ReportCategoryCode, +} from '../../../api/rest'; +import { + buildBundle, + canonicalByteLength, + canonicalBytes, + bundleSha256Hex, + hexToBase64, + EVIDENCE_WINDOW, + type BundleMessage, +} from './evidence-bundle'; +import { selectEvidenceWindow, type SourceMessage } from './window-selection'; +import { + presignEvidence, + putEvidenceBytes, + completeEvidence, + type EvidenceTransport, + type RetryOptions, +} from './evidence-api'; +import { getMemberIdToken } from './member-auth'; +import { trackReport, ReportEvent, type ReportCategory, type ReportSource } from './telemetry'; + +export type EvidenceOutcome = 'skipped' | 'not_allowed' | 'empty' | 'attached' | 'failed'; + +export interface ReportSubmission { + reportedUserId: string; + category: ReportCategory; + details?: string; + alsoBlock: boolean; + /** Present only when the flow started from a chat (used as the bundle conversationId too). */ + conversationId?: string; + source: ReportSource; + /** The evidence checkbox value. Never pre-ticked; only possible from a chat on an eligible category. */ + shareEvidence: boolean; + /** Decrypted source messages for the evidence window (only used when shareEvidence). */ + messages?: SourceMessage[]; + includesMedia?: boolean; +} + +export interface SubmitDeps { + createReport?: (body: CreateReportRequest) => Promise; + evidence?: EvidenceTransport; + evidenceRetry?: RetryOptions; + telemetry?: typeof trackReport; + nowMs?: () => number; +} + +export interface SubmitResult { + reportId: string; + severity: string; + evidenceAllowed: boolean; + blocked: boolean; + evidenceOutcome: EvidenceOutcome; +} + +function defaultEvidenceTransport(): EvidenceTransport { + return { getToken: getMemberIdToken }; +} + +/** Build the exact report body. Only `evidenceOffered` ever varies with the checkbox. */ +export function buildReportBody(sub: ReportSubmission): CreateReportRequest { + const details = sub.details?.trim(); + const body: CreateReportRequest = { + reportedUserId: sub.reportedUserId, + category: sub.category as ReportCategoryCode, + alsoBlock: sub.alsoBlock, + evidenceOffered: sub.shareEvidence, + }; + if (details) body.details = details; + if (sub.conversationId) body.conversationId = sub.conversationId; + return body; +} + +/** + * Upload the decrypted-chat evidence for an already-filed report. Builds the window + canonical + * bundle, hashes it, presigns, PUTs the exact bytes, and completes (with 503 retry). Throws on + * failure (the caller decides whether that fails the report — it must NOT). Returns the outcome + * `'attached'`, or `'empty'` when there is nothing to share. + */ +export async function uploadEvidence( + reportId: string, + conversationId: string, + messages: SourceMessage[], + deps: SubmitDeps = {} +): Promise> { + const nowMs = deps.nowMs ?? (() => Date.now()); + const transport = deps.evidence ?? defaultEvidenceTransport(); + const includesMedia = false; // no media in this release (docs/MODERATION.md §9.3) + + const window: BundleMessage[] = selectEvidenceWindow(messages, { + maxMessages: EVIDENCE_WINDOW.maxMessages, + maxDays: EVIDENCE_WINDOW.maxDays, + nowMs: nowMs(), + }); + if (window.length === 0) return 'empty'; + + const bundle = buildBundle({ + reportId, + conversationId, + messages: window, + attachments: [], + }); + const sha256 = await bundleSha256Hex(bundle); + const sizeBytes = canonicalByteLength(bundle); + const bytes = canonicalBytes(bundle); + + const presign = await presignEvidence(transport, { + reportId, + sizeBytes, + sha256, + messageCount: window.length, + includesMedia, + }); + + // The presigned PUT requires the exact checksum header; if the server didn't echo it, compute it + // from our hash so S3's enforcement still matches our bytes. + const headers = { ...presign.upload.headers }; + if (!headers['x-amz-checksum-sha256']) headers['x-amz-checksum-sha256'] = hexToBase64(sha256); + + await putEvidenceBytes(transport.fetchImpl, { ...presign.upload, headers }, bytes); + + const messageIds = window.map((m) => m.messageId); + const firstSentAt = Date.parse(window[0].sentAt); + const lastSentAt = Date.parse(window[window.length - 1].sentAt); + + await completeEvidence( + transport, + { reportId, evidenceId: presign.evidenceId, messageIds, firstSentAt, lastSentAt }, + deps.evidenceRetry + ); + return 'attached'; +} + +/** + * File the report and, when applicable, attach the decrypted chat. Report failures throw (the UI + * shows a friendly message, handling 429 specially). Evidence failures never throw — they are + * reported through `evidenceOutcome: 'failed'`. + */ +export async function submitReport(sub: ReportSubmission, deps: SubmitDeps = {}): Promise { + const createReport = deps.createReport ?? ((body) => ReportsApi.create(body)); + const emit = deps.telemetry ?? trackReport; + + const evidenceOffered = sub.shareEvidence; + emit(evidenceOffered ? ReportEvent.REPORT_SUBMIT_WITH_EVIDENCE : ReportEvent.REPORT_SUBMIT_WITHOUT_EVIDENCE, { + category: sub.category, + source: sub.source, + evidence: evidenceOffered, + }); + + let response: CreateReportResponse; + try { + response = await createReport(buildReportBody(sub)); + } catch (err) { + emit(ReportEvent.REPORT_SUBMIT_FAILED, { category: sub.category, source: sub.source }); + throw err; + } + + const result: SubmitResult = { + reportId: response.reportId, + severity: response.severity, + evidenceAllowed: response.evidenceAllowed, + blocked: sub.alsoBlock, + evidenceOutcome: 'skipped', + }; + + if (!evidenceOffered) return result; + if (!response.evidenceAllowed) { + result.evidenceOutcome = 'not_allowed'; + return result; + } + + try { + result.evidenceOutcome = await uploadEvidence( + response.reportId, + sub.conversationId ?? '', + sub.messages ?? [], + deps + ); + if (result.evidenceOutcome === 'attached') { + emit(ReportEvent.REPORT_EVIDENCE_UPLOAD_SUCCEEDED, { category: sub.category, source: sub.source }); + } + } catch { + // Evidence failure must NOT fail the report — it is already filed. + result.evidenceOutcome = 'failed'; + emit(ReportEvent.REPORT_EVIDENCE_UPLOAD_FAILED, { category: sub.category, source: sub.source }); + } + return result; +} diff --git a/src/features/moderation/report/telemetry.ts b/src/features/moderation/report/telemetry.ts new file mode 100644 index 0000000..d4c85c2 --- /dev/null +++ b/src/features/moderation/report/telemetry.ts @@ -0,0 +1,47 @@ +/** + * Report flow telemetry (OneHook moderation Phase 2). + * + * The report flow mixes button presses, "shown" impressions and upload outcomes, so the backend + * catalog models it as a dedicated `report_flow` SIGNAL (not `ui_interaction`) — see the ReportEvent + * doc in the generated catalog. This helper is the ONLY place the flow emits telemetry. + * + * HARD PRIVACY RULE: a report_flow signal may carry ONLY these scalar dimensions: + * - event : the ReportEvent id (which step) + * - category : the ReportCategory code (never free text) + * - source : chat | profile (where the flow started) + * - evidence : boolean (whether the decrypted chat was offered), on submit + * It MUST NEVER carry message text, user ids, the reported person, or the report's free-text + * details. `buildReportSignalAttributes` is pure and whitelists exactly those keys so a mistake is + * caught by a unit test, and the analytics client additionally strips any denylisted key. + */ +import { track, ReportEvent, type ReportCategory, type ReportSource } from '../../../lib/analytics/analytics'; + +export { ReportEvent }; +export type { ReportCategory, ReportSource }; + +export interface ReportTelemetryAttrs { + category?: ReportCategory; + source?: ReportSource; + evidence?: boolean; +} + +/** + * Build the scalar attribute bag for a report_flow signal. Only the whitelisted dimensions survive; + * `undefined` values are omitted. Returned values are always enum strings or a boolean — never free + * text, user ids or message content. + */ +export function buildReportSignalAttributes( + event: ReportEvent, + attrs: ReportTelemetryAttrs = {} +): Record { + const out: Record = { event }; + if (attrs.category !== undefined) out.category = attrs.category; + if (attrs.source !== undefined) out.source = attrs.source; + if (attrs.evidence !== undefined) out.evidence = attrs.evidence; + return out; +} + +/** Emit one report_flow signal. No-op unless analytics is enabled + consented (handled downstream). */ +export function trackReport(event: ReportEvent, attrs: ReportTelemetryAttrs = {}): void { + track('report_flow', buildReportSignalAttributes(event, attrs)); +} diff --git a/src/features/moderation/report/window-selection.ts b/src/features/moderation/report/window-selection.ts new file mode 100644 index 0000000..e79d641 --- /dev/null +++ b/src/features/moderation/report/window-selection.ts @@ -0,0 +1,82 @@ +/** + * Evidence window selection (OneHook moderation Phase 2, docs/MODERATION.md §2, §9.3). + * + * Builds the exact set of messages that will be shared as decrypted evidence: the most recent 200 + * messages within the last 30 days (whichever cap bites first), oldest first in the bundle (per the + * canonical sort rule). System and undecryptable messages are skipped; messages from other + * conversations are never seen here because the caller only ever passes ONE conversation's history. + * + * Pure and deterministic so it can be unit-tested without a browser or the network. `nowMs` is + * injected (defaults to Date.now) so the 30-day cutoff is testable. + */ +import type { BundleMessage, SenderSide } from './evidence-bundle'; +import { EVIDENCE_WINDOW, sortMessages } from './evidence-bundle'; + +/** A single decrypted source message as the chat UI already holds it (via the E2EE hook). */ +export interface SourceMessage { + messageId: string; + /** Epoch milliseconds the message was sent. */ + sentAtMs: number; + senderSide: SenderSide; + /** + * Decrypted plaintext, or null for a message that must be skipped: a system message, an + * attachment/media-only message (no text in this release), or anything without a text body. + */ + text: string | null; + /** True when the envelope could not be decrypted on this device; such messages are skipped. */ + undecryptable?: boolean; +} + +export interface WindowOptions { + maxMessages?: number; + maxDays?: number; + nowMs?: number; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** ISO-8601 UTC with millisecond precision, e.g. "2026-10-26T12:34:56.789Z". */ +export function toIsoUtcMillis(epochMs: number): string { + return new Date(epochMs).toISOString(); +} + +/** + * Select and map the evidence window. + * + * 1. Drop undecryptable messages and messages with no text body (system / media-only). + * 2. Drop anything older than `maxDays` before `nowMs` (30-day cap). + * 3. Keep only the most recent `maxMessages` (200-message cap). + * 4. Return them oldest-first, canonically sorted (sentAt then messageId), mapped to BundleMessage. + */ +export function selectEvidenceWindow( + messages: readonly SourceMessage[], + opts: WindowOptions = {} +): BundleMessage[] { + const maxMessages = opts.maxMessages ?? EVIDENCE_WINDOW.maxMessages; + const maxDays = opts.maxDays ?? EVIDENCE_WINDOW.maxDays; + const nowMs = opts.nowMs ?? Date.now(); + const cutoffMs = nowMs - maxDays * MS_PER_DAY; + + const eligible = messages.filter( + (m) => !m.undecryptable && typeof m.text === 'string' && m.sentAtMs >= cutoffMs + ); + + // Keep the most recent `maxMessages`: order by (sentAt, messageId) and take the tail. + const ordered = [...eligible].sort((a, b) => { + if (a.sentAtMs !== b.sentAtMs) return a.sentAtMs - b.sentAtMs; + if (a.messageId < b.messageId) return -1; + if (a.messageId > b.messageId) return 1; + return 0; + }); + const windowed = ordered.length > maxMessages ? ordered.slice(ordered.length - maxMessages) : ordered; + + const mapped: BundleMessage[] = windowed.map((m) => ({ + messageId: m.messageId, + sentAt: toIsoUtcMillis(m.sentAtMs), + senderSide: m.senderSide, + text: m.text as string, + })); + + // Canonical oldest-first order (sentAt then messageId by UTF-16 code unit). + return sortMessages(mapped); +} diff --git a/src/tests/fixtures/moderation/bundle-basic.json b/src/tests/fixtures/moderation/bundle-basic.json new file mode 100644 index 0000000..95aa21b --- /dev/null +++ b/src/tests/fixtures/moderation/bundle-basic.json @@ -0,0 +1,50 @@ +{ + "note": "Two ASCII messages; input order reversed, canonical sort puts m1 (earlier sentAt) first.", + "input": { + "reportId": "rep_01HZ", + "conversationId": "conv_42", + "messages": [ + { + "messageId": "m2", + "sentAt": "2026-10-26T12:00:01.000Z", + "senderSide": "reported", + "text": "stop messaging me" + }, + { + "messageId": "m1", + "sentAt": "2026-10-26T12:00:00.000Z", + "senderSide": "reporter", + "text": "hello" + } + ] + }, + "bundle": { + "v": 1, + "reportId": "rep_01HZ", + "conversationId": "conv_42", + "window": { + "maxMessages": 200, + "maxDays": 30 + }, + "messages": [ + { + "messageId": "m1", + "sentAt": "2026-10-26T12:00:00.000Z", + "senderSide": "reporter", + "text": "hello" + }, + { + "messageId": "m2", + "sentAt": "2026-10-26T12:00:01.000Z", + "senderSide": "reported", + "text": "stop messaging me" + } + ], + "attachments": [] + }, + "canonical": "{\"v\":1,\"reportId\":\"rep_01HZ\",\"conversationId\":\"conv_42\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[{\"messageId\":\"m1\",\"sentAt\":\"2026-10-26T12:00:00.000Z\",\"senderSide\":\"reporter\",\"text\":\"hello\"},{\"messageId\":\"m2\",\"sentAt\":\"2026-10-26T12:00:01.000Z\",\"senderSide\":\"reported\",\"text\":\"stop messaging me\"}],\"attachments\":[]}", + "canonicalByteLength": 328, + "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f3031485a222c22636f6e766572736174696f6e4964223a22636f6e765f3432222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b7b226d6573736167654964223a226d31222c2273656e744174223a22323032362d31302d32365431323a30303a30302e3030305a222c2273656e64657253696465223a227265706f72746572222c2274657874223a2268656c6c6f227d2c7b226d6573736167654964223a226d32222c2273656e744174223a22323032362d31302d32365431323a30303a30312e3030305a222c2273656e64657253696465223a227265706f72746564222c2274657874223a2273746f70206d6573736167696e67206d65227d5d2c226174746163686d656e7473223a5b5d7d", + "sha256Hex": "d0cd2fdab27cee978781a8b341d0fa5f4fffb4da7e483c00c9848d10dd662998", + "sha256Base64": "0M0v2rJ87peHgaizQdD6X0//tNp+SDwAyYSNEN1mKZg=" +} diff --git a/src/tests/fixtures/moderation/bundle-empty.json b/src/tests/fixtures/moderation/bundle-empty.json new file mode 100644 index 0000000..6723e8d --- /dev/null +++ b/src/tests/fixtures/moderation/bundle-empty.json @@ -0,0 +1,24 @@ +{ + "note": "No messages: empty messages[] and attachments[], default window.", + "input": { + "reportId": "rep_empty", + "conversationId": "conv_empty", + "messages": [] + }, + "bundle": { + "v": 1, + "reportId": "rep_empty", + "conversationId": "conv_empty", + "window": { + "maxMessages": 200, + "maxDays": 30 + }, + "messages": [], + "attachments": [] + }, + "canonical": "{\"v\":1,\"reportId\":\"rep_empty\",\"conversationId\":\"conv_empty\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[],\"attachments\":[]}", + "canonicalByteLength": 133, + "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f656d707479222c22636f6e766572736174696f6e4964223a22636f6e765f656d707479222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b5d2c226174746163686d656e7473223a5b5d7d", + "sha256Hex": "256838f9f46c1e37e0e1c2936d70c5956f1ae40ac34a8cb4515c9d744660a1e5", + "sha256Base64": "JWg4+fRsHjfg4cKTbXDFlW8a5ArDSoy0UVyddEZgoeU=" +} diff --git a/src/tests/fixtures/moderation/bundle-escaping.json b/src/tests/fixtures/moderation/bundle-escaping.json new file mode 100644 index 0000000..bd3e569 --- /dev/null +++ b/src/tests/fixtures/moderation/bundle-escaping.json @@ -0,0 +1,38 @@ +{ + "note": "Locks escaping: escape only \" \\ and control<0x20 (\\b\\f\\n\\r\\t else \\u00XX lowercase); never escape / or non-ASCII or DEL.", + "input": { + "reportId": "rep_esc", + "conversationId": "conv_esc", + "messages": [ + { + "messageId": "m1", + "sentAt": "2026-10-26T00:00:00.000Z", + "senderSide": "reporter", + "text": "quote:\" backslash:\\ slash:/ tab:\t newline:\n ctrl:\u0001 del: emoji:😀 accent:café" + } + ] + }, + "bundle": { + "v": 1, + "reportId": "rep_esc", + "conversationId": "conv_esc", + "window": { + "maxMessages": 200, + "maxDays": 30 + }, + "messages": [ + { + "messageId": "m1", + "sentAt": "2026-10-26T00:00:00.000Z", + "senderSide": "reporter", + "text": "quote:\" backslash:\\ slash:/ tab:\t newline:\n ctrl:\u0001 del: emoji:😀 accent:café" + } + ], + "attachments": [] + }, + "canonical": "{\"v\":1,\"reportId\":\"rep_esc\",\"conversationId\":\"conv_esc\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[{\"messageId\":\"m1\",\"sentAt\":\"2026-10-26T00:00:00.000Z\",\"senderSide\":\"reporter\",\"text\":\"quote:\\\" backslash:\\\\ slash:/ tab:\\t newline:\\n ctrl:\\u0001 del: emoji:😀 accent:café\"}],\"attachments\":[]}", + "canonicalByteLength": 306, + "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f657363222c22636f6e766572736174696f6e4964223a22636f6e765f657363222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b7b226d6573736167654964223a226d31222c2273656e744174223a22323032362d31302d32365430303a30303a30302e3030305a222c2273656e64657253696465223a227265706f72746572222c2274657874223a2271756f74653a5c22206261636b736c6173683a5c5c20736c6173683a2f207461623a5c74206e65776c696e653a5c6e206374726c3a5c75303030312064656c3a7f20656d6f6a693af09f988020616363656e743a636166c3a9227d5d2c226174746163686d656e7473223a5b5d7d", + "sha256Hex": "89206a07737afb492a63295099ced43b3b84baa6893f293377e99aaedc2d64bf", + "sha256Base64": "iSBqB3N6+0kqYylQmc7UOzuEuqaJPykzd+martwtZL8=" +} diff --git a/src/tests/fixtures/moderation/bundle-tiebreak-media.json b/src/tests/fixtures/moderation/bundle-tiebreak-media.json new file mode 100644 index 0000000..da0a63a --- /dev/null +++ b/src/tests/fixtures/moderation/bundle-tiebreak-media.json @@ -0,0 +1,57 @@ +{ + "note": "Equal sentAt tie-broken by messageId (a before b); attachments present (media opt-in).", + "input": { + "reportId": "rep_media", + "conversationId": "conv_media", + "messages": [ + { + "messageId": "b", + "sentAt": "2026-10-26T09:30:00.500Z", + "senderSide": "reported", + "text": "two" + }, + { + "messageId": "a", + "sentAt": "2026-10-26T09:30:00.500Z", + "senderSide": "reporter", + "text": "one" + } + ], + "attachments": [ + "att_0001", + "att_0002" + ] + }, + "bundle": { + "v": 1, + "reportId": "rep_media", + "conversationId": "conv_media", + "window": { + "maxMessages": 200, + "maxDays": 30 + }, + "messages": [ + { + "messageId": "a", + "sentAt": "2026-10-26T09:30:00.500Z", + "senderSide": "reporter", + "text": "one" + }, + { + "messageId": "b", + "sentAt": "2026-10-26T09:30:00.500Z", + "senderSide": "reported", + "text": "two" + } + ], + "attachments": [ + "att_0001", + "att_0002" + ] + }, + "canonical": "{\"v\":1,\"reportId\":\"rep_media\",\"conversationId\":\"conv_media\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[{\"messageId\":\"a\",\"sentAt\":\"2026-10-26T09:30:00.500Z\",\"senderSide\":\"reporter\",\"text\":\"one\"},{\"messageId\":\"b\",\"sentAt\":\"2026-10-26T09:30:00.500Z\",\"senderSide\":\"reported\",\"text\":\"two\"}],\"attachments\":[\"att_0001\",\"att_0002\"]}", + "canonicalByteLength": 335, + "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f6d65646961222c22636f6e766572736174696f6e4964223a22636f6e765f6d65646961222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b7b226d6573736167654964223a2261222c2273656e744174223a22323032362d31302d32365430393a33303a30302e3530305a222c2273656e64657253696465223a227265706f72746572222c2274657874223a226f6e65227d2c7b226d6573736167654964223a2262222c2273656e744174223a22323032362d31302d32365430393a33303a30302e3530305a222c2273656e64657253696465223a227265706f72746564222c2274657874223a2274776f227d5d2c226174746163686d656e7473223a5b226174745f30303031222c226174745f30303032225d7d", + "sha256Hex": "975a5b39f428c09269cee0198040a6cc8a5c4387d5b4f86ebbd4dc541f29a870", + "sha256Base64": "l1pbOfQowJJpzuAZgECmzIpcQ4fVtPhuu9TcVB8pqHA=" +} diff --git a/src/tests/report-evidence-bundle.test.ts b/src/tests/report-evidence-bundle.test.ts new file mode 100644 index 0000000..bfd08a0 --- /dev/null +++ b/src/tests/report-evidence-bundle.test.ts @@ -0,0 +1,90 @@ +/** + * Canonical evidence-bundle encoder — golden-vector parity (OneHook moderation Phase 2). + * + * The web encoder MUST reproduce the portal reference's `canonical`, `canonicalBytesHex`, + * `sha256Hex` and `sha256Base64` byte-for-byte for every contract fixture, or the SHA-256 the + * client declares at presign won't match what the server and admin viewer recompute. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { + canonicalString, + canonicalByteLength, + canonicalBytes, + bundleSha256Hex, + bundleSha256Base64, + hexToBase64, + escapeCanonicalString, + type BundleInput, +} from '../features/moderation/report/evidence-bundle'; + +const FIXTURE_DIR = new URL('./fixtures/moderation/', import.meta.url).pathname; + +interface Fixture { + input: BundleInput; + canonical: string; + canonicalByteLength: number; + canonicalBytesHex: string; + sha256Hex: string; + sha256Base64: string; +} + +function loadFixtures(): { name: string; fx: Fixture }[] { + return readdirSync(FIXTURE_DIR) + .filter((f) => f.startsWith('bundle-') && f.endsWith('.json')) + .sort() + .map((name) => ({ name, fx: JSON.parse(readFileSync(join(FIXTURE_DIR, name), 'utf8')) as Fixture })); +} + +function toHex(bytes: Uint8Array): string { + let out = ''; + for (const b of bytes) out += b.toString(16).padStart(2, '0'); + return out; +} + +describe('canonical evidence bundle — golden fixtures', () => { + const fixtures = loadFixtures(); + + it('finds all four contract fixtures', () => { + expect(fixtures.map((f) => f.name)).toEqual([ + 'bundle-basic.json', + 'bundle-empty.json', + 'bundle-escaping.json', + 'bundle-tiebreak-media.json', + ]); + }); + + for (const { name, fx } of fixtures) { + describe(name, () => { + it('reproduces the canonical string', () => { + expect(canonicalString(fx.input)).toBe(fx.canonical); + }); + it('reproduces the canonical byte length', () => { + expect(canonicalByteLength(fx.input)).toBe(fx.canonicalByteLength); + }); + it('reproduces the canonical bytes (hex)', () => { + expect(toHex(canonicalBytes(fx.input))).toBe(fx.canonicalBytesHex); + }); + it('reproduces sha256Hex', async () => { + expect(await bundleSha256Hex(fx.input)).toBe(fx.sha256Hex); + }); + it('reproduces sha256Base64', async () => { + expect(await bundleSha256Base64(fx.input)).toBe(fx.sha256Base64); + }); + it('hexToBase64(sha256Hex) === sha256Base64', () => { + expect(hexToBase64(fx.sha256Hex)).toBe(fx.sha256Base64); + }); + }); + } +}); + +describe('canonical string escaping', () => { + it('escapes only " and \\\\ and control chars; leaves /, DEL and non-ASCII raw', () => { + expect(escapeCanonicalString('a"b\\c')).toBe('"a\\"b\\\\c"'); + expect(escapeCanonicalString('tab\tnl\ncr\rbs\bff\f')).toBe('"tab\\tnl\\ncr\\rbs\\bff\\f"'); + expect(escapeCanonicalString('\u0000\u001f')).toBe('"\\u0000\\u001f"'); + // slash, DEL (0x7f) and non-ASCII are NOT escaped + expect(escapeCanonicalString('/\u007f😀café')).toBe('"/\u007f😀café"'); + }); +}); diff --git a/src/tests/report-submit.test.ts b/src/tests/report-submit.test.ts new file mode 100644 index 0000000..5e962fb --- /dev/null +++ b/src/tests/report-submit.test.ts @@ -0,0 +1,298 @@ +/** + * Report submit orchestration + evidence transport (OneHook moderation Phase 2). + * + * Covers: submit with and without evidence send IDENTICAL report fields except `evidenceOffered`; + * evidence failure leaves the report filed; the 503 EVIDENCE_FINALIZE_RETRY backoff; MINOR_SAFETY + * (evidenceAllowed:false) never uploads; and telemetry carries no text / user ids. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + submitReport, + buildReportBody, + type ReportSubmission, + type SubmitDeps, +} from '../features/moderation/report/submit'; +import { + completeEvidence, + EvidenceError, + type EvidenceTransport, +} from '../features/moderation/report/evidence-api'; +import { ReportCategory } from '../features/moderation/report/categories'; +import type { CreateReportRequest, CreateReportResponse } from '../api/rest'; +import type { SourceMessage } from '../features/moderation/report/window-selection'; + +const NOW = Date.parse('2026-10-26T12:00:00.000Z'); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +const PRESIGN_BODY = { + evidenceId: 'ev_test', + upload: { + url: 'https://evidence-bucket.s3.ap-south-1.amazonaws.com/pending/rep/ev_test.json?X-Amz-Signature=abc', + method: 'PUT', + headers: { + 'x-amz-checksum-sha256': 'PLACEHOLDER', + 'x-amz-server-side-encryption': 'aws:kms', + 'x-amz-server-side-encryption-aws-kms-key-id': 'arn:aws:kms:ap-south-1:1:key/evidence', + 'x-amz-tagging': 'state=pending', + }, + }, + expiresAt: NOW + 900_000, +}; + +interface FetchLog { + presign: number; + put: number; + complete: number; + putHeaders: Record[]; +} + +function makeTransport( + completeResponder: () => Response, + log: FetchLog, + putResponder: () => Response = () => new Response(null, { status: 200 }) +): EvidenceTransport { + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url.endsWith('/evidence/presign')) { + log.presign++; + return jsonResponse(200, PRESIGN_BODY); + } + if (url.endsWith('/evidence/complete')) { + log.complete++; + return completeResponder(); + } + if (method === 'PUT') { + log.put++; + log.putHeaders.push({ ...(init?.headers as Record) }); + return putResponder(); + } + throw new Error(`unexpected fetch ${method} ${url}`); + }) as unknown as typeof fetch; + + return { + baseUrl: 'https://admin.onehook.club/api/p/onehook/gamma/moderation', + fetchImpl, + getToken: async () => 'member-id-token', + sleep: async () => {}, + }; +} + +function newLog(): FetchLog { + return { presign: 0, put: 0, complete: 0, putHeaders: [] }; +} + +const okReport = (over: Partial = {}): CreateReportResponse => ({ + reportId: 'b3f1c2de-4a5b-4c6d-8e9f-0a1b2c3d4e5f', + severity: 'high', + evidenceAllowed: true, + ...over, +}); + +const baseSubmission = (over: Partial = {}): ReportSubmission => ({ + reportedUserId: 'user-reported-123', + category: ReportCategory.HARASSMENT, + details: 'they kept messaging after I asked them to stop', + alsoBlock: true, + conversationId: 'match-abc', + source: 'chat', + shareEvidence: false, + ...over, +}); + +const sampleMessages = (): SourceMessage[] => [ + { messageId: 'm2', sentAtMs: NOW - 1000, senderSide: 'reported', text: 'stop messaging me' }, + { messageId: 'm1', sentAtMs: NOW - 2000, senderSide: 'reporter', text: 'hello' }, +]; + +describe('buildReportBody', () => { + it('sends identical fields except evidenceOffered', () => { + const noShare = buildReportBody(baseSubmission({ shareEvidence: false })); + const share = buildReportBody(baseSubmission({ shareEvidence: true, messages: sampleMessages() })); + expect(noShare.evidenceOffered).toBe(false); + expect(share.evidenceOffered).toBe(true); + const stripEvidence = (b: CreateReportRequest) => ({ ...b, evidenceOffered: undefined }); + expect(stripEvidence(noShare)).toEqual(stripEvidence(share)); + }); + + it('omits empty details and includes conversationId', () => { + const b = buildReportBody(baseSubmission({ details: ' ', conversationId: 'match-xyz' })); + expect(b.details).toBeUndefined(); + expect(b.conversationId).toBe('match-xyz'); + }); +}); + +describe('submitReport — no evidence', () => { + it('files the report and never touches the evidence transport', async () => { + const bodies: CreateReportRequest[] = []; + const log = newLog(); + const deps: SubmitDeps = { + createReport: async (b) => { + bodies.push(b); + return okReport(); + }, + evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log), + nowMs: () => NOW, + }; + const res = await submitReport(baseSubmission({ shareEvidence: false }), deps); + expect(res.evidenceOutcome).toBe('skipped'); + expect(res.blocked).toBe(true); + expect(res.reportId).toBe('b3f1c2de-4a5b-4c6d-8e9f-0a1b2c3d4e5f'); + expect(bodies[0].evidenceOffered).toBe(false); + expect(log.presign + log.put + log.complete).toBe(0); + }); +}); + +describe('submitReport — with evidence', () => { + it('files the report then presigns, PUTs the exact bytes and completes', async () => { + const log = newLog(); + const deps: SubmitDeps = { + createReport: async () => okReport(), + evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log), + nowMs: () => NOW, + }; + const res = await submitReport(baseSubmission({ shareEvidence: true, messages: sampleMessages() }), deps); + expect(res.evidenceOutcome).toBe('attached'); + expect(log.presign).toBe(1); + expect(log.put).toBe(1); + expect(log.complete).toBe(1); + // PUT carries the SSE-KMS + checksum headers the presign returned. + expect(log.putHeaders[0]['x-amz-server-side-encryption']).toBe('aws:kms'); + expect(log.putHeaders[0]['x-amz-checksum-sha256']).toBeTruthy(); + expect(log.putHeaders[0]['Authorization']).toBeUndefined(); + }); + + it('does not upload when the server disallows evidence (e.g. MINOR_SAFETY)', async () => { + const log = newLog(); + const deps: SubmitDeps = { + createReport: async () => okReport({ evidenceAllowed: false }), + evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log), + nowMs: () => NOW, + }; + const res = await submitReport( + baseSubmission({ category: ReportCategory.MINOR_SAFETY, shareEvidence: true, messages: sampleMessages() }), + deps + ); + expect(res.evidenceOutcome).toBe('not_allowed'); + expect(log.presign).toBe(0); + }); + + it('leaves the report FILED when the evidence upload fails', async () => { + const log = newLog(); + const deps: SubmitDeps = { + createReport: async () => okReport(), + evidence: makeTransport( + () => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), + log, + () => new Response('denied', { status: 403 }) // PUT fails + ), + nowMs: () => NOW, + }; + const res = await submitReport(baseSubmission({ shareEvidence: true, messages: sampleMessages() }), deps); + // Report succeeded; evidence failed, non-blocking. + expect(res.reportId).toBeTruthy(); + expect(res.evidenceOutcome).toBe('failed'); + }); +}); + +describe('completeEvidence — 503 EVIDENCE_FINALIZE_RETRY backoff', () => { + it('retries the finalize on 503 then succeeds', async () => { + const log = newLog(); + let n = 0; + const transport = makeTransport(() => { + n++; + if (n <= 2) return jsonResponse(503, { code: 'EVIDENCE_FINALIZE_RETRY', message: 'finalizing' }); + return jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }); + }, log); + const sleeps: number[] = []; + const res = await completeEvidence( + { ...transport, sleep: async (ms) => void sleeps.push(ms) }, + { reportId: 'r', evidenceId: 'ev_test', messageIds: ['m1'], firstSentAt: NOW - 2000, lastSentAt: NOW }, + { baseDelayMs: 10, maxDelayMs: 100 } + ); + expect(res.status).toBe('recorded'); + expect(log.complete).toBe(3); + expect(sleeps).toEqual([10, 20]); // exponential backoff, two retries + }); + + it('gives up after maxRetries and throws', async () => { + const log = newLog(); + const transport = makeTransport( + () => jsonResponse(503, { code: 'EVIDENCE_FINALIZE_RETRY' }), + log + ); + await expect( + completeEvidence( + { ...transport, sleep: async () => {} }, + { reportId: 'r', evidenceId: 'ev_test', messageIds: ['m1'], firstSentAt: NOW, lastSentAt: NOW }, + { maxRetries: 2, baseDelayMs: 1 } + ) + ).rejects.toBeInstanceOf(EvidenceError); + expect(log.complete).toBe(3); // first + 2 retries + }); + + it('does NOT retry a non-retryable error', async () => { + const log = newLog(); + const transport = makeTransport(() => jsonResponse(409, { code: 'EVIDENCE_ALREADY_COMPLETED' }), log); + await expect( + completeEvidence( + { ...transport, sleep: async () => {} }, + { reportId: 'r', evidenceId: 'ev_test', messageIds: ['m1'], firstSentAt: NOW, lastSentAt: NOW } + ) + ).rejects.toMatchObject({ status: 409, code: 'EVIDENCE_ALREADY_COMPLETED' }); + expect(log.complete).toBe(1); + }); +}); + +describe('submitReport — report failure propagates', () => { + it('rethrows the report error and emits submit_failed (429 preserved)', async () => { + const events: { event: string; attrs: Record }[] = []; + const err = Object.assign(new Error('rate limited'), { status: 429 }); + const deps: SubmitDeps = { + createReport: async () => { + throw err; + }, + telemetry: ((event: string, attrs: Record) => events.push({ event, attrs })) as SubmitDeps['telemetry'], + }; + await expect(submitReport(baseSubmission({ shareEvidence: false }), deps)).rejects.toBe(err); + expect(events.some((e) => e.event === 'report.submit_failed')).toBe(true); + }); +}); + +describe('report telemetry carries no text / user ids', () => { + it('only ever emits the whitelisted scalar dimensions', async () => { + const events: { event: string; attrs: Record }[] = []; + const log = newLog(); + const deps: SubmitDeps = { + createReport: async () => okReport(), + evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log), + telemetry: ((event: string, attrs: Record) => events.push({ event, attrs })) as SubmitDeps['telemetry'], + nowMs: () => NOW, + }; + const sub = baseSubmission({ + shareEvidence: true, + messages: sampleMessages(), + details: 'SECRET free text that must never leave the device', + reportedUserId: 'user-reported-123', + }); + await submitReport(sub, deps); + + expect(events.length).toBeGreaterThan(0); + const allowed = new Set(['event', 'category', 'source', 'evidence']); + for (const { attrs } of events) { + for (const [k, v] of Object.entries(attrs)) { + expect(allowed.has(k)).toBe(true); + // no value is free text, the reported user id, or message content + expect(v).not.toBe(sub.details); + expect(v).not.toBe(sub.reportedUserId); + if (typeof v === 'string') expect(v).not.toContain('hello'); + } + } + // the submit + upload success were both reported + expect(events.map((e) => e.event)).toContain('report.submit_with_evidence'); + expect(events.map((e) => e.event)).toContain('report.evidence_upload_succeeded'); + }); +}); diff --git a/src/tests/report-window-categories.test.ts b/src/tests/report-window-categories.test.ts new file mode 100644 index 0000000..e237daf --- /dev/null +++ b/src/tests/report-window-categories.test.ts @@ -0,0 +1,153 @@ +/** + * Evidence window selection + category rules (OneHook moderation Phase 2). + */ +import { describe, it, expect } from 'vitest'; +import { + selectEvidenceWindow, + toIsoUtcMillis, + type SourceMessage, +} from '../features/moderation/report/window-selection'; +import { + REPORT_CATEGORIES, + getCategoryMeta, + ReportCategory, + NOT_REPORTABLE_CASES, + DETAILS_MAX_CHARS, +} from '../features/moderation/report/categories'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.parse('2026-10-26T12:00:00.000Z'); + +function msg(id: string, offsetMs: number, side: 'reporter' | 'reported', text: string | null): SourceMessage { + return { messageId: id, sentAtMs: NOW + offsetMs, senderSide: side, text }; +} + +describe('selectEvidenceWindow', () => { + it('sorts oldest-first by sentAt then messageId and maps to ISO-8601 UTC ms', () => { + const out = selectEvidenceWindow( + [ + msg('b', -1000, 'reported', 'second'), + msg('a', -2000, 'reporter', 'first'), + ], + { nowMs: NOW } + ); + expect(out.map((m) => m.messageId)).toEqual(['a', 'b']); + expect(out[0]).toEqual({ + messageId: 'a', + sentAt: toIsoUtcMillis(NOW - 2000), + senderSide: 'reporter', + text: 'first', + }); + // ISO format has millisecond precision and a Z suffix + expect(out[0].sentAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('breaks sentAt ties by messageId (lexicographic)', () => { + const out = selectEvidenceWindow( + [msg('b', 0, 'reported', 'two'), msg('a', 0, 'reporter', 'one')], + { nowMs: NOW } + ); + expect(out.map((m) => m.messageId)).toEqual(['a', 'b']); + }); + + it('skips undecryptable and system/media (null text) messages', () => { + const out = selectEvidenceWindow( + [ + msg('ok', -1000, 'reporter', 'keep me'), + { ...msg('undec', -900, 'reported', 'x'), undecryptable: true }, + msg('system', -800, 'reported', null), + ], + { nowMs: NOW } + ); + expect(out.map((m) => m.messageId)).toEqual(['ok']); + }); + + it('applies the 30-day cap', () => { + const out = selectEvidenceWindow( + [ + msg('old', -31 * DAY, 'reported', 'too old'), + msg('edge', -29 * DAY, 'reporter', 'inside'), + msg('fresh', -1000, 'reported', 'inside'), + ], + { nowMs: NOW, maxDays: 30 } + ); + expect(out.map((m) => m.messageId)).toEqual(['edge', 'fresh']); + }); + + it('keeps only the most recent 200 messages', () => { + const many: SourceMessage[] = []; + for (let i = 0; i < 250; i++) many.push(msg(`m${String(i).padStart(3, '0')}`, -(250 - i) * 1000, 'reporter', `t${i}`)); + const out = selectEvidenceWindow(many, { nowMs: NOW }); + expect(out).toHaveLength(200); + // The oldest 50 (m000..m049) are dropped; m050 is the first kept. + expect(out[0].messageId).toBe('m050'); + expect(out[out.length - 1].messageId).toBe('m249'); + }); + + it('returns an empty window when nothing is eligible', () => { + expect(selectEvidenceWindow([], { nowMs: NOW })).toEqual([]); + expect( + selectEvidenceWindow([{ ...msg('u', -1, 'reporter', 'x'), undecryptable: true }], { nowMs: NOW }) + ).toEqual([]); + }); +}); + +describe('report categories', () => { + it('has exactly the 11 backend categories', () => { + expect(REPORT_CATEGORIES.map((c) => c.code).sort()).toEqual( + [ + 'FAKE_IMPERSONATION', + 'HARASSMENT', + 'HATE', + 'MINOR_SAFETY', + 'OFF_PLATFORM_HARM', + 'OTHER', + 'SCAM_FRAUD', + 'SELF_HARM_CONCERN', + 'SEXUAL_CONTENT_NONCONSENT', + 'SPAM_PROMOTION', + 'THREATS_VIOLENCE', + ].sort() + ); + }); + + it('every category has a short title and a one-line description', () => { + for (const c of REPORT_CATEGORIES) { + expect(c.title.length).toBeGreaterThan(0); + expect(c.description.length).toBeGreaterThan(0); + } + }); + + it('OTHER requires details; no other category does', () => { + for (const c of REPORT_CATEGORIES) { + expect(c.requiresDetails).toBe(c.code === ReportCategory.OTHER); + } + expect(DETAILS_MAX_CHARS).toBe(2000); + }); + + it('alsoBlock defaults ON except SELF_HARM_CONCERN (OFF)', () => { + expect(getCategoryMeta(ReportCategory.SELF_HARM_CONCERN).alsoBlockDefault).toBe(false); + for (const c of REPORT_CATEGORIES) { + if (c.code !== ReportCategory.SELF_HARM_CONCERN) expect(c.alsoBlockDefault).toBe(true); + } + }); + + it('MINOR_SAFETY is the only category that hides evidence; SELF_HARM allows it', () => { + expect(getCategoryMeta(ReportCategory.MINOR_SAFETY).evidenceEligible).toBe(false); + expect(getCategoryMeta(ReportCategory.MINOR_SAFETY).minorSafety).toBe(true); + expect(getCategoryMeta(ReportCategory.SELF_HARM_CONCERN).evidenceEligible).toBe(true); + for (const c of REPORT_CATEGORIES) { + if (c.code !== ReportCategory.MINOR_SAFETY) expect(c.evidenceEligible).toBe(true); + } + }); + + it('only SELF_HARM_CONCERN is flagged as the self-harm support route', () => { + for (const c of REPORT_CATEGORIES) { + expect(c.selfHarm).toBe(c.code === ReportCategory.SELF_HARM_CONCERN); + } + }); + + it('lists the not-reportable cases', () => { + expect(NOT_REPORTABLE_CASES.length).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/src/utils/env.config.ts b/src/utils/env.config.ts index cd24a24..26ced38 100644 --- a/src/utils/env.config.ts +++ b/src/utils/env.config.ts @@ -19,6 +19,7 @@ interface RuntimeEnv extends ImportMetaEnv { readonly VITE_APP_ENV?: string; readonly VITE_BACKEND_STAGE?: string; readonly VITE_API_BASE_URL?: string; + readonly VITE_PORTAL_MODERATION_BASE_URL?: string; readonly VITE_GRAPHQL_URL?: string; readonly VITE_COGNITO_REGION?: string; readonly VITE_COGNITO_USER_POOL_ID?: string; @@ -40,6 +41,7 @@ interface EnvConfig { backendStage: BackendStage; env: Environment; apiBaseUrl: string; + portalModerationBaseUrl: string; chatbotUrl: string; graphqlUrl: string; cognitoRegion: string; @@ -141,6 +143,10 @@ export function createEnvConfig(envName: Environment = detectEnvironment()): Env env.VITE_API_BASE_URL, envName === 'development' ? '' : deployment.apiBaseUrl ), + portalModerationBaseUrl: normalizeUrl( + env.VITE_PORTAL_MODERATION_BASE_URL, + deployment.portalModerationBaseUrl + ), chatbotUrl: normalizeUrl(env.VITE_CHATBOT_URL, deployment.chatbotUrl), graphqlUrl: normalizeUrl(env.VITE_GRAPHQL_URL, deployment.graphqlUrl), cognitoRegion, @@ -179,6 +185,7 @@ export const config: EnvConfig = createEnvConfig(); export const backendStage = config.backendStage; export const env = config.env; export const apiBaseUrl = config.apiBaseUrl; +export const portalModerationBaseUrl = config.portalModerationBaseUrl; export const chatbotUrl = config.chatbotUrl; export const graphqlUrl = config.graphqlUrl; export const cognitoRegion = config.cognitoRegion; From 516c98388b91af47ec8fc37ac79e222eadb42d56 Mon Sep 17 00:00:00 2001 From: Pushp Date: Tue, 27 Oct 2026 02:58:14 +0530 Subject: [PATCH 03/10] feat(report): report flow dialog + chat/profile entry points, replace spam/report-block menu with Report + Block (+telemetry test) --- src/features/chat/ChatView.tsx | 126 ++- .../moderation/report/ReportFlowDialog.tsx | 754 ++++++++++++++++++ src/features/moderation/report/categories.ts | 4 +- src/tests/report-telemetry.test.ts | 49 ++ 4 files changed, 923 insertions(+), 10 deletions(-) create mode 100644 src/features/moderation/report/ReportFlowDialog.tsx create mode 100644 src/tests/report-telemetry.test.ts diff --git a/src/features/chat/ChatView.tsx b/src/features/chat/ChatView.tsx index 1f28292..baa26cf 100644 --- a/src/features/chat/ChatView.tsx +++ b/src/features/chat/ChatView.tsx @@ -81,6 +81,8 @@ import { AttachmentDecodeError, type AttachmentKind, } from '../../lib/chat-attachments'; +import { ReportFlowDialog } from '../../features/moderation/report/ReportFlowDialog'; +import type { SourceMessage } from '../../features/moderation/report/window-selection'; import { probeImage, probeVideo } from '../../lib/attachment-media'; import type { AttachmentObjectUrlCache } from '../../lib/attachment-transport'; import type { OutgoingAttachment, AttachmentTransferState } from '../../hooks/use-api'; @@ -138,6 +140,11 @@ export function ChatView({ const [profileOpen, setProfileOpen] = useState(false); const [reportReason, setReportReason] = useState(null); const [reporting, setReporting] = useState(false); + // New member report flow (moderation Phase 2). Chat-sourced reports render inside the conversation + // (where the decrypted messages live, for the optional evidence share); profile-sourced reports + // render here without evidence. + const [chatReportOpen, setChatReportOpen] = useState(false); + const [profileReportOpen, setProfileReportOpen] = useState(false); const [portalNode, setPortalNode] = useState(() => { if (typeof document !== 'undefined') { @@ -308,6 +315,26 @@ export function ChatView({ await loadMatches(); }; + // Standalone unmatch (ends the connection without a block) — used by the report flow's + // "This isn't a safety issue" redirect. Uses the existing release-hook call. + const runUnmatch = async () => { + if (!activeMatch?.matchId) return; + try { + await StateApi.releaseHook(activeMatch.matchId, 'OTHER'); + } catch { + showToast('We couldn’t unmatch just now. Please try again.', 'error'); + return; + } + const remaining = matches.filter((m) => m.matchId !== activeMatch.matchId); + const next: Selection = remaining[0]?.matchId ?? MR_ONEHOOK; + setSelection(next); + onSelectMatch?.(next === MR_ONEHOOK ? null : next); + setReporting(false); + showToast('Unmatched.', 'success'); + if (onRefetchState) await onRefetchState(); + await loadMatches(); + }; + // Rows for the conversation picker: Mr.OneHook pinned first, then matches. const pickerRows: (SortableConversation & { photo?: string; @@ -447,12 +474,12 @@ export function ChatView({ disabled={reporting} onClick={() => { setShieldOpen(false); - void runReportAndBlock('SPAM'); + setChatReportOpen(true); }} className="w-full text-left px-4 py-2.5 text-sm hover:bg-surface-hover transition-colors text-red-500 disabled:opacity-40 cursor-pointer" {...trackProps(UiElement.CHAT_REPORT_SPAM)} > - Report spam + Report )} @@ -616,12 +643,12 @@ export function ChatView({ disabled={reporting} onClick={() => { setShieldOpen(false); - void runReportAndBlock('SPAM'); + setChatReportOpen(true); }} className="w-full text-left px-4 py-2.5 text-sm hover:bg-surface-hover transition-colors text-red-500 disabled:opacity-40 cursor-pointer" {...trackProps(UiElement.CHAT_REPORT_SPAM)} > - Report spam + Report )} @@ -713,6 +740,9 @@ export function ChatView({ currentUserId={currentUser.id} wallpaper={wallpaper} onReportPeer={() => setReportReason('OTHER')} + reportOpen={chatReportOpen} + onCloseReport={() => setChatReportOpen(false)} + onRequestUnmatch={() => void runUnmatch()} /> ) : ( setPickerOpen(true)} /> @@ -755,8 +785,29 @@ export function ChatView({ peerName={peerName} peerPhoto={peerPhoto} distanceKm={activeDistance ?? null} + onReport={ + !isMrOneHook && activeMatch?.peerId + ? () => { + setProfileOpen(false); + setProfileReportOpen(true); + } + : undefined + } /> + {/* ── REPORT FLOW (profile entry; no evidence — evidence is chat-only) ── */} + {activeMatch?.peerId && ( + setProfileReportOpen(false)} + source="profile" + reportedUserId={activeMatch.peerId} + reportedName={peerName} + onRequestBlock={() => setReportReason('OTHER')} + onRequestUnmatch={() => void runUnmatch()} + /> + )} + {/* ── REPORT & BLOCK (reason picker) ────────────────────────────────── */} void; + reportOpen?: boolean; + onCloseReport?: () => void; + onRequestUnmatch?: () => void; }) { const { messages, @@ -999,6 +1056,33 @@ function MatchConversation({ historyHorizon, } = useChatMessages(matchId, peerId); const [input, setInput] = useState(''); + + // Snapshot of decrypted messages for the optional evidence share. Text comes from the SAME local + // decrypt the chat UI uses (the E2EE hook); attachments/undecryptable/system messages map to a + // null/undecryptable entry so the window selector skips them. Never leaves the device unless the + // reporter ticks the (never pre-ticked) share box and submits. + const evidenceMessages: SourceMessage[] = useMemo( + () => + messages.map((m) => { + let text: string | null = null; + if (!m.undecryptable) { + try { + const decoded = decodeMessagePlaintext(m.ciphertext); + text = decoded.type === 'attachment' ? null : decoded.text; + } catch { + text = null; + } + } + return { + messageId: m.messageId, + sentAtMs: m.timestamp, + senderSide: m.senderId === 'me' ? 'reporter' : 'reported', + text, + undecryptable: m.undecryptable, + }; + }), + [messages] + ); const { showToast } = useToast(); const messagesEndRef = useRef(null); const [recoveryOpen, setRecoveryOpen] = useState(false); @@ -1296,6 +1380,20 @@ function MatchConversation({ onClose={() => setRecoveryOpen(false)} onRecovered={() => void refetch()} /> + + {peerId && ( + onCloseReport?.()} + source="chat" + reportedUserId={peerId} + reportedName={peerName} + conversationId={matchId} + sourceMessages={evidenceMessages} + onRequestBlock={onReportPeer} + onRequestUnmatch={onRequestUnmatch} + /> + )} ); } @@ -2047,6 +2145,7 @@ function ProfileInspectorModal({ peerName, peerPhoto, distanceKm, + onReport, }: { open: boolean; onClose: () => void; @@ -2055,6 +2154,7 @@ function ProfileInspectorModal({ peerName: string; peerPhoto: string; distanceKm: number | null; + onReport?: () => void; }) { return ( @@ -2132,6 +2232,16 @@ function ProfileInspectorModal({ “{peerProfile.bio}”

)} + {onReport && ( + + )} )} @@ -2174,7 +2284,7 @@ function ReportBlockModal({
-

Report & block

+

Block

Blocking {peerName} ends this connection and prevents further contact. Your chat history @@ -2214,7 +2324,7 @@ function ReportBlockModal({ {...trackProps(UiElement.CHAT_BLOCK_CONFIRM)} className="flex-1 py-3 bg-red-600 text-white text-xs font-semibold rounded-xl hover:bg-red-700 transition-colors flex items-center justify-center gap-2 cursor-pointer" > - {submitting ? : 'Block & report'} + {submitting ? : 'Block'}

diff --git a/src/features/moderation/report/ReportFlowDialog.tsx b/src/features/moderation/report/ReportFlowDialog.tsx new file mode 100644 index 0000000..aa0b5ab --- /dev/null +++ b/src/features/moderation/report/ReportFlowDialog.tsx @@ -0,0 +1,754 @@ +/** + * Member report flow dialog (OneHook moderation Phase 2, web). + * + * Steps: category picker (11 categories + "This isn't a safety issue") -> optional self-harm support + * -> details + also-block + optional decrypted-chat evidence -> submit -> confirmation. Evidence is + * offered ONLY from a chat and only when the category is not MINOR_SAFETY, is never pre-ticked, and + * declining is treated identically to sharing (no dark patterns). Evidence failure never fails the + * report. + * + * Telemetry is the backend `report_flow` SIGNAL (see telemetry.ts / the catalog ReportEvent doc), not + * ui_interaction — so the flow's controls carry `data-track-exempt` (the no-untracked-control guard's + * sanctioned escape) with the meaningful presses emitting a report_flow signal in-handler. + * + * Accessibility: focus trap + Escape to close, aria-live status region, and prefers-reduced-motion + * renders the nudge shield as a static icon. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { Shield, X, AlertTriangle, Phone, ChevronLeft, Check, ChevronRight } from 'lucide-react'; +import { ApiError } from '../../../lib/api-client'; +import { + REPORT_CATEGORIES, + getCategoryMeta, + NOT_REPORTABLE_CASES, + DETAILS_MAX_CHARS, + ReportCategory, + type CategoryMeta, +} from './categories'; +import { REPORT_STRINGS as S, SELF_HARM_RESOURCES } from './strings'; +import { trackReport, ReportEvent, type ReportSource } from './telemetry'; +import { + submitReport, + uploadEvidence, + type SubmitDeps, + type SubmitResult, +} from './submit'; +import { selectEvidenceWindow, type SourceMessage } from './window-selection'; + +type Step = 'category' | 'not_reportable' | 'self_harm' | 'form' | 'preview' | 'confirmation'; + +export interface ReportFlowDialogProps { + open: boolean; + onClose: () => void; + source: ReportSource; + /** Opaque id of the reported member (never shown in telemetry/logs). */ + reportedUserId: string; + /** Display name, UI only. */ + reportedName: string; + /** Match/conversation id — present only when started from a chat. */ + conversationId?: string; + /** Decrypted source messages for evidence (chat only). */ + sourceMessages?: SourceMessage[]; + /** Existing block action (for the not-reportable redirect). */ + onRequestBlock?: () => void; + /** Existing unmatch action (for the not-reportable redirect). */ + onRequestUnmatch?: () => void; + /** Test seam: inject submit dependencies. */ + deps?: SubmitDeps; + /** Test seam: force reduced motion. */ + forceReducedMotion?: boolean; +} + +function usePrefersReducedMotion(force?: boolean): boolean { + const [reduced, setReduced] = useState(force ?? false); + useEffect(() => { + if (force !== undefined) return; + if (typeof window === 'undefined' || !window.matchMedia) return; + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReduced(mq.matches); + const on = () => setReduced(mq.matches); + mq.addEventListener?.('change', on); + return () => mq.removeEventListener?.('change', on); + }, [force]); + return reduced; +} + +const FOCUSABLE = + 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +export function ReportFlowDialog({ + open, + onClose, + source, + reportedUserId, + reportedName, + conversationId, + sourceMessages, + onRequestBlock, + onRequestUnmatch, + deps, + forceReducedMotion, +}: ReportFlowDialogProps) { + const reducedMotion = usePrefersReducedMotion(forceReducedMotion); + const panelRef = useRef(null); + + const [step, setStep] = useState('category'); + const [category, setCategory] = useState(null); + const [details, setDetails] = useState(''); + const [alsoBlock, setAlsoBlock] = useState(true); + const [share, setShare] = useState(false); // NEVER pre-ticked + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [retrying, setRetrying] = useState(false); + const nudgeShownRef = useRef(false); + + const meta: CategoryMeta | null = category ? getCategoryMeta(category) : null; + + // Evidence is only offered from a chat, with a non-MINOR_SAFETY category, when there is chat to share. + const evidenceEligible = + source === 'chat' && !!conversationId && !!meta && meta.evidenceEligible && !meta.minorSafety; + + const previewWindow = useMemo( + () => (evidenceEligible ? selectEvidenceWindow(sourceMessages ?? []) : []), + [evidenceEligible, sourceMessages] + ); + + // Reset to a clean state whenever the dialog opens, and emit report.open. + useEffect(() => { + if (!open) return; + setStep('category'); + setCategory(null); + setDetails(''); + setAlsoBlock(true); + setShare(false); + setSubmitting(false); + setError(null); + setResult(null); + nudgeShownRef.current = false; + trackReport(ReportEvent.REPORT_OPEN, { source }); + }, [open, source]); + + // The nudge is shown once, when the evidence section first appears. + useEffect(() => { + if (open && step === 'form' && evidenceEligible && !nudgeShownRef.current) { + nudgeShownRef.current = true; + trackReport(ReportEvent.REPORT_NUDGE_SHOWN, { category: category ?? undefined, source }); + } + }, [open, step, evidenceEligible, category, source]); + + // Focus trap + Escape. + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + const first = panel.querySelector(FOCUSABLE); + first?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + return; + } + if (e.key !== 'Tab') return; + const nodes = Array.from( + panel.querySelectorAll(FOCUSABLE) + ) as HTMLElement[]; + const visible = nodes.filter((n) => n.offsetParent !== null || n === document.activeElement); + if (visible.length === 0) return; + const firstN = visible[0]; + const lastN = visible[visible.length - 1]; + if (e.shiftKey && document.activeElement === firstN) { + e.preventDefault(); + lastN.focus(); + } else if (!e.shiftKey && document.activeElement === lastN) { + e.preventDefault(); + firstN.focus(); + } + }; + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [open, step, onClose]); + + const chooseCategory = useCallback( + (code: ReportCategory) => { + setCategory(code); + const m = getCategoryMeta(code); + setAlsoBlock(m.alsoBlockDefault); + trackReport(ReportEvent.REPORT_CATEGORY_SELECTED, { category: code, source }); + if (m.selfHarm) { + trackReport(ReportEvent.REPORT_SELF_HARM_RESOURCES_SHOWN, { category: code, source }); + setStep('self_harm'); + } else { + setStep('form'); + } + }, + [source] + ); + + const openNotReportable = useCallback(() => { + trackReport(ReportEvent.REPORT_NOT_REPORTABLE_SHOWN, { source }); + setStep('not_reportable'); + }, [source]); + + const toggleShare = useCallback( + (next: boolean) => { + setShare(next); + trackReport(ReportEvent.REPORT_EVIDENCE_TOGGLE, { + category: category ?? undefined, + source, + evidence: next, + }); + }, + [category, source] + ); + + const openPreview = useCallback(() => { + trackReport(ReportEvent.REPORT_EVIDENCE_PREVIEW, { category: category ?? undefined, source }); + setStep('preview'); + }, [category, source]); + + const detailsRequired = !!meta?.requiresDetails; + const detailsTooLong = details.length > DETAILS_MAX_CHARS; + const canSubmit = + !!meta && !submitting && !detailsTooLong && (!detailsRequired || details.trim().length > 0); + + const handleSubmit = useCallback(async () => { + if (!meta || !canSubmit) return; + setSubmitting(true); + setError(null); + try { + const r = await submitReport( + { + reportedUserId, + category: meta.code, + details, + alsoBlock, + conversationId: source === 'chat' ? conversationId : undefined, + source, + shareEvidence: evidenceEligible && share, + messages: evidenceEligible && share ? sourceMessages : undefined, + }, + deps + ); + setResult(r); + setStep('confirmation'); + } catch (err) { + const status = err instanceof ApiError ? err.status : (err as { status?: number })?.status; + setError(status === 429 ? S.rateLimited : S.submitError); + } finally { + setSubmitting(false); + } + }, [meta, canSubmit, reportedUserId, details, alsoBlock, conversationId, source, evidenceEligible, share, sourceMessages, deps]); + + const handleRetryEvidence = useCallback(async () => { + if (!result) return; + setRetrying(true); + try { + const outcome = await uploadEvidence(result.reportId, conversationId ?? '', sourceMessages ?? [], deps); + trackReport(ReportEvent.REPORT_EVIDENCE_UPLOAD_SUCCEEDED, { + category: category ?? undefined, + source, + }); + setResult({ ...result, evidenceOutcome: outcome }); + } catch { + trackReport(ReportEvent.REPORT_EVIDENCE_UPLOAD_FAILED, { category: category ?? undefined, source }); + setResult({ ...result, evidenceOutcome: 'failed' }); + } finally { + setRetrying(false); + } + }, [result, conversationId, sourceMessages, deps, category, source]); + + if (!open) return null; + + return ( + + +
e.stopPropagation()} + data-track-exempt="backdrop: modal container; stops backdrop dismissal, not an interactive control." + > + {/* Header */} +
+
+ {step !== 'category' && step !== 'confirmation' && ( + + )} +

{S.dialogTitle}

+
+ +
+ + {/* aria-live status for non-blocking outcomes / errors */} +
+ {error ?? (result ? S.confirmationHeading : '')} +
+ +
+ {step === 'category' && ( + + )} + + {step === 'not_reportable' && ( + { + trackReport(ReportEvent.REPORT_NOT_REPORTABLE_BLOCK, { source }); + onClose(); + onRequestBlock?.(); + }} + onUnmatch={ + onRequestUnmatch + ? () => { + onClose(); + onRequestUnmatch(); + } + : undefined + } + /> + )} + + {step === 'self_harm' && meta && ( + setStep('form')} /> + )} + + {step === 'form' && meta && ( + + )} + + {step === 'preview' && ( + + )} + + {step === 'confirmation' && result && ( + + )} +
+ + {/* Footer actions */} + {step === 'form' && meta && ( +
+ {error && ( +

+ {error} +

+ )} + +
+ )} + + {step === 'preview' && ( +
+ +
+ )} + + {step === 'confirmation' && ( +
+ +
+ )} +
+
+
+ ); +} + +// ── Steps ──────────────────────────────────────────────────────────────────── + +function CategoryStep({ + onChoose, + onNotReportable, +}: { + onChoose: (c: ReportCategory) => void; + onNotReportable: () => void; +}) { + return ( +
+
+

{S.pickerHeading}

+

{S.pickerSubheading}

+
+
    + {REPORT_CATEGORIES.map((c) => ( +
  • + +
  • + ))} +
  • + +
  • +
+
+ ); +} + +function NotReportableStep({ onBlock, onUnmatch }: { onBlock: () => void; onUnmatch?: () => void }) { + return ( +
+

{S.notReportableHeading}

+

{S.notReportableBody}

+
    + {NOT_REPORTABLE_CASES.map((c) => ( +
  • + {c} +
  • + ))} +
+
+ + {onUnmatch && ( + + )} +
+
+ ); +} + +function SelfHarmStep({ onContinue }: { onContinue: () => void }) { + return ( +
+
+
+

{S.selfHarmBody}

+ + +
+ ); +} + +function FormStep({ + meta, + reportedName, + details, + onDetails, + detailsRequired, + detailsTooLong, + alsoBlock, + onAlsoBlock, + evidenceEligible, + share, + onShare, + onPreview, + reducedMotion, + previewCount, +}: { + meta: CategoryMeta; + reportedName: string; + details: string; + onDetails: (v: string) => void; + detailsRequired: boolean; + detailsTooLong: boolean; + alsoBlock: boolean; + onAlsoBlock: (v: boolean) => void; + evidenceEligible: boolean; + share: boolean; + onShare: (v: boolean) => void; + onPreview: () => void; + reducedMotion: boolean; + previewCount: number; +}) { + return ( +
+
+

{meta.title}

+

{meta.description}

+
+ + {/* Details */} +
+ +