-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
fix(editor): accept plugin node kinds in the scene save API #490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Aymericr
merged 1 commit into
pascalorg:main
from
konevenkatesh:fix/scene-api-plugin-node-kinds
Aug 4, 2026
+299
−6
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { expect, test } from 'bun:test' | ||
| import { apiGraphSchema } from './graph-schema' | ||
|
|
||
| function buildGraph(nodes: Record<string, unknown>, rootNodeIds: string[] = []) { | ||
| return { nodes, rootNodeIds } | ||
| } | ||
|
|
||
| const LEVEL_ID = 'level_a1b2c3d4e5f6g7h8' | ||
| const TREE_ID = 'tree_a1b2c3d4e5f6g7h8' | ||
|
|
||
| const level = (children: string[] = []) => ({ | ||
| object: 'node', | ||
| id: LEVEL_ID, | ||
| type: 'level', | ||
| parentId: null, | ||
| children, | ||
| level: 0, | ||
| }) | ||
|
|
||
| const pluginTree = (overrides: Record<string, unknown> = {}) => ({ | ||
| object: 'node', | ||
| id: TREE_ID, | ||
| type: 'trees:tree', | ||
| parentId: LEVEL_ID, | ||
| position: [1, 0, 2], | ||
| rotation: 0, | ||
| ...overrides, | ||
| }) | ||
|
|
||
| test('accepts a graph containing a plugin node kind', () => { | ||
| const graph = buildGraph({ [TREE_ID]: pluginTree() }, [LEVEL_ID]) | ||
|
|
||
| expect(apiGraphSchema.safeParse(graph).success).toBe(true) | ||
| }) | ||
|
|
||
| test('accepts a builtin container whose children include a plugin node id', () => { | ||
| const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID]) | ||
|
|
||
| expect(apiGraphSchema.safeParse(graph).success).toBe(true) | ||
| }) | ||
|
|
||
| test('keeps plugin child ids in the parsed graph', () => { | ||
| const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID]) | ||
|
|
||
| const res = apiGraphSchema.safeParse(graph) | ||
|
|
||
| expect(res.success).toBe(true) | ||
| expect((res.data?.nodes[LEVEL_ID] as { children: string[] }).children).toEqual([TREE_ID]) | ||
| }) | ||
|
|
||
| test('preserves installedPlugins alongside a plugin node', () => { | ||
| const res = apiGraphSchema.safeParse({ | ||
| ...buildGraph({ [TREE_ID]: pluginTree() }, [LEVEL_ID]), | ||
| installedPlugins: ['pascal:trees'], | ||
| }) | ||
|
|
||
| expect(res.success).toBe(true) | ||
| expect(res.data?.installedPlugins).toEqual(['pascal:trees']) | ||
| }) | ||
|
|
||
| test('rejects a plugin node that fails the base envelope', () => { | ||
| const graph = buildGraph({ tree_bad: pluginTree({ id: 42 }) }) | ||
|
|
||
| expect(apiGraphSchema.safeParse(graph).success).toBe(false) | ||
| }) | ||
|
|
||
| // The `AssetUrl` allowlist is the whole Phase 3 posture: every scheme outside | ||
| // it is rejected, so this list does not have to be exhaustive to be sound. A | ||
| // denylist would — which is why one isn't used. `169.254.169.254` is the cloud | ||
| // instance-metadata endpoint, the canonical SSRF target. | ||
| test('rejects URL-shaped plugin fields outside the AssetUrl allowlist', () => { | ||
| for (const url of [ | ||
| 'javascript:alert(1)', | ||
| ' file:///etc/passwd', | ||
| 'data:text/html,<script>1</script>', | ||
| 'http://169.254.169.254/latest/meta-data', | ||
| 'http://evil.example/beacon.png', | ||
| 'ws://evil.example/socket', | ||
| 'gopher://evil.example/x', | ||
| 'about:blank', | ||
| // C0 controls inside the scheme: a browser ignores them and navigates, so | ||
| // a prefix match on the raw string is not enough. | ||
| 'java\tscript:alert(1)', | ||
| '\u0000javascript:alert(1)', | ||
| // Scheme matching must be case-insensitive. | ||
| 'DATA:TEXT/HTML,<script>1</script>', | ||
| ]) { | ||
| const graph = buildGraph({ [TREE_ID]: pluginTree({ config: { textures: [{ src: url }] } }) }) | ||
|
|
||
| const res = apiGraphSchema.safeParse(graph) | ||
|
|
||
| expect(res.success, `expected ${JSON.stringify(url)} to be rejected`).toBe(false) | ||
| expect(res.error?.issues[0]?.message).toBe('URL is not in the allowed scheme list') | ||
| } | ||
| }) | ||
|
|
||
| test('accepts the asset URL forms core allows', () => { | ||
| for (const url of [ | ||
| 'data:image/png;base64,iVBORw0KGgo=', | ||
| 'https://cdn.example/tree.webp', | ||
| 'asset://tree-bark', | ||
| 'blob:https://editor.pascal.app/9f1c', | ||
| '/textures/bark.webp', | ||
| 'http://localhost:3000/textures/bark.webp', | ||
| ]) { | ||
| const graph = buildGraph({ [TREE_ID]: pluginTree({ thumbnail: url }) }) | ||
|
|
||
| expect(apiGraphSchema.safeParse(graph).success, `expected ${url} to be accepted`).toBe(true) | ||
| } | ||
| }) | ||
|
|
||
| // Prose that happens to start with a word and a colon is not a URL. A plugin | ||
| // may put arbitrary text in `name` / `metadata`, exactly as builtin nodes do — | ||
| // the allowlist applies to URL-shaped values, not to every string. | ||
| test('does not treat prose or drive paths as URLs', () => { | ||
| for (const text of [ | ||
| 'FTP: north bed', | ||
| 'note: see plan 3', | ||
| 'Data: unavailable', | ||
| 'C:\\Users\\me\\plan.png', | ||
| 'Oak tree', | ||
| ]) { | ||
| const graph = buildGraph({ [TREE_ID]: pluginTree({ name: text, metadata: { note: text } }) }) | ||
|
|
||
| expect(apiGraphSchema.safeParse(graph).success, `expected ${text} to be accepted`).toBe(true) | ||
| } | ||
| }) | ||
|
|
||
| // A recursive walk over untrusted JSON must not throw past `safeParse` — the | ||
| // route would answer 500 where the contract is a 400 with issues. | ||
| test('reports deeply nested plugin nodes as a validation issue, not a crash', () => { | ||
| let nested: unknown = 'leaf' | ||
| for (let i = 0; i < 100_000; i++) nested = [nested] | ||
| const graph = buildGraph({ [TREE_ID]: pluginTree({ nested }) }) | ||
|
|
||
| const res = apiGraphSchema.safeParse(graph) | ||
|
|
||
| expect(res.success).toBe(false) | ||
| expect(res.error?.issues[0]?.message).toBe('Node is too deeply nested to validate') | ||
| }) | ||
|
|
||
| test('still rejects invalid builtin nodes', () => { | ||
| const graph = buildGraph({ | ||
| wall_bad: { object: 'node', id: 'wall_a1b2c3d4e5f6g7h8', type: 'wall' }, | ||
| }) | ||
|
|
||
| expect(apiGraphSchema.safeParse(graph).success).toBe(false) | ||
| }) | ||
|
|
||
| // Unnamespaced kinds are legitimate: `wiki/architecture/plugin-authoring.md` | ||
| // requires plugin *ids* to look like `vendor:pack`, never kinds, and its worked | ||
| // example registers `kind: 'couch'`. Membership is decided by "not in AnyNode", | ||
| // so such a node is validated as foreign rather than rejected outright. | ||
| test('treats an unnamespaced unknown type as a foreign node', () => { | ||
| const couch = { | ||
| object: 'node', | ||
| id: 'couch_a1b2c3d4e5f6g7h8', | ||
| type: 'couch', | ||
| parentId: LEVEL_ID, | ||
| } | ||
|
|
||
| expect(apiGraphSchema.safeParse(buildGraph({ [couch.id]: couch })).success).toBe(true) | ||
| expect( | ||
| apiGraphSchema.safeParse(buildGraph({ [couch.id]: { ...couch, src: 'javascript:alert(1)' } })) | ||
| .success, | ||
| ).toBe(false) | ||
| }) |
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
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
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Foreign ids use wrong key
Medium Severity
The
foreignIdsset is populated with the node's record key, butchildrenarrays reference the node'sidfield. If these differ, foreign child nodes aren't correctly filtered beforeAnyNodevalidation, which can cause builtin containers to fail validation.Reviewed by Cursor Bugbot for commit 4ed4ea1. Configure here.