UNPKG

autotel

Version:
1 lines 14.1 kB
{"version":3,"file":"validate.cjs","names":["createCounter","VALIDATION_METRICS","trace","VALIDATION_ATTR","createStructuredError","hashJson"],"sources":["../src/validate.ts"],"sourcesContent":["/**\n * Validation telemetry — connect runtime input validation (Zod or any\n * `safeParse` schema) to your traces and metrics at the boundaries where bad\n * data actually enters: HTTP bodies, events, messages.\n *\n * Today a `safeParse` failure either throws (no span, no metric, no alert) or\n * is silently swallowed in a handler. `defineValidator` makes the mismatch\n * **observable** — a `validation.*` span attribute set and a counter\n * incremented — with a per-validator `observe` vs `reject` mode:\n *\n * - `reject` (default): record telemetry, then throw a structured 400-shaped\n * error so the boundary can fail cleanly.\n * - `observe`: record telemetry, return the raw input so the handler continues\n * — useful for measuring real-world drift before you enforce it.\n *\n * **Not a security feature by default.** A malformed body is usually a bug or\n * version skew, not an attack. Validation telemetry is first-class on its own\n * metric; escalation to the security path is a deliberate opt-in via\n * {@link onValidationMismatch} (e.g. wired by `autotel-audit`), never automatic.\n *\n * **PII-safe by construction.** Only field *paths*, issue *codes*, and the\n * declared *type* are ever recorded — never the offending value, and never a\n * validator's error `message` (which routinely embeds the received value).\n */\n\nimport { trace } from '@opentelemetry/api';\nimport { createCounter } from './metric-helpers';\nimport {\n createStructuredError,\n type StructuredError,\n} from './structured-error';\nimport { hashJson } from './stable-hash';\nimport type { SchemaLike } from './define-event';\nimport {\n VALIDATION_ATTR,\n VALIDATION_ISSUE_CAP,\n VALIDATION_METRICS,\n} from './validation-attributes';\n\nexport type { SchemaLike } from './define-event';\n\nexport type ValidationMode = 'observe' | 'reject';\nexport type ValidationSeverity = 'info' | 'warning' | 'error';\n\n/** A single failing field, stripped of any payload values. */\nexport interface ValidationIssue {\n /** Dotted field path, e.g. `items.0.price`. Never a value. */\n path: string;\n /** Issue code (e.g. Zod's `invalid_type`, `too_small`). Never a value. */\n code: string;\n /** Declared type/constraint summary, e.g. `string`. Never a received value. */\n expected?: string;\n}\n\n/** Everything the recorder needs — already PII-stripped by the caller. */\nexport interface ValidationMismatch {\n /** Contract id, e.g. `POST /orders` or `order.placed`. */\n name: string;\n boundary: string;\n mode: ValidationMode;\n issues: ValidationIssue[];\n hash?: string;\n severity?: ValidationSeverity;\n}\n\nlet mismatchCounter: ReturnType<typeof createCounter> | undefined;\nfunction counter(): ReturnType<typeof createCounter> {\n if (!mismatchCounter) {\n mismatchCounter = createCounter(VALIDATION_METRICS.mismatches, {\n description: 'Input payloads that did not match their declared shape',\n });\n }\n return mismatchCounter;\n}\n\ntype MismatchListener = (mismatch: ValidationMismatch) => void;\nconst listeners = new Set<MismatchListener>();\n\n/**\n * Register an explicit handler called on every recorded mismatch — the opt-in\n * seam for escalating to security events, a webhook, or a custom sink. There is\n * no automatic, package-presence-driven escalation: nothing fires here unless\n * you (or a package you wire up) register a handler.\n *\n * Multiple subscribers coexist: a package (e.g. `autotel-audit` bridging to\n * security events) and your own app code (a webhook, a logger) can both\n * register and all fire. Returns an unsubscribe fn that removes only this\n * handler; registering the same function twice is a no-op (Set semantics).\n */\nexport function onValidationMismatch(handler: MismatchListener): () => void {\n listeners.add(handler);\n return () => {\n listeners.delete(handler);\n };\n}\n\nconst truncate = (values: string[]): string =>\n values.slice(0, VALIDATION_ISSUE_CAP).join(',');\n\n/**\n * Record a validation mismatch as telemetry: `validation.*` attributes on the\n * active span (if any) and an increment on `autotel.validation.mismatches`.\n * Fail-open — never throws, so instrumentation can't break the boundary.\n */\nexport function recordValidationMismatch(mismatch: ValidationMismatch): void {\n try {\n const paths = mismatch.issues.map((i) => i.path).filter(Boolean);\n const codes = [...new Set(mismatch.issues.map((i) => i.code))];\n\n const span = trace.getActiveSpan();\n if (span) {\n span.setAttributes({\n [VALIDATION_ATTR.name]: mismatch.name,\n [VALIDATION_ATTR.boundary]: mismatch.boundary,\n [VALIDATION_ATTR.mode]: mismatch.mode,\n [VALIDATION_ATTR.issueCount]: mismatch.issues.length,\n [VALIDATION_ATTR.issuePaths]: truncate(paths),\n [VALIDATION_ATTR.issueCodes]: truncate(codes),\n ...(mismatch.hash ? { [VALIDATION_ATTR.hash]: mismatch.hash } : {}),\n ...(mismatch.severity\n ? { [VALIDATION_ATTR.severity]: mismatch.severity }\n : {}),\n });\n }\n\n try {\n counter().add(1, {\n boundary: mismatch.boundary,\n validation: mismatch.name,\n mode: mismatch.mode,\n });\n } catch {\n // meter not initialised yet — skip the count, keep the span attrs\n }\n\n // Dispatch to every subscriber with per-listener fault isolation: one\n // throwing subscriber must not starve its peers or break the boundary.\n // Set iteration tolerates concurrent (un)subscription safely.\n for (const listener of listeners) {\n try {\n listener(mismatch);\n } catch {\n // a misbehaving subscriber must not break the boundary or its peers\n }\n }\n } catch {\n // fail-open: telemetry must never break the validated boundary\n }\n}\n\n/**\n * Normalise an arbitrary validation error into PII-safe issues. Reads only\n * `path`, `code`, and (when it is a declared type name) `expected` — and never\n * `message`, `received`, or any value-bearing field. Understands the Zod shape\n * (`error.issues`) and a generic `error.errors` fallback; returns `[]` for\n * anything unrecognised.\n */\nexport function formatValidationIssues(error: unknown): ValidationIssue[] {\n const raw = extractRawIssues(error);\n return raw.map((issue) => toSafeIssue(issue));\n}\n\nfunction extractRawIssues(error: unknown): Array<Record<string, unknown>> {\n if (error && typeof error === 'object') {\n const candidate =\n (error as { issues?: unknown }).issues ??\n (error as { errors?: unknown }).errors;\n if (Array.isArray(candidate)) {\n return candidate.filter(\n (i): i is Record<string, unknown> =>\n i !== null && typeof i === 'object',\n );\n }\n }\n return [];\n}\n\nfunction toSafeIssue(issue: Record<string, unknown>): ValidationIssue {\n const rawPath = issue.path;\n const path = Array.isArray(rawPath)\n ? rawPath.map(String).join('.')\n : typeof rawPath === 'string'\n ? rawPath\n : '';\n const code = typeof issue.code === 'string' ? issue.code : 'invalid';\n // `expected` is a declared type name in Zod (e.g. 'string'); safe. We never\n // read `received`/`message`/`value`, which can carry the offending payload.\n const expected =\n typeof issue.expected === 'string' ? issue.expected : undefined;\n return expected ? { path, code, expected } : { path, code };\n}\n\nexport interface DefineValidatorOptions<S> {\n /** Where validation runs. Defaults to `input`. */\n boundary?: string;\n /** `reject` (default): record then throw. `observe`: record then continue. */\n onMismatch?: ValidationMode;\n /** Project the schema to JSON Schema for a stable `validation.hash`. */\n toJsonSchema?: (schema: S) => unknown;\n severity?: ValidationSeverity;\n /** Build the error thrown in `reject` mode (defaults to a 400 structured error). */\n onReject?: (issues: ValidationIssue[], name: string) => Error;\n}\n\nexport type ValidatorResult<T> =\n | { success: true; data: T }\n | { success: false; issues: ValidationIssue[] };\n\nexport interface Validator<T> {\n readonly name: string;\n readonly mode: ValidationMode;\n /** Validate and record on failure; never throws. */\n safeParse(input: unknown): ValidatorResult<T>;\n /**\n * Validate, record on failure, then apply the mode: `reject` throws,\n * `observe` returns the raw input so the handler can continue.\n */\n parse(input: unknown): T;\n}\n\nfunction defaultRejectError(\n issues: ValidationIssue[],\n name: string,\n): StructuredError {\n return createStructuredError({\n name: 'ValidationError',\n status: 400,\n code: 'validation_failed',\n message: `Input for \"${name}\" did not match its declared shape.`,\n why: `${issues.length} field(s) failed validation: ${issues\n .map((i) => i.path || '(root)')\n .slice(0, VALIDATION_ISSUE_CAP)\n .join(', ')}.`,\n fix: 'Send a payload that matches the schema, or switch this validator to observe mode while you investigate.',\n // PII-safe: paths + codes only, no received values.\n details: { validation: name, issues },\n });\n}\n\n/**\n * Declare an expected input shape once and get a validator that records every\n * mismatch as telemetry.\n *\n * @example\n * ```ts\n * import { z } from 'zod';\n * import { defineValidator } from 'autotel/validate';\n *\n * const OrderBody = defineValidator('POST /orders', z.object({\n * items: z.array(z.object({ sku: z.string(), qty: z.number().int() })),\n * }), { boundary: 'http', toJsonSchema: (s) => z.toJSONSchema(s) });\n *\n * // reject mode (default): records + throws a 400-shaped structured error\n * const order = OrderBody.parse(req.body);\n *\n * // observe mode: records, returns the result, never throws\n * const result = OrderBody.safeParse(req.body);\n * if (!result.success) metrics.onDrift(result.issues);\n * ```\n */\nexport function defineValidator<T, S extends SchemaLike<T>>(\n name: string,\n schema: S,\n options: DefineValidatorOptions<S> = {},\n): Validator<T> {\n const mode = options.onMismatch ?? 'reject';\n const boundary = options.boundary ?? 'input';\n const hash = options.toJsonSchema\n ? hashJson(options.toJsonSchema(schema))\n : undefined;\n\n const record = (issues: ValidationIssue[]): void => {\n recordValidationMismatch({\n name,\n boundary,\n mode,\n issues,\n hash,\n severity: options.severity,\n });\n };\n\n return {\n name,\n mode,\n safeParse(input: unknown): ValidatorResult<T> {\n const parsed = schema.safeParse(input);\n if (parsed.success) return { success: true, data: parsed.data };\n const issues = formatValidationIssues(parsed.error);\n record(issues);\n return { success: false, issues };\n },\n parse(input: unknown): T {\n const parsed = schema.safeParse(input);\n if (parsed.success) return parsed.data;\n const issues = formatValidationIssues(parsed.error);\n record(issues);\n if (mode === 'reject') {\n throw (\n options.onReject?.(issues, name) ?? defaultRejectError(issues, name)\n );\n }\n // observe: continue with the raw input (documented type caveat)\n return input as T;\n },\n };\n}\n"],"mappings":"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"}