autotel
Version:
Write Once, Observe Anywhere
197 lines (195 loc) • 7.36 kB
JavaScript
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