UNPKG

autotel

Version:
202 lines (200 loc) 8.04 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); const require_values = require('./values-tm4bi0FF.cjs'); const require_structured_error = require('./structured-error-BAXhglRE.cjs'); const require_stable_hash = require('./stable-hash-C9F6xjzx.cjs'); const require_metric_helpers = require('./metric-helpers.cjs'); const require_validation_attributes = require('./validation-attributes.cjs'); let _opentelemetry_api = require("@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 = require_metric_helpers.createCounter(require_validation_attributes.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 = _opentelemetry_api.trace.getActiveSpan(); if (span) { const attributes = {}; attributes[require_validation_attributes.VALIDATION_ATTR.name] = mismatch.name; attributes[require_validation_attributes.VALIDATION_ATTR.boundary] = mismatch.boundary; attributes[require_validation_attributes.VALIDATION_ATTR.mode] = mismatch.mode; attributes[require_validation_attributes.VALIDATION_ATTR.issueCount] = mismatch.issues.length; attributes[require_validation_attributes.VALIDATION_ATTR.issuePaths] = truncate(paths); attributes[require_validation_attributes.VALIDATION_ATTR.issueCodes] = truncate(codes); if (mismatch.hash) attributes[require_validation_attributes.VALIDATION_ATTR.hash] = mismatch.hash; if (mismatch.severity) attributes[require_validation_attributes.VALIDATION_ATTR.severity] = mismatch.severity; span.setAttributes(attributes); } 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) { const raised = require_values.asRecord(error); const candidate = raised?.issues ?? raised?.errors; if (!Array.isArray(candidate)) return []; return candidate.map((issue) => require_values.asRecord(issue)).filter((issue) => issue !== void 0); } function toSafeIssue(issue) { const rawPath = issue.path; const path = Array.isArray(rawPath) ? rawPath.map(String).join(".") : require_values.asString(rawPath) ?? ""; const code = require_values.asString(issue.code) ?? "invalid"; const expected = require_values.asString(issue.expected); return expected ? { path, code, expected } : { path, code }; } function defaultRejectError(issues, name) { return require_structured_error.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 ? require_stable_hash.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 exports.defineValidator = defineValidator; exports.formatValidationIssues = formatValidationIssues; exports.onValidationMismatch = onValidationMismatch; exports.recordValidationMismatch = recordValidationMismatch;