UNPKG

autotel

Version:
197 lines (195 loc) 7.36 kB
import { t as createStructuredError } from "./structured-error-9--cxBay.js"; import { t as hashJson } from "./stable-hash-ChFBIhNt.js"; import { createCounter } from "./metric-helpers.js"; import { VALIDATION_ATTR, VALIDATION_ISSUE_CAP, VALIDATION_METRICS } from "./validation-attributes.js"; import { trace } from "@opentelemetry/api"; //#region src/validate.ts /** * Validation telemetry — connect runtime input validation (Zod or any * `safeParse` schema) to your traces and metrics at the boundaries where bad * data actually enters: HTTP bodies, events, messages. * * Today a `safeParse` failure either throws (no span, no metric, no alert) or * is silently swallowed in a handler. `defineValidator` makes the mismatch * **observable** — a `validation.*` span attribute set and a counter * incremented — with a per-validator `observe` vs `reject` mode: * * - `reject` (default): record telemetry, then throw a structured 400-shaped * error so the boundary can fail cleanly. * - `observe`: record telemetry, return the raw input so the handler continues * — useful for measuring real-world drift before you enforce it. * * **Not a security feature by default.** A malformed body is usually a bug or * version skew, not an attack. Validation telemetry is first-class on its own * metric; escalation to the security path is a deliberate opt-in via * {@link onValidationMismatch} (e.g. wired by `autotel-audit`), never automatic. * * **PII-safe by construction.** Only field *paths*, issue *codes*, and the * declared *type* are ever recorded — never the offending value, and never a * validator's error `message` (which routinely embeds the received value). */ let mismatchCounter; function counter() { if (!mismatchCounter) mismatchCounter = createCounter(VALIDATION_METRICS.mismatches, { description: "Input payloads that did not match their declared shape" }); return mismatchCounter; } const listeners = /* @__PURE__ */ new Set(); /** * Register an explicit handler called on every recorded mismatch — the opt-in * seam for escalating to security events, a webhook, or a custom sink. There is * no automatic, package-presence-driven escalation: nothing fires here unless * you (or a package you wire up) register a handler. * * Multiple subscribers coexist: a package (e.g. `autotel-audit` bridging to * security events) and your own app code (a webhook, a logger) can both * register and all fire. Returns an unsubscribe fn that removes only this * handler; registering the same function twice is a no-op (Set semantics). */ function onValidationMismatch(handler) { listeners.add(handler); return () => { listeners.delete(handler); }; } const truncate = (values) => values.slice(0, 20).join(","); /** * Record a validation mismatch as telemetry: `validation.*` attributes on the * active span (if any) and an increment on `autotel.validation.mismatches`. * Fail-open — never throws, so instrumentation can't break the boundary. */ function recordValidationMismatch(mismatch) { try { const paths = mismatch.issues.map((i) => i.path).filter(Boolean); const codes = [...new Set(mismatch.issues.map((i) => i.code))]; const span = trace.getActiveSpan(); if (span) span.setAttributes({ [VALIDATION_ATTR.name]: mismatch.name, [VALIDATION_ATTR.boundary]: mismatch.boundary, [VALIDATION_ATTR.mode]: mismatch.mode, [VALIDATION_ATTR.issueCount]: mismatch.issues.length, [VALIDATION_ATTR.issuePaths]: truncate(paths), [VALIDATION_ATTR.issueCodes]: truncate(codes), ...mismatch.hash ? { [VALIDATION_ATTR.hash]: mismatch.hash } : {}, ...mismatch.severity ? { [VALIDATION_ATTR.severity]: mismatch.severity } : {} }); try { counter().add(1, { boundary: mismatch.boundary, validation: mismatch.name, mode: mismatch.mode }); } catch {} for (const listener of listeners) try { listener(mismatch); } catch {} } catch {} } /** * Normalise an arbitrary validation error into PII-safe issues. Reads only * `path`, `code`, and (when it is a declared type name) `expected` — and never * `message`, `received`, or any value-bearing field. Understands the Zod shape * (`error.issues`) and a generic `error.errors` fallback; returns `[]` for * anything unrecognised. */ function formatValidationIssues(error) { return extractRawIssues(error).map((issue) => toSafeIssue(issue)); } function extractRawIssues(error) { if (error && typeof error === "object") { const candidate = error.issues ?? error.errors; if (Array.isArray(candidate)) return candidate.filter((i) => i !== null && typeof i === "object"); } return []; } function toSafeIssue(issue) { const rawPath = issue.path; const path = Array.isArray(rawPath) ? rawPath.map(String).join(".") : typeof rawPath === "string" ? rawPath : ""; const code = typeof issue.code === "string" ? issue.code : "invalid"; const expected = typeof issue.expected === "string" ? issue.expected : void 0; return expected ? { path, code, expected } : { path, code }; } function defaultRejectError(issues, name) { return createStructuredError({ name: "ValidationError", status: 400, code: "validation_failed", message: `Input for "${name}" did not match its declared shape.`, why: `${issues.length} field(s) failed validation: ${issues.map((i) => i.path || "(root)").slice(0, 20).join(", ")}.`, fix: "Send a payload that matches the schema, or switch this validator to observe mode while you investigate.", details: { validation: name, issues } }); } /** * Declare an expected input shape once and get a validator that records every * mismatch as telemetry. * * @example * ```ts * import { z } from 'zod'; * import { defineValidator } from 'autotel/validate'; * * const OrderBody = defineValidator('POST /orders', z.object({ * items: z.array(z.object({ sku: z.string(), qty: z.number().int() })), * }), { boundary: 'http', toJsonSchema: (s) => z.toJSONSchema(s) }); * * // reject mode (default): records + throws a 400-shaped structured error * const order = OrderBody.parse(req.body); * * // observe mode: records, returns the result, never throws * const result = OrderBody.safeParse(req.body); * if (!result.success) metrics.onDrift(result.issues); * ``` */ function defineValidator(name, schema, options = {}) { const mode = options.onMismatch ?? "reject"; const boundary = options.boundary ?? "input"; const hash = options.toJsonSchema ? hashJson(options.toJsonSchema(schema)) : void 0; const record = (issues) => { recordValidationMismatch({ name, boundary, mode, issues, hash, severity: options.severity }); }; return { name, mode, safeParse(input) { const parsed = schema.safeParse(input); if (parsed.success) return { success: true, data: parsed.data }; const issues = formatValidationIssues(parsed.error); record(issues); return { success: false, issues }; }, parse(input) { const parsed = schema.safeParse(input); if (parsed.success) return parsed.data; const issues = formatValidationIssues(parsed.error); record(issues); if (mode === "reject") throw options.onReject?.(issues, name) ?? defaultRejectError(issues, name); return input; } }; } //#endregion export { defineValidator, formatValidationIssues, onValidationMismatch, recordValidationMismatch }; //# sourceMappingURL=validate.js.map