# Scratchpad — Invoice Template + Designer Safety Hardening - Plan slug: `invoice-template-designer-safety-hardening` - Created: `2026-02-13` ## What This Is Keep a lightweight, continuously-updated log of discoveries and decisions made while implementing this plan. Prefer short bullets. Append new entries as you learn things, and also *update earlier notes* when a decision changes or an open question is resolved. ## Decisions - (2026-02-13) Canonical designer state is `node.props` + `node.children`. - Rationale: eliminates dual-source-of-truth bugs and aligns with the unified component AST direction. - (2026-02-13) Generic patch/mutation API (`setNodeProp`/`unsetNodeProp`) must reject prototype-pollution keys in paths. - Rationale: path-based writes are otherwise a common source of prototype pollution. - (2026-02-13) Leaf array `unset` splices the array (removes the element) instead of writing `undefined`. - Rationale: avoids sparse arrays and avoids JSON serialization converting `undefined` to `null` in history snapshots. - (2026-02-13) Invoice template AST style identifiers are validated (reject invalid) rather than sanitized at render time. - Rationale: sanitization can create collisions; validation fails fast and keeps rendering deterministic. ## Discoveries / Constraints - (2026-02-13) Recent tracked secrets: `server/.env.local.bak.*` were committed and then removed in `fc2625507`. - (2026-02-13) Added repo `.gitignore` patterns to ignore env-backup files (e.g. `**/.env*.bak*`, `server/.env.local.bak*`) to prevent credential leaks. - (2026-02-13) Added repo guardrails to fail CI if any tracked file matches `.env*.bak*` patterns: - Script: `scripts/guard-no-tracked-env-backups.mjs` - CI: `.github/workflows/secrets-env-backup-guard.yml` - (2026-02-13) Current patch implementation writes to both `props.*` and legacy top-level fields via `expandPaths`: - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - (2026-02-13) Designer store now normalizes legacy patch paths (`name`, `metadata.*`, `layout.*`, `style.*`) to canonical `props.*` and writes only canonical state. - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - (2026-02-13) Legacy node fields (`name`, `metadata`, `layout`, `style`) are treated as derived views of canonical `props.*` (no independent writes). - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - (2026-02-13) Hierarchy mutations now treat `node.children` as canonical and no longer rely on or update `childIds`. - `packages/billing/src/components/invoice-designer/state/patchOps.ts` - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - (2026-02-13) UI call sites now use canonical `props` reads via `getNodeName/getNodeMetadata/getNodeLayout/getNodeStyle` (no direct legacy field reads in UI components touched). - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - `packages/billing/src/components/invoice-designer/palette/OutlineView.tsx` - `packages/billing/src/components/invoice-designer/labelText.ts` - Removed stale `layout.mode`/`layout.sizing` references from section-fit messaging; CSS-first layout now keys off `layout.display`. - (2026-02-13) Continuous typing interactions in the metadata inspector now use `commit=false` on `onChange` and `commit=true` on `onBlur` to avoid history spam. - `packages/billing/src/components/invoice-designer/DesignerShell.tsx` - (2026-02-13) Patch ops currently allow arbitrary object keys and could write `__proto__` unless guarded: - `packages/billing/src/components/invoice-designer/state/patchOps.ts` - (2026-02-13) Patch ops now reject prototype-pollution path segments (`__proto__`, `prototype`, `constructor`) at any depth (safe no-op): - `packages/billing/src/components/invoice-designer/state/patchOps.ts` - Rejection behavior: returns the original `nodes` reference and performs no mutation or history side-effects. - Observability: rejected patches emit a dev-only `console.warn`. - (2026-02-13) Patch ops `unset` for leaf array indices now splices the array (removes element, no `undefined` holes). - `packages/billing/src/components/invoice-designer/state/patchOps.ts` - Nested unsets inside array elements keep the element in place; if the element becomes empty it is left as `{}` to remain JSON-serializable. - (2026-02-13) Patch ops now reject non-JSON values in `setNodeProp` (e.g. `undefined`, `NaN`, `Infinity`, functions, class instances) to keep history snapshots deterministic. - `packages/billing/src/components/invoice-designer/state/patchOps.ts` - Array `set` fills missing indices with `null` to avoid sparse arrays (JSON stringification would otherwise introduce `null` implicitly). - (2026-02-13) Invoice template AST renderer emits CSS selectors/vars based on unvalidated identifiers: - `packages/billing/src/lib/invoice-template-ast/react-renderer.tsx` - `packages/billing/src/lib/invoice-template-ast/schema.ts` - (2026-02-13) Invoice template AST schema now validates style identifiers (class keys, token ids, styleRef tokenIds) against a strict safe identifier regex and rejects invalid inputs. - `packages/billing/src/lib/invoice-template-ast/schema.ts` - (2026-02-13) Invoice template AST renderer now sanitizes style identifiers before emitting CSS selectors and custom properties (defense-in-depth even if schema validation is bypassed). - `packages/billing/src/lib/invoice-template-ast/react-renderer.tsx` - (2026-02-13) Legacy workspace `nodes[]` imports can include `props: {}`; snapshotting now materializes canonical `props.name/metadata/layout/style` from legacy top-level fields so UI helpers can consistently read canonical props. - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - Test: `packages/billing/src/components/invoice-designer/state/designerStore.loadWorkspace.legacy.test.ts` - (2026-02-13) Added hierarchy unit coverage to ensure canonical `children` is authoritative and `childIds` is not used/written during mutations. - Test: `packages/billing/src/components/invoice-designer/state/patchOps.insertChild.test.ts` - (2026-02-13) Extended hierarchy unit coverage for `moveNode` to assert ordering adjustments, cycle prevention, and canonical `children` authority (no legacy `childIds` writes). - Test: `packages/billing/src/components/invoice-designer/state/patchOps.moveNode.test.ts` - (2026-02-13) Extended hierarchy unit coverage for `deleteNode` to ensure subtree traversal keys off canonical `children` and does not mutate legacy `childIds`. - Test: `packages/billing/src/components/invoice-designer/state/patchOps.deleteNode.test.ts` - (2026-02-13) Added UI integration coverage to ensure Outline/tree rendering prefers canonical `props.name` over legacy top-level `name`. - Test: `packages/billing/src/components/invoice-designer/palette/OutlineView.integration.test.tsx` - (2026-02-13) Added a focused DesignerShell UI test to ensure the selected-node header uses `getNodeName` (canonical `props.name` takes precedence over legacy `name`). - Test: `packages/billing/src/components/invoice-designer/DesignerShell.selectedContext.integration.test.tsx` - (2026-02-13) Added unit coverage for leaf array `unset` semantics: leaf index unsets splice the array (no `undefined` holes). - Test: `packages/billing/src/components/invoice-designer/state/patchOps.unsetNodeProp.test.ts` - (2026-02-13) Added unit coverage to ensure nested unsets inside array elements remove only the nested property (do not splice the element). - Test: `packages/billing/src/components/invoice-designer/state/patchOps.unsetNodeProp.test.ts` - (2026-02-13) Added undo/redo coverage for canonical JSON snapshots involving array edits (set + leaf-array unset splice) to ensure history restores exact committed state. - Test: `packages/billing/src/components/invoice-designer/state/designerStore.undoRedo.test.ts` - (2026-02-13) Added undo/redo coverage for a mixed sequence of `setNodeProp` + hierarchy ops (move/delete) to ensure history replay is deterministic. - Test: `packages/billing/src/components/invoice-designer/state/designerStore.undoRedo.test.ts` - (2026-02-13) Added direct store-level tests for `commit=false` vs `commit=true` semantics to prevent history spam during continuous interactions. - Test: `packages/billing/src/components/invoice-designer/state/designerStore.historyCommit.test.ts` - (2026-02-13) Added DesignCanvas pointer-driven resize integration test asserting `onResize(..., commit=false)` during motion and a final `commit=true` on pointer-up. - Test: `packages/billing/src/components/invoice-designer/canvas/DesignCanvas.resizeProps.integration.test.tsx` - (2026-02-13) Added schema coverage ensuring invoice-template AST style class keys must pass safe CSS identifier validation. - Test: `packages/billing/src/lib/invoice-template-ast/schema.test.ts` - (2026-02-13) Added schema coverage ensuring token ids and styleRef tokenIds must pass safe CSS identifier validation. - Test: `packages/billing/src/lib/invoice-template-ast/schema.test.ts` - (2026-02-13) Added authoritative preview coverage: schema validation failures return compile diagnostics and do not proceed to render HTML/CSS output. - Test: `packages/billing/src/actions/invoiceTemplatePreview.schemaFailure.test.ts` - (2026-02-13) Added renderer defense-in-depth coverage: even if invalid identifiers bypass schema validation, CSS selectors/vars and HTML class names are sanitized. - Test: `packages/billing/src/lib/invoice-template-ast/react-renderer.test.tsx` ## Commands / Runbooks - (2026-02-13) Check for tracked env backups: - `git ls-files | rg "\\.env\\.local\\.bak\\.|\\.env\\.bak\\."` - (2026-02-13) Check `.gitignore` contains env-backup ignore patterns: - `node scripts/test-gitignore-env-backups.mjs` - (2026-02-13) Test env-backup guard script (pass + fail cases in a temp git repo): - `node scripts/test-guard-no-tracked-env-backups.mjs` - (2026-02-13) Run invoice-designer unit tests (Vitest config root is `server/`): - `cd server && npx vitest run ../packages/billing/src/components/invoice-designer/state/patchOps.setNodeProp.test.ts` - (2026-02-13) Gotcha: Vitest can fail with `ENOSPC` (no space left on device); running with `--coverage=false` reduces temp output and avoided the issue locally. ## Links / References - PRDs, issues, PRs, docs, dashboards, logs, and key file paths. - Key files: - `packages/billing/src/components/invoice-designer/state/patchOps.ts` - `packages/billing/src/components/invoice-designer/state/designerStore.ts` - `packages/billing/src/components/invoice-designer/utils/nodeProps.ts` - `packages/billing/src/lib/invoice-template-ast/schema.ts` - `packages/billing/src/lib/invoice-template-ast/react-renderer.tsx` ## Open Questions - Questions that block the work or need follow-up. - Do we want patch rejection to be silent no-op, console-warn, or surfaced as a formal diagnostic in the designer UI?