Add /import?src=<url>: a hand-off point for scanning apps - #720
Add /import?src=<url>: a hand-off point for scanning apps#720alxbouchard wants to merge 7 commits into
Conversation
A scanning app (or any external tool) can now open editor.pascal.app/import?src=<https-url> to hand a build JSON to the editor. The fetch happens client-side in the visitor's browser (same trust model as dropping a file on Load Build; the host must allow CORS), the file runs through the same validateBuildJson pre-flight, the visitor reviews the contents, and only an explicit click creates the scene through the regular POST /api/scenes route — so auth, origin checks and apiGraphSchema validation all apply unchanged. src accepts https only (http for localhost during development), no embedded credentials, 25 MB cap. Unit tests for the URL validation.
validateBuildJson dropped the top-level materials table: every scene:<id> slot ref in an imported file pointed at a material that no longer existed, so custom finishes silently reverted to defaults on both Load Build and /import. ParsedBuildJson now carries materials — each entry SceneMaterial-validated individually, invalid ones skipped with a warning so a bad material never takes the import down — and handleConfirmImport hands them to setScene, whose extra.materials support already existed. Unit tests for valid, partially-invalid and non-object materials.
|
Follow-up commit: while testing the import end to end I found that |
Review feedback (Bugbot): a superseded or aborted fetch could overwrite a newer state — including surfacing the cleanup abort as a CORS error — and a src change left the previous review (and its Import button) live against the old file. The effect now resets to 'fetching' on every src change and every state update from a cancelled run is ignored.
|
Addressed the Bugbot review (it ran against the first commit, 53b688c):
|
Review feedback (Bugbot): MAX_IMPORT_BYTES was 25 MB while the sqlite scene store rejects graphs over DEFAULT_MAX_SCENE_BYTES (10 MB) — a file could pass review then fail POST /api/scenes with a 413 shown as a generic error. The cap now matches the store's limit, and a 413 gets its own explanation.
|
Third Bugbot point addressed: |
Review feedback (Bugbot): a second tap on Import could fire before React re-rendered into 'creating', creating two scenes and racing the redirect. A synchronous useRef guard now blocks re-entry; it is released in a finally so a failed create can be retried.
|
Double-tap point addressed: a synchronous |
Review feedback (Bugbot): a failed POST switched to the error phase, unmounting the review and the validated graph — nothing left to retry, and refreshing re-fetches a src URL that may be short-lived. A create failure now stays in the review phase with the error shown inline and the button relabelled 'Try again'.
|
Fifth point addressed: a failed create no longer unmounts the review — the validated graph stays on screen with the error shown inline and the button relabelled "Try again", so a short-lived |
Aymericr
left a comment
There was a problem hiding this comment.
Thanks for this, and genuinely thanks for how you've handled the review rounds — the escalation from "import drops materials" to "validateBuildJson drops materials on the Load Build path too" is a real bug you found for us, and it's the strongest part of the PR. I confirmed it at main: handleConfirmImport passes only installedPlugins to setScene.
A few things before this can land.
One blocker: apps/editor/lib/import-src.test.ts imports from vitest, but vitest isn't a dependency anywhere in the repo, and every other test under apps/editor/lib/ uses bun:test (apps/editor's test script is bun test lib). That file can't resolve its imports, so the "41 pass" in the description can't have run. Please switch it to bun:test and re-run.
Then:
- The size cap uses
text.length, which is UTF-16 code units, not bytes — a graph with non-ASCII names can pass review and still 413 at the store, which is the thing the 10 MB alignment commit was for.new Blob([text]).sizecovers it. validateBuildJsonnow storesSceneMaterial.safeParse().data, so it injects defaults and drops unknown keys.apps/editor/lib/graph-schema.tsdeliberately does the opposite for exactly that reason (there's a comment). I'm fine with normalizing on a client-side import, but let's make it an explicit choice.- In the settings panel, the param is widened to
Record<string, unknown>and then cast back.ParsedBuildJsonis the right type now — please use it directly. - Please drop the
bun.lockchanges; the addedsha512hashes on thegithub:deps are a bun regeneration artifact, not part of this change.
On scope: I'd like to take the materials fix on its own, because it fixes a live bug on Load Build and shouldn't wait on the rest. Would you split it into a separate PR? I'll merge that quickly.
On the import page itself, one thing to sort out first. editor.pascal.app is a separate hosted app from apps/editor, so this page would only ship on the standalone editor, not the hosted one. And we just landed @pascal-app/capture-protocol (#713), which is the versioned, extensible hand-off format for exactly this use case — manifests, locators, capture sources. I don't think these are the same thing (yours is an already-converted build graph becoming a new scene; #713 is a capture session rendered as scan layers), and I can see wanting both. But I'd rather we agree on where the seam sits before adding a second entry point. Have a look at wiki/architecture/capture-runtime.md and tell me whether your tool would be better served by emitting a capture-protocol manifest, or whether the build-JSON path is genuinely the one you need — happy to talk it through.
- import-src.test.ts now imports from bun:test like every other test under apps/editor/lib (vitest is not a repo dependency — the bun runner shimmed the import, which is why the suite did run, but the file was wrong and the description should have said bun test). - The size cap measures real bytes via Blob, not UTF-16 code units — non-ASCII names could otherwise pass review and still 413. - bun.lock restored to main (the sha512 additions were a bun regeneration artifact, not part of this change).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f30fc5e. Configure here.
| for (const [id, value] of Object.entries(materialsRaw)) { | ||
| const result = SceneMaterial.safeParse(value) | ||
| if (result.success) { | ||
| kept[id] = result.data |
There was a problem hiding this comment.
Imported materials get schema-rewritten
Medium Severity
Kept materials are stored as the Zod parse output rather than the original entries. That injects MaterialProperties defaults and drops unknown keys, so Load Build and /import can silently change finishes and strip extra palette fields before setScene or POST /api/scenes.
Reviewed by Cursor Bugbot for commit f30fc5e. Configure here.
| cancelled = true | ||
| controller.abort() | ||
| } | ||
| }, [src]) |
There was a problem hiding this comment.
Scene name stays stale across src
Low Severity
sceneName is initialized from the name query once and the fetch effect only depends on src. A new /import?src=…&name=… navigation reuses the client instance, refetches the file, and still keeps the previous name, so the created scene can be labeled incorrectly.
Reviewed by Cursor Bugbot for commit f30fc5e. Configure here.


What
A new
/import?src=<https-url>[&name=<scene name>]page: an external tool — in our case an iOS LiDAR scanning app — hosts a build JSON at a URL and opens this page; the visitor reviews what the file contains and imports it as a new scene with one click.Until now the only way to get a generated scene into the editor was dragging a file onto Load Build, which does not exist on mobile. With this page, any scan app can end its export flow with "Open in Pascal Editor".
How it works
validateBuildJsonpre-flight as Load Build, and the page shows the node counts, floor area, warnings and errors before anything happens.POST /api/scenesroute — so auth, origin checks andapiGraphSchemavalidation (including the AssetUrl allowlist) all apply unchanged.srcaccepts https only (http for localhost during development), rejects embedded credentials, and caps the document at 25 MB. URL validation lives inlib/import-src.tswith unit tests.Tested
bun test lib: 41 pass (6 new)bun run check-types,biome check: cleanWhy we built it
We build A3 Atlas Scanner, an iOS field tool that captures homes with RoomPlan and already exports your
{nodes, rootNodeIds, materials}graph (catalog items scaled to measured dimensions, measured colors as scene materials, IFC alongside). This page is the missing link that turns every scan into a one-tap Pascal scene. Happy to adjust anything to fit the project's conventions.🤖 Generated with Claude Code
Note
Medium Risk
New user-controlled URL fetch in the browser (mitigated by scheme/credential checks and no server-side fetch) plus scene creation through the existing authenticated API; materials validation changes affect all build JSON imports.
Overview
Adds
/import?src=<url>[&name=…]so external tools (e.g. mobile scan apps) can hand off a CORS-hosted build JSON without Load Build’s file drop. The browser fetches and size-checks the file (10 MB cap, byte-accurate viaBlob), runsvalidateBuildJson, shows stats/errors/warnings and an editable scene name, then creates a scene only on confirm via existingPOST /api/scenes(with abort/cancel handling, double-submit guard, and retry on create failure).parseImportSrcrestrictssrcto absolute https (http on localhost only), blocks credentials and bad schemes, with unit tests.validateBuildJsonnow preserves top-levelmaterialson successful parse (invalid entries warned and skipped). Load Build in settings passesmaterialsintosetScenesoscene:<id>slot refs keep custom finishes.Reviewed by Cursor Bugbot for commit f30fc5e. Bugbot is set up for automated code reviews on this repo. Configure here.