Pulp Engine v0.9.0
Release date: 2026-03-19
Highlights
- Template persistence boundaries are now hardened with runtime Zod validation on all write endpoints.
- POST /templates and PUT /templates/:key reject structurally invalid bodies with
400 { error: 'Invalid Template', issues: [...] }. - PUT /templates/:key additionally rejects
body.key≠ route key with400 { error: 'Key Mismatch', ... }. - POST /templates/:key/versions/:version/restore validates stored definitions before re-persisting — legacy-invalid versions return
400rather than silently restoring corrupt data. - Unknown structural fields are rejected (strict Zod mode) — field typos now surface as errors rather than silently persisting unrecognised data.
keyis validated against/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;versionmust bemajor.minor.patchwith optional prerelease/build metadata suffixes.
Template definition runtime validation
Background
Previously the template write endpoints trusted that any well-formed JSON body was a structurally valid TemplateDefinition. TypeScript types provided compile-time safety for server code but offered no protection against external callers submitting invalid definitions. Stored definitions were also treated as unconditionally trusted at restore time.
What changed
TemplateDefinitionSchema (Zod) is now the single authoritative runtime validator for TemplateDefinition at all HTTP persistence boundaries. It mirrors the @pulp-engine/template-model TypeScript types and enforces:
- All required top-level fields (
key,version,name,inputSchema,document). keymatches/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/— alphanumeric start; subsequent characters may be alphanumeric,_, or-.versionismajor.minor.patchwith optional prerelease (-alpha,-rc.1) and build metadata (+build.123) suffixes. Bare strings like"1"or"1.0"are rejected.documentis a validDocumentNode → SectionNode[] → ContentNode[]tree, validated recursively via a discriminated union on thetypefield (O(1) branch dispatch, clean per-branch error messages).- All structural object schemas use
.strict()— unrecognised fields are rejected, not silently stripped.
Intentional exceptions to strict mode (see schema file for full rationale):
style,paginationHints—z.record(): ~40 optional CSS properties; enumeration would be brittle and adds no security value at this boundarymeta—z.record()by design: arbitrary editor-tooling metadatainputSchema—z.record()by design: arbitrary JSON Schema; deep validation is the schema-validator package’s responsibilityStructuredCondition.value,TableColumnDefinition.formatArgs,FieldMappingEntry.defaultValue—z.unknown()by design: any JSON value is valid
Validated data (not the raw request body) is passed to the store — no unvalidated fields are persisted even if they slipped past the schema.
New error shapes
See Section 4 of the API guide for full shapes, field descriptions, and recovery guidance.
| Error | Status | Endpoint(s) |
|---|---|---|
Invalid Template | 400 | POST /templates, PUT /templates/:key |
Key Mismatch | 400 | PUT /templates/:key |
Stored Version Invalid | 400 | POST /templates/:key/versions/:version/restore |
New files
| File | Purpose |
|---|---|
apps/api/src/validation/template-definition.schema.ts | TemplateDefinitionSchema (Zod) and formatValidationIssues — single authoritative runtime validator for TemplateDefinition at all persistence boundaries |
apps/api/src/validation/template-definition.schema.test.ts | 150+ unit and integration tests covering valid/invalid payloads, all 15 node types in the document tree, strict-mode unknown-field rejection, key/version regex, and HTTP-layer 400 responses for all three write endpoints |
Modified files
| File | Change |
|---|---|
apps/api/src/routes/templates/index.ts | POST /templates, PUT /templates/:key, and POST /templates/:key/versions/:version/restore now call TemplateDefinitionSchema.safeParse() and return 400 on failure; PUT also enforces body.key === route key |
Test coverage
apps/api
| File | Tests | Scope |
|---|---|---|
validation/template-definition.schema.test.ts | 150+ | Valid minimal and full payloads; key regex; semver regex; strict-mode unknown-field rejection; all 15 node types (13 ContentNode types plus SectionNode and DocumentNode); RichText subtree (paragraph, orderedList, unorderedList, inline types); FieldMappingEntry; RenderConfig; HTTP 400 for POST /templates, PUT /templates/:key (invalid body and key mismatch), and restore |
Behaviour changes / migration notes
New 400 errors on write endpoints
If your client treats any non-2xx/409 response from POST /templates or PUT /templates/:key as unexpected, add handling for 400 Bad Request. The body is always { error: 'Invalid Template', issues: [...] }. Each issue has a path (dot-separated field path, e.g. "document.children.0.id") and a message.
Unknown fields are now rejected
Any unrecognised field in a TemplateDefinition body — top-level or inside any node — returns 400 Invalid Template with a message such as "Unrecognized key(s) in object: 'extraField'". Review clients that set non-standard fields.
Restore of legacy-invalid definitions blocked
POST /templates/:key/versions/:version/restore now validates the stored definition before re-persisting it. A historical version saved before this validation was introduced may contain fields now rejected by the current schema; in that case restore returns 400 Stored Version Invalid and leaves version history unchanged.
To recover: retrieve the historical definition via GET /templates/:key/versions/:version, correct the non-conforming fields, and save a new version via PUT /templates/:key.
key and version format enforcement
keymust start with an alphanumeric character. Keys previously starting with_or-are now rejected.versionmust bemajor.minor.patch. Bare strings like"1"or"1.0"are rejected.