Validation Pattern

Zod-based validation on both frontend and backend, with mandatory XSS sanitization for all user-facing text.

Dual Validation

SideToolPurpose
BackendOpenAPIHono + ZodRoute input validation (source of truth)
Frontendreact-hook-form + @hookform/resolvers/zodForm validation (UX feedback)

Shared validation constants: shared/validation-constants.ts (field lengths, regex patterns).

XSS Sanitization Helpers

backend/src/lib/sanitization-helpers.ts provides DOMPurify-based helpers (default max lengths: 1000 for text, 10000 for rich text):

HelperPurposeExample
strictTextField(maxLen) / requiredStrictTextField(maxLen)Strips ALL HTML (optional / required)Names, labels, notes
sanitizedTextField(maxLen, level?) / requiredTextField(maxLen, level?)Non-localized text with explicit levelUser-facing single-language fields
sanitizedLocalizedText(maxLen)Required bilingual {de, en}Entity names
optionalSanitizedLocalizedText(maxLen)Optional bilingual {de, en}Descriptions
richTextLocalizedText(maxLen) / optionalRichTextLocalizedText(maxLen)Admin-only rich textSystem templates

Sanitization Levels

LevelAllowed TagsUse Case
STRICTNone (strips all HTML)99% of fields
BASIC_FORMATTING<b>, <i>, <em>, <strong>, <br>Limited formatting
RICH_TEXTExtended setAdmin-only, never user input

Usage Rules

  • Raw z.string() ONLY for non-user-facing values (tokens, emails, system keys, regex patterns)
  • NEVER configure ALLOWED_TAGS via environment variables
  • NEVER use inline purify.sanitize() calls — always use the helpers
  • NEVER use RICH_TEXT level for user-submitted content

LocalizedText Validation

All bilingual fields use the LocalizedText pattern:

const schema = z.object({
  name: sanitizedLocalizedText(100),
  description: optionalSanitizedLocalizedText(1000),
});

Enforcement: pre-commit hook + validator script + CI quality-checks.yml.

Money — Decimal Strings + Big.js (MANDATORY)

decimal(p, s) columns leave Drizzle as strings, and money stays a decimal string on the wire — never z.number() / <money>: number in DTOs. Parse at the boundary with fromString() from @shared/money (shared/money.ts), compute with its add/sub/mul/div + round(m, 2) (HALF_UP), and stringify back with toString. Never Number()/parseFloat() or native arithmetic on money values. Enforced by quick-lint.sh, the ESLint rule renewa-local/no-number-on-money, and /audit. Spec: docs/superpowers/specs/2026-04-24-money-arithmetic-bigjs-design.md.

Zod Version

Locked to 4.1.13 due to incompatibility between @hookform/resolvers@5.2.2 and Zod 4.2.x.

See Also