From 6e789fe42ea482d604a40be649bc3890821c88b8 Mon Sep 17 00:00:00 2001 From: Juan Mrad Date: Tue, 19 May 2026 15:10:11 -0500 Subject: [PATCH] [Errors] Surface reviewer-friendly last_error and unblock long-id error persistence (#513) * [NCMEC][Errors] Surface reviewer-friendly last_error and unblock long-id error persistence * code review changes --- ...00.11.42.widen_ncmec_report_id_columns.sql | 19 ++++ server/services/ncmecService/index.ts | 1 + .../services/ncmecService/ncmecReporting.ts | 6 +- .../ncmecService/ncmecReviewerErrors.test.ts | 105 ++++++++++++++++++ .../ncmecService/ncmecReviewerErrors.ts | 91 +++++++++++++++ .../RetryFailedNcmecDecisionsJob.ts | 21 +++- 6 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 db/src/scripts/api-server-pg/2026.05.19T00.11.42.widen_ncmec_report_id_columns.sql create mode 100644 server/services/ncmecService/ncmecReviewerErrors.test.ts create mode 100644 server/services/ncmecService/ncmecReviewerErrors.ts diff --git a/db/src/scripts/api-server-pg/2026.05.19T00.11.42.widen_ncmec_report_id_columns.sql b/db/src/scripts/api-server-pg/2026.05.19T00.11.42.widen_ncmec_report_id_columns.sql new file mode 100644 index 0000000..fe65777 --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.05.19T00.11.42.widen_ncmec_report_id_columns.sql @@ -0,0 +1,19 @@ +-- Widen NCMEC report ID columns to text. +-- +-- Context: +-- `ncmec_reports_errors` and `ncmec_reports` store synthetic composite +-- IDs (base64-encoded workflow/job identifiers that can exceed 500 +-- characters) as well as user IDs that are caller-defined and not +-- length-bounded by us. The original varchar(255) columns silently +-- truncated values or threw 22001 errors at insert time, which masked +-- submission errors in the dashboard. + + +ALTER TABLE ncmec_reporting.ncmec_reports_errors + ALTER COLUMN job_id TYPE text, + ALTER COLUMN user_id TYPE text, + ALTER COLUMN user_type_id TYPE text; + +ALTER TABLE ncmec_reporting.ncmec_reports + ALTER COLUMN user_id TYPE text, + ALTER COLUMN user_item_type_id TYPE text; diff --git a/server/services/ncmecService/index.ts b/server/services/ncmecService/index.ts index acf4a9b..8d28de1 100644 --- a/server/services/ncmecService/index.ts +++ b/server/services/ncmecService/index.ts @@ -4,6 +4,7 @@ export { } from './ncmecService.js'; export { NCMECIncidentType } from './ncmecReporting.js'; +export { summarizeNcmecErrorForReviewer } from './ncmecReviewerErrors.js'; export { filterDecisionsToFailedSubmissions } from './ncmecSubmissionFilters.js'; export { buildSubmitReportParamsFromDecision, diff --git a/server/services/ncmecService/ncmecReporting.ts b/server/services/ncmecService/ncmecReporting.ts index ce6a236..3b88867 100644 --- a/server/services/ncmecService/ncmecReporting.ts +++ b/server/services/ncmecService/ncmecReporting.ts @@ -37,6 +37,7 @@ import { ncmecDebugEnabled, ncmecDebugLog, } from './ncmecDebug.js'; +import { summarizeNcmecErrorForReviewer } from './ncmecReviewerErrors.js'; export const NCMECEvent = makeEnumLike([ 'Login', @@ -1698,10 +1699,7 @@ export default class NcmecReporting { jobId: reportParams.jobId, userId: reportParams.reportedUser.id, userTypeId: reportParams.reportedUser.typeId, - error: - e instanceof Error - ? e.message - : 'Unknown NCMEC submission error', + error: summarizeNcmecErrorForReviewer(e), }); } return 'FAILURE'; diff --git a/server/services/ncmecService/ncmecReviewerErrors.test.ts b/server/services/ncmecService/ncmecReviewerErrors.test.ts new file mode 100644 index 0000000..f521fe5 --- /dev/null +++ b/server/services/ncmecService/ncmecReviewerErrors.test.ts @@ -0,0 +1,105 @@ +import { summarizeNcmecErrorForReviewer } from './ncmecReviewerErrors.js'; + +describe('summarizeNcmecErrorForReviewer', () => { + it('maps 401 from a CyberTip request to an auth message', () => { + const e = new Error( + 'CyberTip request to /submit failed: status=401, responseCode=1000', + ); + expect(summarizeNcmecErrorForReviewer(e)).toMatch(/Authentication failed/); + }); + + it('maps 403 to the same auth message', () => { + const e = new Error('CyberTip request to /submit failed: status=403'); + expect(summarizeNcmecErrorForReviewer(e)).toMatch(/Authentication failed/); + }); + + it('maps 429 to rate-limited', () => { + const e = new Error('CyberTip request to /submit failed: status=429'); + expect(summarizeNcmecErrorForReviewer(e)).toMatch(/Rate limited/); + }); + + it('maps 504 to timeout', () => { + const e = new Error('CyberTip request to /submit failed: status=504'); + expect(summarizeNcmecErrorForReviewer(e)).toMatch(/timed out/); + }); + + it('maps 5xx to server error', () => { + const e = new Error('CyberTip request to /submit failed: status=502'); + expect(summarizeNcmecErrorForReviewer(e)).toMatch(/server error/i); + }); + + it('maps generic 4xx to rejected', () => { + const e = new Error('CyberTip request to /submit failed: status=422'); + expect(summarizeNcmecErrorForReviewer(e)).toMatch(/rejected/i); + }); + + it('passes through known reviewer-friendly local errors verbatim', () => { + expect( + summarizeNcmecErrorForReviewer(new Error('No media in report')), + ).toBe('No media in report'); + }); + + it('classifies missing-config throws to a config category', () => { + expect( + summarizeNcmecErrorForReviewer( + new Error('NCMEC reports are not enabled for org acme'), + ), + ).toMatch(/configuration is incomplete/); + expect( + summarizeNcmecErrorForReviewer(new Error('org id not found')), + ).toMatch(/configuration is incomplete/); + // 'Insufficient settings' (and any variant with additional detail + // appended) is routed through CONFIG rather than passed through, so + // reviewers get the "check Settings → NCMEC" guidance. + expect( + summarizeNcmecErrorForReviewer(new Error('Insufficient settings')), + ).toMatch(/configuration is incomplete/); + expect( + summarizeNcmecErrorForReviewer( + new Error('Insufficient settings: missing username'), + ), + ).toMatch(/configuration is incomplete/); + }); + + it('classifies media-assembly throws to a media category', () => { + expect( + summarizeNcmecErrorForReviewer( + new Error('Unable to find reported media in job payload'), + ), + ).toMatch(/reported media/); + expect( + summarizeNcmecErrorForReviewer(new Error('NCMEC file upload failed.')), + ).toMatch(/reported media/); + }); + + it('classifies a responseCode-based submission rejection', () => { + expect( + summarizeNcmecErrorForReviewer( + new Error('NCMEC report submission failed: responseCode=4100'), + ), + ).toMatch(/rejected/i); + }); + + it('falls back to UNKNOWN for unrecognized text', () => { + expect(summarizeNcmecErrorForReviewer(new Error('boom'))).toMatch( + /Unexpected error/, + ); + }); + + it('falls back to UNKNOWN for non-Error, non-string inputs', () => { + expect(summarizeNcmecErrorForReviewer(undefined)).toMatch( + /Unexpected error/, + ); + expect(summarizeNcmecErrorForReviewer({ foo: 'bar' })).toMatch( + /Unexpected error/, + ); + }); + + it('accepts a raw string as input', () => { + expect( + summarizeNcmecErrorForReviewer( + 'CyberTip request to /submit failed: status=401', + ), + ).toMatch(/Authentication failed/); + }); +}); diff --git a/server/services/ncmecService/ncmecReviewerErrors.ts b/server/services/ncmecService/ncmecReviewerErrors.ts new file mode 100644 index 0000000..8bf8dcf --- /dev/null +++ b/server/services/ncmecService/ncmecReviewerErrors.ts @@ -0,0 +1,91 @@ +/** Reviewer-facing classifications of NCMEC submission failures. Written + * to `ncmec_reports_errors.last_error` and surfaced in the MRT dashboard; + * full operator detail stays in logs / spans. */ + +const REVIEWER_ERROR_MESSAGES = { + AUTH: 'Authentication failed. Check NCMEC credentials in Settings → NCMEC.', + RATE_LIMITED: 'Rate limited by NCMEC. Retrying shortly.', + TIMEOUT: 'NCMEC request timed out. Retrying shortly.', + SERVER: 'NCMEC server error. Retrying shortly.', + REJECTED: 'NCMEC rejected the report.', + VALIDATION: 'Report failed validation before submission.', + CONFIG: 'NCMEC configuration is incomplete. Check Settings → NCMEC.', + MEDIA: 'Could not assemble the reported media for submission.', + UNKNOWN: 'Unexpected error submitting to NCMEC. See server logs.', +} as const; + +// Allowlist of thrown messages that are already operator-friendly. New +// throw sites stay opaque until classified explicitly. +const ALREADY_REVIEWER_FRIENDLY: ReadonlySet = new Set([ + 'No media in report', + 'Organization does not have a NCMEC preservation endpoint', + 'NCMEC report requires a non-empty reporter contact email; configure it in Settings → NCMEC.', + 'escalateToHighPriority must be non-blank when supplied and at most 3000 characters', + 'additionalInfo must be non-blank when supplied and at most 3000 characters', +]); + +// First match wins; put more specific prefixes ahead of more general ones. +const REVIEWER_PREFIX_RULES: readonly { + prefix: string; + category: keyof typeof REVIEWER_ERROR_MESSAGES; +}[] = [ + { prefix: 'NCMEC reports are not enabled for org', category: 'CONFIG' }, + { prefix: 'Insufficient settings', category: 'CONFIG' }, + { prefix: 'org id not found', category: 'CONFIG' }, + { prefix: 'Unable to find reported media in job payload', category: 'MEDIA' }, + { prefix: 'Unable to find item type for reported media', category: 'MEDIA' }, + { prefix: 'Invalid media createdAt timestamp', category: 'MEDIA' }, + { prefix: 'Cannot download media from', category: 'MEDIA' }, + { prefix: 'NCMEC file upload failed', category: 'MEDIA' }, + { prefix: 'NCMEC thread CSV upload failed', category: 'MEDIA' }, + { prefix: 'NCMEC thread csv failed', category: 'MEDIA' }, + { prefix: 'No created at for reported media', category: 'MEDIA' }, + { prefix: 'NCMEC Messages failed validation', category: 'VALIDATION' }, + { prefix: 'NCMEC Additional info failed validation', category: 'VALIDATION' }, + { prefix: 'Did not receive additional info back', category: 'VALIDATION' }, + { + prefix: 'NCMEC report submission failed: responseCode=', + category: 'REJECTED', + }, + { prefix: 'NCMEC report finish failed', category: 'SERVER' }, +]; + +function classifyByHttpStatus(status: number | undefined): string { + if (status === undefined) return REVIEWER_ERROR_MESSAGES.UNKNOWN; + if (status === 401 || status === 403) return REVIEWER_ERROR_MESSAGES.AUTH; + if (status === 429) return REVIEWER_ERROR_MESSAGES.RATE_LIMITED; + if (status === 408 || status === 504) return REVIEWER_ERROR_MESSAGES.TIMEOUT; + if (status >= 500) return REVIEWER_ERROR_MESSAGES.SERVER; + if (status >= 400) return REVIEWER_ERROR_MESSAGES.REJECTED; + return REVIEWER_ERROR_MESSAGES.UNKNOWN; +} + +export function summarizeNcmecErrorForReviewer(error: unknown): string { + const raw = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : ''; + if (raw === '') return REVIEWER_ERROR_MESSAGES.UNKNOWN; + if (ALREADY_REVIEWER_FRIENDLY.has(raw)) return raw; + + const cyberTipMatch = /^CyberTip request to .+? failed: status=(\d+)/.exec( + raw, + ); + if (cyberTipMatch) return classifyByHttpStatus(Number(cyberTipMatch[1])); + + if (raw.startsWith('NCMEC Additional info failed with status:')) { + const match = /status:\s*(\d{3})/.exec(raw); + return classifyByHttpStatus(match ? Number(match[1]) : undefined); + } + + if (raw.startsWith('User with ID:') && raw.includes('has existing report')) { + return REVIEWER_ERROR_MESSAGES.REJECTED; + } + + const rule = REVIEWER_PREFIX_RULES.find((r) => raw.startsWith(r.prefix)); + if (rule !== undefined) return REVIEWER_ERROR_MESSAGES[rule.category]; + + return REVIEWER_ERROR_MESSAGES.UNKNOWN; +} diff --git a/server/workers_jobs/RetryFailedNcmecDecisionsJob.ts b/server/workers_jobs/RetryFailedNcmecDecisionsJob.ts index 962ed04..a554b72 100644 --- a/server/workers_jobs/RetryFailedNcmecDecisionsJob.ts +++ b/server/workers_jobs/RetryFailedNcmecDecisionsJob.ts @@ -5,8 +5,11 @@ import { inject } from '../iocContainer/utils.js'; import { buildSubmitReportParamsFromDecision, LEGACY_FALLBACK_INCIDENT_TYPE, + summarizeNcmecErrorForReviewer, } from '../services/ncmecService/index.js'; import { toCorrelationId } from '../utils/correlationIds.js'; +import { jsonStringify } from '../utils/encoding.js'; +import { logErrorJson } from '../utils/logging.js'; export default inject( [ @@ -179,15 +182,25 @@ export default inject( // its retry_count. return; } + // Preserve the full detail in logs before we replace it + // with the reviewer-friendly summary on the row. + // eslint-disable-next-line no-restricted-syntax + logErrorJson({ + error: e, + message: jsonStringify({ + context: 'RetryFailedNcmecDecisionsJob.processDecisionRetry', + jobId: row.job_payload.id, + orgId, + userId: itemId, + userTypeId: itemTypeId, + }), + }); await ncmecService.insertOrUpdateNcmecReportError({ jobId: row.job_payload.id, userId: itemId, userTypeId: itemTypeId, status: 'RETRYABLE_ERROR', - error: - typeof e === 'object' && e !== null && 'message' in e - ? (e as Error).message - : 'Unknown error', + error: summarizeNcmecErrorForReviewer(e), }); } }; -- 2.51.2