{"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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"}