fix(editor): accept plugin node kinds in the scene save API - #490
Conversation
Placing a plugin node (`trees:tree` from the first-party Nature pack) in a saved scene made every later autosave fail with 400: `apiGraphSchema` validated every node against the static `AnyNode` union, which cannot enumerate kinds a plugin registers at runtime. A node whose `type` is outside `AnyNode` is now validated the way `validate-build-json` already treats a kind it cannot resolve — as a foreign node, held to the `BaseNode` envelope plus core's `AssetUrl` allowlist applied to every URL-shaped string it carries. Membership is decided by "not in `AnyNode`", not by a namespace pattern: `plugin-authoring.md` requires plugin *ids* to look like `vendor:pack`, never kinds, and its worked example registers `kind: 'couch'`. Reusing `AssetUrl` keeps the Phase 3 posture intact on a branch that has to accept unknown fields. A scheme denylist would have to enumerate every hostile scheme; `AssetUrl` already enumerates the safe ones, so `//evil.example`, `ws:`, `gopher:`, `about:blank`, the instance-metadata endpoint, and control-character-obfuscated `java\tscript:` are all rejected, and `PASCAL_ALLOWED_ASSET_ORIGINS` keeps narrowing https origins on this path too. Only URL-shaped values are checked, so a plugin can still store prose in `name` / `metadata` exactly as builtin nodes do. The URL scan is depth- and visit-bounded. An unbounded walk over a deeply nested body throws `RangeError` past `safeParse`, which the route answers as a 500 where the contract is a 400 with issues. Co-authored-by: Kone Venkatesh <konevenkatesh@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dbbfb0d to
4ed4ea1
Compare
|
Thanks for this — the bug is real and worth fixing, and your tests are honest (I confirmed 5 of the 8 fail against What I kept: the diagnosis, the container-children insight (validating a copy with foreign child ids stripped, so the stored graph is untouched), and most of your test cases. What changed, and why. 1. The scheme denylist reopened what the strict schema was added to close. It also missed obfuscation, because the regex is start-anchored on the raw string while browsers ignore C0 controls inside a scheme: Core already has the inverse of this — 2. The regex had a second cost: because 3. 4. Rebased onto One implementation note you may find surprising: the node's own
Credit stays with you on the commit. Merging once CI is green. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4ed4ea1. Configure here.
| for (const [nodeId, node] of Object.entries(value.nodes)) { | ||
| const type = (node as { type?: unknown } | null)?.type | ||
| if (typeof type === 'string' && !KNOWN_TYPES.has(type)) foreignIds.add(nodeId) | ||
| } |
There was a problem hiding this comment.
Foreign ids use wrong key
Medium Severity
The foreignIds set is populated with the node's record key, but children arrays reference the node's id field. If these differ, foreign child nodes aren't correctly filtered before AnyNode validation, which can cause builtin containers to fail validation.
Reviewed by Cursor Bugbot for commit 4ed4ea1. Configure here.


Problem
Placing any plugin node (e.g.
trees:treefrom the first-party Nature plugin) in a saved scene makes every subsequent autosave fail with 400.Two validation gaps in
apiGraphSchema(apps/editor/lib/graph-schema.ts):AnyNodeunion, which doesn't include namespaced plugin kinds — the plugin node itself is rejected (Invalid enum value. Expected 'wall' | …, received 'trees:tree').LevelNode.childrenonly accepts builtin typed-id patterns, so a level containing atree_…child id fails too.Repro: run
apps/editor, create a saved scene, place a tree from the Nature panel, watchPUT /api/scenes/[id]return 400 (the editor shows a persistent save error and no further changes persist).Fix
typematches a namespaced plugin kind (ns:kind) validate against theBaseNodeenvelope plus a deep scan that rejects dangerous URL schemes (javascript:,vbscript:,file:,ftp:, non-imagedata:) anywhere in the node. This preserves the Phase 3 SSRF / script-URL posture without importing plugin/renderer code into the API route (plugin schemas live in packages that pull in UI code).AnyNodewith plugin child ids filtered from a copy — the stored graph is unchanged (a regression test pins this).Verification
apps/editor/lib/graph-schema.test.ts(5 of 8 fail without the fix): plugin node accepted, level-with-plugin-child accepted, parsed graph keeps plugin child ids, bad envelope rejected, dangerous URL schemes rejected (withdata:image/…still allowed), invalid builtin nodes and non-namespaced unknown types still rejected.PUT /api/scenes/[id]returns 200 and the stored graph contains thetrees:treenode. Replaying the identical payload against the pre-fix schema returns 400.bun test apps/editor/lib/16/16 pass;biome checkclean; no new type errors.🤖 Generated with Claude Code
Note
Medium Risk
Touches untrusted graph validation at POST/PUT boundaries with new foreign-node logic; changes are security-sensitive but aim to preserve the existing AssetUrl/SSRF posture rather than relax it.
Overview
Fixes 400 autosave failures when saved scenes include plugin nodes (e.g.
trees:tree) by extendingapiGraphSchemaso types outsideAnyNodeare treated as foreign nodes:BaseNodeenvelope plus a bounded deep walk that applies the coreAssetUrlallowlist to URL-shaped strings (with C0-control stripping and prose/drive paths excluded).Builtin containers still go through
AnyNode, but plugin child ids are removed only from a validation copy so levels and similar nodes can list plugin children without changing what gets stored.AssetUrlis re-exported from@pascal-app/core/schemafor the editor validator, andgraph-schema.test.tsadds regression coverage for acceptance, SSRF-style URLs, nesting limits, and unchanged builtin rejection behavior.Reviewed by Cursor Bugbot for commit 4ed4ea1. Bugbot is set up for automated code reviews on this repo. Configure here.