export type JsonObject = Record; export type InputFailure = { ok: false; status: 400 | 413 | 415; error: string; }; export type InputResult = { ok: true; value: T } | InputFailure; export type NormalizedProperties = Record; export interface NormalizedEvent { name: string; timestampMillis: number; properties: NormalizedProperties; schemaVersion: 1; } export interface NormalizedBreadcrumb { name: string; timestampMillis: number; properties: NormalizedProperties; } export interface NormalizedDevice { deviceName: string; deviceModel: string; operatingSystem: string; network: string; batteryLevel: string; } export interface NormalizedEventsPayload { batchId: string; installId: string; appVersion: string; platform: string; events: NormalizedEvent[]; } export interface NormalizedCrashPayload { id: string; installId: string; appVersion: string; platform: string; exceptionType: string; exceptionMessage: string; stackTrace: string; occurredAt: number; diagnosticsEnabledAtCapture: boolean; breadcrumbs: NormalizedBreadcrumb[]; schemaVersion: 1; } export interface NormalizedBugPayload { id: string; installId: string; appVersion: string; platform: string; occurredAt: number; whatHappened: string; expected: string; steps: string; contact: string; logs: string; device: NormalizedDevice; breadcrumbs: NormalizedBreadcrumb[]; schemaVersion: 1; } export const MAX_LOG_BYTES = 192 * 1024; export const MAX_BREADCRUMBS_JSON_BYTES = 16_000; export const MAX_DEVICE_JSON_BYTES = 4_000; const MAX_PROPERTIES = 12; const MAX_BREADCRUMBS = 40; const MISSING = Symbol("missing"); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UTF8_ENCODER = new TextEncoder(); export async function readJsonObject( request: Request, maxBytes: number, ): Promise> { if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { throw new RangeError("maxBytes must be a positive safe integer"); } if (!isApplicationJson(request.headers.get("content-type"))) { return failure(415, "unsupported_media_type"); } const contentLength = request.headers.get("content-length"); if (contentLength != null) { const trimmed = contentLength.trim(); if (!/^\d+$/.test(trimmed)) { return failure(400, "invalid_content_length"); } const normalizedLength = trimmed.replace(/^0+(?=\d)/, ""); const maxLength = String(maxBytes); if ( normalizedLength.length > maxLength.length || (normalizedLength.length === maxLength.length && normalizedLength > maxLength) ) { return failure(413, "payload_too_large"); } const declaredBytes = Number(trimmed); if (!Number.isSafeInteger(declaredBytes)) { return failure(400, "invalid_content_length"); } } if (request.body == null) { return failure(400, "invalid_json"); } let reader: ReadableStreamDefaultReader; try { reader = request.body.getReader(); } catch { return failure(400, "invalid_body"); } const chunks: Uint8Array[] = []; let totalBytes = 0; try { while (true) { const chunk = await reader.read(); if (chunk.done) break; if (chunk.value.byteLength > maxBytes - totalBytes) { try { await reader.cancel("payload_too_large"); } catch { // The size failure remains authoritative if cancellation also fails. } return failure(413, "payload_too_large"); } totalBytes += chunk.value.byteLength; chunks.push(chunk.value); } } catch { return failure(400, "invalid_body"); } finally { reader.releaseLock(); } const bytes = joinChunks(chunks, totalBytes); let raw: string; try { raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); } catch { return failure(400, "invalid_utf8"); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return failure(400, "invalid_json"); } if (!isPlainObject(parsed)) { return failure(400, "invalid_body"); } return success(parsed); } export function normalizeEvents( body: JsonObject, maxEvents = 50, ): InputResult { if (!isPlainObject(body)) return failure(400, "invalid_body"); if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) { throw new RangeError("maxEvents must be a positive safe integer"); } const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id"); if (!batchId.ok) return batchId; const installId = installIdField(body); if (!installId.ok) return installId; const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version"); if (!appVersion.ok) return appVersion; const platform = stringField(body, ["platform"], 40, "invalid_platform"); if (!platform.ok) return platform; const batchSchema = schemaVersion(body); if (!batchSchema.ok) return batchSchema; const rawEvents = pick(body, ["events"]); if (!Array.isArray(rawEvents)) return failure(400, "invalid_events"); if (rawEvents.length === 0) return failure(400, "empty_batch"); if (rawEvents.length > maxEvents) return failure(400, "batch_too_large"); const events: NormalizedEvent[] = []; for (const rawEvent of rawEvents) { const event = normalizeEvent(rawEvent); if (!event.ok) return event; events.push(event.value); } return success({ batchId: batchId.value, installId: installId.value, appVersion: appVersion.value, platform: platform.value, events, }); } export function normalizeCrash(body: JsonObject): InputResult { if (!isPlainObject(body)) return failure(400, "invalid_body"); const id = idField(body, ["id"], "invalid_id"); if (!id.ok) return id; const installId = installIdField(body); if (!installId.ok) return installId; const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version"); if (!appVersion.ok) return appVersion; const platform = stringField(body, ["platform"], 40, "invalid_platform"); if (!platform.ok) return platform; const exceptionType = stringField( body, ["exceptionType", "exception_type"], 120, "invalid_exception_type", true, true, ); if (!exceptionType.ok) return exceptionType; const exceptionMessage = stringField( body, ["exceptionMessage", "exception_message"], 2_000, "invalid_exception_message", true, ); if (!exceptionMessage.ok) return exceptionMessage; const stackTrace = stringField( body, ["stackTrace", "stack_trace"], 32_000, "invalid_stack_trace", true, ); if (!stackTrace.ok) return stackTrace; const occurredAt = timestampField( body, ["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"], ); if (!occurredAt.ok) return occurredAt; const diagnosticsEnabled = booleanField( body, [ "diagnosticsEnabledAtCapture", "diagnostics_enabled_at_capture", "diagnostics_enabled", ], "invalid_diagnostics_enabled", true, ); if (!diagnosticsEnabled.ok) return diagnosticsEnabled; const version = schemaVersion(body); if (!version.ok) return version; const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"])); if (!breadcrumbs.ok) return breadcrumbs; return success({ id: id.value, installId: installId.value, appVersion: appVersion.value, platform: platform.value, exceptionType: exceptionType.value, exceptionMessage: exceptionMessage.value, stackTrace: stackTrace.value, occurredAt: occurredAt.value, diagnosticsEnabledAtCapture: diagnosticsEnabled.value, breadcrumbs: breadcrumbs.value, schemaVersion: version.value, }); } export function normalizeBug(body: JsonObject): InputResult { if (!isPlainObject(body)) return failure(400, "invalid_body"); const id = idField(body, ["id"], "invalid_id"); if (!id.ok) return id; const installId = installIdField(body); if (!installId.ok) return installId; const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version"); if (!appVersion.ok) return appVersion; const platform = stringField(body, ["platform"], 40, "invalid_platform"); if (!platform.ok) return platform; const occurredAt = timestampField( body, ["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"], ); if (!occurredAt.ok) return occurredAt; const whatHappened = stringField( body, ["whatHappened", "what_happened"], 4_000, "missing_fields", true, true, ); if (!whatHappened.ok) return whatHappened; const expected = stringField(body, ["expected"], 4_000, "missing_fields", true, true); if (!expected.ok) return expected; const steps = stringField(body, ["steps"], 4_000, "invalid_steps"); if (!steps.ok) return steps; const contact = stringField(body, ["contact"], 320, "invalid_contact"); if (!contact.ok) return contact; const includeLogs = booleanField( body, ["includeLogs", "include_logs"], "invalid_include_logs", false, ); if (!includeLogs.ok) return includeLogs; const logs = stringField(body, ["logs"], MAX_LOG_BYTES, "invalid_logs"); if (!logs.ok) return logs; const device = normalizeDevice(pick(body, ["device"])); if (!device.ok) return device; const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"])); if (!breadcrumbs.ok) return breadcrumbs; const version = schemaVersion(body); if (!version.ok) return version; return success({ id: id.value, installId: installId.value, appVersion: appVersion.value, platform: platform.value, occurredAt: occurredAt.value, whatHappened: whatHappened.value, expected: expected.value, steps: steps.value, contact: contact.value, logs: includeLogs.value === true ? logs.value : "", device: device.value, breadcrumbs: breadcrumbs.value, schemaVersion: version.value, }); } function normalizeEvent(raw: unknown): InputResult { if (!isPlainObject(raw)) return failure(400, "invalid_event"); const name = stringField(raw, ["name"], 64, "invalid_event", true, true); if (!name.ok) return name; const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]); if (!timestamp.ok) return failure(400, "invalid_event"); const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event"); if (!properties.ok) return properties; const version = schemaVersion(raw); if (!version.ok) return version; return success({ name: name.value, timestampMillis: timestamp.value, properties: properties.value, schemaVersion: version.value, }); } function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult { if (raw === MISSING) return success([]); if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs"); const breadcrumbs: NormalizedBreadcrumb[] = []; for (const item of raw.slice(0, MAX_BREADCRUMBS)) { if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs"); const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true); if (!name.ok) return name; const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]); if (!timestamp.ok) return failure(400, "invalid_breadcrumbs"); const properties = normalizeProperties( pick(item, ["properties", "props"]), "invalid_breadcrumbs", ); if (!properties.ok) return properties; breadcrumbs.push({ name: name.value, timestampMillis: timestamp.value, properties: properties.value, }); if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) { breadcrumbs.pop(); break; } } return success(breadcrumbs); } function normalizeProperties( raw: unknown | typeof MISSING, error: string, ): InputResult { if (raw === MISSING) return success({}); if (!isPlainObject(raw)) return failure(400, error); const entries: Array<[string, string]> = []; const normalizedKeys = new Set(); for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) { if (typeof value !== "string") return failure(400, error); const normalizedKey = truncateUtf8(key, 40); if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) { return failure(400, error); } normalizedKeys.add(normalizedKey); entries.push([normalizedKey, truncateUtf8(value, 128)]); } return success(Object.fromEntries(entries)); } function normalizeDevice(raw: unknown | typeof MISSING): InputResult { if (raw === MISSING) raw = {}; if (!isPlainObject(raw)) return failure(400, "invalid_device"); const deviceName = stringField(raw, ["deviceName", "device_name"], 128, "invalid_device"); if (!deviceName.ok) return deviceName; const deviceModel = stringField(raw, ["deviceModel", "device_model"], 128, "invalid_device"); if (!deviceModel.ok) return deviceModel; const operatingSystem = stringField( raw, ["operatingSystem", "operating_system"], 192, "invalid_device", ); if (!operatingSystem.ok) return operatingSystem; const network = stringField(raw, ["network"], 96, "invalid_device"); if (!network.ok) return network; const batteryLevel = stringField(raw, ["batteryLevel", "battery_level"], 64, "invalid_device"); if (!batteryLevel.ok) return batteryLevel; const device: NormalizedDevice = { deviceName: deviceName.value, deviceModel: deviceModel.value, operatingSystem: operatingSystem.value, network: network.value, batteryLevel: batteryLevel.value, }; if (jsonBytes(device) > MAX_DEVICE_JSON_BYTES) { return failure(400, "invalid_device"); } return success(device); } function stringField( object: JsonObject, keys: readonly string[], maxBytes: number, error: string, required = false, nonEmpty = false, ): InputResult { const raw = pick(object, keys); if (raw === MISSING) { return required ? failure(400, error) : success(""); } if (typeof raw !== "string") return failure(400, error); const value = truncateUtf8(raw, maxBytes); if (nonEmpty && value.trim().length === 0) return failure(400, error); return success(value); } function idField( object: JsonObject, keys: readonly string[], error: string, ): InputResult { const raw = pick(object, keys); if (typeof raw !== "string" || !UUID_PATTERN.test(raw)) return failure(400, error); return success(raw.toLowerCase()); } function installIdField(object: JsonObject): InputResult { const raw = pick(object, ["installId", "install_id"]); if (raw === MISSING || raw === "") return success("unknown"); if ( typeof raw !== "string" || raw.trim().length === 0 || /[\u0000-\u001f\u007f]/.test(raw) ) { return failure(400, "invalid_install_id"); } return success(truncateUtf8(raw, 80)); } function timestampField(object: JsonObject, keys: readonly string[]): InputResult { const raw = pick(object, keys); if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) { return failure(400, "invalid_timestamp"); } return success(raw); } function booleanField( object: JsonObject, keys: readonly string[], error: string, required: true, ): InputResult; function booleanField( object: JsonObject, keys: readonly string[], error: string, required: false, ): InputResult; function booleanField( object: JsonObject, keys: readonly string[], error: string, required: boolean, ): InputResult { const raw = pick(object, keys); if (raw === MISSING) { return required ? failure(400, error) : success(undefined); } return typeof raw === "boolean" ? success(raw) : failure(400, error); } function schemaVersion(object: JsonObject): InputResult<1> { const raw = pick(object, ["schemaVersion", "schema_version"]); if (raw === MISSING || raw === 1) return success(1); return failure(400, "unsupported_schema_version"); } function pick(object: JsonObject, keys: readonly string[]): unknown | typeof MISSING { for (const key of keys) { if (Object.prototype.hasOwnProperty.call(object, key)) return object[key]; } return MISSING; } function isPlainObject(value: unknown): value is JsonObject { if (value == null || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function isApplicationJson(contentType: string | null): boolean { if (contentType == null) return false; const [mediaType, ...parameters] = contentType.split(";"); if (mediaType?.trim().toLowerCase() !== "application/json") return false; for (const parameter of parameters) { const separator = parameter.indexOf("="); if (separator < 0) continue; if (parameter.slice(0, separator).trim().toLowerCase() !== "charset") continue; const charset = parameter .slice(separator + 1) .trim() .replace(/^"(.*)"$/, "$1") .toLowerCase(); if (charset !== "utf-8" && charset !== "utf8") return false; } return true; } function joinChunks(chunks: readonly Uint8Array[], totalBytes: number): Uint8Array { if (chunks.length === 1) return chunks[0] ?? new Uint8Array(); const bytes = new Uint8Array(totalBytes); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes; } function truncateUtf8(value: string, maxBytes: number): string { const bytes = UTF8_ENCODER.encode(value); if (bytes.byteLength <= maxBytes) return value; for (let end = maxBytes; end >= Math.max(0, maxBytes - 3); end -= 1) { try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( bytes.subarray(0, end), ); } catch { // A UTF-8 boundary is at most three bytes behind the byte cap. } } return ""; } function jsonBytes(value: unknown): number { return UTF8_ENCODER.encode(JSON.stringify(value)).byteLength; } function success(value: T): InputResult { return { ok: true, value }; } function failure(status: 400 | 413 | 415, error: string): InputFailure { return { ok: false, status, error }; }