fix: surface superstruct validation errors and normalize update-API paths#1546
Open
drlukeangel wants to merge 2 commits into
Open
fix: surface superstruct validation errors and normalize update-API paths#1546drlukeangel wants to merge 2 commits into
drlukeangel wants to merge 2 commits into
Conversation
…bare "Bad data"
The update API's schema validation catch block was returning a constant
"Bad data" string body with no information about which field failed or
why. This made diagnosing routine save failures (mismatched paths,
invalid base64, etc.) extremely difficult — the only path to the actual
error required patching node_modules locally to surface the StructError.
This change widens the catch to inspect the error and, when it is a
superstruct StructError, returns a JSON body with the failing path,
type, message, and per-failure detail. Non-StructError exceptions still
return the original bare "Bad data" string, preserving the existing
behavior for any unexpected error class.
The response structure:
{
"error": "Bad data",
"path": "deletions.0.path",
"type": "string",
"message": "Expected a value of type `string`, but received: ...",
"failures": [{ "path": [...], "type": ..., "message": ..., "refinement": ... }]
}
Existing clients that read the response body as a plain string for the
generic 400 branch are unaffected. Clients that want richer diagnostics
can parse JSON when the content-type header is application/json.
|
When an entry contains a markdoc field that references an asset via a
relative path (e.g. `../../assets/blog/x.png`), the editor's wire-format
path for that asset is built as:
{basePath}/{slug}/{field}/{relative_asset_path}
For the example above the result is:
src/content/blog/{slug}/content/../../assets/blog/x.png
That string was sent to /api/keystatic/update verbatim. Inside the API,
`getIsPathValid` rejects any path segment equal to `..`, so the
refinement on `additions[].path` / `deletions[].path` fails and the
endpoint returns the generic 400 "Bad data" response. The user sees no
hint that the failure is a path-shape problem.
This change adds a small POSIX path normalizer (`normalizePosixPath`)
and uses it at the two `fetch('/api/keystatic/update')` sites in
`useUpsertItem` and `useDeleteItem`. Normalization happens only at the
wire boundary so the upstream diff math (additions vs initialFiles to
compute deletions) continues to run on the raw, unnormalized paths and
their symmetric cancellation is preserved.
Tests cover the standard normalization cases (single `..`, consecutive
`..`, `.`, double slashes, leading `..` preservation, absolute root
behavior) plus the realistic Keystatic case from the bug.
drlukeangel
added a commit
to drlukeangel/lukeangel
that referenced
this pull request
Jun 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related fixes for a single user-facing bug
Saving an entry that contains a markdoc field with a relative-path asset reference (e.g.
) fails with HTTP 400 and a body of literally"Bad data"— no path, no field, no schema hint.The cause is two-fold, and this PR addresses both in one place because the second fix is hard to verify without the first.
1.
fix(api): surface superstruct validation errorspackages/keystatic/src/api/api-node.tsThe update endpoint's
try { s.create(...) } catch { ... }was returning a constant'Bad data'string and discarding theStructError. That error contains.path,.type,.message, and.failures()— all of which were silently dropped.This PR widens the catch to inspect the error. When it's a
superstructStructError, the response body becomes JSON:{ "error": "Bad data", "path": "deletions.0.path", "type": "string", "message": "Expected a value of type `string`, but received: ...", "failures": [ { "path": ["deletions", 0, "path"], "type": "string", "refinement": "filepath", "message": "..." } ] }Non-
StructErrorexceptions still return the bare'Bad data'string (backwards-compatible with anyone reading the body as a plain string).content-type: application/jsonis set on the structured branch only, so clients can dispatch on the header.2.
fix(app): normalize update-API paths at the wire boundarypackages/keystatic/src/app/path-utils.ts,packages/keystatic/src/app/updating.tsxThe editor builds wire-format paths for asset additions/deletions as:
For a markdoc body like
insrc/content/blog/{slug}.mdoc, the wire path becomes:That string was sent to the API verbatim. The API's
getIsPathValidrefinement rejects any path segment equal to.., so the schema validation fails onadditions[].pathordeletions[].path. Combined with fix #1 this is now diagnosable; without it the user sees onlyBad data.This PR adds a small POSIX-style path normalizer (
normalizePosixPath) and applies it at the twofetch('/api/keystatic/update', { ... })sites inuseUpsertItemanduseDeleteItem. Normalization happens at the wire boundary only, so the upstream diff math (additions vsinitialFilesto derive deletions) continues to run on the raw, unnormalized paths and the symmetric cancellation between them is preserved. Only the final JSON sent over the wire has..segments resolved.Tests
packages/keystatic/src/app/path-utils.test.tscovers:..resolution...segment removal/collapse..preservation when there is no parent to resolve against/../a→/a)Why one PR
Fix #1 is unambiguously good. Fix #2 fixes the user-visible failure but is meaningfully easier to review together with #1, because #1 is the diagnostic that makes the failure shape obvious in the first place. Splitting them risks #2 looking arbitrary without the context #1 provides.
Happy to revise the response shape, the location of the normalizer (
path-utils.tsvs inline), the test layout, or anything else.