autotel
Version:
Write Once, Observe Anywhere
130 lines (126 loc) • 5.92 kB
TypeScript
import { S as SchemaLike } from './define-event-BL6Li7CM.js';
import './event-subscriber.js';
/**
* 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).
*/
type ValidationMode = 'observe' | 'reject';
type ValidationSeverity = 'info' | 'warning' | 'error';
/** A single failing field, stripped of any payload values. */
interface ValidationIssue {
/** Dotted field path, e.g. `items.0.price`. Never a value. */
path: string;
/** Issue code (e.g. Zod's `invalid_type`, `too_small`). Never a value. */
code: string;
/** Declared type/constraint summary, e.g. `string`. Never a received value. */
expected?: string;
}
/** Everything the recorder needs — already PII-stripped by the caller. */
interface ValidationMismatch {
/** Contract id, e.g. `POST /orders` or `order.placed`. */
name: string;
boundary: string;
mode: ValidationMode;
issues: ValidationIssue[];
hash?: string;
severity?: ValidationSeverity;
}
type MismatchListener = (mismatch: ValidationMismatch) => void;
/**
* 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).
*/
declare function onValidationMismatch(handler: MismatchListener): () => void;
/**
* 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.
*/
declare function recordValidationMismatch(mismatch: ValidationMismatch): void;
/**
* 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.
*/
declare function formatValidationIssues(error: unknown): ValidationIssue[];
interface DefineValidatorOptions<S> {
/** Where validation runs. Defaults to `input`. */
boundary?: string;
/** `reject` (default): record then throw. `observe`: record then continue. */
onMismatch?: ValidationMode;
/** Project the schema to JSON Schema for a stable `validation.hash`. */
toJsonSchema?: (schema: S) => unknown;
severity?: ValidationSeverity;
/** Build the error thrown in `reject` mode (defaults to a 400 structured error). */
onReject?: (issues: ValidationIssue[], name: string) => Error;
}
type ValidatorResult<T> = {
success: true;
data: T;
} | {
success: false;
issues: ValidationIssue[];
};
interface Validator<T> {
readonly name: string;
readonly mode: ValidationMode;
/** Validate and record on failure; never throws. */
safeParse(input: unknown): ValidatorResult<T>;
/**
* Validate, record on failure, then apply the mode: `reject` throws,
* `observe` returns the raw input so the handler can continue.
*/
parse(input: unknown): T;
}
/**
* 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);
* ```
*/
declare function defineValidator<T, S extends SchemaLike<T>>(name: string, schema: S, options?: DefineValidatorOptions<S>): Validator<T>;
export { type DefineValidatorOptions, SchemaLike, type ValidationIssue, type ValidationMismatch, type ValidationMode, type ValidationSeverity, type Validator, type ValidatorResult, defineValidator, formatValidationIssues, onValidationMismatch, recordValidationMismatch };