import { z } from "zod"; import { isBindable, isBindRef } from "./bindable.js"; import type { Carte, CarteEntry } from "./carte.js"; import { checkFilter, type Filter } from "./filters.js"; import { getParamsControls, getParamsSchema } from "./params.js"; import { planSchema } from "./plan.js"; import { checkSort, type Sort } from "./sorts.js"; import type { UIAdapter } from "./ui-adapter.js"; export interface PlanValidationError { /** Index of the panel where the error occurred, or -1 for plan-level errors. */ panel: number; /** Dotted path within the panel/plan. */ path: string; message: string; } export type PlanValidationResult = | { valid: true; errors: [] } | { valid: false; errors: PlanValidationError[] }; declare const validatedPlanBrand: unique symbol; export type ValidatedPlan = import("./plan.js").Plan & { readonly [validatedPlanBrand]: true }; export type ParsePlanResult = | { ok: true; plan: ValidatedPlan } | { ok: false; errors: PlanValidationError[] }; /** * Validates a plan against a carte and a UI adapter: * 1. Plan shape (layout + panels) parses * 2. Every `query.id` exists and `ctx` passes its `access` predicate * 3. Every `query.params` validates against the entry's `params` schema * 4. Every `component.id` exists in the UI adapter * 5. Every prop is either a literal matching its schema, or a `{ $bind }` ref * whose target exists in the entry's `returns` shape (or its array element). * `$bind` on a non-bindable prop produces a friendly error rather than a * raw Zod union mismatch. * 6. Required props are present */ export function parsePlan( plan: unknown, carte: Carte, uiAdapter: UIAdapter, ctx: TCtx, ): ParsePlanResult { const parsed = planSchema.safeParse(plan); if (!parsed.success) { return { ok: false, errors: parsed.error.issues.map((i) => ({ panel: -1, path: i.path.join("."), message: i.message, })), }; } const errors: PlanValidationError[] = []; const normalizedPanels: import("./plan.js").Panel[] = []; parsed.data.panels.forEach((panel, i) => { const entry = carte[panel.query.id]; if (!entry) { errors.push({ panel: i, path: "query.id", message: `Unknown query: ${panel.query.id}`, }); return; } if (entry.access && !entry.access(ctx)) { errors.push({ panel: i, path: "query.id", message: `Access denied for query: ${panel.query.id}`, }); return; } const paramsResult = getParamsSchema(entry.params).safeParse(panel.query.params); if (!paramsResult.success) { for (const issue of paramsResult.error.issues) { errors.push({ panel: i, path: joinPath("query.params", issue.path), message: issue.message, }); } } else { const controls = getParamsControls(entry.params); validateFilters(paramsResult.data, controls?.filters, entry, i, errors); validateSorts(paramsResult.data, controls?.sorts, entry, i, errors); } if (!uiAdapter.hasComponent(panel.component.id)) { errors.push({ panel: i, path: "component.id", message: `Unknown component: ${panel.component.id}`, }); return; } const props = uiAdapter.getProps(panel.component.id); const shape = (props?.shape ?? {}) as Record; const returnFields = collectBindableFields(entry.returns); for (const [key, value] of Object.entries(panel.component.props)) { const propSchema = shape[key]; if (!propSchema) { errors.push({ panel: i, path: `component.props.${key}`, message: `Unknown prop: ${key}`, }); continue; } if (isBindRef(value)) { if (!isBindable(propSchema)) { errors.push({ panel: i, path: `component.props.${key}`, message: `Prop "${key}" is not bindable but received a $bind reference`, }); continue; } // "*" is the wildcard that binds the entire query result (used by // table-shaped components that want every row + every column). if (value.$bind !== "*" && !isBindablePath(value.$bind, returnFields)) { errors.push({ panel: i, path: `component.props.${key}.$bind`, message: `$bind references unknown field "${value.$bind}" on query "${entry.id}"`, }); } continue; } const result = propSchema.safeParse(value); if (!result.success) { for (const issue of result.error.issues) { errors.push({ panel: i, path: joinPath(`component.props.${key}`, issue.path), message: issue.message, }); } } } normalizedPanels[i] = { ...panel, query: { ...panel.query, params: (paramsResult.success ? paramsResult.data : panel.query.params) as Record< string, unknown >, }, }; for (const [propKey, propSchema] of Object.entries(shape)) { if (propKey in panel.component.props) continue; if (!propSchema) continue; if (propSchema instanceof z.ZodOptional || propSchema instanceof z.ZodDefault) continue; errors.push({ panel: i, path: `component.props.${propKey}`, message: `Missing required prop: ${propKey}`, }); } }); if (errors.length > 0) return { ok: false, errors }; return { ok: true, plan: { layout: parsed.data.layout, panels: normalizedPanels, } as ValidatedPlan, }; } export function validatePlan( plan: unknown, carte: Carte, uiAdapter: UIAdapter, ctx: TCtx, ): PlanValidationResult { const result = parsePlan(plan, carte, uiAdapter, ctx); return result.ok ? { valid: true, errors: [] } : { valid: false, errors: result.errors }; } /** * Maximum depth `collectBindableFields` walks before stopping. Prevents * stack blowup on accidentally cyclic schemas (`z.lazy(...)` chains) and * keeps prompt-time field enumeration bounded. */ const MAX_BIND_PATH_DEPTH = 6; /** * Maximum `Optional` / `Nullable` / `Default` layers `unwrapSchema` will * peel before bailing. Real schemas rarely stack more than two or three; * the limit exists so an unexpected wrapper type returned by a future * Zod version doesn't silently loop forever. */ const MAX_WRAPPER_NESTING = 5; /** * Suffix appended to a path to mark every descendant as bindable without * enumerating each one. The validator treats `"runs.*"` as "accept * `runs`, `runs.`, `runs..`, …" — used to * cover schema shapes whose key set isn't statically knowable * (`ZodArray` element indices, `ZodRecord` keys, `ZodAny`/`ZodUnknown`). */ const WILDCARD_SUFFIX = ".*"; /** * Collects the field names that a `$bind` may reference, including dotted * paths into nested values. Mixed semantics: * * - Object return → every key, plus dotted paths into nested objects. * `{ kpis: { mrr: { value: string } } }` produces * `["kpis", "kpis.mrr", "kpis.mrr.value"]`. * - Array-of-object return ("rows") → the element's keys at the * current prefix — column extraction is the contract: `$bind: * "jobType"` against `Array<{ jobType, ... }>` resolves to a column. * - Mid-path arrays — walking continues into the element type, AND a * wildcard sentinel is emitted (`"runs.*"`) so the validator accepts * numeric-index segments like `"runs.0"` / `"runs.0.value"`. The * resolver allows them via `Object.hasOwn` on numeric-string keys; * enumerating every index in the validator isn't possible without * runtime data, so a wildcard is the right shape. * - `ZodRecord` → wildcard at the record's prefix. Any key * resolves at runtime, so the validator can't enumerate them but * should still accept the binding. * - `ZodAny` / `ZodUnknown` → wildcard at the prefix. Schema author * opted out of typing this subtree; the validator follows suit. * - Union / optional / nullable / default wrappers are unwrapped. * `ZodLazy` is intentionally not followed (cyclic-schema escape hatch). * * Intermediate keys are included: `"kpis"` and `"kpis.mrr"` both pass * validation, even if they resolve to objects at hydration time. Renderer * components decide whether an unexpected shape is acceptable. */ function collectBindableFields(returns: z.ZodType): Set { const out = new Set(); walkSchemaFields(returns, "", out, 0); return out; } /** * True if `field` matches one of the collected paths, either by exact * match or by being covered by a wildcard sentinel (`"runs.*"` covers * `"runs"`, `"runs.0"`, `"runs.0.value"`, etc.). */ function isBindablePath(field: string, fields: ReadonlySet): boolean { if (fields.has(field)) return true; for (const f of fields) { if (!f.endsWith(WILDCARD_SUFFIX)) continue; const root = f.slice(0, -WILDCARD_SUFFIX.length); if (field === root || field.startsWith(`${root}.`)) return true; } return false; } function walkSchemaFields( schema: z.ZodType, prefix: string, out: Set, depth: number, ): void { if (depth > MAX_BIND_PATH_DEPTH) return; const unwrapped = unwrapSchema(schema); if (unwrapped instanceof z.ZodObject) { for (const [key, value] of Object.entries(unwrapped.shape)) { const path = prefix ? `${prefix}.${key}` : key; out.add(path); if (value instanceof z.ZodType) { walkSchemaFields(value, path, out, depth + 1); } } return; } if (unwrapped instanceof z.ZodArray) { // Column-extract: each element's fields are addressable WITHOUT // consuming a path segment. `Array<{ jobType, ... }>` exposes // `jobType` directly, not `0.jobType`. walkSchemaFields(unwrapped.element as z.ZodType, prefix, out, depth + 1); // Also accept any further-segment path past this point — the // resolver supports numeric-index walks (`"runs.0.value"`) and the // validator can't enumerate runtime indices statically. if (prefix) out.add(`${prefix}${WILDCARD_SUFFIX}`); return; } if (unwrapped instanceof z.ZodRecord) { // Record keys aren't statically knowable. Accept anything past the // record's prefix. if (prefix) out.add(`${prefix}${WILDCARD_SUFFIX}`); return; } if (unwrapped instanceof z.ZodAny || unwrapped instanceof z.ZodUnknown) { // Schema author opted out of typing this subtree; mirror that. if (prefix) out.add(`${prefix}${WILDCARD_SUFFIX}`); return; } if (unwrapped instanceof z.ZodUnion) { // Take the union of every branch's fields. Any of them may resolve // at hydration time. for (const branch of unwrapped.options as ReadonlyArray) { walkSchemaFields(branch, prefix, out, depth + 1); } } } function unwrapSchema(schema: z.ZodType): z.ZodType { let current: z.ZodType = schema; for (let i = 0; i < MAX_WRAPPER_NESTING; i++) { if ( current instanceof z.ZodOptional || current instanceof z.ZodNullable || current instanceof z.ZodDefault ) { const unwrapped = ( current as unknown as { unwrap?: () => z.ZodType } ).unwrap?.(); if (!unwrapped) break; current = unwrapped; continue; } break; } return current; } function joinPath(prefix: string, suffix: ReadonlyArray): string { if (suffix.length === 0) return prefix; return `${prefix}.${suffix.map(String).join(".")}`; } /** * Walk the parsed `params.filters` array (if any) and validate each entry * against `entry.allowedFilters`. Three failure modes: * * - The plan emits filters but `entry.allowedFilters` is undefined → * the carte author didn't sign off on filtering for this query. * - The filter targets a field that's not in `allowedFilters`. * - The filter uses an operator the field doesn't allow, or the value * type is wrong (datetime requires ISO 8601 string, in/nin require * arrays of the right element type, etc.) — see `checkFilter`. */ /** * Pulls a named array out of parsed `params` if it's actually present and * non-empty. Returns `undefined` otherwise (params object missing, key * missing, value not an array, or array empty). Used to gate the optional * filter/sort validation passes. */ function getParamsArray(parsedParams: unknown, key: string): T[] | undefined { if (typeof parsedParams !== "object" || parsedParams === null) return undefined; const v = (parsedParams as Record)[key]; if (!Array.isArray(v) || v.length === 0) return undefined; return v as T[]; } function validateFilters( parsedParams: unknown, allowed: import("./filters.js").AllowedFilters | undefined, entry: CarteEntry, panelIndex: number, errors: PlanValidationError[], ): void { const filters = getParamsArray(parsedParams, "filters"); if (!filters) return; if (!allowed) { errors.push({ panel: panelIndex, path: "query.params.filters", message: `Query "${entry.id}" does not declare built-in filters; filters are not permitted on this query.`, }); return; } filters.forEach((filter, fi) => { const err = checkFilter(filter, allowed); if (err) { errors.push({ panel: panelIndex, path: `query.params.filters.${fi}`, message: err, }); } }); } /** * Walk the parsed `params.sorts` array (if any) and validate each entry * against `entry.allowedSorts`. Mirrors `validateFilters`. */ function validateSorts( parsedParams: unknown, allowed: import("./sorts.js").AllowedSorts | undefined, entry: CarteEntry, panelIndex: number, errors: PlanValidationError[], ): void { const sorts = getParamsArray(parsedParams, "sorts"); if (!sorts) return; if (!allowed) { errors.push({ panel: panelIndex, path: "query.params.sorts", message: `Query "${entry.id}" does not declare built-in sorts; sorts are not permitted on this query.`, }); return; } sorts.forEach((sort, si) => { const err = checkSort(sort, allowed); if (err) { errors.push({ panel: panelIndex, path: `query.params.sorts.${si}`, message: err, }); } }); }