From 452018e7059791d9cf254d9e2f4e61b4c6442d55 Mon Sep 17 00:00:00 2001 From: evolv3ai Date: Mon, 27 Jul 2026 12:30:51 -0500 Subject: [PATCH 1/3] fix(editor): use a literal dev port so `bun dev` works on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, `bun run` executes package scripts with Bun's own built-in shell rather than a POSIX shell. That shell does not implement default-value parameter expansion, so `${PORT:-3002}` is passed through to Next.js verbatim and the dev server exits immediately: error: option '-p, --port ' argument '${PORT:-3002}' is invalid. '${PORT:-3002}' is not a non-negative number. Setting PORT in the environment does not help — the literal is never expanded either way. Replace the expansion with the documented default port (3002). Co-Authored-By: Claude Opus 5 --- apps/editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 862df6503..c06a4a56e 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "dev": "dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}", + "dev": "dotenv -e ../../.env.local -- next dev --port 3002", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", From 76add12919b4cee8348df43f1e4183038133ed1a Mon Sep 17 00:00:00 2001 From: evolv3ai Date: Mon, 27 Jul 2026 17:21:48 -0500 Subject: [PATCH 2/3] fix(editor): serve MCP `editorUrl` paths and pin the dev server hostname The scene store hands out `/editor/` as a project's canonical URL (`editorUrlForScene` in packages/mcp/src/storage/sqlite-scene-store.ts) and `/api/scenes` reports it verbatim, but this app only routes `/scene/[id]`. Every MCP-reported `editorUrl` therefore 404s. Rewrite `/editor/:id` to `/scene/:id` rather than redirect: client code parses the project id back out of the browser path (scan upload in packages/editor/src/components/ui/action-menu/view-toggles.tsx), so the `/editor/` prefix has to survive the hop. `/scene/` keeps working for the app's own links, and the MCP-side tests asserting `/editor/` stay valid, so no test or storage changes are needed. Also pass `--hostname 0.0.0.0` explicitly to `next dev`. Next already binds every interface by default, so this pins that behaviour rather than changing it, and surfaces the Network URL in dev output. Reaching the dev server from another device still requires an inbound firewall rule for port 3002. Co-Authored-By: Claude Opus 5 --- apps/editor/next.config.ts | 12 ++++++++++++ apps/editor/package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 18fb18206..d01630cb9 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -4,6 +4,18 @@ const nextConfig: NextConfig = { logging: { browserToTerminal: true, }, + // The scene store hands out `/editor/` as the canonical project URL + // (`packages/mcp/src/storage/sqlite-scene-store.ts` `editorUrlForScene`), and + // the hosted product serves that path. This app names its route `/scene/[id]`, + // so every MCP-reported `editorUrl` 404s here. + // + // Rewrite rather than redirect: the browser path has to keep the `/editor/` + // prefix because client code parses the project id back out of it (see + // `packages/editor/src/components/ui/action-menu/view-toggles.tsx`, scan + // upload). `/scene/` keeps working for the app's own links. + async rewrites() { + return [{ source: '/editor/:id', destination: '/scene/:id' }] + }, typescript: { ignoreBuildErrors: true, }, diff --git a/apps/editor/package.json b/apps/editor/package.json index c06a4a56e..6818c59d1 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "dev": "dotenv -e ../../.env.local -- next dev --port 3002", + "dev": "dotenv -e ../../.env.local -- next dev --hostname 0.0.0.0 --port 3002", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", From d90667b62301e34d1a1eebd5d0268decd009f3e6 Mon Sep 17 00:00:00 2001 From: evolv3ai Date: Mon, 27 Jul 2026 17:59:32 -0500 Subject: [PATCH 3/3] fix(editor): refuse a scene save that clears all authored content The editor autosaves on a debounce. When that timer fires before the initial scene load resolves, the client PUTs the empty graph it started from -- or the bare site/building/level scaffold it falls back to. The stored version is still the one the client read, so `expectedVersion` matches, optimistic concurrency waves the write through, and the stored scene is destroyed. Observed against a 49-node scene: six consecutive writes took it to 0, 3, 0, 3, 0, then 4 nodes. `PUT /api/scenes/[id]` now rejects a write that would remove the last authored node from a scene that had one, with 409 and the current ETag, so the existing client conflict path resyncs instead of clobbering. Callers that mean it pass `allowContentClear: true`. The check counts authored nodes rather than raw node count: site, building, and level are the scaffold a fresh session starts from, so a scaffold-only write is a wipe too and a naive emptiness test would have let three of the six through. This addresses the server side only. The client-side race in components/scene-loader.tsx -- autosave arming before the initial graph lands -- is the underlying cause and is still open; the guard is what prevents it reaching the store. Note `expectedVersion: expectedVersion ?? existing.version` on the same handler: a caller that omits `If-Match` gets the current version as its own precondition, which always matches. The guard covers that path too, but the fallback remains a weak point worth revisiting. Co-Authored-By: Claude Opus 5 --- apps/editor/app/api/scenes/[id]/route.ts | 31 ++++++++++ apps/editor/lib/scene-content-guard.test.ts | 67 +++++++++++++++++++++ apps/editor/lib/scene-content-guard.ts | 42 +++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 apps/editor/lib/scene-content-guard.test.ts create mode 100644 apps/editor/lib/scene-content-guard.ts diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index 1712ad4ad..10fc523b9 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -7,6 +7,7 @@ import { sceneApiPreflight, withSceneApiHeaders, } from '@/lib/scene-api-security' +import { countContentNodes, wouldClearSceneContent } from '@/lib/scene-content-guard' import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -18,6 +19,12 @@ const putSceneSchema = z.object({ graph: apiGraphSchema, thumbnailUrl: z.string().url().nullable().optional(), expectedVersion: z.number().int().nonnegative().optional(), + /** + * Opt out of the content-clear guard. Required for a deliberate + * "delete everything" save, which is otherwise indistinguishable from the + * client autosaving before it has loaded the scene. + */ + allowContentClear: z.boolean().optional(), }) const patchSceneSchema = z.object({ @@ -83,6 +90,30 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { if (!existing) { return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } + + // A client that autosaves before its initial load resolves sends an empty + // graph — or the bare site/building/level scaffold — and the version still + // matches, so `expectedVersion` waves it straight through and the stored + // scene is destroyed. Refuse to be the last writer in that sequence: a save + // may shrink a scene freely, but it may not take the last content node with + // it unless the caller says so explicitly. + if ( + !parsed.data.allowContentClear && + wouldClearSceneContent(existing.graph, parsed.data.graph) + ) { + return sceneApiJson( + request, + { + error: 'content_clear_rejected', + details: + 'Refusing to replace a non-empty scene with an empty one. Reload the scene, or resend with allowContentClear: true if the clear is intentional.', + existingContentNodes: countContentNodes(existing.graph), + version: existing.version, + }, + { status: 409, headers: { ETag: `"${existing.version}"` } }, + ) + } + const meta = await operations.saveScene({ id, name: parsed.data.name ?? existing.name, diff --git a/apps/editor/lib/scene-content-guard.test.ts b/apps/editor/lib/scene-content-guard.test.ts new file mode 100644 index 000000000..b6f5717a9 --- /dev/null +++ b/apps/editor/lib/scene-content-guard.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import { countContentNodes, wouldClearSceneContent } from './scene-content-guard' + +const scaffold = { + nodes: { + site_a: { type: 'site' }, + building_a: { type: 'building' }, + level_a: { type: 'level' }, + }, +} + +const house = { + nodes: { + site_a: { type: 'site' }, + building_a: { type: 'building' }, + level_a: { type: 'level' }, + slab_a: { type: 'slab' }, + wall_a: { type: 'wall' }, + wall_b: { type: 'wall' }, + zone_a: { type: 'zone' }, + }, +} + +describe('countContentNodes', () => { + test('ignores the site/building/level scaffold', () => { + expect(countContentNodes(scaffold)).toBe(0) + }) + + test('counts authored nodes only', () => { + expect(countContentNodes(house)).toBe(4) + }) + + test('treats an empty or malformed graph as no content', () => { + expect(countContentNodes({ nodes: {} })).toBe(0) + expect(countContentNodes({})).toBe(0) + expect(countContentNodes(null)).toBe(0) + expect(countContentNodes(undefined)).toBe(0) + expect(countContentNodes({ nodes: { a: null } })).toBe(0) + }) +}) + +describe('wouldClearSceneContent', () => { + test('rejects the empty-graph autosave that wiped the stored scene', () => { + expect(wouldClearSceneContent(house, { nodes: {} })).toBe(true) + }) + + test('rejects a scaffold-only autosave, not just a zero-node one', () => { + // The regression this guards: versions 51/53/55 wrote 3-4 scaffold nodes, + // which a naive "is the graph empty" check would have let through. + expect(wouldClearSceneContent(house, scaffold)).toBe(true) + }) + + test('allows ordinary edits that shrink the scene', () => { + const trimmed = { + nodes: { site_a: { type: 'site' }, level_a: { type: 'level' }, wall_a: { type: 'wall' } }, + } + expect(wouldClearSceneContent(house, trimmed)).toBe(false) + }) + + test('allows growing a scene', () => { + expect(wouldClearSceneContent(scaffold, house)).toBe(false) + }) + + test('allows writing a scaffold over a scene that had no content anyway', () => { + expect(wouldClearSceneContent(scaffold, { nodes: {} })).toBe(false) + }) +}) diff --git a/apps/editor/lib/scene-content-guard.ts b/apps/editor/lib/scene-content-guard.ts new file mode 100644 index 000000000..a725fb81e --- /dev/null +++ b/apps/editor/lib/scene-content-guard.ts @@ -0,0 +1,42 @@ +/** + * Guard against a save that silently destroys an authored scene. + * + * The editor autosaves on a debounce. If that timer fires before the initial + * scene load resolves, the client sends the empty graph it started from — or + * the bare site/building/level scaffold it falls back to. The stored version is + * still the one the client read, so `expectedVersion` matches and optimistic + * concurrency waves the write through. The authored scene is then gone. + * + * Shrinking a scene is legitimate; removing its last authored node as part of + * an unattended write is not distinguishable from the failure above, so callers + * that mean it have to say so explicitly. + */ + +/** + * The scaffold a fresh editor session starts from. These carry no authored + * content on their own, so a graph containing only these is "empty" for the + * purposes of this guard. + */ +export const SCAFFOLD_NODE_TYPES: ReadonlySet = new Set(['site', 'building', 'level']) + +/** Counts nodes a user or agent actually authored (walls, slabs, zones, openings, roofs, items). */ +export function countContentNodes(graph: unknown): number { + const nodes = (graph as { nodes?: Record } | null | undefined) + ?.nodes + if (!nodes || typeof nodes !== 'object') return 0 + + let count = 0 + for (const node of Object.values(nodes)) { + const type = node?.type + if (typeof type === 'string' && !SCAFFOLD_NODE_TYPES.has(type)) count += 1 + } + return count +} + +/** + * True when writing `incoming` over `existing` would strip the scene of every + * authored node. Callers should reject such a write unless it is explicit. + */ +export function wouldClearSceneContent(existing: unknown, incoming: unknown): boolean { + return countContentNodes(existing) > 0 && countContentNodes(incoming) === 0 +}