import { isBindRef } from "./bindable.js"; /** * Walk every prop on a panel's component, replacing each `{ $bind: "field" }` * reference with a literal value drawn from `result`. Renderer-agnostic: every * renderer integration calls this before producing its own IR (a json-render * `Spec`, a markdown string, Slack Block Kit JSON, etc.). Non-bind props are * passed through unchanged. */ export function resolveBindings( props: Record, result: unknown, ): Record { const resolved: Record = {}; for (const [key, value] of Object.entries(props)) { resolved[key] = isBindRef(value) ? extractBoundValue(result, value.$bind) : value; } return resolved; } /** * Extract a single bound value from a panel's result by field name. * * Resolution rules: * - `"*"` → the entire result, unchanged. Used by table-shaped components * that want every row + every column. * - Array result + field → column-extracted across rows. * `[{ jobType, failureRate }, ...]` + `"jobType"` * resolves to `["email_sender", "webhook_delivery", ...]`. * - Object result + field → the field's value directly. * - **Dotted paths** (e.g. `"kpis.mrr.value"`) walk nested values. Against * an array result each row is walked, producing a column of nested * values: `[{ a: { b: 1 } }, { a: { b: 2 } }]` + `"a.b"` → `[1, 2]`. * - **Numeric-string segments** index into arrays (`"rows.0"` against * `{ rows: [{ id: "r_1" }, ...] }` returns the first element). Works * consistently with JS bracket-access semantics. * - Anything else → `undefined` (the renderer falls back). * * Backwards-compat: a field name without a `.` resolves identically to * the flat-key behaviour shipped before this change. The walker uses * `Object.hasOwn` for every property access — own-properties only, no * prototype-chain traversal — so an attacker-influenced `$bind` like * `"__proto__.toString"` resolves to `undefined`, never a function. * That is a deliberate strengthening over the pre-dotted-path resolver, * which used direct bracket access without an own-property guard. * * Field names that contain a literal dot (e.g. a database column called * `"foo.bar"`) are not addressable as a single segment — the resolver * always treats `.` as a path separator. Pick column names accordingly, * or bind via `"*"` and walk client-side. * * **Trust contract.** Property access goes through direct subscript * (`current[segment]`), so JavaScript getters fire and Proxies see the * read. Carte's threat model trusts query results — they came from your * `query` function. If a `query` returns objects with side-effectful * getters, those side effects WILL run at hydration time. Keep `query` * functions side-effect-free or use plain data objects. */ export function extractBoundValue(result: unknown, field: string): unknown { if (field === "*") return result; const segments = field.split("."); if (Array.isArray(result)) { return result.map((row) => walkPath(row, segments)); } return walkPath(result, segments); } /** * Walk a path through a value, descending into own properties at each * segment. Returns `undefined` if any intermediate is `null` / * `undefined` / non-object, or if a segment is not an own property. * Numeric-string segments naturally index into arrays via * `Object.hasOwn(array, "0")`. */ function walkPath(value: unknown, segments: readonly string[]): unknown { let current: unknown = value; for (const segment of segments) { if (current === null || current === undefined) return undefined; if (typeof current !== "object") return undefined; // Own-properties only. Refuses `__proto__`, `constructor`, // `toString`, etc. — prototype-chain values would otherwise leak // into renderer output for attacker-influenced bind paths. if (!Object.hasOwn(current, segment)) return undefined; current = (current as Record)[segment]; } return current; }