From b1a25ec44cc69eb6f4e67dac32bb901297f5bde9 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:07:46 +0300 Subject: [PATCH 001/128] feat(editor): wire the warehouse plugin and fix the dev script on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers `@ovurrsl/plugin-warehouse` through `extendPluginDiscovery` rather than `setPluginDiscovery`, which would replace the whole chain and silently drop the trees pack registered above it. The panel goes in separately via `registerEditorHostPanel` — it is host UI, not part of the node manifest. `allowedDevOrigins` gains the LAN and WAN addresses so the dev server can be reached from another machine. Note this exposes an unauthenticated editor with readable source maps to whatever can route to it. The `dev` script did not run on Windows at all: next dev --port ${PORT:-3002} error: '${PORT:-3002}' is not a non-negative number `${PORT:-3002}` is POSIX parameter expansion. Bun's script runner does not expand it on Windows, so the literal string reached Next's argument parser. The `dotenv -e ../../.env.local` prefix was also loading a file that does not exist, and every entry in `.env.example` is optional — so both go, leaving a script that runs on either platform. Also carries the scene-clipboard fallback that lets plugin-contributed kinds be duplicated (submitted upstream as pascalorg/editor#547). `AnyNode` is a hand-maintained union of built-in kinds, so a plugin kind can never be a member and `capabilities.duplicable` cannot be honoured for any plugin — including the first-party trees pack, which declares it and fails the same way. The fallback tries `AnyNode` first, so built-in behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/lib/bootstrap.ts | 8 +++ apps/editor/next.config.ts | 10 +++ apps/editor/package.json | 3 +- bun.lock | 3 + .../editor/src/lib/scene-clipboard.test.ts | 70 ++++++++++++++++++- packages/editor/src/lib/scene-clipboard.ts | 31 +++++++- 6 files changed, 121 insertions(+), 4 deletions(-) diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index a24f64cb5..58bb72531 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -1,3 +1,4 @@ +import { warehouseCatalogPanel, warehousePlugin } from '@ovurrsl/plugin-warehouse' import { type AnyNodeDefinition, discoverPlugins, @@ -86,5 +87,12 @@ export async function loadExternalPlugins(): Promise { extendPluginDiscovery(async () => [treesPlugin]) registerEditorHostPanel(treesHostPanel) +// Warehouse & logistics pack. Composed onto the discovery chain rather than +// replacing it — `setPluginDiscovery` would drop every plugin registered above. +// Both calls must precede `loadExternalPlugins()` below: it fires at import +// time behind a module-closure flag, so a later registration is a no-op. +extendPluginDiscovery(async () => [warehousePlugin]) +registerEditorHostPanel(warehouseCatalogPanel) + loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 18fb18206..0955701f8 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -1,6 +1,15 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { + // Dev-only. Without these, the dev server refuses the HMR websocket for any + // origin other than localhost, and because Turbopack delivers module updates + // over that socket the page loads its shell and then hangs waiting for lazy + // chunks that never arrive — with no error beyond a websocket handshake + // failure. Needed to open the editor from a tablet or another machine. + // The public entry (via the router's forwarded port) has to be listed too — + // the check is on the Host header, so a LAN entry does not cover the same + // machine reached from outside. Update this if the WAN address changes. + allowedDevOrigins: ['192.168.1.101', '192.168.1.*', '*.local', '95.70.136.179'], logging: { browserToTerminal: true, }, @@ -14,6 +23,7 @@ const nextConfig: NextConfig = { '@pascal-app/editor', '@pascal-app/mcp', '@pascal-app/plugin-trees', + '@ovurrsl/plugin-warehouse', '@dgreenheck/ez-tree', ], turbopack: { diff --git a/apps/editor/package.json b/apps/editor/package.json index 862df6503..523a72703 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": "next dev --port 3002", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", @@ -13,6 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#189715cab00f5f84fd6422eef440e92a82e2f180", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 5cbd6c7a7..b30b61d38 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#189715cab00f5f84fd6422eef440e92a82e2f180", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -566,6 +567,8 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#189715cab00f5f84fd6422eef440e92a82e2f180", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "189715cab00f5f84fd6422eef440e92a82e2f180"], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.132.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg=="], diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts index 9117e4857..97355fa79 100644 --- a/packages/editor/src/lib/scene-clipboard.test.ts +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { type AnyNode, + type AnyNodeDefinition, type AnyNodeId, CabinetModuleNode, type CabinetModuleNode as CabinetModuleNodeType, @@ -8,6 +9,8 @@ import { type CabinetNode as CabinetNodeType, type LevelNode, MeasurementNode, + nodeRegistry, + registerNode, SceneMaterial, type SceneMaterialId, useScene, @@ -15,6 +18,7 @@ import { WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { z } from 'zod' import { copySelectedNodesToEditorClipboard, getEditorClipboardSnapshot, @@ -104,6 +108,12 @@ describe('scene clipboard', () => { useScene.temporal.getState().clear() }) + // The plugin-kind test registers a definition; drop it so it can't leak into + // any other test's registry lookups. + afterEach(() => { + nodeRegistry._reset() + }) + test('copies a selected cabinet run as one subtree instead of independent modules', () => { const copied = copySelectedNodesToEditorClipboard([runId, leftModuleId, rightModuleId]) @@ -294,6 +304,64 @@ describe('scene clipboard', () => { } }) + test('duplicates a plugin-contributed kind that AnyNode cannot describe', () => { + // `AnyNode` is a hand-maintained union of built-in kinds, so a plugin kind + // can never be a member of it — the registry validates those against + // `def.schema` instead. Registering one here reproduces exactly what a + // plugin does at load time; before the registry fallback this copy threw + // `invalid_union / no matching discriminator` and duplicate silently failed + // for every plugin, including the first-party trees pack. + const PluginNode = z.object({ + object: z.literal('node').default('node'), + id: z.string(), + type: z.literal('warehouse:pallet').default('warehouse:pallet'), + name: z.string().optional(), + parentId: z.string().nullable().default(null), + visible: z.boolean().optional().default(true), + metadata: z.json().optional().default({}), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + preset: z.string().default('epal-1'), + }) + registerNode({ + kind: 'warehouse:pallet', + schemaVersion: 1, + schema: PluginNode, + category: 'furnish', + defaults: () => ({}), + capabilities: { duplicable: true, deletable: true }, + } as unknown as AnyNodeDefinition) + + const pluginNodeId = 'warehouse-pallet_clipboard' as AnyNodeId + const pluginNode = PluginNode.parse({ + id: pluginNodeId, + parentId: sourceLevelId, + position: [2, 0, 1], + preset: 'epal-2', + }) as unknown as AnyNode + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [sourceLevelId]: makeLevel(sourceLevelId, [pluginNodeId]), + [pluginNodeId]: pluginNode, + }, + })) + + expect(copySelectedNodesToEditorClipboard([pluginNodeId])).toBe(true) + + const result = pasteEditorClipboardToLevel(targetLevelId) + expect(result?.pastedIds).toHaveLength(1) + + const pastedId = result?.pastedIds[0] + const pasted = pastedId ? useScene.getState().nodes[pastedId] : undefined + expect(pasted?.type).toBe('warehouse:pallet') + expect(pasted?.id).not.toBe(pluginNodeId) + expect(pasted?.parentId).toBe(targetLevelId) + // The registry schema — not `AnyNode` — is what validated it, so kind-owned + // fields have to survive the round trip. + expect((pasted as unknown as { preset?: string } | undefined)?.preset).toBe('epal-2') + }) + test('waits for an in-flight copy before reading the browser clipboard', async () => { let systemClipboardText = 'older clipboard contents' let finishWrite!: () => void diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index 081836c33..f30e316bf 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -13,6 +13,27 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +/** + * Validate a node on its way through the clipboard. + * + * `AnyNode` is the hand-maintained union of built-in kinds, so a + * plugin-contributed kind can never be a member of it — the registry validates + * those against `def.schema` at runtime instead. Parsing with `AnyNode` alone + * therefore rejects every plugin node, which makes `capabilities.duplicable` + * unhonourable for plugins including the first-party `pascal:trees` pack. + * + * Built-in behaviour is unchanged: `AnyNode` is still tried first, and a type + * that is neither built-in nor registered still raises the original error. + */ +function parseClipboardNode(candidate: unknown): AnyNode { + const builtin = AnyNode.safeParse(candidate) + if (builtin.success) return builtin.data + const type = (candidate as { type?: unknown } | null)?.type + const definition = typeof type === 'string' ? nodeRegistry.get(type) : undefined + if (definition) return definition.schema.parse(candidate) as AnyNode + throw builtin.error +} + type ClipboardPayload = { copiedAt: number materials: SceneMaterial[] @@ -250,7 +271,7 @@ function remapNodeReferences( delete metadata.isTransient ;(clone as Record).metadata = metadata - return AnyNode.parse(clone) + return parseClipboardNode(clone) } function remapSceneMaterialReferences( @@ -374,7 +395,13 @@ function parseClipboardPayload(text: string): ClipboardPayload | null { return null } - const nodes = candidate.nodes.map((node) => AnyNode.safeParse(node)) + const nodes = candidate.nodes.map((node) => { + try { + return { success: true as const, data: parseClipboardNode(node) } + } catch { + return { success: false as const, data: undefined } + } + }) if (nodes.some((result) => !result.success)) return null const materials = Array.isArray(candidate.materials) ? candidate.materials.map((material) => SceneMaterial.safeParse(material)) From 83517b3c108e7ab7eb0fddd943717ed460b5348c Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:45:09 +0300 Subject: [PATCH 002/128] fix: node id prefixes split at the wrong underscore, breaking plugin duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as a paste failure: Failed to paste copied node "pallet_rack_7vp5f3t58sm5iuch" pattern: "^pallet_rack_[\s\S]{0,}$" `extractIdPrefix` recovered the prefix by splitting at the FIRST underscore. `generateId` builds ids as `${prefix}_${customId()}` where the suffix is drawn from `0123456789abcdefghijklmnopqrstuvwxyz` — it never contains an underscore — so the prefix is everything before the LAST one. Built-in kinds never noticed, because none of their prefixes contains an underscore: `wall_abc` splits the same either way. A plugin kind whose prefix does — `pallet_rack` — cloned as `pallet_abc`, and the kind's own schema then rejected it. Duplicate and copy/paste were unusable for that kind, and the error named the plugin rather than the id helper. The same three-line helper had been copied into three files, so all three had the bug: `editor/lib/scene-clipboard.ts`, `core/registry/subtree.ts`, and `core/utils/clone-scene-graph.ts`. Each now uses `lastIndexOf`. Behaviour for every built-in kind is byte-identical, which is why the existing clipboard tests pass unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- packages/core/src/registry/subtree.ts | 6 +++++- packages/core/src/utils/clone-scene-graph.ts | 10 ++++++++-- packages/editor/src/lib/scene-clipboard.ts | 8 +++++++- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 523a72703..324d3b0ba 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#189715cab00f5f84fd6422eef440e92a82e2f180", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#533cc0c8ded4dfb8636100b430433b287c7c5d54", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index b30b61d38..d54744cd2 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#189715cab00f5f84fd6422eef440e92a82e2f180", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#533cc0c8ded4dfb8636100b430433b287c7c5d54", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#189715cab00f5f84fd6422eef440e92a82e2f180", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "189715cab00f5f84fd6422eef440e92a82e2f180"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#533cc0c8ded4dfb8636100b430433b287c7c5d54", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "533cc0c8ded4dfb8636100b430433b287c7c5d54"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts index f3e3f10d0..ce2403422 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -32,7 +32,11 @@ export type Subtree = { } function extractIdPrefix(id: string): string { - const i = id.indexOf('_') + // The LAST underscore: `generateId` suffixes never contain one, so this + // recovers the exact prefix even when the prefix itself does — a plugin + // kind's `pallet_rack_` must clone as `pallet_rack_*`, and the + // first-underscore split minted `pallet_*`, which its schema rejects. + const i = id.lastIndexOf('_') return i === -1 ? 'node' : id.slice(0, i) } diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 5c6ec8bca..e89e2926d 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -16,10 +16,16 @@ export type SceneGraph = { } /** - * Extracts the type prefix from a node ID (e.g., "wall_abc123" -> "wall") + * Extracts the type prefix from a node ID (e.g., "wall_abc123" -> "wall"). + * + * The LAST underscore, because `generateId` suffixes are drawn from `0-9a-z` + * and never contain one — so this recovers the exact prefix even when the + * prefix itself does (a plugin kind's `pallet_rack_abc123` -> `pallet_rack`). + * Splitting at the first underscore cloned those as `pallet_*`, which the + * kind's own schema rejects. */ function extractIdPrefix(id: string): string { - const underscoreIndex = id.indexOf('_') + const underscoreIndex = id.lastIndexOf('_') return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex) } diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index f30e316bf..49d274da7 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -94,7 +94,13 @@ export function hasEditorClipboard() { } function extractIdPrefix(id: string) { - const underscoreIndex = id.indexOf('_') + // The LAST underscore, not the first: `generateId` appends a suffix drawn + // from `0-9a-z` (never `_`), so everything before the last underscore is + // exactly the prefix — including prefixes that themselves contain one, like + // the plugin kind id `pallet_rack_`. Splitting at the first + // underscore minted duplicates as `pallet_`, which the kind's own + // schema then rejected, and every paste of that node was silently skipped. + const underscoreIndex = id.lastIndexOf('_') return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex) } From 3c625eafa8d3177cfe6c155446fa49bcec97484b Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:39:27 +0300 Subject: [PATCH 003/128] fix: surface upload failures, survive insecure contexts, stop mid-drag GPU disposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects one debugging session paid for in full: - saveAsset called crypto.randomUUID unconditionally; it exists only in secure contexts, so an editor served over plain http on a LAN threw on the first line of every upload. getRandomValues is not gated the same way and provides the same uniqueness. - Two guide-image catch{} blocks swallowed the reason. The message the user saw ("Could not add that guide image.") was true and useless; the cause now reaches both the console and the toast. - DragBoundingBox minted geometry per dimension change and disposed the previous one in effect cleanup — once per frame during a resize drag, while WebGPU could still be executing the command buffer referencing the destroyed buffers, which drops the ENTIRE frame ("Vertex buffer slot … was not set"). Unit geometry scaled through the mesh transform and colour-cached materials: nothing is built or disposed mid-drag. Also carries the clipboard regression test for multi-underscore id prefixes, and pins plugin-warehouse at the current catalogue. Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 +- packages/core/src/lib/asset-storage.ts | 19 ++- .../tools/shared/drag-bounding-box.tsx | 114 +++++++++++------- .../ui/action-menu/view-toggles.tsx | 11 +- .../ui/sidebar/panels/site-panel/index.tsx | 12 +- .../editor/src/lib/scene-clipboard.test.ts | 11 +- 7 files changed, 120 insertions(+), 53 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 324d3b0ba..0a890d42b 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#533cc0c8ded4dfb8636100b430433b287c7c5d54", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f78bac56f2c55962283514ca10525969690e1211", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index d54744cd2..900234320 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#533cc0c8ded4dfb8636100b430433b287c7c5d54", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f78bac56f2c55962283514ca10525969690e1211", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#533cc0c8ded4dfb8636100b430433b287c7c5d54", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "533cc0c8ded4dfb8636100b430433b287c7c5d54"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f78bac56f2c55962283514ca10525969690e1211", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "f78bac56f2c55962283514ca10525969690e1211"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], diff --git a/packages/core/src/lib/asset-storage.ts b/packages/core/src/lib/asset-storage.ts index 72f577a34..5681e4cb1 100644 --- a/packages/core/src/lib/asset-storage.ts +++ b/packages/core/src/lib/asset-storage.ts @@ -5,11 +5,28 @@ export const ASSET_PREFIX = 'asset_data:' // Cache for active object URLs to prevent leaks and flickering const urlCache = new Map() +/** + * `crypto.randomUUID` exists only in secure contexts, and an editor served + * over plain http on a LAN is not one — the first line of every upload threw + * `TypeError` and the user saw "Could not add that guide image" with no cause. + * `getRandomValues` is NOT secure-context-gated, so the fallback keeps the + * same entropy; the id only needs uniqueness, never secrecy. + */ +function randomAssetId(): string { + const webCrypto = globalThis.crypto + if (webCrypto?.randomUUID) return webCrypto.randomUUID() + const bytes = webCrypto?.getRandomValues?.(new Uint8Array(16)) + if (bytes) { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` +} + /** * Save a file to IndexedDB and return a custom protocol URL */ export async function saveAsset(file: File): Promise { - const id = crypto.randomUUID() + const id = randomAssetId() await set(`${ASSET_PREFIX}${id}`, file) return `asset://${id}` } diff --git a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx index f487fb405..92ad2a9aa 100644 --- a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx +++ b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx @@ -22,6 +22,70 @@ const NO_RAYCAST = () => null /** green-500 — matches the item placement box's "placeable" state. */ const DEFAULT_COLOR = 0x22_c5_5e +/** + * Module-level unit geometry, scaled per frame through the mesh transform — + * NEVER rebuilt or disposed mid-drag. + * + * The previous version minted a `BoxGeometry`/`PlaneGeometry` per dimension + * change and disposed the old one in an effect cleanup. During a resize drag + * that is once per frame, and WebGPU may still be executing the command + * buffer that references the disposed buffers — the renderer then drops the + * ENTIRE frame's command buffer ("Vertex buffer slot … was not set"), which + * blanks the whole scene for a frame, not just this overlay. Unit geometry + + * `scale` moves the per-frame change into the object matrix, where it + * belongs, and the shared buffers live for the session. + */ +const UNIT_EDGES = (() => { + const box = new BoxGeometry(1, 1, 1) + const edges = new EdgesGeometry(box) + box.dispose() + return edges +})() + +const UNIT_PLANE = (() => { + const plane = new PlaneGeometry(1, 1) + plane.rotateX(-Math.PI / 2) + return plane +})() + +/** + * Materials by colour — two in practice (placeable green / blocked red). + * Cached for the same reason the geometry is shared: disposing a material the + * in-flight pass still references is the same command-buffer drop, and a + * colour flip happens mid-drag by design. + */ +const edgeMaterials = new Map() +const planeMaterials = new Map() + +function getEdgeMaterial(color: number): LineBasicNodeMaterial { + let material = edgeMaterials.get(color) + if (!material) { + material = new LineBasicNodeMaterial({ + color, + linewidth: 3, + depthTest: false, + depthWrite: false, + }) + edgeMaterials.set(color, material) + } + return material +} + +function getPlaneMaterial(color: number): MeshBasicNodeMaterial { + let material = planeMaterials.get(color) + if (!material) { + material = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthTest: false, + depthWrite: false, + }) + material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) + planeMaterials.set(color, material) + } + return material +} + type LocalBounds = { size: [number, number, number]; center: [number, number, number] } /** @@ -113,47 +177,8 @@ export function DragBoundingBox({ const minY = cy - h / 2 const groundY = minY + 0.01 - const edgeGeometry = useMemo(() => { - const box = new BoxGeometry(w, h, d) - const edges = new EdgesGeometry(box) - box.dispose() - return edges - }, [w, h, d]) - - // Flat on the ground (XZ) at the box's base, nudged up 0.01m to avoid - // z-fighting with slabs. - const planeGeometry = useMemo(() => { - const plane = new PlaneGeometry(w, d) - plane.rotateX(-Math.PI / 2) - plane.translate(cx, groundY, cz) - return plane - }, [w, d, cx, groundY, cz]) - - const edgeMaterial = useMemo( - () => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }), - [color], - ) - - const planeMaterial = useMemo(() => { - const material = new MeshBasicNodeMaterial({ - color, - transparent: true, - depthTest: false, - depthWrite: false, - }) - material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) - return material - }, [color]) - - useEffect( - () => () => { - edgeGeometry.dispose() - planeGeometry.dispose() - edgeMaterial.dispose() - planeMaterial.dispose() - }, - [edgeGeometry, planeGeometry, edgeMaterial, planeMaterial], - ) + const edgeMaterial = getEdgeMaterial(color) + const planeMaterial = getPlaneMaterial(color) // Publish the facing pose to the editor-side overlay (the single triangle // renderer) rather than drawing it here. The node origin is `position`; the @@ -176,19 +201,22 @@ export function DragBoundingBox({ return ( ) diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index e3023323c..aa9dd7ec1 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -119,8 +119,15 @@ function UploadButton({ onError }: { onError: (message: string | null) => void } setShowGuides(true) setSelectedReferenceId(guide.id) setSelection({ selectedIds: [], zoneId: null }) - } catch { - onError('Could not add that guide image.') + } catch (error) { + // The reason must survive: this path swallowed a secure-context + // TypeError for a whole debugging session. + console.error('[guide-image]', error) + onError( + error instanceof Error && error.message + ? `Could not add that guide image: ${error.message}` + : 'Could not add that guide image.', + ) } finally { setIsAddingGuide(false) } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 87a6352b4..fad8cfa74 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -493,8 +493,16 @@ const LevelReferences = memo(function LevelReferences({ setSelection({ selectedIds: [], zoneId: null }) useUploadStore.getState().setResult(levelId, guide.url) window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600) - } catch { - useUploadStore.getState().setError(levelId, 'Could not add that guide image.') + } catch (error) { + console.error('[guide-image]', error) + useUploadStore + .getState() + .setError( + levelId, + error instanceof Error && error.message + ? `Could not add that guide image: ${error.message}` + : 'Could not add that guide image.', + ) } return } diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts index 97355fa79..2a33b50fb 100644 --- a/packages/editor/src/lib/scene-clipboard.test.ts +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -332,7 +332,11 @@ describe('scene clipboard', () => { capabilities: { duplicable: true, deletable: true }, } as unknown as AnyNodeDefinition) - const pluginNodeId = 'warehouse-pallet_clipboard' as AnyNodeId + // Deliberately an id whose *prefix* contains an underscore. Plugin kinds + // routinely have one — `pallet_rack_` — and `extractIdPrefix` split + // at the first underscore, so a pasted rack came back as `pallet_`, + // matched no kind, and the paste failed with a bare console error. + const pluginNodeId = 'pallet_rack_clipboard' as AnyNodeId const pluginNode = PluginNode.parse({ id: pluginNodeId, parentId: sourceLevelId, @@ -354,8 +358,11 @@ describe('scene clipboard', () => { const pastedId = result?.pastedIds[0] const pasted = pastedId ? useScene.getState().nodes[pastedId] : undefined - expect(pasted?.type).toBe('warehouse:pallet') + // `type` is typed as the built-in union, which a plugin kind can never be a + // member of — that is the whole premise of this test. + expect(pasted?.type as string | undefined).toBe('warehouse:pallet') expect(pasted?.id).not.toBe(pluginNodeId) + expect(pasted?.id.startsWith('pallet_rack_')).toBe(true) expect(pasted?.parentId).toBe(targetLevelId) // The registry schema — not `AnyNode` — is what validated it, so kind-owned // fields have to survive the round trip. From 7c1387319179e617a32cddbc32f38a24e2a321f8 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:42:29 +0300 Subject: [PATCH 004/128] chore: pin plugin-warehouse at the per-level-clears catalogue Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 0a890d42b..3b1a6715e 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f78bac56f2c55962283514ca10525969690e1211", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#6cd44f9338abb1981e674b14ef63c5f969f8507a", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 900234320..9efa50517 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f78bac56f2c55962283514ca10525969690e1211", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#6cd44f9338abb1981e674b14ef63c5f969f8507a", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f78bac56f2c55962283514ca10525969690e1211", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "f78bac56f2c55962283514ca10525969690e1211"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#6cd44f9338abb1981e674b14ef63c5f969f8507a", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "6cd44f9338abb1981e674b14ef63c5f969f8507a"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 4384898bd2456c1e33db2721bf34ef1679e0c5f8 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:05:43 +0300 Subject: [PATCH 005/128] chore: pin plugin-warehouse at the telescopic conveyor Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 3b1a6715e..102faf3e5 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#6cd44f9338abb1981e674b14ef63c5f969f8507a", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#cad66515d39cc540c1fc25530a55699c7e67edd1", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 9efa50517..018ea6395 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#6cd44f9338abb1981e674b14ef63c5f969f8507a", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#cad66515d39cc540c1fc25530a55699c7e67edd1", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#6cd44f9338abb1981e674b14ef63c5f969f8507a", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "6cd44f9338abb1981e674b14ef63c5f969f8507a"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#cad66515d39cc540c1fc25530a55699c7e67edd1", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "cad66515d39cc540c1fc25530a55699c7e67edd1"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 35bc745d3c89645d5d548888280f0c2c145f0471 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:31:22 +0300 Subject: [PATCH 006/128] chore: pin plugin-warehouse at the shared-collider perf fix Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 102faf3e5..316f66f4d 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#cad66515d39cc540c1fc25530a55699c7e67edd1", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#77f3393380d4a0ca773021e8f570996f2759c128", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 018ea6395..3f06f4e88 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#cad66515d39cc540c1fc25530a55699c7e67edd1", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#77f3393380d4a0ca773021e8f570996f2759c128", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#cad66515d39cc540c1fc25530a55699c7e67edd1", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "cad66515d39cc540c1fc25530a55699c7e67edd1"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#77f3393380d4a0ca773021e8f570996f2759c128", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "77f3393380d4a0ca773021e8f570996f2759c128"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 03bd8ebdd0f9c1c779ceed7cdf6347ab81939294 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:04:55 +0300 Subject: [PATCH 007/128] chore: pin plugin-warehouse at instancing + slice 8 Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 316f66f4d..671e3e36e 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#77f3393380d4a0ca773021e8f570996f2759c128", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#34840a0ae2f4415257461331cdb8903e84cbcf2c", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 3f06f4e88..7eae71e73 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#77f3393380d4a0ca773021e8f570996f2759c128", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#34840a0ae2f4415257461331cdb8903e84cbcf2c", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#77f3393380d4a0ca773021e8f570996f2759c128", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "77f3393380d4a0ca773021e8f570996f2759c128"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#34840a0ae2f4415257461331cdb8903e84cbcf2c", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "34840a0ae2f4415257461331cdb8903e84cbcf2c"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 9b4dbcbcd826ecc29da77a3fb8ae18292763302f Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:12:03 +0300 Subject: [PATCH 008/128] chore: pin plugin-warehouse at the ghost-stock target fix Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 671e3e36e..b08a4c4ab 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#34840a0ae2f4415257461331cdb8903e84cbcf2c", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#c5230c1a885e8febfb9c8774d676fd31c991d4d8", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 7eae71e73..66db05b07 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#34840a0ae2f4415257461331cdb8903e84cbcf2c", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#c5230c1a885e8febfb9c8774d676fd31c991d4d8", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#34840a0ae2f4415257461331cdb8903e84cbcf2c", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "34840a0ae2f4415257461331cdb8903e84cbcf2c"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#c5230c1a885e8febfb9c8774d676fd31c991d4d8", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "c5230c1a885e8febfb9c8774d676fd31c991d4d8"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 1a1742e9a9254114017a15d9f0492e5cb69f31cd Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:16:44 +0300 Subject: [PATCH 009/128] chore: pin plugin-warehouse at the audit fixes Co-Authored-By: Claude Opus 5 (1M context) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index b08a4c4ab..561f71b0e 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#c5230c1a885e8febfb9c8774d676fd31c991d4d8", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 66db05b07..c252a8c55 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#c5230c1a885e8febfb9c8774d676fd31c991d4d8", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -567,7 +567,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#c5230c1a885e8febfb9c8774d676fd31c991d4d8", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "c5230c1a885e8febfb9c8774d676fd31c991d4d8"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 7dd18381b2119bc964f22009875bda88e5f7fb84 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:57:05 +0300 Subject: [PATCH 010/128] chore: pin plugin-warehouse at telescopic sensor+platform (20bffb3) Adds the boom-tip anti-collision sensor and operator step platform, researched against real telescopic conveyor manufacturers (Feifer, Dahan) per the user's reference photos. --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 1de5b43ef..b657f9790 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#20bffb3dc97a181431d2f2932a4a2369bf35b1d8", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index b78b23df4..3ee5fb5fe 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#20bffb3dc97a181431d2f2932a4a2369bf35b1d8", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -568,7 +568,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "f1f70da8e5cf28021c27b5cf0f4a3f3a9d88d74a"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#20bffb3dc97a181431d2f2932a4a2369bf35b1d8", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "20bffb3dc97a181431d2f2932a4a2369bf35b1d8"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From ba8ec6d9f4870c02dd76a1c728c143dda16472c0 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:16:30 +0300 Subject: [PATCH 011/128] chore: pin plugin-warehouse at telescopic nose-equipment panel toggles (0927769) hasSensor / hasPlatform now controllable from the parametrics panel instead of hardcoded always-on. --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index b657f9790..b958293d4 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#20bffb3dc97a181431d2f2932a4a2369bf35b1d8", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#0927769a12efd35ef8c3a57283d5d276c0b82db5", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 3ee5fb5fe..a162f99d2 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#20bffb3dc97a181431d2f2932a4a2369bf35b1d8", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#0927769a12efd35ef8c3a57283d5d276c0b82db5", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -568,7 +568,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#20bffb3dc97a181431d2f2932a4a2369bf35b1d8", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "20bffb3dc97a181431d2f2932a4a2369bf35b1d8"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#0927769a12efd35ef8c3a57283d5d276c0b82db5", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "0927769a12efd35ef8c3a57283d5d276c0b82db5"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 936d4d4627e79a0e3552aade453e597f4041b3b9 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:33:09 +0300 Subject: [PATCH 012/128] chore: pin plugin-warehouse at lamp-in-hazard-stripe (e959a11) --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index b958293d4..c8d528eb8 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#0927769a12efd35ef8c3a57283d5d276c0b82db5", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e959a11c8199a32e4b6e0f163096586b0805874a", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index a162f99d2..6f4cf69ea 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#0927769a12efd35ef8c3a57283d5d276c0b82db5", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e959a11c8199a32e4b6e0f163096586b0805874a", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -568,7 +568,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#0927769a12efd35ef8c3a57283d5d276c0b82db5", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "0927769a12efd35ef8c3a57283d5d276c0b82db5"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e959a11c8199a32e4b6e0f163096586b0805874a", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "e959a11c8199a32e4b6e0f163096586b0805874a"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 9a1360c00ebb9150869ca7142ef60f3572f5d937 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:20:35 +0300 Subject: [PATCH 013/128] chore: pin plugin-warehouse at mezzanine Phase 1 (2c54178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds warehouse:mezzanine — Mecalux multi-tier structural steel platform, structural skeleton only (grid/column/beam/floor). No staircases, gates, railings, or rack-on-mezzanine integration yet. --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index c8d528eb8..822cfcf54 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e959a11c8199a32e4b6e0f163096586b0805874a", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#2c5417876ec6b04865f04ea9bc04341438817e09", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index 6f4cf69ea..0f76e6c98 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e959a11c8199a32e4b6e0f163096586b0805874a", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#2c5417876ec6b04865f04ea9bc04341438817e09", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -568,7 +568,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e959a11c8199a32e4b6e0f163096586b0805874a", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "e959a11c8199a32e4b6e0f163096586b0805874a"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#2c5417876ec6b04865f04ea9bc04341438817e09", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "2c5417876ec6b04865f04ea9bc04341438817e09"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 18fb842cf615a4e0ede9f7ea9d6ffe0e538f5570 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:26:26 +0300 Subject: [PATCH 014/128] chore: sync lockfile and generated types after full monorepo rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/ifc-converter/next-env.d.ts and bun.lock picked up churn from rebuilding every @pascal-app/* package's stale dist/ against the 1.0.0-beta.1 release commit — needed to unblock the mezzanine deploy (next build failed on missing exports from unbuilt package dists). --- apps/ifc-converter/next-env.d.ts | 2 +- bun.lock | 38 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/bun.lock b/bun.lock index 0f76e6c98..dcd95257c 100644 --- a/bun.lock +++ b/bun.lock @@ -99,7 +99,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.9.2", + "version": "1.0.0-beta.1", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -125,7 +125,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.9.2", + "version": "1.0.0-beta.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -162,8 +162,8 @@ "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@pascal/typescript-config": "*", "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", @@ -175,8 +175,8 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -204,7 +204,7 @@ }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", - "version": "0.1.2", + "version": "1.0.0-beta.1", "dependencies": { "@pascal-app/core": "*", "nanoid": "^5.1.6", @@ -218,7 +218,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.3.2", + "version": "1.0.0-beta.1", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, @@ -228,22 +228,22 @@ "zod": "^4.3.5", }, "devDependencies": { - "@pascal-app/core": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", }, }, "packages/nodes": { "name": "@pascal-app/nodes", - "version": "0.1.1", + "version": "1.0.0-beta.1", "devDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/editor": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/editor": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", @@ -252,9 +252,9 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/editor": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/editor": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "lucide-react": "^1", @@ -286,7 +286,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.9.2", + "version": "1.0.0-beta.1", "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", @@ -300,7 +300,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", From 677b3153f3cc93170aaaf904e820ef87ec310ab4 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:05:37 +0300 Subject: [PATCH 015/128] chore: pin plugin-warehouse at mezzanine Phases 2-4 (fe05208) Staircases (EN ISO 14122-3), railings derived from openings, gates, stair voids as panel exclusion, plan symbols sharing the 3D calculators, and rack-on-mezzanine load-class checking + 3D clash. --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 822cfcf54..b8d5d7b0b 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#2c5417876ec6b04865f04ea9bc04341438817e09", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#fe05208e1fd7edf5c0461114d8896f0b5f9a112f", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index dcd95257c..f4e682749 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#2c5417876ec6b04865f04ea9bc04341438817e09", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#fe05208e1fd7edf5c0461114d8896f0b5f9a112f", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -568,7 +568,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#2c5417876ec6b04865f04ea9bc04341438817e09", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "2c5417876ec6b04865f04ea9bc04341438817e09"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#fe05208e1fd7edf5c0461114d8896f0b5f9a112f", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "fe05208e1fd7edf5c0461114d8896f0b5f9a112f"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 94a872eed6eff5a8cc96948c5710801a9957a4f9 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:34:33 +0300 Subject: [PATCH 016/128] chore: pin plugin-warehouse at live racking (ee29014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds warehouse:live-racking — Mecalux gravity-flow channel. One node is one channel column; bay width and roller length derive from the pallet by catalogue formula. Joins the capacity readout as storage-only depth. --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index b8d5d7b0b..7637f25f9 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#fe05208e1fd7edf5c0461114d8896f0b5f9a112f", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ee290143dfb1d7c33e2d06f9807bded320e345fc", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index f4e682749..a8b93ee1b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#fe05208e1fd7edf5c0461114d8896f0b5f9a112f", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ee290143dfb1d7c33e2d06f9807bded320e345fc", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -568,7 +568,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#fe05208e1fd7edf5c0461114d8896f0b5f9a112f", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "fe05208e1fd7edf5c0461114d8896f0b5f9a112f"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ee290143dfb1d7c33e2d06f9807bded320e345fc", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "ee290143dfb1d7c33e2d06f9807bded320e345fc"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From 5df7ddb8193afec9c2828a0d2437018e50008685 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:04:45 +0300 Subject: [PATCH 017/128] chore: pin plugin-warehouse at the mezzanine deck-slab release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mezzanine now publishes a real host `slab` per tier, which is the only way `spatialGridManager` will elect it as a support surface — so racks, pallets and conveyors can finally be placed on a mezzanine deck. Host level filtering and pointer-based tier selection come free from the existing support machinery. Co-Authored-By: Claude Opus 5 --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 7637f25f9..a748dc8fc 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ee290143dfb1d7c33e2d06f9807bded320e345fc", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#948da25fa88579af3f2a42e41b2ad1d6b783af24", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", diff --git a/bun.lock b/bun.lock index a8b93ee1b..34a6b0a96 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ee290143dfb1d7c33e2d06f9807bded320e345fc", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#948da25fa88579af3f2a42e41b2ad1d6b783af24", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -568,7 +568,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ee290143dfb1d7c33e2d06f9807bded320e345fc", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "ee290143dfb1d7c33e2d06f9807bded320e345fc"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#948da25fa88579af3f2a42e41b2ad1d6b783af24", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "948da25fa88579af3f2a42e41b2ad1d6b783af24"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], From dc3aff0ff60ed2e97c6723280efb9adf46024c97 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:46:38 +0300 Subject: [PATCH 018/128] chore: pin plugin-warehouse at the live-racking Phase 2 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow hardware now exists as geometry (brake rollers + drums, centralising strips, retainers, exit beam, end stop) and `variant` finally changes the mesh — FIFO and LIFO were byte-identical before. Adds the floor-set pallet truck level and clad-rack configurations. 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 a748dc8fc..8c4e67b76 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#948da25fa88579af3f2a42e41b2ad1d6b783af24", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e58c4fe6a8c91d5038b272a3b46b0c5c8cffa8e2", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 4c0245da07f42011011ee22ca2b408c4dfc0534e Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:27:20 +0300 Subject: [PATCH 019/128] chore: pin plugin-warehouse at the live-racking SKU release Adds per-level SKU on live racking channels and an unconditional plan label, so a layout reader can see which lane holds which reference without selecting it. 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 8c4e67b76..bf924bf38 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e58c4fe6a8c91d5038b272a3b46b0c5c8cffa8e2", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#48a5e019e9ad8515bf2ec081d97d1475d2235f65", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 7d8153bc5bc1d635e5f851a8b76239279c208944 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 17:32:35 +0000 Subject: [PATCH 020/128] deploy: self-contained standalone build for Hostinger Node.js hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three deploy-only changes; packages/* and dependency pins untouched: - next.config.ts: output 'standalone' so the build emits a bundle that runs without the monorepo's root node_modules (Hostinger only ships the output directory). - package.json: drop the dotenv -e ../../.env.local build wrapper — .env.local is gitignored so the file never exists on the build host, and the app builds with no env vars. - hostinger-server.js: entry file copied to .next/standalone/server.js at build time; chdirs into apps/editor so the standalone output finds .next/ and public/, and passes PORT through untouched so a Unix socket path works as well as a number. Verified locally: clean install, turbo build --filter=editor, and the standalone server serving /, /api/health, /_next/static/* and public/ assets over both a TCP port and a Unix socket. Note: bun resolves the two github git dependencies through the GitHub API tarball endpoint, so the git+ssh pin installs anonymously as long as the repo is public; for a private repo set GITHUB_TOKEN in the host environment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/hostinger-server.js | 37 +++++++++++++++++++++++++++++++++ apps/editor/next.config.ts | 2 ++ apps/editor/package.json | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/editor/hostinger-server.js diff --git a/apps/editor/hostinger-server.js b/apps/editor/hostinger-server.js new file mode 100644 index 000000000..18380008e --- /dev/null +++ b/apps/editor/hostinger-server.js @@ -0,0 +1,37 @@ +// Hostinger Node.js Web App entry point. The build step copies this file to +// .next/standalone/server.js, next to the standalone bundle's node_modules. +// +// Why not Next's generated server.js: +// - Hostinger may hand PORT over as a Unix socket path instead of a number; +// the generated server parseInt()s it and silently falls back to 3000. +// node:http's listen() accepts both, so we pass PORT through untouched. +// - The process cwd on Hostinger is not the app dir, so the generated server +// cannot resolve .next/ and public/ by relative path. We chdir first. + +const path = require('node:path') +const { createServer } = require('node:http') +const { parse } = require('node:url') +const next = require('next') + +const appDir = path.join(__dirname, 'apps', 'editor') +process.chdir(appDir) + +const app = next({ dev: false, dir: appDir }) +const handle = app.getRequestHandler() + +app + .prepare() + .then(() => { + const server = createServer((req, res) => { + handle(req, res, parse(req.url, true)) + }) + + const listenTarget = process.env.PORT || 3000 + server.listen(listenTarget, () => { + console.log(`Pascal Editor listening on ${listenTarget}`) + }) + }) + .catch((err) => { + console.error('Failed to start Next.js:', err) + process.exit(1) + }) diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 0955701f8..c814dc9e2 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -1,6 +1,8 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { + // Hostinger runs the app from a self-contained bundle; see hostinger-server.js. + output: 'standalone', // Dev-only. Without these, the dev server refuses the HMR websocket for any // origin other than localhost, and because Turbopack delivers module updates // over that socket the page loads its shell and then hangs waiting for lazy diff --git a/apps/editor/package.json b/apps/editor/package.json index a748dc8fc..d2121ceb3 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "dev": "next dev --port 3002", - "build": "dotenv -e ../../.env.local -- next build", + "build": "next build", "start": "next start", "lint": "biome lint", "check-types": "next typegen && tsgo --noEmit" From 1c2d91d69557d8030a716f57b3517d78dbdfe90a Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:53:03 +0300 Subject: [PATCH 021/128] chore: pin plugin-warehouse at the mezzanine control-honesty release Four mezzanine controls offered a choice and changed nothing: the two gate types drew identical boxes, turn90 and turn180 cut identical floor voids, SIGMA silently discarded profile overrides, and hatch2D was populated on all seven floor types but never read. Each now has a real effect or says plainly that it is ignored. 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 bf924bf38..c4c77addb 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#48a5e019e9ad8515bf2ec081d97d1475d2235f65", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#1c32173ebf67c1181bb91b626929b95742401241", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 4800db4f764cea79c8c334df51ec6bf225119054 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 17:56:32 +0000 Subject: [PATCH 022/128] deploy: run the full standalone assembly from the root build script Hostinger's Node.js app builder only offers fixed build commands (npm run build), so the root build script now runs the editor-only turbo build plus the standalone completion steps (public/, .next/static, and the hostinger-server.js entry copy) that were previously meant for a custom build command. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c318deab..56ee5404f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "editor", "private": true, "scripts": { - "build": "turbo run build", + "build": "turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", "dev": "set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose", "lint": "biome lint", "lint:fix": "biome lint --write", From 50bde99282c28d92324d2d907f580abd80676070 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 18:10:31 +0000 Subject: [PATCH 023/128] deploy: reinstall with bun before building on the host Hostinger's fixed install step runs npm install, and npm 7+ auto-installs peer dependencies: the plugin packages pin @pascal-app/* peers to '>=0.9.2 <1', which the workspace's 1.0.0-beta.1 prerelease does not satisfy, so npm shadowed the workspace packages with stale published copies under apps/editor/node_modules and the build failed on missing exports. The build host ships bun, so the build script now clears npm's tree and reinstalls from bun.lock, reproducing the locally verified resolution before running turbo. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 56ee5404f..f7180e1e5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "editor", "private": true, "scripts": { - "build": "turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", + "build": "rm -rf node_modules apps/editor/node_modules apps/ifc-converter/node_modules packages/*/node_modules tooling/*/node_modules && bun install && turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", "dev": "set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose", "lint": "biome lint", "lint:fix": "biome lint --write", From e120fb747ddd426597ddaeaf33b55c6024644a4f Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:14:12 +0300 Subject: [PATCH 024/128] chore: pin plugin-warehouse at the mezzanine staircase release Staircases now carry railings on their open edges, a real landing platform between flights, and a multi-flight layout driven by the landing type. Adds the 4000 mm single-straight-flight exception and the 15-step auto-split. 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 c4c77addb..e887b04de 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#1c32173ebf67c1181bb91b626929b95742401241", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#b5bce6b8ded7cb56a28c1f8708a6ed3cc9936086", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 22d31a2ea031c8564513f0c1974818258e898f36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 18:41:22 +0000 Subject: [PATCH 025/128] deploy: hoisted install layout and hand off to Next's generated server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified this time against a copy of the standalone directory moved out of the repo, which is what the host actually deploys — the earlier in-place verification silently resolved modules from the repo's root node_modules, masking two failures: - bun 1.3 defaults workspaces to the isolated linker, and the standalone bundle it produces carries node_modules/.bun symlinks that break once the output directory is moved (Cannot find module 'next'). Install with --linker=hoisted so the bundle contains plain directories. - the custom entry used the programmatic next() API, whose dependency surface (e.g. next/dist/compiled/webpack/webpack-lib) is not part of the file-traced bundle. The entry is now a thin CJS shim that dynamic- imports the server Next itself generates in the bundle, which handles cwd and PORT wiring. The moved-copy smoke test now passes: /api/health, /, page CSS and public/ assets all 200. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/hostinger-server.js | 43 +++++++-------------------------- package.json | 2 +- 2 files changed, 10 insertions(+), 35 deletions(-) diff --git a/apps/editor/hostinger-server.js b/apps/editor/hostinger-server.js index 18380008e..656d36e9c 100644 --- a/apps/editor/hostinger-server.js +++ b/apps/editor/hostinger-server.js @@ -1,37 +1,12 @@ // Hostinger Node.js Web App entry point. The build step copies this file to -// .next/standalone/server.js, next to the standalone bundle's node_modules. -// -// Why not Next's generated server.js: -// - Hostinger may hand PORT over as a Unix socket path instead of a number; -// the generated server parseInt()s it and silently falls back to 3000. -// node:http's listen() accepts both, so we pass PORT through untouched. -// - The process cwd on Hostinger is not the app dir, so the generated server -// cannot resolve .next/ and public/ by relative path. We chdir first. - +// .next/standalone/server.js. It hands off to the server Next generates +// inside the standalone bundle (apps/editor/server.js), which chdirs into +// the app directory and wires PORT/HOSTNAME itself. A CJS shim with dynamic +// import() because apps/editor is ESM ("type": "module"). const path = require('node:path') -const { createServer } = require('node:http') -const { parse } = require('node:url') -const next = require('next') - -const appDir = path.join(__dirname, 'apps', 'editor') -process.chdir(appDir) - -const app = next({ dev: false, dir: appDir }) -const handle = app.getRequestHandler() - -app - .prepare() - .then(() => { - const server = createServer((req, res) => { - handle(req, res, parse(req.url, true)) - }) +const { pathToFileURL } = require('node:url') - const listenTarget = process.env.PORT || 3000 - server.listen(listenTarget, () => { - console.log(`Pascal Editor listening on ${listenTarget}`) - }) - }) - .catch((err) => { - console.error('Failed to start Next.js:', err) - process.exit(1) - }) +import(pathToFileURL(path.join(__dirname, 'apps', 'editor', 'server.js')).href).catch((err) => { + console.error('Failed to start Next.js standalone server:', err) + process.exit(1) +}) diff --git a/package.json b/package.json index f7180e1e5..e0d2bd57a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "editor", "private": true, "scripts": { - "build": "rm -rf node_modules apps/editor/node_modules apps/ifc-converter/node_modules packages/*/node_modules tooling/*/node_modules && bun install && turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", + "build": "rm -rf node_modules apps/editor/node_modules apps/ifc-converter/node_modules packages/*/node_modules tooling/*/node_modules && bun install --linker=hoisted && turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", "dev": "set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose", "lint": "biome lint", "lint:fix": "biome lint --write", From 1caedd67f031eaa63c8ffed49d591cfdfd19b504 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:46:26 +0300 Subject: [PATCH 026/128] chore: pin plugin-warehouse at the mezzanine catalogue release Catalogue tiles now ship staircases and pallet gates, so a mezzanine placed from the catalogue can actually be reached. Adds the missing MIXED tile, a step-count picker and safety-zone creation in the accessory editor. 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 e887b04de..edcbd9052 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#b5bce6b8ded7cb56a28c1f8708a6ed3cc9936086", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#d7c07e5c2d31351d59850fceca0429c7b5e8a8ac", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From dd80b012ba0ad2e87be7f45bbac2400a28b2f674 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 18:53:52 +0000 Subject: [PATCH 027/128] deploy: restore execute bits after bun install on the host bun's hoisted linker drops the execute bit when materializing packages from a warm cache on the build host, so spawning the turbo native binary failed with EACCES. Re-apply execute permissions to node_modules/.bin and the turbo platform package before running the build. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e0d2bd57a..09e01e8a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "editor", "private": true, "scripts": { - "build": "rm -rf node_modules apps/editor/node_modules apps/ifc-converter/node_modules packages/*/node_modules tooling/*/node_modules && bun install --linker=hoisted && turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", + "build": "rm -rf node_modules apps/editor/node_modules apps/ifc-converter/node_modules packages/*/node_modules tooling/*/node_modules && bun install --linker=hoisted && chmod -R +x node_modules/.bin node_modules/@turbo && turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", "dev": "set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose", "lint": "biome lint", "lint:fix": "biome lint --write", From 2a98222da8f8c4cbe72139ef17c55dc0fd5d3775 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:10:12 +0300 Subject: [PATCH 028/128] chore: pin plugin-warehouse at the mezzanine tier-targeting fix Stacked mezzanine decks could not be targeted: the host's pointed-surface election takes the nearest ray crossing, which from a camera above is always the topmost deck. Placement now honours an explicit active-deck selection. 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 edcbd9052..693dd547d 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#d7c07e5c2d31351d59850fceca0429c7b5e8a8ac", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f330fc257c1d65211b39de078a3362ccc6c57e77", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 0ea9cea4590cab204790f5d4c64d3cbb3b71e899 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:31:19 +0300 Subject: [PATCH 029/128] chore: pin plugin-warehouse at the mezzanine custom-outline release Mezzanine decks can now carry an arbitrary polygon outline instead of the grid rectangle. Columns, floor panels, the support slab and the 2D symbol all follow it; railings and accessories still track the bounding rectangle and the panel says so. 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 693dd547d..08adacd5d 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#f330fc257c1d65211b39de078a3362ccc6c57e77", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ad93358760206b43e2b8f67fc5611f5565c14221", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 5b8660ae9061fa4e7141498f18f624e9be0fd12a Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:38:40 +0300 Subject: [PATCH 030/128] chore: pin plugin-warehouse at the free stair placement release Staircases can now be positioned anywhere on a mezzanine deck with their own rotation, not just anchored to a named edge. The schema and geometry already supported it; only the accessory editor never asked. 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 08adacd5d..395220d9d 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#ad93358760206b43e2b8f67fc5611f5565c14221", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#be68812e99632396910f1648ed872dda4000839c", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 665f69d07839d3c7d8416bbb31491fba1d20d57e Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:51:26 +0300 Subject: [PATCH 031/128] chore: pin plugin-warehouse at the mezzanine outline drawing release The mezzanine tool now has a draw mode: D starts it, clicks add corners, and returning to the first corner or Enter closes the outline. Validity and normalisation live in a pure module so degenerate shapes are refused rather than committed. 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 395220d9d..1a1e5b042 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#be68812e99632396910f1648ed872dda4000839c", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#39514e0013e0e29f208aaf0a1a66d3984214d069", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 3f48f18db028e72c0201cf7c948501e74a6ef030 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 20:38:56 +0000 Subject: [PATCH 032/128] feat(mcp): add a MySQL scene store and let browsers reach the scene API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenes could only live in a local SQLite file, which a host wipes on every release, and the API refused every browser request from a real domain, so saving a scene was impossible once deployed. Storage: a MySQL implementation of SceneStore alongside the SQLite one, selected by PASCAL_MYSQL_URL. It mirrors the SQLite schema but stores timestamps as ISO strings so values round-trip identically regardless of server timezone, and persists graph_hash so listing scenes never pulls whole graph payloads over the wire. Version checks take a row lock rather than relying on SQLite's immediate transactions. The helpers both stores share moved to scene-store-shared. API access: the same-origin check now compares hosts, honouring x-forwarded-host — behind a TLS-terminating proxy the request URL keeps the internal scheme, so comparing whole origins rejected the app's own pages. With no PASCAL_SCENE_API_TOKEN set, requests carrying an allowed Origin now pass instead of being limited to loopback; everything cross-origin is still rejected, and requests with no Origin at all still need the token. Per-user rules arrive with sign-in. Verified against MariaDB 10.11: 24 store cases covering round-trip, version conflicts, owner filtering, rename, events, cascade delete, size cap and unicode; plus create/list/read through the running app with proxy headers, and a cross-origin POST still refused. The package's 297 existing tests pass unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/lib/scene-api-security.ts | 22 +- bun.lock | 19 + packages/mcp/package.json | 1 + packages/mcp/src/storage/index.ts | 17 +- packages/mcp/src/storage/mysql-scene-store.ts | 638 ++++++++++++++++++ .../mcp/src/storage/scene-store-shared.ts | 90 +++ .../mcp/src/storage/sqlite-scene-store.ts | 98 +-- packages/mcp/src/storage/types.ts | 2 +- 8 files changed, 789 insertions(+), 98 deletions(-) create mode 100644 packages/mcp/src/storage/mysql-scene-store.ts create mode 100644 packages/mcp/src/storage/scene-store-shared.ts diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts index 2dd3819a6..0bbe8e6eb 100644 --- a/apps/editor/lib/scene-api-security.ts +++ b/apps/editor/lib/scene-api-security.ts @@ -66,6 +66,11 @@ function validateAuth(request: Request): NextResponse | null { const token = process.env.PASCAL_SCENE_API_TOKEN if (!token) { if (isLoopbackRequest(request)) return null + // With no token configured the API still serves the app's own pages: the + // origin check above rejects everything cross-origin, and requests that + // carry no Origin at all — scripts, other servers — fall through to 503. + const origin = request.headers.get('origin') + if (origin && isOriginAllowed(request, origin)) return null return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 }) } @@ -142,16 +147,25 @@ function configuredOrigins(): Set { ) } +/** + * Compares hosts rather than full origins: behind a TLS-terminating proxy the + * request URL keeps the internal scheme, so a scheme comparison would reject + * the app's own pages. The host is the part that matters for CSRF anyway. + */ function isSameOrigin(request: Request, origin: string): boolean { const parsedOrigin = parseUrl(origin) if (!parsedOrigin) return false - const requestUrl = new URL(request.url) - return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl) + return parsedOrigin.host.toLowerCase() === requestHost(request) +} + +function requestHost(request: Request): string { + const forwarded = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() + const host = forwarded || request.headers.get('host') || new URL(request.url).host + return host.toLowerCase() } function isLoopbackRequest(request: Request): boolean { - const host = request.headers.get('host') ?? new URL(request.url).host - return isLoopbackHostname(stripPort(host)) + return isLoopbackHostname(stripPort(requestHost(request))) } function isLoopbackHostname(hostname: string): boolean { diff --git a/bun.lock b/bun.lock index 34a6b0a96..f815c4b01 100644 --- a/bun.lock +++ b/bun.lock @@ -225,6 +225,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@pascal-app/lingo": "^0.2.0", + "mysql2": "^3.15.4", "zod": "^4.3.5", }, "devDependencies": { @@ -984,6 +985,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -1098,6 +1101,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "deslop-js": ["deslop-js@0.0.21", "", { "dependencies": { "@oxc-project/types": "^0.132.0", "fast-glob": "^3.3.3", "minimatch": "^10.2.5", "oxc-parser": "^0.132.0", "oxc-resolver": "^11.19.1", "typescript": "^6.0.3" } }, "sha512-1fYusMl4tDaQ/xdHFtLfDe8kFrEeU0Vu0Pfiy2k/Gd4HSqYlQj1Z7iRqldNneRyuCzNMhwNBvhaL07Urbh5VOA=="], @@ -1256,6 +1261,8 @@ "geist": ["geist@1.7.2", "", { "peerDependencies": { "next": ">=13.2.0" } }, "sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -1374,6 +1381,8 @@ "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -1470,10 +1479,14 @@ "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], @@ -1526,6 +1539,10 @@ "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "mysql2": ["mysql2@3.23.2", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -1728,6 +1745,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "sql-escaper": ["sql-escaper@1.5.1", "", {}, "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], diff --git a/packages/mcp/package.json b/packages/mcp/package.json index bdfbe3ae1..a5f2b8188 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -60,6 +60,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@pascal-app/lingo": "^0.2.0", + "mysql2": "^3.15.4", "zod": "^4.3.5" }, "devDependencies": { diff --git a/packages/mcp/src/storage/index.ts b/packages/mcp/src/storage/index.ts index 36e48835e..228ee2959 100644 --- a/packages/mcp/src/storage/index.ts +++ b/packages/mcp/src/storage/index.ts @@ -1,17 +1,26 @@ +import { resolveMysqlUrl } from './mysql-scene-store' import type { SceneStore } from './types' +export * from './mysql-scene-store' export * from './slug' export * from './sqlite-scene-store' export * from './types' /** - * Factory for Pascal's local-first scene store. + * Factory for Pascal's scene store. * - * The store is backed by the runtime's built-in SQLite driver. By default it - * writes to `~/.pascal/data/pascal.db`; set `PASCAL_DB_PATH` for an exact file - * path or `PASCAL_DATA_DIR` for a directory containing `pascal.db`. + * Defaults to the local-first SQLite backend, which writes to + * `~/.pascal/data/pascal.db`; set `PASCAL_DB_PATH` for an exact file path or + * `PASCAL_DATA_DIR` for a directory containing `pascal.db`. + * + * Set `PASCAL_MYSQL_URL` to store scenes in MySQL instead — the backend for + * hosts whose filesystem does not survive a redeploy. */ export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise { + if (resolveMysqlUrl(env ?? process.env)) { + const mod = await import('./mysql-scene-store') + return new mod.MysqlSceneStore({ env }) + } const mod = await import('./sqlite-scene-store') return new mod.SqliteSceneStore({ env }) } diff --git a/packages/mcp/src/storage/mysql-scene-store.ts b/packages/mcp/src/storage/mysql-scene-store.ts new file mode 100644 index 000000000..b667e06f8 --- /dev/null +++ b/packages/mcp/src/storage/mysql-scene-store.ts @@ -0,0 +1,638 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { + assertValidName, + DEFAULT_LIST_LIMIT, + editorUrlForScene, + hashGraphJson, + parseGraph, + resolveMaxSceneBytes, + serializeGraph, +} from './scene-store-shared' +import { generateSlug, isValidSlug, sanitizeSlug } from './slug' +import { + type ProjectCreateOptions, + type ProjectStatus, + type SceneEvent, + type SceneEventAppendOptions, + type SceneEventListOptions, + SceneInvalidError, + type SceneListOptions, + type SceneMeta, + type SceneMutateOptions, + SceneNotFoundError, + type SceneSaveOptions, + type SceneStore, + SceneTooLargeError, + SceneVersionConflictError, + type SceneWithGraph, +} from './types' + +export interface MysqlSceneStoreOptions { + /** Connection URL, e.g. `mysql://user:pass@host:3306/database`. Falls back to env. */ + url?: string + /** Optional env override for URL and size-limit resolution. */ + env?: NodeJS.ProcessEnv + /** Maximum UTF-8 byte length of graph JSON. Defaults to 10 MB. */ + maxSceneBytes?: number +} + +interface SceneRow { + id: string + name: string + project_id: string | null + owner_id: string | null + thumbnail_url: string | null + version: number + created_at: string + updated_at: string + size_bytes: number + node_count: number + graph_hash: string +} + +interface SceneRowWithGraph extends SceneRow { + graph_json: string +} + +interface SceneEventRow { + event_id: number + scene_id: string + version: number + kind: string + created_at: string + graph_json: string +} + +interface ProjectPlaceholder { + id: string + name: string + ownerId: string | null + thumbnailUrl: string | null + createdAt: string + updatedAt: string +} + +/** The slice of `mysql2/promise` this store uses, so the driver stays a dynamic import. */ +interface MysqlQueryable { + query(sql: string, values?: unknown[]): Promise<[unknown, unknown]> + execute(sql: string, values?: unknown[]): Promise<[unknown, unknown]> +} + +interface MysqlConnection extends MysqlQueryable { + beginTransaction(): Promise + commit(): Promise + rollback(): Promise + release(): void +} + +interface MysqlPool extends MysqlQueryable { + getConnection(): Promise + end(): Promise +} + +const SCENE_COLUMNS = + 'id, name, project_id, owner_id, thumbnail_url, version, created_at, updated_at, size_bytes, node_count, graph_hash' + +export function resolveMysqlUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { + const raw = env.PASCAL_MYSQL_URL + return raw && raw.length > 0 ? raw : undefined +} + +function rows(result: unknown): Record[] { + return Array.isArray(result) ? (result as Record[]) : [] +} + +function firstRow(result: unknown): T | null { + const list = rows(result) + return list.length > 0 ? (list[0] as T) : null +} + +function rowToMeta(row: SceneRow): SceneMeta { + const editorUrl = editorUrlForScene(row.id) + return { + id: row.id, + name: row.name, + projectId: row.project_id, + ownerId: row.owner_id, + thumbnailUrl: row.thumbnail_url, + version: Number(row.version), + createdAt: row.created_at, + updatedAt: row.updated_at, + sizeBytes: Number(row.size_bytes), + nodeCount: Number(row.node_count), + editorUrl, + url: editorUrl, + published: true, + graphHash: row.graph_hash, + } +} + +function rowToProjectStatus(row: SceneRow): ProjectStatus { + const editorUrl = editorUrlForScene(row.id) + const version = Number(row.version) + return { + id: row.id, + projectId: row.project_id ?? row.id, + name: row.name, + editorUrl, + url: editorUrl, + ownerId: row.owner_id, + thumbnailUrl: row.thumbnail_url, + publishedVersion: version, + latestVersion: version, + draftVersion: null, + browserVisibleVersion: version, + version, + isEmpty: Number(row.node_count) === 0, + sizeBytes: Number(row.size_bytes), + nodeCount: Number(row.node_count), + graphHash: row.graph_hash, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function placeholderToProjectStatus(project: ProjectPlaceholder): ProjectStatus { + const editorUrl = editorUrlForScene(project.id) + return { + id: project.id, + projectId: project.id, + name: project.name, + editorUrl, + url: editorUrl, + ownerId: project.ownerId, + thumbnailUrl: project.thumbnailUrl, + publishedVersion: null, + latestVersion: null, + draftVersion: null, + browserVisibleVersion: null, + version: 0, + isEmpty: true, + sizeBytes: 0, + nodeCount: 0, + graphHash: null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } +} + +function rowToSceneEvent(row: SceneEventRow): SceneEvent { + return { + eventId: Number(row.event_id), + sceneId: row.scene_id, + version: Number(row.version), + kind: row.kind, + createdAt: row.created_at, + graph: parseGraph(row.graph_json, `${row.scene_id}@${row.version}`), + } +} + +/** + * MySQL-backed implementation of `SceneStore`, for deployments where the + * filesystem is not durable across releases. + * + * Timestamps are stored as ISO 8601 strings rather than DATETIME so values + * round-trip identically to the SQLite store regardless of server timezone, + * and `graph_hash` is persisted so listing scenes never has to pull whole + * graph payloads over the wire. + */ +export class MysqlSceneStore implements SceneStore { + readonly backend = 'mysql' as const + + private readonly url: string + private readonly maxSceneBytes: number + private readonly projectPlaceholders = new Map() + private pool: MysqlPool | null = null + private poolPromise: Promise | null = null + + constructor(opts: MysqlSceneStoreOptions = {}) { + const env = opts.env ?? process.env + const url = opts.url ?? resolveMysqlUrl(env) + if (!url) { + throw new SceneInvalidError('MySQL scene store requires a connection URL (PASCAL_MYSQL_URL)') + } + this.url = url + this.maxSceneBytes = resolveMaxSceneBytes(env, opts.maxSceneBytes) + } + + async createProject(opts: ProjectCreateOptions): Promise { + const pool = await this.database() + assertValidName(opts.name) + const id = opts.id ? sanitizeSlug(opts.id) : await this.generateUniqueId(pool) + if (!isValidSlug(id)) { + throw new SceneInvalidError(`Invalid project id after sanitization: "${id}"`) + } + if (await this.getRow(pool, id)) { + throw new SceneInvalidError(`Project with id "${id}" already exists`) + } + const now = new Date().toISOString() + const project: ProjectPlaceholder = { + id, + name: opts.name, + ownerId: opts.ownerId ?? null, + thumbnailUrl: null, + createdAt: now, + updatedAt: now, + } + this.projectPlaceholders.set(id, project) + return placeholderToProjectStatus(project) + } + + async getProjectStatus(id: string): Promise { + const pool = await this.database() + const safeId = sanitizeSlug(id) + const row = await this.getRow(pool, safeId) + if (row) return rowToProjectStatus(row) + const placeholder = this.projectPlaceholders.get(safeId) + return placeholder ? placeholderToProjectStatus(placeholder) : null + } + + async save(opts: SceneSaveOptions): Promise { + return this.withWriteTransaction(async (conn) => { + assertValidName(opts.name) + if (!opts.graph || typeof opts.graph !== 'object') { + throw new SceneInvalidError('graph is required') + } + + const providedId = opts.id + const id = providedId ? sanitizeSlug(providedId) : await this.generateUniqueId(conn) + if (!isValidSlug(id)) { + throw new SceneInvalidError(`Invalid scene id after sanitization: "${id}"`) + } + + const existing = await this.getRow(conn, id, { forUpdate: true }) + const placeholder = this.projectPlaceholders.get(id) + + if (existing && providedId !== undefined && opts.expectedVersion === undefined) { + throw new SceneInvalidError( + `Scene with id "${id}" already exists. Pass a different id or provide expectedVersion to overwrite.`, + ) + } + + if (opts.expectedVersion !== undefined) { + const currentVersion = existing ? Number(existing.version) : 0 + if (currentVersion !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`, + ) + } + } + + const graphJson = serializeGraph(opts.graph) + const sizeBytes = Buffer.byteLength(graphJson, 'utf8') + if (sizeBytes > this.maxSceneBytes) { + throw new SceneTooLargeError( + `Scene "${id}" is ${sizeBytes} bytes, exceeds cap of ${this.maxSceneBytes} bytes`, + ) + } + + const now = new Date().toISOString() + const version = (existing ? Number(existing.version) : 0) + 1 + const createdAt = existing?.created_at ?? placeholder?.createdAt ?? now + const nodeCount = Object.keys(opts.graph.nodes ?? {}).length + const projectId = opts.projectId ?? existing?.project_id ?? (placeholder ? id : null) + const ownerId = opts.ownerId ?? existing?.owner_id ?? placeholder?.ownerId ?? null + const thumbnailUrl = + opts.thumbnailUrl ?? existing?.thumbnail_url ?? placeholder?.thumbnailUrl ?? null + const graphHash = hashGraphJson(graphJson) + + if (existing) { + await conn.execute( + `UPDATE scenes + SET name = ?, + project_id = ?, + owner_id = ?, + thumbnail_url = ?, + version = ?, + updated_at = ?, + size_bytes = ?, + node_count = ?, + graph_json = ?, + graph_hash = ? + WHERE id = ?`, + [ + opts.name, + projectId, + ownerId, + thumbnailUrl, + version, + now, + sizeBytes, + nodeCount, + graphJson, + graphHash, + id, + ], + ) + } else { + await conn.execute( + `INSERT INTO scenes ( + id, name, project_id, owner_id, thumbnail_url, version, + created_at, updated_at, size_bytes, node_count, graph_json, graph_hash + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + opts.name, + projectId, + ownerId, + thumbnailUrl, + version, + createdAt, + now, + sizeBytes, + nodeCount, + graphJson, + graphHash, + ], + ) + } + + await conn.execute( + `INSERT INTO scene_revisions ( + scene_id, version, graph_json, author_kind, author_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + [id, version, graphJson, 'mcp', ownerId, now], + ) + + this.projectPlaceholders.delete(id) + + return { + id, + name: opts.name, + projectId, + ownerId, + thumbnailUrl, + version, + createdAt, + updatedAt: now, + sizeBytes, + nodeCount, + editorUrl: editorUrlForScene(id), + url: editorUrlForScene(id), + published: true, + graphHash, + } + }) + } + + async load(id: string): Promise { + const pool = await this.database() + const [result] = await pool.execute( + `SELECT ${SCENE_COLUMNS}, graph_json FROM scenes WHERE id = ?`, + [sanitizeSlug(id)], + ) + const row = firstRow(result) + if (!row) return null + return { + ...rowToMeta(row), + graph: parseGraph(row.graph_json, row.id), + } + } + + async list(opts: SceneListOptions = {}): Promise { + const clauses: string[] = [] + const bindings: Array = [] + + if (opts.projectId !== undefined) { + clauses.push('project_id = ?') + bindings.push(opts.projectId) + } + if (opts.ownerId !== undefined) { + clauses.push('owner_id = ?') + bindings.push(opts.ownerId) + } + + const requestedLimit = opts.limit ?? DEFAULT_LIST_LIMIT + const limit = Number.isInteger(requestedLimit) && requestedLimit >= 0 ? requestedLimit : 0 + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const pool = await this.database() + // LIMIT is interpolated because the driver binds placeholders as strings, + // which MySQL rejects there; `limit` is an integer by the check above. + const [result] = await pool.execute( + `SELECT ${SCENE_COLUMNS} + FROM scenes + ${where} + ORDER BY updated_at DESC, id ASC + LIMIT ${limit}`, + bindings, + ) + + return rows(result).map((row) => rowToMeta(row as unknown as SceneRow)) + } + + async delete(id: string, opts: SceneMutateOptions = {}): Promise { + return this.withWriteTransaction(async (conn) => { + const safeId = sanitizeSlug(id) + const existing = await this.getRow(conn, safeId, { forUpdate: true }) + if (!existing) return false + if (opts.expectedVersion !== undefined && Number(existing.version) !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`, + ) + } + await conn.execute('DELETE FROM scenes WHERE id = ?', [safeId]) + return true + }) + } + + async rename(id: string, newName: string, opts: SceneMutateOptions = {}): Promise { + return this.withWriteTransaction(async (conn) => { + assertValidName(newName) + const safeId = sanitizeSlug(id) + const existing = await this.getRow(conn, safeId, { forUpdate: true }) + if (!existing) { + throw new SceneNotFoundError(`Scene "${safeId}" not found`) + } + if (opts.expectedVersion !== undefined && Number(existing.version) !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`, + ) + } + + const now = new Date().toISOString() + const nextVersion = Number(existing.version) + 1 + await conn.execute('UPDATE scenes SET name = ?, version = ?, updated_at = ? WHERE id = ?', [ + newName, + nextVersion, + now, + safeId, + ]) + + const [graphResult] = await conn.execute('SELECT graph_json FROM scenes WHERE id = ?', [ + safeId, + ]) + const graphRow = firstRow<{ graph_json: string }>(graphResult) + await conn.execute( + `INSERT INTO scene_revisions ( + scene_id, version, graph_json, author_kind, author_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + [safeId, nextVersion, graphRow?.graph_json ?? '', 'mcp', existing.owner_id, now], + ) + + return { + ...rowToMeta(existing), + name: newName, + version: nextVersion, + updatedAt: now, + } + }) + } + + async appendSceneEvent(opts: SceneEventAppendOptions): Promise { + return this.withWriteTransaction(async (conn) => { + const safeId = sanitizeSlug(opts.sceneId) + const existing = await this.getRow(conn, safeId) + if (!existing) { + throw new SceneNotFoundError(`Scene "${safeId}" not found`) + } + + const graphJson = serializeGraph(opts.graph) + const now = new Date().toISOString() + const [result] = await conn.execute( + `INSERT INTO scene_events ( + scene_id, version, kind, created_at, graph_json + ) VALUES (?, ?, ?, ?, ?)`, + [safeId, opts.version, opts.kind, now, graphJson], + ) + + return { + eventId: Number((result as { insertId?: number })?.insertId ?? 0), + sceneId: safeId, + version: opts.version, + kind: opts.kind, + createdAt: now, + graph: opts.graph as SceneGraph, + } + }) + } + + async listSceneEvents(sceneId: string, opts: SceneEventListOptions = {}): Promise { + const afterEventId = Math.max(0, opts.afterEventId ?? 0) + const requestedLimit = opts.limit ?? 100 + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : 100 + const pool = await this.database() + const [result] = await pool.execute( + `SELECT event_id, scene_id, version, kind, created_at, graph_json + FROM scene_events + WHERE scene_id = ? + AND event_id > ? + ORDER BY event_id ASC + LIMIT ${limit}`, + [sanitizeSlug(sceneId), afterEventId], + ) + + return rows(result).map((row) => rowToSceneEvent(row as unknown as SceneEventRow)) + } + + async close(): Promise { + await this.pool?.end() + this.pool = null + this.poolPromise = null + } + + private async database(): Promise { + if (this.pool) return this.pool + if (!this.poolPromise) { + this.poolPromise = (async () => { + const mod = (await import('mysql2/promise')) as unknown as { + createPool: (config: { uri: string; connectionLimit: number }) => MysqlPool + } + const pool = mod.createPool({ uri: this.url, connectionLimit: 5 }) + await this.migrate(pool) + this.pool = pool + return pool + })() + } + return this.poolPromise + } + + private async migrate(pool: MysqlPool): Promise { + await pool.query(` + CREATE TABLE IF NOT EXISTS scenes ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + project_id VARCHAR(200) NULL, + owner_id VARCHAR(64) NULL, + thumbnail_url TEXT NULL, + version INT UNSIGNED NOT NULL, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL, + size_bytes INT UNSIGNED NOT NULL, + node_count INT UNSIGNED NOT NULL, + graph_json LONGTEXT NOT NULL, + graph_hash CHAR(64) NOT NULL, + INDEX scenes_project_updated_idx (project_id, updated_at), + INDEX scenes_owner_updated_idx (owner_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) + + await pool.query(` + CREATE TABLE IF NOT EXISTS scene_revisions ( + scene_id VARCHAR(64) NOT NULL, + version INT UNSIGNED NOT NULL, + graph_json LONGTEXT NOT NULL, + author_kind VARCHAR(32) NOT NULL, + author_id VARCHAR(64) NULL, + created_at VARCHAR(32) NOT NULL, + PRIMARY KEY (scene_id, version), + CONSTRAINT scene_revisions_scene_fk FOREIGN KEY (scene_id) + REFERENCES scenes(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) + + await pool.query(` + CREATE TABLE IF NOT EXISTS scene_events ( + event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + scene_id VARCHAR(64) NOT NULL, + version INT UNSIGNED NOT NULL, + kind VARCHAR(64) NOT NULL, + created_at VARCHAR(32) NOT NULL, + graph_json LONGTEXT NOT NULL, + INDEX scene_events_scene_event_idx (scene_id, event_id), + CONSTRAINT scene_events_scene_fk FOREIGN KEY (scene_id) + REFERENCES scenes(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) + } + + private async withWriteTransaction(fn: (conn: MysqlConnection) => Promise): Promise { + const pool = await this.database() + const conn = await pool.getConnection() + await conn.beginTransaction() + try { + const result = await fn(conn) + await conn.commit() + return result + } catch (err) { + try { + await conn.rollback() + } catch { + // Ignore rollback errors so the original failure is preserved. + } + throw err + } finally { + conn.release() + } + } + + private async getRow( + queryable: MysqlQueryable, + id: string, + opts: { forUpdate?: boolean } = {}, + ): Promise { + const [result] = await queryable.execute( + `SELECT ${SCENE_COLUMNS} FROM scenes WHERE id = ?${opts.forUpdate ? ' FOR UPDATE' : ''}`, + [id], + ) + return firstRow(result) + } + + private async generateUniqueId(queryable: MysqlQueryable): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const id = generateSlug() + if (!(await this.getRow(queryable, id))) return id + } + throw new SceneInvalidError('Failed to generate a unique scene id') + } +} diff --git a/packages/mcp/src/storage/scene-store-shared.ts b/packages/mcp/src/storage/scene-store-shared.ts new file mode 100644 index 000000000..57518b327 --- /dev/null +++ b/packages/mcp/src/storage/scene-store-shared.ts @@ -0,0 +1,90 @@ +import { createHash } from 'node:crypto' +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { z } from 'zod' +import { SceneInvalidError } from './types' + +export const DEFAULT_MAX_SCENE_BYTES = 10 * 1024 * 1024 +export const DEFAULT_LIST_LIMIT = 100 +export const MAX_NAME_LENGTH = 200 +export const MIN_NAME_LENGTH = 1 + +const GraphSchema = z.object({ + nodes: z.record(z.string(), z.unknown()), + rootNodeIds: z.array(z.string()), + collections: z.record(z.string(), z.unknown()).optional(), +}) + +export function resolveMaxSceneBytes( + env: NodeJS.ProcessEnv | undefined, + explicit: number | undefined, +): number { + if (explicit !== undefined) { + if (!Number.isInteger(explicit) || explicit <= 0) { + throw new SceneInvalidError('maxSceneBytes must be a positive integer') + } + return explicit + } + + const raw = env?.PASCAL_MAX_SCENE_BYTES + if (raw === undefined || raw === '') return DEFAULT_MAX_SCENE_BYTES + const parsed = Number.parseInt(raw, 10) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new SceneInvalidError('PASCAL_MAX_SCENE_BYTES must be a positive integer') + } + return parsed +} + +export function editorUrlForScene(id: string): string { + return `/editor/${id}` +} + +export function hashGraphJson(graphJson: string): string { + return createHash('sha256').update(graphJson).digest('hex') +} + +export function assertValidName(name: string): void { + if (typeof name !== 'string') { + throw new SceneInvalidError('Scene name must be a string') + } + const trimmed = name.trim() + if (trimmed.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) { + throw new SceneInvalidError( + `Scene name must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters (got ${name.length})`, + ) + } +} + +export function serializeGraph(graph: SceneGraph): string { + return JSON.stringify(graph) +} + +export function parseGraph(raw: string, context: string): SceneGraph { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + throw new SceneInvalidError( + `Failed to parse scene graph for ${context}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const result = GraphSchema.safeParse(parsed) + if (!result.success) { + throw new SceneInvalidError(`Scene graph for ${context} has invalid shape: ${result.error}`) + } + + const graph = result.data + for (const [nodeId, node] of Object.entries(graph.nodes)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + throw new SceneInvalidError(`Scene graph for ${context} has non-object node at "${nodeId}"`) + } + const typeField = (node as { type?: unknown }).type + if (typeof typeField !== 'string' || typeField.length === 0) { + throw new SceneInvalidError( + `Scene graph for ${context} has node "${nodeId}" missing a string "type"`, + ) + } + } + + return graph as SceneGraph +} diff --git a/packages/mcp/src/storage/sqlite-scene-store.ts b/packages/mcp/src/storage/sqlite-scene-store.ts index f705a8793..8337693c9 100644 --- a/packages/mcp/src/storage/sqlite-scene-store.ts +++ b/packages/mcp/src/storage/sqlite-scene-store.ts @@ -1,9 +1,15 @@ -import { createHash } from 'node:crypto' import { mkdirSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' -import { z } from 'zod' +import { + assertValidName, + DEFAULT_LIST_LIMIT, + editorUrlForScene, + hashGraphJson, + parseGraph, + resolveMaxSceneBytes, + serializeGraph, +} from './scene-store-shared' import { generateSlug, isValidSlug, sanitizeSlug } from './slug' import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver' import { @@ -24,11 +30,6 @@ import { type SceneWithGraph, } from './types' -const DEFAULT_MAX_SCENE_BYTES = 10 * 1024 * 1024 -const DEFAULT_LIST_LIMIT = 100 -const MAX_NAME_LENGTH = 200 -const MIN_NAME_LENGTH = 1 - export interface SqliteSceneStoreOptions { /** Exact SQLite database file path. If omitted, resolved from env. */ databasePath?: string @@ -70,12 +71,6 @@ interface ProjectPlaceholder { updatedAt: string } -const GraphSchema = z.object({ - nodes: z.record(z.string(), z.unknown()), - rootNodeIds: z.array(z.string()), - collections: z.record(z.string(), z.unknown()).optional(), -}) - /** * Resolves Pascal's local SQLite database path. * @@ -107,26 +102,6 @@ export function resolveDefaultDatabasePath(env: NodeJS.ProcessEnv = process.env) return path.join(os.homedir(), '.pascal', 'data', 'pascal.db') } -function resolveMaxSceneBytes( - env: NodeJS.ProcessEnv | undefined, - explicit: number | undefined, -): number { - if (explicit !== undefined) { - if (!Number.isInteger(explicit) || explicit <= 0) { - throw new SceneInvalidError('maxSceneBytes must be a positive integer') - } - return explicit - } - - const raw = env?.PASCAL_MAX_SCENE_BYTES - if (raw === undefined || raw === '') return DEFAULT_MAX_SCENE_BYTES - const parsed = Number.parseInt(raw, 10) - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new SceneInvalidError('PASCAL_MAX_SCENE_BYTES must be a positive integer') - } - return parsed -} - function rowToMeta(row: SceneRow): SceneMeta { const editorUrl = editorUrlForScene(row.id) return { @@ -147,14 +122,6 @@ function rowToMeta(row: SceneRow): SceneMeta { } } -function editorUrlForScene(id: string): string { - return `/editor/${id}` -} - -function hashGraphJson(graphJson: string): string { - return createHash('sha256').update(graphJson).digest('hex') -} - function rowToProjectStatus(row: SceneRow): ProjectStatus { const editorUrl = editorUrlForScene(row.id) return { @@ -203,53 +170,6 @@ function placeholderToProjectStatus(project: ProjectPlaceholder): ProjectStatus } } -function assertValidName(name: string): void { - if (typeof name !== 'string') { - throw new SceneInvalidError('Scene name must be a string') - } - const trimmed = name.trim() - if (trimmed.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) { - throw new SceneInvalidError( - `Scene name must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters (got ${name.length})`, - ) - } -} - -function serializeGraph(graph: SceneGraph): string { - return JSON.stringify(graph) -} - -function parseGraph(raw: string, context: string): SceneGraph { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (err) { - throw new SceneInvalidError( - `Failed to parse scene graph for ${context}: ${err instanceof Error ? err.message : String(err)}`, - ) - } - - const result = GraphSchema.safeParse(parsed) - if (!result.success) { - throw new SceneInvalidError(`Scene graph for ${context} has invalid shape: ${result.error}`) - } - - const graph = result.data - for (const [nodeId, node] of Object.entries(graph.nodes)) { - if (!node || typeof node !== 'object' || Array.isArray(node)) { - throw new SceneInvalidError(`Scene graph for ${context} has non-object node at "${nodeId}"`) - } - const typeField = (node as { type?: unknown }).type - if (typeof typeField !== 'string' || typeField.length === 0) { - throw new SceneInvalidError( - `Scene graph for ${context} has node "${nodeId}" missing a string "type"`, - ) - } - } - - return graph as SceneGraph -} - function asSceneRow(value: unknown): SceneRow | null { if (!value || typeof value !== 'object') return null return value as SceneRow diff --git a/packages/mcp/src/storage/types.ts b/packages/mcp/src/storage/types.ts index 0780684a2..22539be1f 100644 --- a/packages/mcp/src/storage/types.ts +++ b/packages/mcp/src/storage/types.ts @@ -119,7 +119,7 @@ export interface ProjectStatus { } export interface SceneStore { - readonly backend: 'sqlite' | 'supabase' + readonly backend: 'sqlite' | 'mysql' | 'supabase' createProject?(opts: ProjectCreateOptions): Promise getProjectStatus?(id: SceneId): Promise save(opts: SceneSaveOptions): Promise From 4368ca622756adf25e9b3482159f9801ec17cc0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:05:38 +0000 Subject: [PATCH 033/128] fix: render scene pages from the store, and index MySQL for older servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deployment failures, both invisible until the app ran behind a real domain. The scene pages fetched the app's own HTTP API from a server component. That request carries no Origin header, so the scene API answered 503 and /scene/ died with "Failed to load scene: 503" while /scenes silently rendered empty. They now call the scene operations directly, which is what the API route does anyway — one less round trip, and no authentication dance with itself. The scenes table also failed to create on servers still defaulting to the COMPACT row format: the index on project_id needs 800 bytes under utf8mb4 against a 767-byte limit. It is indexed by prefix now. Verified against a MariaDB instance forced to COMPACT: the 24 store cases pass, and /scenes and /scene/ both render with proxy headers and no Origin, with the scene name present in the listing. The package's 297 tests still pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/app/scene/[id]/page.tsx | 28 ++---------------- apps/editor/app/scenes/page.tsx | 29 ++----------------- packages/mcp/src/storage/mysql-scene-store.ts | 5 +++- 3 files changed, 10 insertions(+), 52 deletions(-) diff --git a/apps/editor/app/scene/[id]/page.tsx b/apps/editor/app/scene/[id]/page.tsx index 95df49f36..c2b0c3545 100644 --- a/apps/editor/app/scene/[id]/page.tsx +++ b/apps/editor/app/scene/[id]/page.tsx @@ -1,7 +1,7 @@ import type { SceneGraph } from '@pascal-app/editor' -import { headers } from 'next/headers' import Link from 'next/link' import { SceneLoader, type SceneMeta } from '@/components/scene-loader' +import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -9,31 +9,9 @@ interface SceneWithGraph extends SceneMeta { graph: SceneGraph } -async function resolveBaseUrl(): Promise { - if (process.env.NEXT_PUBLIC_APP_URL) { - return process.env.NEXT_PUBLIC_APP_URL - } - const h = await headers() - const host = h.get('x-forwarded-host') ?? h.get('host') - const proto = h.get('x-forwarded-proto') ?? 'http' - if (!host) { - return 'http://localhost:3000' - } - return `${proto}://${host}` -} - async function fetchScene(id: string): Promise { - const base = await resolveBaseUrl() - const response = await fetch(`${base}/api/scenes/${encodeURIComponent(id)}`, { - cache: 'no-store', - }) - if (response.status === 404) { - return null - } - if (!response.ok) { - throw new Error(`Failed to load scene: ${response.status}`) - } - return (await response.json()) as SceneWithGraph + const operations = await getSceneOperations() + return (await operations.loadStoredScene(id)) as SceneWithGraph | null } export default async function ScenePage({ params }: { params: Promise<{ id: string }> }) { diff --git a/apps/editor/app/scenes/page.tsx b/apps/editor/app/scenes/page.tsx index 39b8dbf07..91c0deea9 100644 --- a/apps/editor/app/scenes/page.tsx +++ b/apps/editor/app/scenes/page.tsx @@ -1,36 +1,13 @@ -import { headers } from 'next/headers' import Link from 'next/link' import { CreateSceneButton } from '@/components/save-button' import type { SceneMeta } from '@/components/scene-loader' +import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' -async function resolveBaseUrl(): Promise { - if (process.env.NEXT_PUBLIC_APP_URL) { - return process.env.NEXT_PUBLIC_APP_URL - } - const h = await headers() - const host = h.get('x-forwarded-host') ?? h.get('host') - const proto = h.get('x-forwarded-proto') ?? 'http' - if (!host) { - return 'http://localhost:3000' - } - return `${proto}://${host}` -} - async function fetchScenes(): Promise { - const base = await resolveBaseUrl() - const response = await fetch(`${base}/api/scenes?limit=50`, { - cache: 'no-store', - }) - if (!response.ok) { - return [] - } - const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[] - if (Array.isArray(payload)) { - return payload - } - return payload.scenes ?? [] + const operations = await getSceneOperations() + return (await operations.listScenes({ limit: 50 })) as SceneMeta[] } function formatDate(iso: string): string { diff --git a/packages/mcp/src/storage/mysql-scene-store.ts b/packages/mcp/src/storage/mysql-scene-store.ts index b667e06f8..d13198c27 100644 --- a/packages/mcp/src/storage/mysql-scene-store.ts +++ b/packages/mcp/src/storage/mysql-scene-store.ts @@ -562,7 +562,10 @@ export class MysqlSceneStore implements SceneStore { node_count INT UNSIGNED NOT NULL, graph_json LONGTEXT NOT NULL, graph_hash CHAR(64) NOT NULL, - INDEX scenes_project_updated_idx (project_id, updated_at), + -- project_id is indexed by prefix: servers still defaulting to the + -- COMPACT row format cap an index key at 767 bytes, and the whole + -- column is 800 under utf8mb4. + INDEX scenes_project_updated_idx (project_id(150), updated_at), INDEX scenes_owner_updated_idx (owner_id, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `) From 0152eae43a142034a8562da709c6211bf0b1d1ae Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:15:56 +0300 Subject: [PATCH 034/128] chore: pin plugin-warehouse at the polygon railing release Railings now follow the drawn outline in both 3D and the 2D plan, with outward normals derived from the polygon rather than its winding. Removes the dead rectangle-only span helper. 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 1a1e5b042..694e243fa 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#39514e0013e0e29f208aaf0a1a66d3984214d069", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e7b6860a38316960c789171e7714bb589bc8e850", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From c9df99cc8fe002e5829a6e56d0cab8139fa3d11c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:18:19 +0000 Subject: [PATCH 035/128] feat(mcp): log which scene backend was selected When the deployment silently kept using SQLite there was no way to tell from the logs whether PASCAL_MYSQL_URL had reached the process. The factory now says which backend it picked, with the MySQL host and database but never the password, and points at the variable when it falls back to SQLite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- packages/mcp/src/storage/index.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/storage/index.ts b/packages/mcp/src/storage/index.ts index 228ee2959..2c5f042a7 100644 --- a/packages/mcp/src/storage/index.ts +++ b/packages/mcp/src/storage/index.ts @@ -17,10 +17,28 @@ export * from './types' * hosts whose filesystem does not survive a redeploy. */ export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise { - if (resolveMysqlUrl(env ?? process.env)) { + const resolved = env ?? process.env + const mysqlUrl = resolveMysqlUrl(resolved) + if (mysqlUrl) { const mod = await import('./mysql-scene-store') - return new mod.MysqlSceneStore({ env }) + const store = new mod.MysqlSceneStore({ env }) + console.log(`[pascal:storage] backend=mysql ${describeMysqlTarget(mysqlUrl)}`) + return store } const mod = await import('./sqlite-scene-store') - return new mod.SqliteSceneStore({ env }) + const store = new mod.SqliteSceneStore({ env }) + console.log( + `[pascal:storage] backend=sqlite path=${store.databasePath} (set PASCAL_MYSQL_URL to use MySQL)`, + ) + return store +} + +/** Host and database only — the URL carries a password. */ +function describeMysqlTarget(url: string): string { + try { + const parsed = new URL(url) + return `host=${parsed.hostname}:${parsed.port || '3306'} database=${parsed.pathname.replace(/^\//, '')}` + } catch { + return 'target=unparseable' + } } From 0103c190f5bd27be18a94cac8c0241d1aeeed29c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:51:11 +0000 Subject: [PATCH 036/128] ci: rebuild and publish the deployment bundle Plugins compile into the app, so a plugin release only reaches the site through a rebuild. This runs one: install, build, complete the standalone output, flatten it to the top level, smoke test it, and force push the result to the deployment repository. Triggers on a push that touches the app or its packages, on demand, and on a repository_dispatch of type plugin-updated so the plugin repo can kick it off after a release. Needs a DEPLOY_TOKEN secret with write access to the deployment repository and read access to the plugin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- .github/deploy/README.md | 62 +++++++++++++++++++ .github/deploy/package.json | 22 +++++++ .github/workflows/deploy-bundle.yml | 94 +++++++++++++++++++++++++++++ bun.lock | 6 +- 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 .github/deploy/README.md create mode 100644 .github/deploy/package.json create mode 100644 .github/workflows/deploy-bundle.yml diff --git a/.github/deploy/README.md b/.github/deploy/README.md new file mode 100644 index 000000000..af64200e8 --- /dev/null +++ b/.github/deploy/README.md @@ -0,0 +1,62 @@ +# digitaltwin + +Pascal Editor, compiled and ready to run on Hostinger's Node.js hosting. Built +from [pascalorg/editor](https://github.com/pascalorg/editor) at commit +`08e2279`, plus the MySQL scene store from +[ovurrsl/editor](https://github.com/ovurrsl/editor). + +The application sits at the root of this repository — there is one +`package.json` and one entry point, so the host cannot pick the wrong +directory. It follows the stock sequence: `npm install` fetches the runtime +packages, `npm run build` has nothing to compile and exits cleanly, and +`server.js` starts the app. `PORT` and `HOSTNAME` are read from the +environment. + +## hPanel settings + +Everything is the default except the output directory. + +| Field | Value | +|---|---| +| Repository | `ovurrsl/digitaltwin` | +| Branch | `main` | +| Framework preset | Other | +| Root directory | `./` | +| Node.js version | 22.x | +| Package manager | npm | +| Build command | `npm run build` | +| Output directory | `./` | +| Entry file | `server.js` | + +## Environment variables + +| Name | Value | +|---|---| +| `PASCAL_MYSQL_URL` | `mysql://user:password@localhost:3306/database` | + +Without it the app falls back to a SQLite file under `~/.pascal/data`, which +the host discards on every release. With it, scenes are stored in MySQL and +survive redeploys; the three tables are created on first connection. + +Percent-encode any of `@ : / ? # [ ] %` that appear in the password — `@` +becomes `%40`, `#` becomes `%23`. + +## Layout + + server.js entry point, generated by the standalone build + package.json runtime dependencies and the build/start scripts + .next/ the compiled application + public/ static assets: models, textures, icons, sounds + +## Regenerating + +Build the source with `output: 'standalone'` in `apps/editor/next.config.ts`. +From the resulting `apps/editor/.next/standalone` tree, lift `apps/editor/.next`, +`apps/editor/public` and `apps/editor/server.js` to the top level and drop the +rest — the vendored `node_modules` is replaced by the dependency list in +`package.json`, and the app's original manifest cannot be reused because it +names workspace packages that are not published to npm. Two things the +standalone build leaves out: copy `apps/editor/public` and +`apps/editor/.next/static` into the tree before lifting, and add `mysql2` to +the dependency list by hand — the store imports it dynamically, so file +tracing does not see it. diff --git a/.github/deploy/package.json b/.github/deploy/package.json new file mode 100644 index 000000000..cef03e542 --- /dev/null +++ b/.github/deploy/package.json @@ -0,0 +1,22 @@ +{ + "name": "digitaltwin", + "version": "1.4.0", + "private": true, + "type": "module", + "description": "Pascal Editor, compiled and ready to run. Nothing is built at deploy time.", + "engines": { + "node": ">=20.9" + }, + "scripts": { + "build": "echo 'Prebuilt bundle - nothing to compile.'", + "start": "node server.js" + }, + "dependencies": { + "@opentelemetry/api": "1.9.1", + "mysql2": "3.23.2", + "next": "16.2.9", + "react": "19.2.7", + "react-dom": "19.2.7", + "sharp": "0.34.5" + } +} diff --git a/.github/workflows/deploy-bundle.yml b/.github/workflows/deploy-bundle.yml new file mode 100644 index 000000000..318983f3c --- /dev/null +++ b/.github/workflows/deploy-bundle.yml @@ -0,0 +1,94 @@ +name: Deploy bundle + +# Plugins compile into the app, so a plugin release only reaches the site +# through a rebuild. Run this after pinning a new plugin commit, or send a +# repository_dispatch from the plugin repo to have it run itself. +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'apps/editor/**' + - 'packages/**' + - 'bun.lock' + repository_dispatch: + types: [plugin-updated] + +concurrency: + group: deploy-bundle + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.0 + + # The plugin is pinned over ssh; rewrite it to an authenticated https + # URL so the runner can fetch it whether or not the repo is public. + - name: Allow git dependencies over https + run: | + git config --global \ + url."https://x-access-token:${{ secrets.DEPLOY_TOKEN }}@github.com/".insteadOf \ + "ssh://git@github.com/" + + # The hoisted linker keeps the standalone output free of symlinks, which + # break once the host moves the deployed directory. + - name: Install + run: bun install --linker=hoisted + + - name: Build + run: bunx turbo run build --filter=editor + + # The standalone output omits both of these by design. + - name: Complete the standalone output + run: | + mkdir -p apps/editor/.next/standalone/apps/editor/public \ + apps/editor/.next/standalone/apps/editor/.next/static + cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ + cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ + + # Lift the app to the top level: the host picks the directory holding + # package.json, and a nested second one sends it to the wrong place. + - name: Assemble the bundle + run: | + mkdir -p bundle + cp -a apps/editor/.next/standalone/apps/editor/.next bundle/.next + cp -a apps/editor/.next/standalone/apps/editor/public bundle/public + cp -a apps/editor/.next/standalone/apps/editor/server.js bundle/server.js + cp .github/deploy/package.json bundle/package.json + cp .github/deploy/README.md bundle/README.md + printf 'node_modules/\n' > bundle/.gitignore + + - name: Smoke test + run: | + cd bundle + npm install --no-audit --no-fund + npm run build + node server.js & + for _ in $(seq 1 30); do + sleep 2 + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/health || true) + [ "$code" = "200" ] && break + done + test "$code" = "200" || { echo "health check failed: $code"; exit 1; } + curl -sf -o /dev/null http://127.0.0.1:3000/ || { echo "home page failed"; exit 1; } + + - name: Publish + env: + DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} + run: | + cd bundle + rm -rf node_modules + git init -q + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git add -A + git commit -q -m "Build from ${GITHUB_SHA::7}" + git push -q --force \ + "https://x-access-token:${DEPLOY_TOKEN}@github.com/ovurrsl/digitaltwin.git" \ + HEAD:main diff --git a/bun.lock b/bun.lock index f815c4b01..898d68dc0 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#948da25fa88579af3f2a42e41b2ad1d6b783af24", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e7b6860a38316960c789171e7714bb589bc8e850", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", @@ -569,7 +569,7 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#948da25fa88579af3f2a42e41b2ad1d6b783af24", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "948da25fa88579af3f2a42e41b2ad1d6b783af24"], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e7b6860a38316960c789171e7714bb589bc8e850", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=0.9.2 <1", "@pascal-app/editor": ">=0.9.2 <1", "@pascal-app/viewer": ">=0.9.2 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "e7b6860a38316960c789171e7714bb589bc8e850"], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], @@ -701,7 +701,7 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], - "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], + "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pt-56d978c", "sha512-eA0vW3p4OTTpntMkcVs/okmCtaYJHCf3MpFu0VRUYIVa4U2MSEmhxEHfv45wNW0KjAVcxz3tAWJmvqGMs5ArLg=="], "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], From bf3798a3510a3ab9d78a0868d21e3b0ee653bc9c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 22:03:25 +0000 Subject: [PATCH 037/128] fix: let the live scene stream through, retry a failed pool, add discrete DB vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a scene showed "Live scene connection closed": browsers omit Origin on same-origin GETs, which is exactly how the editor opens its event stream, so the token gate answered 503. Same-origin browser requests are now recognised by Sec-Fetch-Site — sent on every browser request and not settable from script — with Origin still checked when present, scripts without either still needing the token, and cross-origin still refused. The MySQL pool also cached its first failure: a database briefly unreachable at boot poisoned the store until the process restarted. The failed promise is dropped so the next request retries. PASCAL_MYSQL_HOST/_USER/_PASSWORD/_DATABASE/_PORT now compose a connection URL when PASCAL_MYSQL_URL is absent — control panels hand out those fields individually, and some mangle values containing :// and @. Verified against MariaDB: the event stream answers 200 without an Origin header, a scripted GET still gets 503, a cross-origin POST still gets 403, a store that failed while the database was down recovers without a restart, and the discrete variables compose with a percent-encoded password. 297 package tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/lib/scene-api-security.ts | 21 ++++++++++---- packages/mcp/src/storage/mysql-scene-store.ts | 28 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts index 0bbe8e6eb..af1e0bcea 100644 --- a/apps/editor/lib/scene-api-security.ts +++ b/apps/editor/lib/scene-api-security.ts @@ -66,11 +66,11 @@ function validateAuth(request: Request): NextResponse | null { const token = process.env.PASCAL_SCENE_API_TOKEN if (!token) { if (isLoopbackRequest(request)) return null - // With no token configured the API still serves the app's own pages: the - // origin check above rejects everything cross-origin, and requests that - // carry no Origin at all — scripts, other servers — fall through to 503. - const origin = request.headers.get('origin') - if (origin && isOriginAllowed(request, origin)) return null + // With no token configured the API still serves the app's own pages. + // Everything cross-origin was already rejected above; anything that is + // neither loopback nor a same-origin browser request — scripts, other + // servers — falls through and needs the token. + if (isSameOriginBrowserRequest(request)) return null return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 }) } @@ -158,6 +158,17 @@ function isSameOrigin(request: Request, origin: string): boolean { return parsedOrigin.host.toLowerCase() === requestHost(request) } +/** + * Browsers omit Origin on same-origin GETs — which is how the editor opens its + * live scene event stream — but they do send Sec-Fetch-Site on every request, + * and it cannot be set from script. Non-browser callers send neither. + */ +function isSameOriginBrowserRequest(request: Request): boolean { + const origin = request.headers.get('origin') + if (origin) return isOriginAllowed(request, origin) + return request.headers.get('sec-fetch-site') === 'same-origin' +} + function requestHost(request: Request): string { const forwarded = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() const host = forwarded || request.headers.get('host') || new URL(request.url).host diff --git a/packages/mcp/src/storage/mysql-scene-store.ts b/packages/mcp/src/storage/mysql-scene-store.ts index d13198c27..a4cc3acdc 100644 --- a/packages/mcp/src/storage/mysql-scene-store.ts +++ b/packages/mcp/src/storage/mysql-scene-store.ts @@ -93,9 +93,24 @@ interface MysqlPool extends MysqlQueryable { const SCENE_COLUMNS = 'id, name, project_id, owner_id, thumbnail_url, version, created_at, updated_at, size_bytes, node_count, graph_hash' +/** + * Reads the connection target from `PASCAL_MYSQL_URL`, or assembles one from + * `PASCAL_MYSQL_HOST`/`_USER`/`_PASSWORD`/`_DATABASE`/`_PORT`. The separate + * variables exist because control panels hand out those fields individually, + * and some mangle a value containing `://` and `@`. + */ export function resolveMysqlUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { const raw = env.PASCAL_MYSQL_URL - return raw && raw.length > 0 ? raw : undefined + if (raw && raw.length > 0) return raw + + const host = env.PASCAL_MYSQL_HOST + const user = env.PASCAL_MYSQL_USER + const database = env.PASCAL_MYSQL_DATABASE + if (!host || !user || !database) return undefined + + const password = env.PASCAL_MYSQL_PASSWORD ?? '' + const port = env.PASCAL_MYSQL_PORT ?? '3306' + return `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}` } function rows(result: unknown): Record[] { @@ -539,7 +554,16 @@ export class MysqlSceneStore implements SceneStore { createPool: (config: { uri: string; connectionLimit: number }) => MysqlPool } const pool = mod.createPool({ uri: this.url, connectionLimit: 5 }) - await this.migrate(pool) + try { + await this.migrate(pool) + } catch (err) { + // Don't cache the failure: a database that was briefly unreachable + // at boot would otherwise poison the store until the process + // restarts. Drop the pool and let the next call retry. + this.poolPromise = null + await pool.end().catch(() => {}) + throw err + } this.pool = pool return pool })() From 0b92e68d1946f305ec80e3162d68ecf196197b1c Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:59:11 +0300 Subject: [PATCH 038/128] chore: pin plugin-warehouse at the outline editor release A selected mezzanine now shows corner handles: drag to move a corner, drag an edge midpoint to add one, Alt-click to delete. Drafts commit once on release, and editing never recentres the node so local-frame accessories stay put. 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 694e243fa..8b1adc08d 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#e7b6860a38316960c789171e7714bb589bc8e850", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#470090b3f93593e67d14b98badd0528d64bbaf9e", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 3a145fedfce7caee653e1d6ab5a727a78145278f Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:36:19 +0300 Subject: [PATCH 039/128] chore: pin plugin-warehouse at the mezzanine 2D completion release Edge-anchored stairs now sit on the representative outline edge of their cardinal, and the 2D plan gained its missing vocabulary: beam grid, swing gate arc with hinge, a distinct up-and-over symbol, safety-zone chain and dimension labels. 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 8b1adc08d..32f222e53 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#470090b3f93593e67d14b98badd0528d64bbaf9e", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#a7ae64d01ab9ce142cab5c50cb3b505bb8ee248d", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From cebff47cad8498a7ce833a7f0630bfc2af4e8c59 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:58:23 +0300 Subject: [PATCH 040/128] chore: pin plugin-warehouse at the 2D tier selector + GL2000 release The floorplan now draws the targeted tier, columns got base plates and anchors, GL2000 embeds its secondary beams and offers intumescent paint, and the remaining unread catalogue constants were either locked to the schema by tests or deleted. 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 32f222e53..c54531b92 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#a7ae64d01ab9ce142cab5c50cb3b505bb8ee248d", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#3f0d9247cc79b17182f8ce51fbf6974990425c6a", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From e721d18936fd5e451803b5baca83df31ebb7b26c Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:11:22 +0300 Subject: [PATCH 041/128] chore: pin plugin-warehouse at the panel reachability release Adds a permanent guard test asserting every schema field of every kind is editable from its panel or carries a reasoned exemption. Its first run surfaced three real gaps, all closed: rack level types got a panel editor, mezzanine profile overrides got selectors, and the inert truck pick/drop slot placeholders were deleted. 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 c54531b92..645982722 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#3f0d9247cc79b17182f8ce51fbf6974990425c6a", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#12bfc90c22f1d3f7e3d2bd985512a61e0e48f70e", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From 12788f16aeaf5ef1ef009e50e5de5fc1d65ab5e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:35:45 +0000 Subject: [PATCH 042/128] feat: require MySQL in production and persist projects in the database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app silently fell back to a local SQLite file whenever the MySQL env vars were absent, and a shared host wipes that file on every release — so a misconfigured deploy looked healthy while quietly losing every scene. - createSceneStore now refuses the SQLite fallback when NODE_ENV is production, unless PASCAL_ALLOW_SQLITE=1 is set. The error names the variables to set. - resolveMysqlUrl throws on a partially configured HOST/USER/DATABASE trio instead of degrading to SQLite, and trims empty values. - A new instrumentation.ts forces a real store connection at startup, so a bad deploy dies in the host log instead of on the first scene request; /api/health now reports {backend, db} so one curl verifies a deploy end to end. - createProject placeholders moved from a process-local Map to a project_placeholders table in both stores, so projects survive a restart and live entirely in the database. - CI gains a MySQL service: the bundle smoke test asserts backend=mysql and db=ok, and a negative check confirms the server refuses to boot with no database. Verified against MariaDB: the production gate rejects with no config and passes with the escape hatch; the strict resolver throws on a partial trio; placeholders survive a store reopen; boot exits non-zero without a database and serves with backend=mysql/db=ok with one. 306 package tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- .github/deploy/README.md | 25 ++++- .github/workflows/deploy-bundle.yml | 41 +++++++- apps/editor/app/api/health/route.ts | 32 +++++- apps/editor/instrumentation.ts | 21 ++++ apps/editor/public/pascal-logo-full.svg | 11 --- apps/editor/public/pascal-logo-shape.svg | 5 - apps/editor/public/pascal.svg | 8 -- packages/mcp/src/storage/index.ts | 24 ++++- .../mcp/src/storage/mysql-scene-store.test.ts | 75 ++++++++++++++ packages/mcp/src/storage/mysql-scene-store.ts | 97 ++++++++++++++++--- .../mcp/src/storage/sqlite-scene-store.ts | 47 ++++++++- 11 files changed, 328 insertions(+), 58 deletions(-) create mode 100644 apps/editor/instrumentation.ts delete mode 100644 apps/editor/public/pascal-logo-full.svg delete mode 100644 apps/editor/public/pascal-logo-shape.svg delete mode 100644 apps/editor/public/pascal.svg create mode 100644 packages/mcp/src/storage/mysql-scene-store.test.ts diff --git a/.github/deploy/README.md b/.github/deploy/README.md index af64200e8..637829af5 100644 --- a/.github/deploy/README.md +++ b/.github/deploy/README.md @@ -34,12 +34,27 @@ Everything is the default except the output directory. |---|---| | `PASCAL_MYSQL_URL` | `mysql://user:password@localhost:3306/database` | -Without it the app falls back to a SQLite file under `~/.pascal/data`, which -the host discards on every release. With it, scenes are stored in MySQL and -survive redeploys; the three tables are created on first connection. +or, if the panel mangles URL values, the separate fields: -Percent-encode any of `@ : / ? # [ ] %` that appear in the password — `@` -becomes `%40`, `#` becomes `%23`. +| Name | Value | +|---|---| +| `PASCAL_MYSQL_HOST` | `localhost` | +| `PASCAL_MYSQL_USER` | database user | +| `PASCAL_MYSQL_PASSWORD` | database password | +| `PASCAL_MYSQL_DATABASE` | database name | +| `PASCAL_MYSQL_PORT` | `3306` (optional) | + +**MySQL is required.** Without a database configured the server refuses to +start — check the runtime log for the reason. Tables are created on first +connection. `/api/health` reports the selected backend +(`"backend":"mysql"`) and whether the database answers (`"db":"ok"`), so one +curl verifies a deploy. Setting `PASCAL_ALLOW_SQLITE=1` overrides the +requirement, writing scenes to a local file the host discards on every +release — never set it here. + +In `PASCAL_MYSQL_URL`, percent-encode any of `@ : / ? # [ ] %` that appear in +the password — `@` becomes `%40`, `#` becomes `%23`. The separate fields need +no encoding. ## Layout diff --git a/.github/workflows/deploy-bundle.yml b/.github/workflows/deploy-bundle.yml index 318983f3c..8f7b495d7 100644 --- a/.github/workflows/deploy-bundle.yml +++ b/.github/workflows/deploy-bundle.yml @@ -21,6 +21,19 @@ concurrency: jobs: build: runs-on: ubuntu-latest + services: + mysql: + image: mysql:8 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: digitaltwin + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@v4 @@ -64,18 +77,38 @@ jobs: cp .github/deploy/README.md bundle/README.md printf 'node_modules/\n' > bundle/.gitignore - - name: Smoke test + # MySQL is required in production: with no database configured the + # server must refuse to boot rather than silently write to a local + # SQLite file the host wipes on release. + - name: Smoke test — boot without a database must fail run: | cd bundle npm install --no-audit --no-fund npm run build + set +e + timeout 20 node server.js + status=$? + set -e + if [ "$status" = "0" ] || [ "$status" = "124" ]; then + echo "server started (or kept running) without a database; expected a startup failure" + exit 1 + fi + echo "refused to boot without a database (exit $status), as intended" + + - name: Smoke test — serve against MySQL + env: + PASCAL_MYSQL_URL: mysql://root@127.0.0.1:3306/digitaltwin + run: | + cd bundle node server.js & for _ in $(seq 1 30); do sleep 2 - code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/health || true) - [ "$code" = "200" ] && break + body=$(curl -s http://127.0.0.1:3000/api/health || true) + echo "$body" | grep -q '"status":"ok"' && break done - test "$code" = "200" || { echo "health check failed: $code"; exit 1; } + echo "health: $body" + echo "$body" | grep -q '"backend":"mysql"' || { echo "expected backend=mysql"; exit 1; } + echo "$body" | grep -q '"db":"ok"' || { echo "expected db=ok"; exit 1; } curl -sf -o /dev/null http://127.0.0.1:3000/ || { echo "home page failed"; exit 1; } - name: Publish diff --git a/apps/editor/app/api/health/route.ts b/apps/editor/app/api/health/route.ts index 300230ba3..5edee5d67 100644 --- a/apps/editor/app/api/health/route.ts +++ b/apps/editor/app/api/health/route.ts @@ -1,3 +1,31 @@ -export function GET() { - return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() }) +import { getSceneStore } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' + +/** + * Exercises the scene store so one curl verifies a deploy end to end: which + * backend was selected and whether the database actually answers. + */ +export async function GET() { + try { + const store = await getSceneStore() + await store.list({ limit: 1 }) + return Response.json({ + status: 'ok', + app: 'digitaltwin', + backend: store.backend, + db: 'ok', + timestamp: new Date().toISOString(), + }) + } catch (error) { + return Response.json( + { + status: 'error', + app: 'digitaltwin', + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString(), + }, + { status: 503 }, + ) + } } diff --git a/apps/editor/instrumentation.ts b/apps/editor/instrumentation.ts new file mode 100644 index 000000000..1659a29d5 --- /dev/null +++ b/apps/editor/instrumentation.ts @@ -0,0 +1,21 @@ +/** + * Runs once per server process before any request. The scene store is lazy + * and its first consumer is a page request, so a misconfigured or unreachable + * database would otherwise keep the deploy looking healthy until someone + * opens /scenes. Force the connection here instead: a bad deploy dies at + * startup, where the host's log makes the reason obvious. + */ +export async function register() { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + const { getSceneStore } = await import('./lib/scene-store-server') + try { + const store = await getSceneStore() + await store.list({ limit: 1 }) + console.log(`[digitaltwin:boot] scene store ready backend=${store.backend}`) + } catch (err) { + console.error('[digitaltwin:boot] scene store unavailable:', err) + if (process.env.NODE_ENV === 'production') { + process.exit(1) + } + } +} diff --git a/apps/editor/public/pascal-logo-full.svg b/apps/editor/public/pascal-logo-full.svg deleted file mode 100644 index 313883578..000000000 --- a/apps/editor/public/pascal-logo-full.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/editor/public/pascal-logo-shape.svg b/apps/editor/public/pascal-logo-shape.svg deleted file mode 100644 index 94b8e41e6..000000000 --- a/apps/editor/public/pascal-logo-shape.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/editor/public/pascal.svg b/apps/editor/public/pascal.svg deleted file mode 100644 index c50259df1..000000000 --- a/apps/editor/public/pascal.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/packages/mcp/src/storage/index.ts b/packages/mcp/src/storage/index.ts index 2c5f042a7..1bc529f40 100644 --- a/packages/mcp/src/storage/index.ts +++ b/packages/mcp/src/storage/index.ts @@ -1,5 +1,5 @@ import { resolveMysqlUrl } from './mysql-scene-store' -import type { SceneStore } from './types' +import { SceneInvalidError, type SceneStore } from './types' export * from './mysql-scene-store' export * from './slug' @@ -13,8 +13,10 @@ export * from './types' * `~/.pascal/data/pascal.db`; set `PASCAL_DB_PATH` for an exact file path or * `PASCAL_DATA_DIR` for a directory containing `pascal.db`. * - * Set `PASCAL_MYSQL_URL` to store scenes in MySQL instead — the backend for - * hosts whose filesystem does not survive a redeploy. + * Set `PASCAL_MYSQL_URL` (or the HOST/USER/DATABASE trio) to store scenes in + * MySQL. In production MySQL is required: a host's filesystem rarely survives + * a redeploy, so silently writing to a local file loses every scene. Set + * `PASCAL_ALLOW_SQLITE=1` to override for throwaway production runs. */ export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise { const resolved = env ?? process.env @@ -22,13 +24,25 @@ export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise { + it('returns undefined when nothing is configured', () => { + expect(resolveMysqlUrl({})).toBeUndefined() + }) + + it('treats an empty or whitespace URL as unset', () => { + expect(resolveMysqlUrl({ PASCAL_MYSQL_URL: '' })).toBeUndefined() + expect(resolveMysqlUrl({ PASCAL_MYSQL_URL: ' ' })).toBeUndefined() + }) + + it('passes a URL through', () => { + expect(resolveMysqlUrl({ PASCAL_MYSQL_URL: 'mysql://u:p@h:3306/d' })).toBe( + 'mysql://u:p@h:3306/d', + ) + }) + + it('composes the discrete fields, percent-encoding credentials', () => { + const url = resolveMysqlUrl({ + PASCAL_MYSQL_HOST: 'db.example', + PASCAL_MYSQL_USER: 'user', + PASCAL_MYSQL_PASSWORD: 'p@ss:w/rd', + PASCAL_MYSQL_DATABASE: 'scenes', + }) + expect(url).toBe('mysql://user:p%40ss%3Aw%2Frd@db.example:3306/scenes') + }) + + it('defaults the port to 3306', () => { + const url = resolveMysqlUrl({ + PASCAL_MYSQL_HOST: 'h', + PASCAL_MYSQL_USER: 'u', + PASCAL_MYSQL_DATABASE: 'd', + }) + expect(url).toContain('@h:3306/d') + }) + + it('throws, rather than silently falling back, on a partial trio', () => { + expect(() => + resolveMysqlUrl({ PASCAL_MYSQL_HOST: 'h', PASCAL_MYSQL_USER: 'u' }), + ).toThrow(SceneInvalidError) + try { + resolveMysqlUrl({ PASCAL_MYSQL_HOST: 'h', PASCAL_MYSQL_USER: 'u' }) + } catch (err) { + expect((err as Error).message).toContain('PASCAL_MYSQL_DATABASE') + } + }) +}) + +describe('createSceneStore production gate', () => { + it('refuses to fall back to SQLite in production', async () => { + await expect(createSceneStore({ NODE_ENV: 'production', HOME: '/tmp' })).rejects.toThrow( + SceneInvalidError, + ) + }) + + it('allows SQLite in production with the explicit escape hatch', async () => { + const store = await createSceneStore({ + NODE_ENV: 'production', + PASCAL_ALLOW_SQLITE: '1', + HOME: '/tmp/dt-gate-test', + }) + expect(store.backend).toBe('sqlite') + await store.close?.() + }) + + it('uses SQLite in development without configuration', async () => { + const store = await createSceneStore({ NODE_ENV: 'development', HOME: '/tmp/dt-gate-test' }) + expect(store.backend).toBe('sqlite') + await store.close?.() + }) +}) diff --git a/packages/mcp/src/storage/mysql-scene-store.ts b/packages/mcp/src/storage/mysql-scene-store.ts index a4cc3acdc..f4bd26a7a 100644 --- a/packages/mcp/src/storage/mysql-scene-store.ts +++ b/packages/mcp/src/storage/mysql-scene-store.ts @@ -98,18 +98,39 @@ const SCENE_COLUMNS = * `PASCAL_MYSQL_HOST`/`_USER`/`_PASSWORD`/`_DATABASE`/`_PORT`. The separate * variables exist because control panels hand out those fields individually, * and some mangle a value containing `://` and `@`. + * + * A partially configured trio throws instead of returning undefined: someone + * clearly meant to point at MySQL, and silently falling back to SQLite loses + * their data on the next release. */ export function resolveMysqlUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { - const raw = env.PASCAL_MYSQL_URL - if (raw && raw.length > 0) return raw + const read = (name: string): string | undefined => { + const value = env[name]?.trim() + return value && value.length > 0 ? value : undefined + } - const host = env.PASCAL_MYSQL_HOST - const user = env.PASCAL_MYSQL_USER - const database = env.PASCAL_MYSQL_DATABASE - if (!host || !user || !database) return undefined + const raw = read('PASCAL_MYSQL_URL') + if (raw) return raw + + const host = read('PASCAL_MYSQL_HOST') + const user = read('PASCAL_MYSQL_USER') + const database = read('PASCAL_MYSQL_DATABASE') + if (!host && !user && !database) return undefined + if (!host || !user || !database) { + const missing = [ + !host && 'PASCAL_MYSQL_HOST', + !user && 'PASCAL_MYSQL_USER', + !database && 'PASCAL_MYSQL_DATABASE', + ].filter(Boolean) + throw new SceneInvalidError( + `Incomplete MySQL configuration: missing ${missing.join(', ')}. ` + + 'Set all of PASCAL_MYSQL_HOST, PASCAL_MYSQL_USER and PASCAL_MYSQL_DATABASE ' + + '(plus PASCAL_MYSQL_PASSWORD/_PORT as needed), or a single PASCAL_MYSQL_URL.', + ) + } - const password = env.PASCAL_MYSQL_PASSWORD ?? '' - const port = env.PASCAL_MYSQL_PORT ?? '3306' + const password = read('PASCAL_MYSQL_PASSWORD') ?? '' + const port = read('PASCAL_MYSQL_PORT') ?? '3306' return `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}` } @@ -216,7 +237,6 @@ export class MysqlSceneStore implements SceneStore { private readonly url: string private readonly maxSceneBytes: number - private readonly projectPlaceholders = new Map() private pool: MysqlPool | null = null private poolPromise: Promise | null = null @@ -249,7 +269,18 @@ export class MysqlSceneStore implements SceneStore { createdAt: now, updatedAt: now, } - this.projectPlaceholders.set(id, project) + try { + await pool.execute( + `INSERT INTO project_placeholders (id, name, owner_id, thumbnail_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [id, project.name, project.ownerId, project.thumbnailUrl, now, now], + ) + } catch (err) { + if ((err as { code?: string })?.code === 'ER_DUP_ENTRY') { + throw new SceneInvalidError(`Project with id "${id}" already exists`) + } + throw err + } return placeholderToProjectStatus(project) } @@ -258,7 +289,7 @@ export class MysqlSceneStore implements SceneStore { const safeId = sanitizeSlug(id) const row = await this.getRow(pool, safeId) if (row) return rowToProjectStatus(row) - const placeholder = this.projectPlaceholders.get(safeId) + const placeholder = await this.getPlaceholder(pool, safeId) return placeholder ? placeholderToProjectStatus(placeholder) : null } @@ -276,7 +307,7 @@ export class MysqlSceneStore implements SceneStore { } const existing = await this.getRow(conn, id, { forUpdate: true }) - const placeholder = this.projectPlaceholders.get(id) + const placeholder = await this.getPlaceholder(conn, id, { forUpdate: true }) if (existing && providedId !== undefined && opts.expectedVersion === undefined) { throw new SceneInvalidError( @@ -369,7 +400,7 @@ export class MysqlSceneStore implements SceneStore { [id, version, graphJson, 'mcp', ownerId, now], ) - this.projectPlaceholders.delete(id) + await conn.execute('DELETE FROM project_placeholders WHERE id = ?', [id]) return { id, @@ -608,6 +639,17 @@ export class MysqlSceneStore implements SceneStore { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `) + await pool.query(` + CREATE TABLE IF NOT EXISTS project_placeholders ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + owner_id VARCHAR(64) NULL, + thumbnail_url TEXT NULL, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) + await pool.query(` CREATE TABLE IF NOT EXISTS scene_events ( event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, @@ -643,6 +685,35 @@ export class MysqlSceneStore implements SceneStore { } } + private async getPlaceholder( + queryable: MysqlQueryable, + id: string, + opts: { forUpdate?: boolean } = {}, + ): Promise { + const [result] = await queryable.execute( + `SELECT id, name, owner_id, thumbnail_url, created_at, updated_at + FROM project_placeholders WHERE id = ?${opts.forUpdate ? ' FOR UPDATE' : ''}`, + [id], + ) + const row = firstRow<{ + id: string + name: string + owner_id: string | null + thumbnail_url: string | null + created_at: string + updated_at: string + }>(result) + if (!row) return null + return { + id: row.id, + name: row.name, + ownerId: row.owner_id, + thumbnailUrl: row.thumbnail_url, + createdAt: row.created_at, + updatedAt: row.updated_at, + } + } + private async getRow( queryable: MysqlQueryable, id: string, diff --git a/packages/mcp/src/storage/sqlite-scene-store.ts b/packages/mcp/src/storage/sqlite-scene-store.ts index 8337693c9..b2452cbfd 100644 --- a/packages/mcp/src/storage/sqlite-scene-store.ts +++ b/packages/mcp/src/storage/sqlite-scene-store.ts @@ -198,7 +198,6 @@ export class SqliteSceneStore implements SceneStore { readonly databasePath: string private readonly maxSceneBytes: number - private readonly projectPlaceholders = new Map() private db: SqliteDatabase | null = null private dbPromise: Promise | null = null @@ -227,7 +226,10 @@ export class SqliteSceneStore implements SceneStore { createdAt: now, updatedAt: now, } - this.projectPlaceholders.set(id, project) + db.query( + `INSERT INTO project_placeholders (id, name, owner_id, thumbnail_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, project.name, project.ownerId, project.thumbnailUrl, now, now) return placeholderToProjectStatus(project) } @@ -236,7 +238,7 @@ export class SqliteSceneStore implements SceneStore { const safeId = sanitizeSlug(id) const row = this.getRow(db, safeId) if (row) return rowToProjectStatus(row) - const placeholder = this.projectPlaceholders.get(safeId) + const placeholder = this.getPlaceholder(db, safeId) return placeholder ? placeholderToProjectStatus(placeholder) : null } @@ -254,7 +256,7 @@ export class SqliteSceneStore implements SceneStore { } const existing = this.getRow(db, id) - const placeholder = this.projectPlaceholders.get(id) + const placeholder = this.getPlaceholder(db, id) if (existing && providedId !== undefined && opts.expectedVersion === undefined) { throw new SceneInvalidError( @@ -340,7 +342,7 @@ export class SqliteSceneStore implements SceneStore { ) VALUES (?, ?, ?, ?, ?, ?)`, ).run(id, version, graphJson, 'mcp', ownerId, now) - this.projectPlaceholders.delete(id) + db.query('DELETE FROM project_placeholders WHERE id = ?').run(id) return { id, @@ -561,6 +563,15 @@ export class SqliteSceneStore implements SceneStore { FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS project_placeholders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT, + thumbnail_url TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS scene_events ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, scene_id TEXT NOT NULL, @@ -593,6 +604,32 @@ export class SqliteSceneStore implements SceneStore { } } + private getPlaceholder(db: SqliteDatabase, id: string): ProjectPlaceholder | null { + const row = db + .query( + `SELECT id, name, owner_id, thumbnail_url, created_at, updated_at + FROM project_placeholders + WHERE id = ?`, + ) + .get(id) as { + id: string + name: string + owner_id: string | null + thumbnail_url: string | null + created_at: string + updated_at: string + } | null + if (!row) return null + return { + id: row.id, + name: row.name, + ownerId: row.owner_id, + thumbnailUrl: row.thumbnail_url, + createdAt: row.created_at, + updatedAt: row.updated_at, + } + } + private getRow(db: SqliteDatabase, id: string): SceneRow | null { return asSceneRow( db From 9a7aca898c66dbfa1cf49f81109f54733568fdd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:36:29 +0000 Subject: [PATCH 043/128] chore: rebrand the user-visible and console surface to digitaltwin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything a visitor or a casual DevTools glance would see now reads digitaltwin instead of pascal. Internal identifiers stay untouched so upstream merges and the plugins keep working: @pascal-app/* package names, the x-pascal-scene-token header, PASCAL_* env vars, DOM data attributes, CSS class names, and persisted keys (localStorage, pascal:editor/floorplan, plugin ids, GLB userData) are all deliberately left as-is. - layout.tsx gains page metadata (there was none): title "DigitalTwin Editor". - terms/privacy: entity, product name and support email rebranded; the email is a TODO placeholder pending a real address. - plugins panel "Create a Pascal plugin", the WebGPU fallback message, and the material-picker source label now say DigitalTwin (the material source id stays 'pascal' — it is persisted). - console registry prefix [pascal:registry] -> [digitaltwin:registry]. Verified in the compiled bundle: the tab title is "DigitalTwin Editor", the old console prefix is gone, and /terms and /privacy contain no "pascal". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/app/layout.tsx | 9 +++++++ apps/editor/app/privacy/page.tsx | 14 +++++----- apps/editor/app/terms/page.tsx | 26 +++++++++---------- apps/editor/lib/bootstrap.ts | 4 +-- .../ui/controls/material-picker.tsx | 2 +- .../ui/sidebar/panels/plugins-panel.tsx | 4 +-- .../viewer/src/components/viewer/index.tsx | 2 +- 7 files changed, 35 insertions(+), 26 deletions(-) diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 01965f4ba..81a630f80 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -1,10 +1,19 @@ import { Agentation } from 'agentation' import { GeistPixelSquare } from 'geist/font/pixel' +import type { Metadata } from 'next' import { Barlow } from 'next/font/google' import localFont from 'next/font/local' import { ClientBootstrap } from './client-bootstrap' import './globals.css' +export const metadata: Metadata = { + title: { + default: 'DigitalTwin Editor', + template: '%s | DigitalTwin', + }, + description: '3D building editor', +} + const geistSans = localFont({ src: './fonts/GeistVF.woff', variable: '--font-geist-sans', diff --git a/apps/editor/app/privacy/page.tsx b/apps/editor/app/privacy/page.tsx index 450757898..c760fcd1a 100644 --- a/apps/editor/app/privacy/page.tsx +++ b/apps/editor/app/privacy/page.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' export const metadata: Metadata = { title: 'Privacy Policy', - description: 'Privacy Policy for Pascal Editor and the Pascal platform.', + description: 'Privacy Policy for the DigitalTwin editor and platform.', } export default function PrivacyPage() { @@ -39,8 +39,8 @@ export default function PrivacyPage() {

1. Introduction

- Pascal Group Inc. ("we," "us," or "our") operates the - Pascal Editor and Platform at pascal.app. This Privacy Policy explains how we collect, + DigitalTwin ("we," "us," or "our") operates the + DigitalTwin editor and platform. This Privacy Policy explains how we collect, use, and protect your information when you use our services.

@@ -151,9 +151,9 @@ export default function PrivacyPage() { To exercise any of these rights, please contact us at{' '} - support@pascal.app + support@example.com .

@@ -193,9 +193,9 @@ export default function PrivacyPage() { contact us at{' '} - support@pascal.app + support@example.com .

diff --git a/apps/editor/app/terms/page.tsx b/apps/editor/app/terms/page.tsx index f8afb3e17..99f070d4a 100644 --- a/apps/editor/app/terms/page.tsx +++ b/apps/editor/app/terms/page.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' export const metadata: Metadata = { title: 'Terms of Service', - description: 'Terms of Service for Pascal Editor and the Pascal platform.', + description: 'Terms of Service for the DigitalTwin editor and platform.', } export default function TermsPage() { @@ -39,8 +39,8 @@ export default function TermsPage() {

1. Introduction

- Welcome to Pascal Editor ("Editor") and the Pascal platform at pascal.app - ("Platform"), operated by Pascal Group Inc. ("we," "us," + Welcome to the DigitalTwin editor ("Editor") and the DigitalTwin platform + ("Platform"), operated by DigitalTwin ("we," "us," or "our"). By accessing or using our services, you agree to these Terms of Service.

@@ -49,14 +49,14 @@ export default function TermsPage() {

2. The Editor and Platform

- The Pascal Editor is open-source software released under the MIT License. You may use, + The DigitalTwin editor is open-source software released under the MIT License. You may use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Editor software in accordance with the MIT License terms.

- The Pascal platform (pascal.app) and its associated services, including user accounts, + The DigitalTwin platform and its associated services, including user accounts, cloud storage, and project hosting, are proprietary services owned and operated by - Pascal Group Inc. These Terms govern your use of the Platform. + DigitalTwin. These Terms govern your use of the Platform.

@@ -105,8 +105,8 @@ export default function TermsPage() {

6. Platform Ownership

- The Platform, including its design, features, and proprietary code, is owned by Pascal - Group Inc. and protected by intellectual property laws. While the Editor source code + The Platform, including its design, features, and proprietary code, is owned by + DigitalTwin. and protected by intellectual property laws. While the Editor source code is open-source under the MIT License, the Platform services, branding, and infrastructure remain our proprietary property.

@@ -120,9 +120,9 @@ export default function TermsPage() { may also delete your account at any time by contacting us at{' '} - support@pascal.app + support@example.com .

@@ -145,7 +145,7 @@ export default function TermsPage() {

9. Limitation of Liability

- TO THE MAXIMUM EXTENT PERMITTED BY LAW, PASCAL GROUP INC. SHALL NOT BE LIABLE FOR ANY + TO THE MAXIMUM EXTENT PERMITTED BY LAW, DIGITALTWIN SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING LOSS OF DATA, PROFITS, OR GOODWILL, ARISING FROM YOUR USE OF THE PLATFORM.

@@ -166,9 +166,9 @@ export default function TermsPage() { If you have questions about these Terms, please contact us at{' '} - support@pascal.app + support@example.com .

diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 58bb72531..cfeb33834 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -50,7 +50,7 @@ function loadBuiltinsSync(): void { const kinds = Array.from(nodeRegistry.entries(), ([k]) => k) if (typeof console !== 'undefined') { console.info( - `[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`, + `[digitaltwin:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`, ) } // Expose the registry on globalThis for ad-hoc dev inspection. In @@ -77,7 +77,7 @@ export async function loadExternalPlugins(): Promise { await loadPlugin(plugin) } if (isDev() && externals.length > 0 && typeof console !== 'undefined') { - console.info(`[pascal:registry] + ${externals.length} discovered plugin(s)`) + console.info(`[digitaltwin:registry] + ${externals.length} discovered plugin(s)`) } } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 328a63d21..cf272cf56 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -30,7 +30,7 @@ export type MaterialPickerProps = { const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ { id: 'all', label: 'All' }, - { id: 'pascal', label: 'Pascal' }, + { id: 'pascal', label: 'DigitalTwin' }, { id: 'mine', label: 'Mine' }, { id: 'workspace', label: 'Workspace' }, { id: 'community', label: 'Community' }, diff --git a/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx b/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx index 6dd265c9a..787d4b2bd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx @@ -151,7 +151,7 @@ export function PluginsPanel() { rel="noreferrer" target="_blank" > - Create a Pascal plugin + Create a DigitalTwin plugin @@ -209,7 +209,7 @@ export function PluginsPanel() { rel="noreferrer" target="_blank" > - Create a Pascal plugin + Create a DigitalTwin plugin diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 2de9cade6..91ba57042 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -111,7 +111,7 @@ function UnsupportedGpuViewerFallback() {

3D viewer unavailable

- This browser or environment does not expose WebGPU or WebGL, so Pascal cannot render the + This browser or environment does not expose WebGPU or WebGL, so DigitalTwin cannot render the 3D scene here. Try opening the editor in a browser with hardware acceleration enabled.

From d11efe5ce79ff2d974858ae880496d33d69e0f63 Mon Sep 17 00:00:00 2001 From: ovurrsl <92828257+ovurrsl@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:45:31 +0300 Subject: [PATCH 044/128] chore: pin plugin-warehouse at the slot pinning release Trucks can now pin their duty-cycle source and target slots from the panel; a pin that goes stale falls back to the deterministic draw and the panel says so. 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 645982722..a53baa567 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -13,7 +13,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#12bfc90c22f1d3f7e3d2bd985512a61e0e48f70e", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#938518f95b3005037ef7eca35c6b4745f56e2a37", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", From d2d7d2405627e4be1d1984784b47d90fcc52cb9f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:31:10 +0000 Subject: [PATCH 045/128] feat(auth): email+password sign-in on MySQL, scenes owned by users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds authentication so scenes belong to the user who creates them, using the database and infrastructure already in place — no new runtime dependency. Password hashing is node:crypto scrypt; sessions are opaque tokens stored hashed, delivered as an httpOnly SameSite=Lax cookie whose Secure flag follows x-forwarded-proto (the Hostinger proxy terminates TLS). Auth uses its own small mysql2 pool from the same PASCAL_MYSQL_URL and creates its two tables (users, user_sessions) at boot via instrumentation.ts, mirroring the scene store. - lib/auth: db (pool + migrate + authAvailable), password (hash/verify), session (token + cookie + getSessionUser), service (register/login/ session/logout + DIGITALTWIN_ADMIN_EMAIL admin seed), guard (per-scene mutation authorization). - API: POST /api/auth/register|login|logout, GET /api/auth/session — origin-guarded (no scene token), login rate-limited, force-dynamic. - Scenes: POST stamps ownerId from the session (401 when signed out); GET and /scenes list filter to the caller; PUT/PATCH/DELETE authorize against the scene's owner (403 across users, admin may edit any). Pre-existing null-owner scenes become unowned — absent from user lists, still openable by direct URL; a later admin panel manages them. - Client: SessionProvider + useSession, a dependency-free auth dialog, an AuthMenu on the editor and /scenes; create/save/save-as and the autosave path open the dialog when signed out and on a 401. - MySQL-only: with no database (SQLite dev) auth is disabled — endpoints report it, creation stays open and unowned, nothing regresses. - health reports auth: ok|disabled. Verified against MariaDB end to end: register sets a Secure cookie under forwarded https; session round-trips; a signed-in POST stamps owner_id; the list shows only the caller's scenes; a second user gets 403 deleting the first's scene; logout returns 204 and clears the session. Unit tests cover hashing and the cookie-secure logic; an env-gated integration test covers register/login/session/admin-seed. 306 mcp tests still pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/app/api/auth/login/route.ts | 67 ++++++++++ apps/editor/app/api/auth/logout/route.ts | 25 ++++ apps/editor/app/api/auth/register/route.ts | 49 +++++++ apps/editor/app/api/auth/session/route.ts | 24 ++++ apps/editor/app/api/health/route.ts | 2 + apps/editor/app/api/scenes/[id]/route.ts | 15 +++ apps/editor/app/api/scenes/route.ts | 22 ++++ apps/editor/app/client-bootstrap.tsx | 3 +- apps/editor/app/page.tsx | 6 + apps/editor/app/scenes/page.tsx | 18 ++- apps/editor/components/auth/auth-dialog.tsx | 120 ++++++++++++++++++ apps/editor/components/auth/auth-menu.tsx | 35 +++++ .../components/auth/session-provider.tsx | 80 ++++++++++++ apps/editor/components/save-button.tsx | 33 ++++- apps/editor/components/scene-loader.tsx | 10 +- apps/editor/instrumentation.ts | 13 ++ apps/editor/lib/auth/auth.integration.test.ts | 76 +++++++++++ apps/editor/lib/auth/db.ts | 90 +++++++++++++ apps/editor/lib/auth/guard.ts | 25 ++++ apps/editor/lib/auth/password.test.ts | 28 ++++ apps/editor/lib/auth/password.ts | 67 ++++++++++ apps/editor/lib/auth/service.ts | 112 ++++++++++++++++ apps/editor/lib/auth/session.test.ts | 32 +++++ apps/editor/lib/auth/session.ts | 98 ++++++++++++++ 24 files changed, 1043 insertions(+), 7 deletions(-) create mode 100644 apps/editor/app/api/auth/login/route.ts create mode 100644 apps/editor/app/api/auth/logout/route.ts create mode 100644 apps/editor/app/api/auth/register/route.ts create mode 100644 apps/editor/app/api/auth/session/route.ts create mode 100644 apps/editor/components/auth/auth-dialog.tsx create mode 100644 apps/editor/components/auth/auth-menu.tsx create mode 100644 apps/editor/components/auth/session-provider.tsx create mode 100644 apps/editor/lib/auth/auth.integration.test.ts create mode 100644 apps/editor/lib/auth/db.ts create mode 100644 apps/editor/lib/auth/guard.ts create mode 100644 apps/editor/lib/auth/password.test.ts create mode 100644 apps/editor/lib/auth/password.ts create mode 100644 apps/editor/lib/auth/service.ts create mode 100644 apps/editor/lib/auth/session.test.ts create mode 100644 apps/editor/lib/auth/session.ts diff --git a/apps/editor/app/api/auth/login/route.ts b/apps/editor/app/api/auth/login/route.ts new file mode 100644 index 000000000..bd6811283 --- /dev/null +++ b/apps/editor/app/api/auth/login/route.ts @@ -0,0 +1,67 @@ +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { authAvailable } from '@/lib/auth/db' +import { createSession, InvalidCredentialsError, loginUser } from '@/lib/auth/service' +import { setSessionCookie } from '@/lib/auth/session' +import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ + email: z.string().email().max(320), + password: z.string().min(1).max(200), +}) + +const WINDOW_MS = 60_000 +const MAX_ATTEMPTS = 10 +const attempts = new Map() + +function throttle(request: NextRequest): boolean { + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown' + const now = Date.now() + const bucket = attempts.get(ip) + if (!bucket || bucket.resetAt <= now) { + attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }) + return false + } + bucket.count++ + return bucket.count > MAX_ATTEMPTS +} + +export async function POST(request: NextRequest) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + if (!authAvailable()) { + return sceneApiJson(request, { error: 'auth_unavailable' }, { status: 503 }) + } + if (throttle(request)) { + return sceneApiJson(request, { error: 'rate_limited' }, { status: 429 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + const parsed = schema.safeParse(body) + if (!parsed.success) { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + + try { + const user = await loginUser(parsed.data) + const token = await createSession(user.id) + await setSessionCookie(token) + return sceneApiJson(request, { user }, { status: 200 }) + } catch (err) { + if (err instanceof InvalidCredentialsError) { + return sceneApiJson(request, { error: 'invalid_credentials' }, { status: 401 }) + } + const message = err instanceof Error ? err.message : 'unexpected_error' + return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 }) + } +} diff --git a/apps/editor/app/api/auth/logout/route.ts b/apps/editor/app/api/auth/logout/route.ts new file mode 100644 index 000000000..9f51627fa --- /dev/null +++ b/apps/editor/app/api/auth/logout/route.ts @@ -0,0 +1,25 @@ +import { cookies } from 'next/headers' +import { type NextRequest, NextResponse } from 'next/server' +import { destroySession } from '@/lib/auth/service' +import { clearSessionCookie, SESSION_COOKIE } from '@/lib/auth/session' +import { guardSceneApiRequest, withSceneApiHeaders } from '@/lib/scene-api-security' + +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + + const token = (await cookies()).get(SESSION_COOKIE)?.value + if (token) { + try { + await destroySession(token) + } catch { + // Clearing the cookie below still signs the browser out even if the + // row delete fails; the session simply expires server-side. + } + } + await clearSessionCookie() + // 204 must not carry a body — return an empty response, not JSON null. + return withSceneApiHeaders(request, new NextResponse(null, { status: 204 })) +} diff --git a/apps/editor/app/api/auth/register/route.ts b/apps/editor/app/api/auth/register/route.ts new file mode 100644 index 000000000..07a350552 --- /dev/null +++ b/apps/editor/app/api/auth/register/route.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { authAvailable } from '@/lib/auth/db' +import { createSession, EmailTakenError, registerUser } from '@/lib/auth/service' +import { setSessionCookie } from '@/lib/auth/session' +import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ + email: z.string().email().max(320), + password: z.string().min(8).max(200), +}) + +export async function POST(request: NextRequest) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + if (!authAvailable()) { + return sceneApiJson(request, { error: 'auth_unavailable' }, { status: 503 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + const parsed = schema.safeParse(body) + if (!parsed.success) { + return sceneApiJson( + request, + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + try { + const user = await registerUser(parsed.data) + const token = await createSession(user.id) + await setSessionCookie(token) + return sceneApiJson(request, { user }, { status: 201 }) + } catch (err) { + if (err instanceof EmailTakenError) { + return sceneApiJson(request, { error: 'email_taken' }, { status: 409 }) + } + const message = err instanceof Error ? err.message : 'unexpected_error' + return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 }) + } +} diff --git a/apps/editor/app/api/auth/session/route.ts b/apps/editor/app/api/auth/session/route.ts new file mode 100644 index 000000000..aa8e2f914 --- /dev/null +++ b/apps/editor/app/api/auth/session/route.ts @@ -0,0 +1,24 @@ +import type { NextRequest } from 'next/server' +import { authAvailable } from '@/lib/auth/db' +import { getSessionUser } from '@/lib/auth/session' +import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' + +export const dynamic = 'force-dynamic' + +/** + * Returns the current user, or null when signed out or auth is unavailable + * (SQLite dev). Always 200 so the client hook stays simple. + */ +export async function GET(request: NextRequest) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + if (!authAvailable()) { + return sceneApiJson(request, { user: null }, { headers: { 'Cache-Control': 'no-store' } }) + } + try { + const user = await getSessionUser() + return sceneApiJson(request, { user }, { headers: { 'Cache-Control': 'no-store' } }) + } catch { + return sceneApiJson(request, { user: null }, { headers: { 'Cache-Control': 'no-store' } }) + } +} diff --git a/apps/editor/app/api/health/route.ts b/apps/editor/app/api/health/route.ts index 5edee5d67..55a4086ef 100644 --- a/apps/editor/app/api/health/route.ts +++ b/apps/editor/app/api/health/route.ts @@ -1,3 +1,4 @@ +import { authAvailable } from '@/lib/auth/db' import { getSceneStore } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -15,6 +16,7 @@ export async function GET() { app: 'digitaltwin', backend: store.backend, db: 'ok', + auth: authAvailable() ? 'ok' : 'disabled', timestamp: new Date().toISOString(), }) } catch (error) { diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index 1712ad4ad..30f3d3db2 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { authorizeSceneMutation } from '@/lib/auth/guard' import { apiGraphSchema } from '@/lib/graph-schema' import { guardSceneApiRequest, @@ -83,6 +84,8 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { if (!existing) { return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } + const auth = await authorizeSceneMutation(existing.ownerId) + if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status }) const meta = await operations.saveScene({ id, name: parsed.data.name ?? existing.name, @@ -110,6 +113,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const operations = await getSceneOperations() try { + const existing = await operations.loadStoredScene(id) + if (!existing) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + const auth = await authorizeSceneMutation(existing.ownerId) + if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status }) const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch }) if (!removed) { return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) @@ -151,6 +160,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const operations = await getSceneOperations() try { + const existing = await operations.loadStoredScene(id) + if (!existing) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + const auth = await authorizeSceneMutation(existing.ownerId) + if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status }) const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion }) return sceneApiJson(request, meta, { headers: { ETag: `"${meta.version}"` }, diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts index 01ffc3ba8..122b45aec 100644 --- a/apps/editor/app/api/scenes/route.ts +++ b/apps/editor/app/api/scenes/route.ts @@ -1,5 +1,7 @@ import type { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { authAvailable } from '@/lib/auth/db' +import { getSessionUser } from '@/lib/auth/session' import { apiGraphSchema } from '@/lib/graph-schema' import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security' import { getSceneOperations } from '@/lib/scene-store-server' @@ -40,9 +42,19 @@ export async function GET(request: NextRequest) { ) } + // With auth on, a signed-in user sees only their own scenes and a signed-out + // caller sees none. Without auth (SQLite dev), the list stays unfiltered. + let ownerId: string | undefined + if (authAvailable()) { + const user = await getSessionUser() + if (!user) return sceneApiJson(request, { scenes: [] }) + ownerId = user.id + } + const operations = await getSceneOperations() const scenes = await operations.listScenes({ projectId: parsed.data.projectId, + ownerId, limit: parsed.data.limit, }) return sceneApiJson(request, { scenes }) @@ -52,6 +64,15 @@ export async function POST(request: NextRequest) { const guard = guardSceneApiRequest(request) if (guard) return guard + // With auth on, creating a scene requires being signed in and stamps the + // owner. Without auth (SQLite dev), creation stays open and unowned. + let ownerId: string | undefined + if (authAvailable()) { + const user = await getSessionUser() + if (!user) return sceneApiJson(request, { error: 'auth_required' }, { status: 401 }) + ownerId = user.id + } + let body: unknown try { body = await request.json() @@ -78,6 +99,7 @@ export async function POST(request: NextRequest) { id: parsed.data.id, name: parsed.data.name, projectId: parsed.data.projectId ?? null, + ownerId, graph: parsed.data.graph as never, thumbnailUrl: parsed.data.thumbnailUrl ?? null, }) diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx index 821544fec..70a7018f5 100644 --- a/apps/editor/app/client-bootstrap.tsx +++ b/apps/editor/app/client-bootstrap.tsx @@ -10,6 +10,7 @@ // idempotent under HMR. import '../lib/bootstrap' import { type ReactNode, useEffect } from 'react' +import { SessionProvider } from '@/components/auth/session-provider' export function ClientBootstrap({ children, @@ -22,5 +23,5 @@ export function ClientBootstrap({ if (!enableDevDiagnostics) return import('react-scan').then(({ scan }) => scan({ enabled: true })) }, [enableDevDiagnostics]) - return children + return {children} } diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index e4ccf7ed3..9993f2a1a 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -4,6 +4,7 @@ import { Editor, ItemsPanel } from '@pascal-app/editor' import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' +import { AuthMenu } from '@/components/auth/auth-menu' import { BuildTab } from '@/components/build-tab' import { CommunityViewerToolbarLeft, @@ -105,6 +106,11 @@ export default function Home() { )} +
+
+ +
+
{ + // With auth on, list only the signed-in user's scenes; signed-out shows + // none. Without auth (SQLite dev), list everything. + let ownerId: string | undefined + if (authAvailable()) { + const user = await getSessionUser() + if (!user) return [] + ownerId = user.id + } const operations = await getSceneOperations() - return (await operations.listScenes({ limit: 50 })) as SceneMeta[] + return (await operations.listScenes({ ownerId, limit: 50 })) as SceneMeta[] } function formatDate(iso: string): string { @@ -35,7 +46,10 @@ export default async function ScenesPage() { / Scenes - +
+ + +
diff --git a/apps/editor/components/auth/auth-dialog.tsx b/apps/editor/components/auth/auth-dialog.tsx new file mode 100644 index 000000000..5eca2ba67 --- /dev/null +++ b/apps/editor/components/auth/auth-dialog.tsx @@ -0,0 +1,120 @@ +'use client' + +import { type FormEvent, useState } from 'react' + +interface AuthDialogProps { + onClose: () => void + onSuccess: () => void +} + +/** + * A dependency-free modal (fixed overlay + centered card) using the same + * Tailwind tokens as the rest of the editor, rather than pulling in a dialog + * library. Sign in / register in one card. + */ +export function AuthDialog({ onClose, onSuccess }: AuthDialogProps) { + const [mode, setMode] = useState<'login' | 'register'>('login') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + const res = await fetch(`/api/auth/${mode}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (res.ok) { + onSuccess() + return + } + const body = (await res.json().catch(() => ({}))) as { error?: string } + setError(messageFor(body.error, res.status)) + } catch { + setError('Something went wrong. Please try again.') + } finally { + setBusy(false) + } + } + + return ( +
+
e.stopPropagation()} + > +
+ + +
+ +
+ setEmail(e.target.value)} + className="rounded-md border border-border bg-transparent px-3 py-2 text-sm" + /> + setPassword(e.target.value)} + className="rounded-md border border-border bg-transparent px-3 py-2 text-sm" + /> + {error &&

{error}

} + +
+
+
+ ) +} + +function messageFor(error: string | undefined, status: number): string { + switch (error) { + case 'invalid_credentials': + return 'Invalid email or password.' + case 'email_taken': + return 'That email is already registered.' + case 'auth_unavailable': + return 'Sign-in is not available on this deployment.' + case 'rate_limited': + return 'Too many attempts. Please wait a minute.' + case 'invalid_request': + return 'Please enter a valid email and a password of at least 8 characters.' + default: + return `Something went wrong (${status}).` + } +} diff --git a/apps/editor/components/auth/auth-menu.tsx b/apps/editor/components/auth/auth-menu.tsx new file mode 100644 index 000000000..b9944d624 --- /dev/null +++ b/apps/editor/components/auth/auth-menu.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useSession } from './session-provider' + +/** Header control: sign-in button when signed out, email + sign-out when in. */ +export function AuthMenu() { + const { user, loading, openAuth, signOut } = useSession() + + if (loading) return null + + if (!user) { + return ( + + ) + } + + return ( +
+ {user.email} + +
+ ) +} diff --git a/apps/editor/components/auth/session-provider.tsx b/apps/editor/components/auth/session-provider.tsx new file mode 100644 index 000000000..41ccdf901 --- /dev/null +++ b/apps/editor/components/auth/session-provider.tsx @@ -0,0 +1,80 @@ +'use client' + +import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react' +import { AuthDialog } from './auth-dialog' + +export interface SessionUser { + id: string + email: string + role: 'user' | 'admin' +} + +interface SessionValue { + user: SessionUser | null + loading: boolean + refresh: () => Promise + signOut: () => Promise + /** Opens the sign-in dialog; used by gated actions when signed out or on 401. */ + openAuth: () => void +} + +const SessionContext = createContext(null) + +export function SessionProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + + const refresh = useCallback(async () => { + try { + const res = await fetch('/api/auth/session', { cache: 'no-store' }) + const body = (await res.json()) as { user: SessionUser | null } + setUser(body.user ?? null) + } catch { + setUser(null) + } finally { + setLoading(false) + } + }, []) + + const signOut = useCallback(async () => { + await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}) + setUser(null) + }, []) + + const openAuth = useCallback(() => setDialogOpen(true), []) + + useEffect(() => { + void refresh() + }, [refresh]) + + return ( + + {children} + {dialogOpen && ( + setDialogOpen(false)} + onSuccess={() => { + setDialogOpen(false) + void refresh() + }} + /> + )} + + ) +} + +export function useSession(): SessionValue { + const ctx = useContext(SessionContext) + if (!ctx) { + // Rendered outside the provider (shouldn't happen); degrade to signed-out. + return { + user: null, + loading: false, + refresh: async () => {}, + signOut: async () => {}, + openAuth: () => {}, + } + } + return ctx +} diff --git a/apps/editor/components/save-button.tsx b/apps/editor/components/save-button.tsx index bd227b83a..7325609bf 100644 --- a/apps/editor/components/save-button.tsx +++ b/apps/editor/components/save-button.tsx @@ -3,6 +3,7 @@ import type { SceneGraph } from '@pascal-app/editor' import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' +import { useSession } from '@/components/auth/session-provider' const EMPTY_GRAPH: SceneGraph = { nodes: {}, @@ -21,10 +22,15 @@ interface SaveButtonProps { */ export function CreateSceneButton({ label = 'Create new scene' }: { label?: string } = {}) { const router = useRouter() + const { user, openAuth } = useSession() const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) const handleCreate = useCallback(async () => { + if (!user) { + openAuth() + return + } setIsCreating(true) setError(null) try { @@ -33,6 +39,10 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Untitled scene', graph: EMPTY_GRAPH }), }) + if (response.status === 401) { + openAuth() + return + } if (!response.ok) { setError(`Failed to create scene (${response.status})`) return @@ -44,7 +54,7 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri } finally { setIsCreating(false) } - }, [router]) + }, [router, user, openAuth]) return (
@@ -68,10 +78,15 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri */ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps) { const router = useRouter() + const { user, openAuth } = useSession() const [isSaving, setIsSaving] = useState(false) const [status, setStatus] = useState(null) const handleSave = useCallback(async () => { + if (!user) { + openAuth() + return + } const graph = getGraph() if (!graph) { setStatus('No scene to save') @@ -88,6 +103,10 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps }, body: JSON.stringify({ name, graph }), }) + if (response.status === 401) { + openAuth() + return + } if (response.status === 409) { setStatus('Conflict — reload to continue') return @@ -102,9 +121,13 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps } finally { setIsSaving(false) } - }, [getGraph, name, sceneId, version]) + }, [getGraph, name, sceneId, version, user, openAuth]) const handleSaveAs = useCallback(async () => { + if (!user) { + openAuth() + return + } const graph = getGraph() if (!graph) { setStatus('No scene to save') @@ -120,6 +143,10 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, graph }), }) + if (response.status === 401) { + openAuth() + return + } if (!response.ok) { setStatus(`Save-as failed (${response.status})`) return @@ -131,7 +158,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps } finally { setIsSaving(false) } - }, [getGraph, name, router]) + }, [getGraph, name, router, user, openAuth]) return (
diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 9b3b6797b..ba79c5174 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -14,6 +14,7 @@ import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' +import { useSession } from '@/components/auth/session-provider' import { BuildTab } from './build-tab' import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar' @@ -99,6 +100,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const suppressRemoteSaveUntilRef = useRef(0) const [conflict, setConflict] = useState(false) const [saveError, setSaveError] = useState(null) + const { openAuth } = useSession() const handleLoad = useCallback(async () => initialScene, [initialScene]) @@ -133,6 +135,12 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { return } + if (response.status === 401) { + setSaveError('Sign in to save your changes.') + openAuth() + return + } + if (!response.ok) { setSaveError(`Save failed (${response.status})`) return @@ -145,7 +153,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { setSaveError(error instanceof Error ? error.message : 'Save failed') } }, - [meta.id, meta.name], + [meta.id, meta.name, openAuth], ) useEffect(() => { diff --git a/apps/editor/instrumentation.ts b/apps/editor/instrumentation.ts index 1659a29d5..7a966e741 100644 --- a/apps/editor/instrumentation.ts +++ b/apps/editor/instrumentation.ts @@ -18,4 +18,17 @@ export async function register() { process.exit(1) } } + + const { authAvailable, migrateAuth } = await import('./lib/auth/db') + if (authAvailable()) { + try { + await migrateAuth() + console.log('[digitaltwin:boot] auth tables ready') + } catch (err) { + console.error('[digitaltwin:boot] auth unavailable:', err) + if (process.env.NODE_ENV === 'production') { + process.exit(1) + } + } + } } diff --git a/apps/editor/lib/auth/auth.integration.test.ts b/apps/editor/lib/auth/auth.integration.test.ts new file mode 100644 index 000000000..6662995a4 --- /dev/null +++ b/apps/editor/lib/auth/auth.integration.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { getAuthPool, migrateAuth } from './db' +import { + createSession, + destroySession, + EmailTakenError, + InvalidCredentialsError, + loginUser, + registerUser, +} from './service' +import { hashToken } from './session' + +// Runs only when a MySQL target is configured, mirroring the scene-store +// integration approach. In CI/dev: PASCAL_MYSQL_URL=mysql://root@127.0.0.1:3306/dt_test +const hasDb = Boolean(process.env.PASCAL_MYSQL_URL || process.env.PASCAL_MYSQL_HOST) + +describe.skipIf(!hasDb)('auth service (integration)', () => { + beforeAll(async () => { + await migrateAuth() + const pool = await getAuthPool() + await pool.query('DELETE FROM user_sessions') + await pool.query('DELETE FROM users') + }) + + afterAll(async () => { + const pool = await getAuthPool() + await pool.end() + }) + + const email = 'tester@example.com' + const password = 'super-secret-1' + + it('registers a user and rejects a duplicate email (case-insensitive)', async () => { + const user = await registerUser({ email, password }) + expect(user.email).toBe(email) + expect(user.role).toBe('user') + await expect(registerUser({ email: 'TESTER@example.com', password })).rejects.toBeInstanceOf( + EmailTakenError, + ) + }) + + it('logs in with the right password and rejects the wrong one', async () => { + const user = await loginUser({ email, password }) + expect(user.email).toBe(email) + await expect(loginUser({ email, password: 'nope' })).rejects.toBeInstanceOf( + InvalidCredentialsError, + ) + await expect(loginUser({ email: 'ghost@example.com', password })).rejects.toBeInstanceOf( + InvalidCredentialsError, + ) + }) + + it('creates and destroys a session row keyed by token hash', async () => { + const user = await loginUser({ email, password }) + const token = await createSession(user.id) + const pool = await getAuthPool() + const [rows] = await pool.execute('SELECT user_id FROM user_sessions WHERE token_hash = ?', [ + hashToken(token), + ]) + expect(Array.isArray(rows) && rows.length).toBe(1) + await destroySession(token) + const [after] = await pool.execute('SELECT user_id FROM user_sessions WHERE token_hash = ?', [ + hashToken(token), + ]) + expect(Array.isArray(after) && after.length).toBe(0) + }) + + it('seeds the admin role from DIGITALTWIN_ADMIN_EMAIL on login', async () => { + const adminEmail = 'admin@example.com' + await registerUser({ email: adminEmail, password }) + process.env.DIGITALTWIN_ADMIN_EMAIL = adminEmail + const promoted = await loginUser({ email: adminEmail, password }) + expect(promoted.role).toBe('admin') + process.env.DIGITALTWIN_ADMIN_EMAIL = undefined + }) +}) diff --git a/apps/editor/lib/auth/db.ts b/apps/editor/lib/auth/db.ts new file mode 100644 index 000000000..f72636d0c --- /dev/null +++ b/apps/editor/lib/auth/db.ts @@ -0,0 +1,90 @@ +import { resolveMysqlUrl } from '@pascal-app/mcp/storage' + +/** + * Auth stores users and sessions in the same MySQL database as scenes, but + * through its own small pool rather than reaching into the scene store's + * private one. mysql2 is a dynamic import (never a static one) so it stays a + * traceable runtime dependency in the standalone bundle, exactly as the scene + * store does it. node:crypto covers hashing, so auth adds no new dependency. + */ + +interface MysqlQueryable { + query(sql: string, values?: unknown[]): Promise<[unknown, unknown]> + execute(sql: string, values?: unknown[]): Promise<[unknown, unknown]> +} + +export interface MysqlPool extends MysqlQueryable { + end(): Promise +} + +let pool: MysqlPool | null = null +let poolPromise: Promise | null = null + +/** True when a MySQL target is configured. Auth is unavailable without one. */ +export function authAvailable(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(resolveMysqlUrl(env)) +} + +export async function getAuthPool(): Promise { + if (pool) return pool + if (!poolPromise) { + poolPromise = (async () => { + const url = resolveMysqlUrl(process.env) + if (!url) { + throw new Error('Auth requires a MySQL connection (PASCAL_MYSQL_URL or the trio).') + } + const mod = (await import('mysql2/promise')) as unknown as { + createPool: (config: { uri: string; connectionLimit: number }) => MysqlPool + } + const created = mod.createPool({ uri: url, connectionLimit: 3 }) + try { + await migrate(created) + } catch (err) { + // Don't cache a failed pool: a database briefly unreachable at boot + // would otherwise poison auth until the process restarts. + poolPromise = null + await created.end().catch(() => {}) + throw err + } + pool = created + return created + })() + } + return poolPromise +} + +/** Creates the auth tables. Safe to call repeatedly. */ +export async function migrateAuth(): Promise { + await getAuthPool() +} + +async function migrate(p: MysqlPool): Promise { + await p.query(` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + email VARCHAR(320) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('user','admin') NOT NULL DEFAULT 'user', + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL, + -- email is normalized to lowercase in app code before every insert and + -- lookup, so a plain unique prefix index enforces case-insensitive + -- uniqueness. 191 keeps the key under the 767-byte COMPACT-row cap. + UNIQUE KEY users_email_uidx (email(191)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) + + await p.query(` + CREATE TABLE IF NOT EXISTS user_sessions ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + token_hash CHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + created_at VARCHAR(32) NOT NULL, + expires_at VARCHAR(32) NOT NULL, + UNIQUE KEY user_sessions_token_uidx (token_hash), + INDEX user_sessions_user_idx (user_id), + CONSTRAINT user_sessions_user_fk FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) +} diff --git a/apps/editor/lib/auth/guard.ts b/apps/editor/lib/auth/guard.ts new file mode 100644 index 000000000..99f907352 --- /dev/null +++ b/apps/editor/lib/auth/guard.ts @@ -0,0 +1,25 @@ +import { authAvailable } from './db' +import { getSessionUser, type SessionUser } from './session' + +export type MutationAuth = + | { ok: true; user: SessionUser | null } + | { ok: false; status: 401 | 403; error: string } + +/** + * Authorizes a write against an existing scene. + * + * - Auth off (SQLite dev): always allowed, no identity. + * - Signed out: 401. + * - Owned by someone else and caller is not an admin: 403. + * - Unowned (legacy null-owner scenes) or owned by the caller: allowed. An + * admin may write any scene. + */ +export async function authorizeSceneMutation(ownerId: string | null): Promise { + if (!authAvailable()) return { ok: true, user: null } + const user = await getSessionUser() + if (!user) return { ok: false, status: 401, error: 'auth_required' } + if (ownerId && ownerId !== user.id && user.role !== 'admin') { + return { ok: false, status: 403, error: 'forbidden' } + } + return { ok: true, user } +} diff --git a/apps/editor/lib/auth/password.test.ts b/apps/editor/lib/auth/password.test.ts new file mode 100644 index 000000000..44a6683f1 --- /dev/null +++ b/apps/editor/lib/auth/password.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'bun:test' +import { hashPassword, verifyPassword } from './password' + +describe('hashPassword / verifyPassword', () => { + it('produces a self-describing salted hash', async () => { + const hash = await hashPassword('correct horse battery staple') + expect(hash.startsWith('scrypt$16384$8$1$')).toBe(true) + expect(hash.split('$')).toHaveLength(6) + }) + + it('salts: the same password hashes differently each time', async () => { + const a = await hashPassword('same-password') + const b = await hashPassword('same-password') + expect(a).not.toBe(b) + }) + + it('accepts the right password and rejects the wrong one', async () => { + const hash = await hashPassword('s3cret-password') + expect(await verifyPassword('s3cret-password', hash)).toBe(true) + expect(await verifyPassword('wrong-password', hash)).toBe(false) + }) + + it('returns false for malformed stored hashes instead of throwing', async () => { + for (const bad of ['', 'notscrypt', 'scrypt$1$2', 'scrypt$x$8$1$aaaa$bbbb', 'a$b$c$d$e$f']) { + expect(await verifyPassword('whatever', bad)).toBe(false) + } + }) +}) diff --git a/apps/editor/lib/auth/password.ts b/apps/editor/lib/auth/password.ts new file mode 100644 index 000000000..24585e021 --- /dev/null +++ b/apps/editor/lib/auth/password.ts @@ -0,0 +1,67 @@ +import { randomBytes, type ScryptOptions, scrypt as scryptCb, timingSafeEqual } from 'node:crypto' +import { promisify } from 'node:util' + +const scrypt = promisify(scryptCb) as ( + password: string | Buffer, + salt: string | Buffer, + keylen: number, + options: ScryptOptions, +) => Promise + +const KEYLEN = 64 +// N=16384, r=8, p=1 is a well-established interactive-login cost (~bcrypt-10). +const N = 16384 +const R = 8 +const P = 1 +const MAXMEM = 64 * 1024 * 1024 + +/** + * Returns a self-describing hash: `scrypt$N$r$p$$`. + * Encoding the parameters lets us raise the cost later without breaking old + * hashes. + */ +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16) + const derived = await scrypt(password, salt, KEYLEN, { N, r: R, p: P, maxmem: MAXMEM }) + return `scrypt$${N}$${R}$${P}$${salt.toString('base64url')}$${derived.toString('base64url')}` +} + +/** + * Constant-time verification. Returns false for any malformed encoded string + * rather than throwing, so a corrupt row can never crash a login. + */ +export async function verifyPassword(password: string, stored: string): Promise { + const parts = stored.split('$') + if (parts.length !== 6 || parts[0] !== 'scrypt') return false + const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts as [ + string, + string, + string, + string, + string, + string, + ] + const n = Number.parseInt(nRaw, 10) + const r = Number.parseInt(rRaw, 10) + const p = Number.parseInt(pRaw, 10) + if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false + + let salt: Buffer + let expected: Buffer + try { + salt = Buffer.from(saltRaw, 'base64url') + expected = Buffer.from(hashRaw, 'base64url') + } catch { + return false + } + if (salt.length === 0 || expected.length === 0) return false + + let derived: Buffer + try { + derived = await scrypt(password, salt, expected.length, { N: n, r, p, maxmem: MAXMEM }) + } catch { + return false + } + if (derived.length !== expected.length) return false + return timingSafeEqual(derived, expected) +} diff --git a/apps/editor/lib/auth/service.ts b/apps/editor/lib/auth/service.ts new file mode 100644 index 000000000..61ae63048 --- /dev/null +++ b/apps/editor/lib/auth/service.ts @@ -0,0 +1,112 @@ +import { generateSlug } from '@pascal-app/mcp/storage' +import { getAuthPool } from './db' +import { hashPassword, verifyPassword } from './password' +import { hashToken, newToken, type SessionUser, sessionExpiry } from './session' + +export class EmailTakenError extends Error { + readonly code = 'email_taken' + constructor() { + super('That email is already registered.') + } +} + +export class InvalidCredentialsError extends Error { + readonly code = 'invalid_credentials' + constructor() { + super('Invalid email or password.') + } +} + +/** Lowercase + trim so lookups and the unique index are case-insensitive. */ +function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +/** The first admin: whoever registers or signs in as DIGITALTWIN_ADMIN_EMAIL. */ +function isAdminEmail(email: string): boolean { + const configured = process.env.DIGITALTWIN_ADMIN_EMAIL?.trim().toLowerCase() + return Boolean(configured) && configured === email +} + +interface UserRow { + id: string + email: string + password_hash: string + role: 'user' | 'admin' +} + +export async function registerUser(input: { + email: string + password: string +}): Promise { + const email = normalizeEmail(input.email) + const pool = await getAuthPool() + const id = generateSlug() + const now = new Date().toISOString() + const passwordHash = await hashPassword(input.password) + const role = isAdminEmail(email) ? 'admin' : 'user' + + try { + await pool.execute( + `INSERT INTO users (id, email, password_hash, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [id, email, passwordHash, role, now, now], + ) + } catch (err) { + if ((err as { code?: string })?.code === 'ER_DUP_ENTRY') { + throw new EmailTakenError() + } + throw err + } + return { id, email, role } +} + +export async function loginUser(input: { email: string; password: string }): Promise { + const email = normalizeEmail(input.email) + const pool = await getAuthPool() + const [rows] = await pool.execute( + 'SELECT id, email, password_hash, role FROM users WHERE email = ?', + [email], + ) + const row = Array.isArray(rows) && rows.length > 0 ? (rows[0] as UserRow) : null + + // Verify against the stored hash when present, otherwise against a throwaway + // hash, so a missing email and a wrong password take the same time and the + // response can't be used to enumerate accounts. + const ok = await verifyPassword( + input.password, + row?.password_hash ?? + 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ) + if (!row || !ok) throw new InvalidCredentialsError() + + // Promote the configured admin if they registered before the env was set. + let role = row.role + if (isAdminEmail(email) && role !== 'admin') { + await pool.execute('UPDATE users SET role = ?, updated_at = ? WHERE id = ?', [ + 'admin', + new Date().toISOString(), + row.id, + ]) + role = 'admin' + } + + return { id: row.id, email: row.email, role } +} + +export async function createSession(userId: string): Promise { + const pool = await getAuthPool() + const token = newToken() + const now = new Date() + await pool.execute( + `INSERT INTO user_sessions (id, token_hash, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?, ?)`, + [generateSlug(), hashToken(token), userId, now.toISOString(), sessionExpiry(now)], + ) + return token +} + +export async function destroySession(token: string): Promise { + const pool = await getAuthPool() + await pool.execute('DELETE FROM user_sessions WHERE token_hash = ?', [hashToken(token)]) +} diff --git a/apps/editor/lib/auth/session.test.ts b/apps/editor/lib/auth/session.test.ts new file mode 100644 index 000000000..829cdf574 --- /dev/null +++ b/apps/editor/lib/auth/session.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'bun:test' +import { hashToken, isSecureScheme, newToken } from './session' + +describe('hashToken / newToken', () => { + it('hashes deterministically to 64 hex chars', () => { + expect(hashToken('abc')).toBe(hashToken('abc')) + expect(hashToken('abc')).toMatch(/^[0-9a-f]{64}$/) + expect(hashToken('abc')).not.toBe(hashToken('abd')) + }) + + it('mints distinct opaque tokens', () => { + expect(newToken()).not.toBe(newToken()) + expect(newToken().length).toBeGreaterThan(30) + }) +}) + +describe('isSecureScheme', () => { + it('is secure when the proxy reports https', () => { + expect(isSecureScheme('https', 'development')).toBe(true) + expect(isSecureScheme('https,http', 'development')).toBe(true) + }) + + it('is secure in production regardless of scheme', () => { + expect(isSecureScheme(null, 'production')).toBe(true) + expect(isSecureScheme('http', 'production')).toBe(true) + }) + + it('is not secure on plain http in development, so localhost login works', () => { + expect(isSecureScheme('http', 'development')).toBe(false) + expect(isSecureScheme(null, 'development')).toBe(false) + }) +}) diff --git a/apps/editor/lib/auth/session.ts b/apps/editor/lib/auth/session.ts new file mode 100644 index 000000000..35c7c9588 --- /dev/null +++ b/apps/editor/lib/auth/session.ts @@ -0,0 +1,98 @@ +import { createHash, randomBytes } from 'node:crypto' +import { cookies, headers } from 'next/headers' +import { getAuthPool } from './db' + +export const SESSION_COOKIE = 'dt_session' +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days + +export interface SessionUser { + id: string + email: string + role: 'user' | 'admin' +} + +/** A raw token is delivered to the browser; only its hash is ever stored. */ +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +export function newToken(): string { + return randomBytes(32).toString('base64url') +} + +/** + * Secure must be off on localhost http (or login can't set a cookie in dev) + * and on in production. Hostinger terminates TLS at its proxy, so the internal + * request scheme is http — we detect the real scheme from x-forwarded-proto, + * the same header the scene API uses for its origin check. + */ +export function isSecureScheme(forwardedProto: string | null, nodeEnv: string | undefined): boolean { + const proto = forwardedProto?.split(',')[0]?.trim() + return proto === 'https' || nodeEnv === 'production' +} + +export async function cookieSecure(): Promise { + const h = await headers() + return isSecureScheme(h.get('x-forwarded-proto'), process.env.NODE_ENV) +} + +export async function setSessionCookie(token: string): Promise { + const store = await cookies() + store.set(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + secure: await cookieSecure(), + path: '/', + maxAge: SESSION_TTL_MS / 1000, + }) +} + +export async function clearSessionCookie(): Promise { + const store = await cookies() + store.set(SESSION_COOKIE, '', { + httpOnly: true, + sameSite: 'lax', + secure: await cookieSecure(), + path: '/', + maxAge: 0, + }) +} + +export function sessionExpiry(now = new Date()): string { + return new Date(now.getTime() + SESSION_TTL_MS).toISOString() +} + +interface SessionRow { + user_id: string + email: string + role: 'user' | 'admin' + expires_at: string +} + +/** + * Resolves the signed-in user from the session cookie, or null. Expired + * sessions are treated as signed-out and lazily deleted. + */ +export async function getSessionUser(): Promise { + const store = await cookies() + const token = store.get(SESSION_COOKIE)?.value + if (!token) return null + + const pool = await getAuthPool() + const [rows] = await pool.execute( + `SELECT s.user_id, s.expires_at, u.email, u.role + FROM user_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?`, + [hashToken(token)], + ) + const row = Array.isArray(rows) && rows.length > 0 ? (rows[0] as SessionRow) : null + if (!row) return null + + if (new Date(row.expires_at).getTime() <= Date.now()) { + await pool.execute('DELETE FROM user_sessions WHERE token_hash = ?', [hashToken(token)]) + return null + } + + return { id: row.user_id, email: row.email, role: row.role } +} From 69292ca9ec892f13e3c4660e526ed9280fa10c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:41:24 +0000 Subject: [PATCH 046/128] feat(admin): admin panel for users and scene ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admin-only /admin page (any other visitor gets a 404) that lists users and scenes and manages access, built on the role column and session already in place. - Users table: email, role, scene count, join date; promote/demote a user's role. An admin can't demote themselves out of the panel. - Scenes table: every scene with its owner; reassign a scene to any user or make it unowned; one-click adopt of all legacy null-owner scenes to the admin — the migration path promised when ownership landed. - API under /api/admin/* is guarded by role admin (403 otherwise); the page redirects non-admins to a 404. - AuthMenu shows an Admin link only to admins. Verified against MariaDB: a non-admin gets 404 on /admin and 403 on the APIs; DIGITALTWIN_ADMIN_EMAIL makes the first account admin; promote, self-demote guard, adopt-unowned, and scene reassignment (valid, invalid owner, make-unowned) all behave. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/app/admin/page.tsx | 47 +++++ .../app/api/admin/scenes/[id]/owner/route.ts | 39 ++++ .../api/admin/scenes/adopt-unowned/route.ts | 33 +++ .../app/api/admin/users/[id]/role/route.ts | 37 ++++ apps/editor/components/admin/admin-panel.tsx | 190 ++++++++++++++++++ apps/editor/components/auth/auth-menu.tsx | 5 + apps/editor/lib/auth/admin.ts | 89 ++++++++ 7 files changed, 440 insertions(+) create mode 100644 apps/editor/app/admin/page.tsx create mode 100644 apps/editor/app/api/admin/scenes/[id]/owner/route.ts create mode 100644 apps/editor/app/api/admin/scenes/adopt-unowned/route.ts create mode 100644 apps/editor/app/api/admin/users/[id]/role/route.ts create mode 100644 apps/editor/components/admin/admin-panel.tsx create mode 100644 apps/editor/lib/auth/admin.ts diff --git a/apps/editor/app/admin/page.tsx b/apps/editor/app/admin/page.tsx new file mode 100644 index 000000000..47119c225 --- /dev/null +++ b/apps/editor/app/admin/page.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { AdminPanel, type AdminScene } from '@/components/admin/admin-panel' +import { listUsers, ownerEmails, requireAdmin } from '@/lib/auth/admin' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' + +export default async function AdminPage() { + // Admin-only. Non-admins (and signed-out visitors) get a 404 so the page's + // existence isn't advertised. + const admin = await requireAdmin() + if (!admin) notFound() + + const [users, operations] = await Promise.all([listUsers(), getSceneOperations()]) + const scenes = await operations.listScenes({ limit: 500 }) + const emails = await ownerEmails(scenes.map((s) => s.ownerId).filter((x): x is string => !!x)) + + const adminScenes: AdminScene[] = scenes.map((s) => ({ + id: s.id, + name: s.name, + ownerId: s.ownerId, + ownerEmail: s.ownerId ? (emails.get(s.ownerId) ?? null) : null, + updatedAt: s.updatedAt, + nodeCount: s.nodeCount, + })) + + return ( +
+
+
+ + {admin.email} +
+
+
+ +
+
+ ) +} diff --git a/apps/editor/app/api/admin/scenes/[id]/owner/route.ts b/apps/editor/app/api/admin/scenes/[id]/owner/route.ts new file mode 100644 index 000000000..9b7c82b4f --- /dev/null +++ b/apps/editor/app/api/admin/scenes/[id]/owner/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { reassignScene, requireAdmin, userExists } from '@/lib/auth/admin' +import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ ownerId: z.string().min(1).max(64).nullable() }) + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + const admin = await requireAdmin() + if (!admin) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 }) + + const { id } = await params + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + const parsed = schema.safeParse(body) + if (!parsed.success) { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + + const operations = await getSceneOperations() + const scene = await operations.loadStoredScene(id) + if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + + if (parsed.data.ownerId && !(await userExists(parsed.data.ownerId))) { + return sceneApiJson(request, { error: 'owner_not_found' }, { status: 400 }) + } + + await reassignScene(id, parsed.data.ownerId) + return sceneApiJson(request, { ok: true }) +} diff --git a/apps/editor/app/api/admin/scenes/adopt-unowned/route.ts b/apps/editor/app/api/admin/scenes/adopt-unowned/route.ts new file mode 100644 index 000000000..21866b332 --- /dev/null +++ b/apps/editor/app/api/admin/scenes/adopt-unowned/route.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { adoptUnownedScenes, requireAdmin, userExists } from '@/lib/auth/admin' +import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ ownerId: z.string().min(1).max(64) }) + +/** Adopts every legacy null-owner scene to one user. */ +export async function POST(request: NextRequest) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + const admin = await requireAdmin() + if (!admin) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 }) + + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + const parsed = schema.safeParse(body) + if (!parsed.success) { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + if (!(await userExists(parsed.data.ownerId))) { + return sceneApiJson(request, { error: 'owner_not_found' }, { status: 400 }) + } + + const adopted = await adoptUnownedScenes(parsed.data.ownerId) + return sceneApiJson(request, { ok: true, adopted }) +} diff --git a/apps/editor/app/api/admin/users/[id]/role/route.ts b/apps/editor/app/api/admin/users/[id]/role/route.ts new file mode 100644 index 000000000..7130509e1 --- /dev/null +++ b/apps/editor/app/api/admin/users/[id]/role/route.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { requireAdmin, setUserRole, userExists } from '@/lib/auth/admin' +import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ role: z.enum(['user', 'admin']) }) + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + const admin = await requireAdmin() + if (!admin) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 }) + + const { id } = await params + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + const parsed = schema.safeParse(body) + if (!parsed.success) { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + if (id === admin.id && parsed.data.role !== 'admin') { + // Don't let an admin lock themselves out of the panel. + return sceneApiJson(request, { error: 'cannot_demote_self' }, { status: 400 }) + } + if (!(await userExists(id))) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + + await setUserRole(id, parsed.data.role) + return sceneApiJson(request, { ok: true }) +} diff --git a/apps/editor/components/admin/admin-panel.tsx b/apps/editor/components/admin/admin-panel.tsx new file mode 100644 index 000000000..3a623e953 --- /dev/null +++ b/apps/editor/components/admin/admin-panel.tsx @@ -0,0 +1,190 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +interface AdminUser { + id: string + email: string + role: 'user' | 'admin' + createdAt: string + sceneCount: number +} + +export interface AdminScene { + id: string + name: string + ownerId: string | null + ownerEmail: string | null + updatedAt: string + nodeCount: number +} + +export function AdminPanel({ + users, + scenes, + currentAdminId, +}: { + users: AdminUser[] + scenes: AdminScene[] + currentAdminId: string +}) { + const router = useRouter() + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + + async function call(url: string, body: unknown, key: string) { + setBusy(key) + setError(null) + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string } + setError(b.error ?? `Request failed (${res.status})`) + return + } + router.refresh() + } catch { + setError('Something went wrong.') + } finally { + setBusy(null) + } + } + + const unownedCount = scenes.filter((s) => !s.ownerId).length + + return ( +
+ {error && ( +

+ {error} +

+ )} + +
+

Users ({users.length})

+
+ + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
EmailRoleScenesJoined +
{u.email} + {u.role} + {u.sceneCount} + {new Date(u.createdAt).toLocaleDateString()} + + {u.id === currentAdminId ? ( + you + ) : ( + + )} +
+
+
+ +
+
+

Scenes ({scenes.length})

+ {unownedCount > 0 && ( + + )} +
+
+ + + + + + + + + + + {scenes.map((s) => ( + + + + + + + + ))} + +
SceneOwnerNodesUpdated +
+ + {s.name} + + + {s.ownerEmail ?? (s.ownerId ? s.ownerId : unowned)} + {s.nodeCount} + {new Date(s.updatedAt).toLocaleDateString()} + + +
+
+
+
+ ) +} diff --git a/apps/editor/components/auth/auth-menu.tsx b/apps/editor/components/auth/auth-menu.tsx index b9944d624..42de46ff7 100644 --- a/apps/editor/components/auth/auth-menu.tsx +++ b/apps/editor/components/auth/auth-menu.tsx @@ -22,6 +22,11 @@ export function AuthMenu() { return (
+ {user.role === 'admin' && ( + + Admin + + )} {user.email}
@@ -63,9 +65,13 @@ export default async function ScenesPage() { {scenes.length === 0 ? (
-

You haven't saved any scenes yet.

-
+

+ You haven't saved any scenes yet. Start from scratch, or import an IFC model + exported from Revit, ArchiCAD or similar. +

+
+
) : ( diff --git a/apps/editor/components/ifc-import-button.tsx b/apps/editor/components/ifc-import-button.tsx new file mode 100644 index 000000000..9a46b5ef9 --- /dev/null +++ b/apps/editor/components/ifc-import-button.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useCallback, useRef, useState } from 'react' +import { useSession } from '@/components/auth/session-provider' + +/** + * Imports an IFC building model as a new scene. + * + * Conversion runs in the browser: web-ifc is a WASM parser, and a model is + * routinely tens of megabytes, so shipping the bytes to the server first would + * cost an upload for work the page can do itself. Only the converted scene + * graph is posted, through the same endpoint as "Create new scene", so the + * result is owned by the signed-in user and stored like any other scene. + * + * The converter and its WASM are pulled in on first use rather than imported + * at the top, keeping roughly a megabyte and a half out of the initial load + * for everyone who never imports a model. + */ +export function IfcImportButton() { + const router = useRouter() + const { user, openAuth } = useSession() + const inputRef = useRef(null) + const [status, setStatus] = useState(null) + const [error, setError] = useState(null) + + const handleFile = useCallback( + async (file: File) => { + setError(null) + setStatus('Reading file…') + try { + const bytes = new Uint8Array(await file.arrayBuffer()) + + setStatus('Loading converter…') + const { convertIfcToPascal } = await import('@pascal-app/ifc-converter') + + const graph = await convertIfcToPascal(bytes, (message, percent) => { + setStatus(`${message} (${percent}%)`) + }) + + const nodeCount = Object.keys(graph.nodes).length + if (nodeCount === 0) { + setError('No convertible elements were found in that file.') + return + } + + setStatus(`Saving ${nodeCount} elements…`) + const response = await fetch('/api/scenes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: file.name.replace(/\.ifc$/i, '') || 'Imported model', + graph: { nodes: graph.nodes, rootNodeIds: graph.rootNodeIds }, + }), + }) + if (response.status === 401) { + openAuth() + return + } + if (!response.ok) { + setError(`Could not save the imported scene (${response.status})`) + return + } + const meta = (await response.json()) as { id: string } + router.push(`/scene/${meta.id}`) + } catch (err) { + // A conversion failure is the expected case for an unusual export, so + // surface what the parser said rather than a generic message. + setError(err instanceof Error ? err.message : 'Could not read that IFC file.') + } finally { + setStatus(null) + } + }, + [router, openAuth], + ) + + const busy = status !== null + + return ( +
+ { + const file = event.target.files?.[0] + // Clear the input so picking the same file twice still fires. + event.target.value = '' + if (file) void handleFile(file) + }} + ref={inputRef} + type="file" + /> + + {status && {status}} + {error && {error}} +
+ ) +} diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index c814dc9e2..446f7d214 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -24,6 +24,7 @@ const nextConfig: NextConfig = { '@pascal-app/core', '@pascal-app/editor', '@pascal-app/mcp', + '@pascal-app/ifc-converter', '@pascal-app/plugin-trees', '@ovurrsl/plugin-warehouse', '@dgreenheck/ez-tree', @@ -41,6 +42,13 @@ const nextConfig: NextConfig = { bodySizeLimit: '100mb', }, }, + // web-ifc parses IFC in a WASM module. It is fetched from the app's own + // origin (scripts/copy-web-ifc-wasm.mjs puts the blobs in public/), which + // keeps `WebAssembly.instantiateStreaming` happy about the MIME type. + webpack: (config) => { + config.experiments = { ...config.experiments, asyncWebAssembly: true } + return config + }, images: { unoptimized: process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false, remotePatterns: [ diff --git a/apps/editor/package.json b/apps/editor/package.json index c6ed01bc0..a20479271 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -4,7 +4,9 @@ "type": "module", "private": true, "scripts": { + "predev": "node scripts/copy-web-ifc-wasm.mjs", "dev": "next dev --port 3002", + "prebuild": "node scripts/copy-web-ifc-wasm.mjs", "build": "next build", "start": "next start", "lint": "biome lint", @@ -16,6 +18,7 @@ "@ovurrsl/plugin-warehouse": "github:ovurrsl/plugin-warehouse#938518f95b3005037ef7eca35c6b4745f56e2a37", "@pascal-app/core": "*", "@pascal-app/editor": "*", + "@pascal-app/ifc-converter": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", @@ -34,6 +37,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "three": "^0.185.0", + "web-ifc": "^0.0.77", "zod": "^4.3.5" }, "devDependencies": { diff --git a/apps/editor/public/.gitignore b/apps/editor/public/.gitignore new file mode 100644 index 000000000..681508c03 --- /dev/null +++ b/apps/editor/public/.gitignore @@ -0,0 +1,4 @@ +# Copied from node_modules/web-ifc/ by scripts/copy-web-ifc-wasm.mjs +# (runs on predev / prebuild). +web-ifc.wasm +web-ifc-mt.wasm diff --git a/apps/editor/scripts/copy-web-ifc-wasm.mjs b/apps/editor/scripts/copy-web-ifc-wasm.mjs new file mode 100644 index 000000000..3d589c269 --- /dev/null +++ b/apps/editor/scripts/copy-web-ifc-wasm.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// web-ifc ships its WASM binaries inside node_modules. Next.js needs to +// serve them at the app root URL (the library hardcodes `/web-ifc.wasm` +// when no `wasmPath` override is set), so copy the three blobs into +// `public/` so they're served from /web-ifc*.wasm. +// +// Run on `postinstall` and again on `predev` / `prebuild` so a forgotten +// install step doesn't leave the dev server with a stale or missing +// copy. Idempotent: skips files that already match by size. + +import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +// web-ifc's package.json doesn't expose subpath exports, so we can't use +// require.resolve('web-ifc/package.json'). Walk up the script directory +// looking for the package folder inside any node_modules along the way. +function findWebIfcDir(startDir) { + let dir = startDir + while (dir && dir !== '/') { + const candidate = join(dir, 'node_modules', 'web-ifc') + if (existsSync(join(candidate, 'web-ifc.wasm'))) return candidate + dir = resolve(dir, '..') + } + return null +} + +const webIfcDir = findWebIfcDir(import.meta.dirname) +if (!webIfcDir) { + console.warn('[editor] web-ifc package not found — wasm copy skipped.') + process.exit(0) +} +const publicDir = join(import.meta.dirname, '..', 'public') + +mkdirSync(publicDir, { recursive: true }) + +// Browser blobs only — `web-ifc-node.wasm` is never fetched by a page and +// would add a megabyte of dead weight to the deployed bundle. +const files = ['web-ifc.wasm', 'web-ifc-mt.wasm'] +for (const name of files) { + const src = join(webIfcDir, name) + const dst = join(publicDir, name) + try { + const srcSize = statSync(src).size + let dstSize = 0 + try { + dstSize = statSync(dst).size + } catch { + /* not present yet */ + } + if (srcSize === dstSize) { + continue + } + copyFileSync(src, dst) + console.log(`[editor] copied ${name} (${(srcSize / 1024).toFixed(0)} KB)`) + } catch (err) { + console.warn(`[editor] could not copy ${name}:`, err.message) + } +} From 02da3fb244393678ef721d00d5e79ce38c3ccf84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 10:23:14 +0000 Subject: [PATCH 054/128] feat(mcp): serve MCP per user, granted from the admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An AI assistant can now edit scenes through the same operations the editor uses, over /api/mcp. The MCP tools have no notion of a user: left alone they write scenes with no owner, which appear in nobody's list yet can be opened and changed by anyone holding the link — a hole straight through the ownership rules. So access is per person. A bearer token, issued from the admin panel, resolves to a user, and a wrapper binds the scene store to them: writes are stamped with their id, lists are confined to what they own, and someone else's scene reads as missing rather than forbidden so an agent cannot probe for ids. Admins get no bypass here. They can already reach every scene through the panel; letting an agent inherit that would mean one leaked token edits the whole installation. Only the sha256 of a token is stored, as for sessions, and granting again replaces the previous token so access removed from a machine cannot be resurrected by an older copy. Sessions hold the agent's working scene between requests, dropped on idle and capped, since the host is one long-lived process. Not yet exercised against a live client — the endpoint compiles and the panel wiring is in place, but the round trip is untested. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/app/admin/page.tsx | 14 ++- .../app/api/admin/users/[id]/mcp/route.ts | 47 ++++++++ apps/editor/app/api/mcp/route.ts | 52 +++++++++ apps/editor/components/admin/admin-panel.tsx | 35 +++++- apps/editor/lib/auth/db.ts | 18 +++ apps/editor/lib/mcp/owner-scoped-store.ts | 96 ++++++++++++++++ apps/editor/lib/mcp/sessions.ts | 102 +++++++++++++++++ apps/editor/lib/mcp/tokens.ts | 104 ++++++++++++++++++ 8 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 apps/editor/app/api/admin/users/[id]/mcp/route.ts create mode 100644 apps/editor/app/api/mcp/route.ts create mode 100644 apps/editor/lib/mcp/owner-scoped-store.ts create mode 100644 apps/editor/lib/mcp/sessions.ts create mode 100644 apps/editor/lib/mcp/tokens.ts diff --git a/apps/editor/app/admin/page.tsx b/apps/editor/app/admin/page.tsx index 47119c225..f5cbe00db 100644 --- a/apps/editor/app/admin/page.tsx +++ b/apps/editor/app/admin/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { notFound } from 'next/navigation' import { AdminPanel, type AdminScene } from '@/components/admin/admin-panel' import { listUsers, ownerEmails, requireAdmin } from '@/lib/auth/admin' +import { listMcpGrants } from '@/lib/mcp/tokens' import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -12,7 +13,12 @@ export default async function AdminPage() { const admin = await requireAdmin() if (!admin) notFound() - const [users, operations] = await Promise.all([listUsers(), getSceneOperations()]) + const [users, operations, grants] = await Promise.all([ + listUsers(), + getSceneOperations(), + listMcpGrants(), + ]) + const withMcp = new Set(grants.map((g) => g.userId)) const scenes = await operations.listScenes({ limit: 500 }) const emails = await ownerEmails(scenes.map((s) => s.ownerId).filter((x): x is string => !!x)) @@ -40,7 +46,11 @@ export default async function AdminPage() {
- + ({ ...u, mcpEnabled: withMcp.has(u.id) }))} + />
) diff --git a/apps/editor/app/api/admin/users/[id]/mcp/route.ts b/apps/editor/app/api/admin/users/[id]/mcp/route.ts new file mode 100644 index 000000000..cd691c894 --- /dev/null +++ b/apps/editor/app/api/admin/users/[id]/mcp/route.ts @@ -0,0 +1,47 @@ +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { requireAdmin, userExists } from '@/lib/auth/admin' +import { grantMcpAccess, revokeMcpAccess } from '@/lib/mcp/tokens' +import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' + +export const dynamic = 'force-dynamic' + +const schema = z.object({ enabled: z.boolean() }) + +/** + * Grants or revokes a user's agent (MCP) access. + * + * A grant returns the raw token once. It is never readable again — only its + * hash is stored — so the panel has to show it immediately and the admin has + * to pass it on. Granting twice replaces the previous token rather than adding + * one, so access removed from a machine cannot be resurrected by an older copy. + */ +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = guardSceneApiRequest(request, { skipAuth: true }) + if (guard) return guard + const admin = await requireAdmin() + if (!admin) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 }) + + const { id } = await params + let body: unknown + try { + body = await request.json() + } catch { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + const parsed = schema.safeParse(body) + if (!parsed.success) { + return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) + } + if (!(await userExists(id))) { + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + } + + if (!parsed.data.enabled) { + await revokeMcpAccess(id) + return sceneApiJson(request, { ok: true }) + } + + const token = await grantMcpAccess(id) + return sceneApiJson(request, { ok: true, token }) +} diff --git a/apps/editor/app/api/mcp/route.ts b/apps/editor/app/api/mcp/route.ts new file mode 100644 index 000000000..e6e8647c8 --- /dev/null +++ b/apps/editor/app/api/mcp/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server' +import { createSession, getSession } from '@/lib/mcp/sessions' +import { bearerToken, userForMcpToken } from '@/lib/mcp/tokens' + +export const dynamic = 'force-dynamic' +// The transport streams and holds per-session state in memory, both of which +// need the Node runtime. +export const runtime = 'nodejs' + +/** + * Model Context Protocol endpoint, so an AI assistant can edit scenes through + * the same operations the editor uses. + * + * Access is per user and granted from the admin panel: the bearer token + * resolves to a person, and every scene the agent touches is scoped to them. + * An agent therefore has exactly the reach of the account behind its token — + * no more, and never the ownerless free-for-all the MCP tools would produce on + * their own. + * + * A desktop MCP client sends no cookie and no Origin, so the usual same-origin + * check does not apply here; the token is the whole of the authentication, and + * it is the only credential this route accepts. + */ +async function handle(request: Request): Promise { + const user = await userForMcpToken(bearerToken(request)) + if (!user) { + return NextResponse.json( + { error: 'mcp_access_required' }, + // WWW-Authenticate tells a compliant client this is an auth failure it + // can act on, not a server fault to retry. + { status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="digitaltwin-mcp"' } }, + ) + } + + const sessionId = request.headers.get('mcp-session-id') + if (sessionId) { + const existing = getSession(sessionId, user.id) + if (!existing) { + // Expired, swept, or someone else's. Say so rather than silently opening + // a fresh one, so the client re-initializes instead of losing edits. + return NextResponse.json({ error: 'mcp_session_expired' }, { status: 404 }) + } + return existing.transport.handleRequest(request) + } + + const session = await createSession(user) + return session.transport.handleRequest(request) +} + +export const GET = handle +export const POST = handle +export const DELETE = handle diff --git a/apps/editor/components/admin/admin-panel.tsx b/apps/editor/components/admin/admin-panel.tsx index 3a623e953..0ec40aa90 100644 --- a/apps/editor/components/admin/admin-panel.tsx +++ b/apps/editor/components/admin/admin-panel.tsx @@ -9,6 +9,7 @@ interface AdminUser { role: 'user' | 'admin' createdAt: string sceneCount: number + mcpEnabled: boolean } export interface AdminScene { @@ -32,8 +33,11 @@ export function AdminPanel({ const router = useRouter() const [busy, setBusy] = useState(null) const [error, setError] = useState(null) + // An issued agent token is readable exactly once; hold it here so the admin + // can copy it before it becomes a hash and nothing else. + const [issued, setIssued] = useState<{ email: string; token: string } | null>(null) - async function call(url: string, body: unknown, key: string) { + async function call(url: string, body: unknown, key: string): Promise> { setBusy(key) setError(null) try { @@ -42,19 +46,30 @@ export function AdminPanel({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) + const parsed = (await res.json().catch(() => ({}))) as Record if (!res.ok) { - const b = (await res.json().catch(() => ({}))) as { error?: string } - setError(b.error ?? `Request failed (${res.status})`) - return + setError((parsed.error as string) ?? `Request failed (${res.status})`) + return {} } router.refresh() + return parsed } catch { setError('Something went wrong.') + return {} } finally { setBusy(null) } } + async function toggleMcp(user: AdminUser) { + const result = await call( + `/api/admin/users/${user.id}/mcp`, + { enabled: !user.mcpEnabled }, + `mcp-${user.id}`, + ) + if (typeof result.token === 'string') setIssued({ email: user.email, token: result.token }) + } + const unownedCount = scenes.filter((s) => !s.ownerId).length return ( @@ -75,6 +90,7 @@ export function AdminPanel({ Role Scenes Joined + AI access @@ -89,6 +105,17 @@ export function AdminPanel({ {new Date(u.createdAt).toLocaleDateString()} + + + {u.mcpEnabled && on} + {u.id === currentAdminId ? ( you diff --git a/apps/editor/lib/auth/db.ts b/apps/editor/lib/auth/db.ts index 05f9d61cd..1a6fa6b00 100644 --- a/apps/editor/lib/auth/db.ts +++ b/apps/editor/lib/auth/db.ts @@ -87,4 +87,22 @@ async function migrate(p: MysqlPool): Promise { REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `) + + await p.query(` + CREATE TABLE IF NOT EXISTS mcp_tokens ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + -- Only the sha256 of the token is stored, as for sessions: a leaked + -- database row must not hand out working agent access. + token_hash CHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + created_at VARCHAR(32) NOT NULL, + last_used_at VARCHAR(32) NULL, + UNIQUE KEY mcp_tokens_token_uidx (token_hash), + -- One live token per user: granting again replaces the old one, so a + -- revoked laptop cannot keep editing. + UNIQUE KEY mcp_tokens_user_uidx (user_id), + CONSTRAINT mcp_tokens_user_fk FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) } diff --git a/apps/editor/lib/mcp/owner-scoped-store.ts b/apps/editor/lib/mcp/owner-scoped-store.ts new file mode 100644 index 000000000..7ac5babaf --- /dev/null +++ b/apps/editor/lib/mcp/owner-scoped-store.ts @@ -0,0 +1,96 @@ +import { SceneNotFoundError } from '@pascal-app/mcp/storage' +import type { + SceneEvent, + SceneEventAppendOptions, + SceneEventListOptions, + SceneId, + SceneListOptions, + SceneMeta, + SceneMutateOptions, + SceneSaveOptions, + SceneStore, + SceneWithGraph, +} from '@pascal-app/mcp/storage/types' + +/** + * Wraps the scene store so an agent acts as one person rather than as nobody. + * + * The MCP tools have no notion of a user — left alone they write scenes with + * no owner, which appear in nobody's list yet can be opened and changed by + * anyone holding the link. This binds every call to the user whose token + * authenticated the request: writes are stamped with their id, reads and lists + * are confined to what they own, and someone else's scene is reported missing + * rather than refused, so an agent cannot probe for scenes it may not touch. + * + * An admin is deliberately not given a bypass here. Admins can already reach + * every scene through the panel; letting an agent inherit that would mean a + * single leaked token edits the whole installation. + */ +export function ownerScopedStore(store: SceneStore, ownerId: string): SceneStore { + const ownsOrMissing = async (id: SceneId): Promise => { + const scene = await store.load(id) + // Same error for absent and not-yours: a different message would let an + // agent enumerate which scene ids exist. + if (!scene || (scene.ownerId != null && scene.ownerId !== ownerId)) { + throw new SceneNotFoundError() + } + return scene + } + + const scoped: SceneStore = { + backend: store.backend, + + save(opts: SceneSaveOptions): Promise { + return store.save({ ...opts, ownerId }) + }, + + async load(id: SceneId): Promise { + const scene = await store.load(id) + if (!scene) return null + if (scene.ownerId != null && scene.ownerId !== ownerId) return null + return scene + }, + + list(opts?: SceneListOptions): Promise { + return store.list({ ...opts, ownerId }) + }, + + async delete(id: SceneId, opts?: SceneMutateOptions): Promise { + await ownsOrMissing(id) + return store.delete(id, opts) + }, + + async rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise { + await ownsOrMissing(id) + return store.rename(id, newName, opts) + }, + } + + // Optional members are forwarded only when the backend has them, so + // `'createProject' in store` stays an honest capability check downstream. + if (store.createProject) { + scoped.createProject = (opts) => + store.createProject?.(opts) as ReturnType> + } + if (store.getProjectStatus) { + scoped.getProjectStatus = (id) => + store.getProjectStatus?.(id) as ReturnType> + } + if (store.appendSceneEvent) { + scoped.appendSceneEvent = async (opts: SceneEventAppendOptions): Promise => { + await ownsOrMissing(opts.sceneId) + return store.appendSceneEvent?.(opts) as Promise + } + } + if (store.listSceneEvents) { + scoped.listSceneEvents = async ( + sceneId: SceneId, + opts?: SceneEventListOptions, + ): Promise => { + await ownsOrMissing(sceneId) + return store.listSceneEvents?.(sceneId, opts) as Promise + } + } + + return scoped +} diff --git a/apps/editor/lib/mcp/sessions.ts b/apps/editor/lib/mcp/sessions.ts new file mode 100644 index 000000000..5085a7214 --- /dev/null +++ b/apps/editor/lib/mcp/sessions.ts @@ -0,0 +1,102 @@ +import type { SessionUser } from '@/lib/auth/session' +import { getSceneStore } from '@/lib/scene-store-server' +import { ownerScopedStore } from './owner-scoped-store' + +/** + * An MCP conversation is stateful: the agent loads a scene, edits it over + * several calls, then saves. That working scene lives in a `SceneBridge` held + * in memory, so it has to survive between HTTP requests — the transport's + * session id is what ties them together. + * + * The host runs one long-lived Node process, so an in-process map is the right + * shape. Sessions are dropped on idle rather than kept forever: an abandoned + * desktop client would otherwise pin a scene graph in memory indefinitely. + */ + +const IDLE_TIMEOUT_MS = 60 * 60 * 1000 // 1 hour +const MAX_SESSIONS = 64 + +interface McpSession { + userId: string + transport: { handleRequest(req: Request): Promise } + lastSeen: number + close(): Promise +} + +const sessions = new Map() + +function sweep(now: number): void { + for (const [id, session] of sessions) { + if (now - session.lastSeen > IDLE_TIMEOUT_MS) { + sessions.delete(id) + void session.close().catch(() => {}) + } + } + // A hard cap as well as a timeout: many short-lived clients could otherwise + // accumulate faster than the idle sweep retires them. + while (sessions.size > MAX_SESSIONS) { + const oldest = [...sessions.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0] + if (!oldest) break + sessions.delete(oldest[0]) + void oldest[1].close().catch(() => {}) + } +} + +export function getSession(id: string, userId: string): McpSession | null { + const session = sessions.get(id) + if (!session) return null + // A session id is a bearer of its own; refuse to hand one user another's + // in-flight scene even if they somehow learned the id. + if (session.userId !== userId) return null + session.lastSeen = Date.now() + return session +} + +/** + * Builds an MCP server bound to one user and attaches it to a Web-standard + * streamable transport, which takes a `Request` and returns a `Response` — + * exactly what a route handler has, with no Node req/res shim in between. + */ +export async function createSession(user: SessionUser): Promise { + const [{ SceneBridge }, { createPascalMcpServer }, { WebStandardStreamableHTTPServerTransport }] = + await Promise.all([ + import('@pascal-app/mcp/bridge'), + import('@pascal-app/mcp/server'), + import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'), + ]) + + const store = ownerScopedStore(await getSceneStore(), user.id) + const bridge = new SceneBridge() + const server = createPascalMcpServer({ bridge, store }) + + let sessionId: string | null = null + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (id: string) => { + sessionId = id + const now = Date.now() + sessions.set(id, entry) + entry.lastSeen = now + sweep(now) + }, + }) + + const entry: McpSession = { + userId: user.id, + transport, + lastSeen: Date.now(), + close: async () => { + if (sessionId) sessions.delete(sessionId) + await server.close().catch(() => {}) + }, + } + + await server.connect(transport) + return entry +} + +/** Test seam: drop every session without waiting for the idle sweep. */ +export function clearSessions(): void { + for (const session of sessions.values()) void session.close().catch(() => {}) + sessions.clear() +} diff --git a/apps/editor/lib/mcp/tokens.ts b/apps/editor/lib/mcp/tokens.ts new file mode 100644 index 000000000..71e675ccd --- /dev/null +++ b/apps/editor/lib/mcp/tokens.ts @@ -0,0 +1,104 @@ +import { randomBytes } from 'node:crypto' +import { getAuthPool } from '@/lib/auth/db' +import { hashToken, newToken, type SessionUser } from '@/lib/auth/session' + +/** + * Agent access to the scene API is granted per user from the admin panel and + * carried by a bearer token, because an MCP client is a desktop app rather + * than a browser and has no session cookie to send. + * + * The token identifies a user, so an agent edits as that person: scenes it + * creates are owned, listed and authorized exactly like ones made in the + * editor. Without this the agent would write ownerless scenes that appear in + * nobody's list yet anyone holding the link could change. + */ + +/** Prefixed so a leaked string is recognisable in a log or a paste. */ +const TOKEN_PREFIX = 'dtmcp_' + +export interface McpGrant { + userId: string + createdAt: string + lastUsedAt: string | null +} + +function rows(result: unknown): Record[] { + return Array.isArray(result) ? (result as Record[]) : [] +} + +/** + * Issues a token for a user, replacing any existing one. Returns the raw + * token — the only time it exists in readable form, so the caller must show + * it once and never store it. + */ +export async function grantMcpAccess(userId: string): Promise { + const pool = await getAuthPool() + const token = `${TOKEN_PREFIX}${newToken()}` + const id = randomBytes(16).toString('hex') + const now = new Date().toISOString() + // Replacing rather than adding: a second grant should retire the first, so + // revoking access from one machine cannot be undone by an older token. + await pool.execute('DELETE FROM mcp_tokens WHERE user_id = ?', [userId]) + await pool.execute( + 'INSERT INTO mcp_tokens (id, token_hash, user_id, created_at) VALUES (?, ?, ?, ?)', + [id, hashToken(token), userId, now], + ) + return token +} + +export async function revokeMcpAccess(userId: string): Promise { + const pool = await getAuthPool() + await pool.execute('DELETE FROM mcp_tokens WHERE user_id = ?', [userId]) +} + +/** Which users currently hold agent access, for the admin table. */ +export async function listMcpGrants(): Promise { + const pool = await getAuthPool() + const [result] = await pool.query( + 'SELECT user_id, created_at, last_used_at FROM mcp_tokens ORDER BY created_at DESC', + ) + return rows(result).map((row) => ({ + userId: String(row.user_id), + createdAt: String(row.created_at), + lastUsedAt: row.last_used_at === null ? null : String(row.last_used_at), + })) +} + +/** + * Resolves a bearer token to the user it belongs to, or null. A revoked user + * row cascades the token away, so a deleted account cannot keep agent access. + */ +export async function userForMcpToken(token: string | null): Promise { + if (!token || !token.startsWith(TOKEN_PREFIX)) return null + const pool = await getAuthPool() + const [result] = await pool.query( + `SELECT u.id, u.email, u.role + FROM mcp_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ? + LIMIT 1`, + [hashToken(token)], + ) + const row = rows(result)[0] + if (!row) return null + // Best-effort: a failed timestamp write must not deny a valid request. + void pool + .execute('UPDATE mcp_tokens SET last_used_at = ? WHERE token_hash = ?', [ + new Date().toISOString(), + hashToken(token), + ]) + .catch(() => {}) + return { + id: String(row.id), + email: String(row.email), + role: row.role === 'admin' ? 'admin' : 'user', + } +} + +/** Reads the bearer token from an MCP request. */ +export function bearerToken(request: Request): string | null { + const header = request.headers.get('authorization') + if (!header) return null + const match = header.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() ?? null +} From 46cc03e2be86a75adef2a780a019bc393072034f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 10:52:07 +0000 Subject: [PATCH 055/128] Revert "feat(mcp): serve MCP per user, granted from the admin panel" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 02da3fb2. The endpoint cannot work as written, and the reason is structural rather than a bug to chase. MCP's tools edit through the live scene store, and that store is a 'use client' module. Bundled into Next's server graph it is replaced by a client-reference stub that throws on every call — the built output literally contains `throw Error("... is on the client")`, which is the `getState is not a function` seen at runtime. Marking the packages external is the documented escape, but Next rejects it: the same packages must be transpiled for the editor UI, and `serverExternalPackages` and `transpilePackages` cannot both claim them. Carrying it meanwhile costs something real: a dead /api/mcp route and a migration creating an mcp_tokens table on a live database for a feature that does not exist. The work is preserved in 02da3fb2 and restores with `git revert` of this commit once the packaging question is settled — either vendoring the packages so Node loads them outside the bundle, or running MCP as its own process. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/app/admin/page.tsx | 14 +-- .../app/api/admin/users/[id]/mcp/route.ts | 47 -------- apps/editor/app/api/mcp/route.ts | 52 --------- apps/editor/components/admin/admin-panel.tsx | 35 +----- apps/editor/lib/auth/db.ts | 18 --- apps/editor/lib/mcp/owner-scoped-store.ts | 96 ---------------- apps/editor/lib/mcp/sessions.ts | 102 ----------------- apps/editor/lib/mcp/tokens.ts | 104 ------------------ 8 files changed, 6 insertions(+), 462 deletions(-) delete mode 100644 apps/editor/app/api/admin/users/[id]/mcp/route.ts delete mode 100644 apps/editor/app/api/mcp/route.ts delete mode 100644 apps/editor/lib/mcp/owner-scoped-store.ts delete mode 100644 apps/editor/lib/mcp/sessions.ts delete mode 100644 apps/editor/lib/mcp/tokens.ts diff --git a/apps/editor/app/admin/page.tsx b/apps/editor/app/admin/page.tsx index f5cbe00db..47119c225 100644 --- a/apps/editor/app/admin/page.tsx +++ b/apps/editor/app/admin/page.tsx @@ -2,7 +2,6 @@ import Link from 'next/link' import { notFound } from 'next/navigation' import { AdminPanel, type AdminScene } from '@/components/admin/admin-panel' import { listUsers, ownerEmails, requireAdmin } from '@/lib/auth/admin' -import { listMcpGrants } from '@/lib/mcp/tokens' import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -13,12 +12,7 @@ export default async function AdminPage() { const admin = await requireAdmin() if (!admin) notFound() - const [users, operations, grants] = await Promise.all([ - listUsers(), - getSceneOperations(), - listMcpGrants(), - ]) - const withMcp = new Set(grants.map((g) => g.userId)) + const [users, operations] = await Promise.all([listUsers(), getSceneOperations()]) const scenes = await operations.listScenes({ limit: 500 }) const emails = await ownerEmails(scenes.map((s) => s.ownerId).filter((x): x is string => !!x)) @@ -46,11 +40,7 @@ export default async function AdminPage() {
- ({ ...u, mcpEnabled: withMcp.has(u.id) }))} - /> +
) diff --git a/apps/editor/app/api/admin/users/[id]/mcp/route.ts b/apps/editor/app/api/admin/users/[id]/mcp/route.ts deleted file mode 100644 index cd691c894..000000000 --- a/apps/editor/app/api/admin/users/[id]/mcp/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { NextRequest } from 'next/server' -import { z } from 'zod' -import { requireAdmin, userExists } from '@/lib/auth/admin' -import { grantMcpAccess, revokeMcpAccess } from '@/lib/mcp/tokens' -import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security' - -export const dynamic = 'force-dynamic' - -const schema = z.object({ enabled: z.boolean() }) - -/** - * Grants or revokes a user's agent (MCP) access. - * - * A grant returns the raw token once. It is never readable again — only its - * hash is stored — so the panel has to show it immediately and the admin has - * to pass it on. Granting twice replaces the previous token rather than adding - * one, so access removed from a machine cannot be resurrected by an older copy. - */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const guard = guardSceneApiRequest(request, { skipAuth: true }) - if (guard) return guard - const admin = await requireAdmin() - if (!admin) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 }) - - const { id } = await params - let body: unknown - try { - body = await request.json() - } catch { - return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) - } - const parsed = schema.safeParse(body) - if (!parsed.success) { - return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 }) - } - if (!(await userExists(id))) { - return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) - } - - if (!parsed.data.enabled) { - await revokeMcpAccess(id) - return sceneApiJson(request, { ok: true }) - } - - const token = await grantMcpAccess(id) - return sceneApiJson(request, { ok: true, token }) -} diff --git a/apps/editor/app/api/mcp/route.ts b/apps/editor/app/api/mcp/route.ts deleted file mode 100644 index e6e8647c8..000000000 --- a/apps/editor/app/api/mcp/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from 'next/server' -import { createSession, getSession } from '@/lib/mcp/sessions' -import { bearerToken, userForMcpToken } from '@/lib/mcp/tokens' - -export const dynamic = 'force-dynamic' -// The transport streams and holds per-session state in memory, both of which -// need the Node runtime. -export const runtime = 'nodejs' - -/** - * Model Context Protocol endpoint, so an AI assistant can edit scenes through - * the same operations the editor uses. - * - * Access is per user and granted from the admin panel: the bearer token - * resolves to a person, and every scene the agent touches is scoped to them. - * An agent therefore has exactly the reach of the account behind its token — - * no more, and never the ownerless free-for-all the MCP tools would produce on - * their own. - * - * A desktop MCP client sends no cookie and no Origin, so the usual same-origin - * check does not apply here; the token is the whole of the authentication, and - * it is the only credential this route accepts. - */ -async function handle(request: Request): Promise { - const user = await userForMcpToken(bearerToken(request)) - if (!user) { - return NextResponse.json( - { error: 'mcp_access_required' }, - // WWW-Authenticate tells a compliant client this is an auth failure it - // can act on, not a server fault to retry. - { status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="digitaltwin-mcp"' } }, - ) - } - - const sessionId = request.headers.get('mcp-session-id') - if (sessionId) { - const existing = getSession(sessionId, user.id) - if (!existing) { - // Expired, swept, or someone else's. Say so rather than silently opening - // a fresh one, so the client re-initializes instead of losing edits. - return NextResponse.json({ error: 'mcp_session_expired' }, { status: 404 }) - } - return existing.transport.handleRequest(request) - } - - const session = await createSession(user) - return session.transport.handleRequest(request) -} - -export const GET = handle -export const POST = handle -export const DELETE = handle diff --git a/apps/editor/components/admin/admin-panel.tsx b/apps/editor/components/admin/admin-panel.tsx index 0ec40aa90..3a623e953 100644 --- a/apps/editor/components/admin/admin-panel.tsx +++ b/apps/editor/components/admin/admin-panel.tsx @@ -9,7 +9,6 @@ interface AdminUser { role: 'user' | 'admin' createdAt: string sceneCount: number - mcpEnabled: boolean } export interface AdminScene { @@ -33,11 +32,8 @@ export function AdminPanel({ const router = useRouter() const [busy, setBusy] = useState(null) const [error, setError] = useState(null) - // An issued agent token is readable exactly once; hold it here so the admin - // can copy it before it becomes a hash and nothing else. - const [issued, setIssued] = useState<{ email: string; token: string } | null>(null) - async function call(url: string, body: unknown, key: string): Promise> { + async function call(url: string, body: unknown, key: string) { setBusy(key) setError(null) try { @@ -46,30 +42,19 @@ export function AdminPanel({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - const parsed = (await res.json().catch(() => ({}))) as Record if (!res.ok) { - setError((parsed.error as string) ?? `Request failed (${res.status})`) - return {} + const b = (await res.json().catch(() => ({}))) as { error?: string } + setError(b.error ?? `Request failed (${res.status})`) + return } router.refresh() - return parsed } catch { setError('Something went wrong.') - return {} } finally { setBusy(null) } } - async function toggleMcp(user: AdminUser) { - const result = await call( - `/api/admin/users/${user.id}/mcp`, - { enabled: !user.mcpEnabled }, - `mcp-${user.id}`, - ) - if (typeof result.token === 'string') setIssued({ email: user.email, token: result.token }) - } - const unownedCount = scenes.filter((s) => !s.ownerId).length return ( @@ -90,7 +75,6 @@ export function AdminPanel({ Role Scenes Joined - AI access @@ -105,17 +89,6 @@ export function AdminPanel({ {new Date(u.createdAt).toLocaleDateString()} - - - {u.mcpEnabled && on} - {u.id === currentAdminId ? ( you diff --git a/apps/editor/lib/auth/db.ts b/apps/editor/lib/auth/db.ts index 1a6fa6b00..05f9d61cd 100644 --- a/apps/editor/lib/auth/db.ts +++ b/apps/editor/lib/auth/db.ts @@ -87,22 +87,4 @@ async function migrate(p: MysqlPool): Promise { REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `) - - await p.query(` - CREATE TABLE IF NOT EXISTS mcp_tokens ( - id VARCHAR(64) NOT NULL PRIMARY KEY, - -- Only the sha256 of the token is stored, as for sessions: a leaked - -- database row must not hand out working agent access. - token_hash CHAR(64) NOT NULL, - user_id VARCHAR(64) NOT NULL, - created_at VARCHAR(32) NOT NULL, - last_used_at VARCHAR(32) NULL, - UNIQUE KEY mcp_tokens_token_uidx (token_hash), - -- One live token per user: granting again replaces the old one, so a - -- revoked laptop cannot keep editing. - UNIQUE KEY mcp_tokens_user_uidx (user_id), - CONSTRAINT mcp_tokens_user_fk FOREIGN KEY (user_id) - REFERENCES users(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - `) } diff --git a/apps/editor/lib/mcp/owner-scoped-store.ts b/apps/editor/lib/mcp/owner-scoped-store.ts deleted file mode 100644 index 7ac5babaf..000000000 --- a/apps/editor/lib/mcp/owner-scoped-store.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { SceneNotFoundError } from '@pascal-app/mcp/storage' -import type { - SceneEvent, - SceneEventAppendOptions, - SceneEventListOptions, - SceneId, - SceneListOptions, - SceneMeta, - SceneMutateOptions, - SceneSaveOptions, - SceneStore, - SceneWithGraph, -} from '@pascal-app/mcp/storage/types' - -/** - * Wraps the scene store so an agent acts as one person rather than as nobody. - * - * The MCP tools have no notion of a user — left alone they write scenes with - * no owner, which appear in nobody's list yet can be opened and changed by - * anyone holding the link. This binds every call to the user whose token - * authenticated the request: writes are stamped with their id, reads and lists - * are confined to what they own, and someone else's scene is reported missing - * rather than refused, so an agent cannot probe for scenes it may not touch. - * - * An admin is deliberately not given a bypass here. Admins can already reach - * every scene through the panel; letting an agent inherit that would mean a - * single leaked token edits the whole installation. - */ -export function ownerScopedStore(store: SceneStore, ownerId: string): SceneStore { - const ownsOrMissing = async (id: SceneId): Promise => { - const scene = await store.load(id) - // Same error for absent and not-yours: a different message would let an - // agent enumerate which scene ids exist. - if (!scene || (scene.ownerId != null && scene.ownerId !== ownerId)) { - throw new SceneNotFoundError() - } - return scene - } - - const scoped: SceneStore = { - backend: store.backend, - - save(opts: SceneSaveOptions): Promise { - return store.save({ ...opts, ownerId }) - }, - - async load(id: SceneId): Promise { - const scene = await store.load(id) - if (!scene) return null - if (scene.ownerId != null && scene.ownerId !== ownerId) return null - return scene - }, - - list(opts?: SceneListOptions): Promise { - return store.list({ ...opts, ownerId }) - }, - - async delete(id: SceneId, opts?: SceneMutateOptions): Promise { - await ownsOrMissing(id) - return store.delete(id, opts) - }, - - async rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise { - await ownsOrMissing(id) - return store.rename(id, newName, opts) - }, - } - - // Optional members are forwarded only when the backend has them, so - // `'createProject' in store` stays an honest capability check downstream. - if (store.createProject) { - scoped.createProject = (opts) => - store.createProject?.(opts) as ReturnType> - } - if (store.getProjectStatus) { - scoped.getProjectStatus = (id) => - store.getProjectStatus?.(id) as ReturnType> - } - if (store.appendSceneEvent) { - scoped.appendSceneEvent = async (opts: SceneEventAppendOptions): Promise => { - await ownsOrMissing(opts.sceneId) - return store.appendSceneEvent?.(opts) as Promise - } - } - if (store.listSceneEvents) { - scoped.listSceneEvents = async ( - sceneId: SceneId, - opts?: SceneEventListOptions, - ): Promise => { - await ownsOrMissing(sceneId) - return store.listSceneEvents?.(sceneId, opts) as Promise - } - } - - return scoped -} diff --git a/apps/editor/lib/mcp/sessions.ts b/apps/editor/lib/mcp/sessions.ts deleted file mode 100644 index 5085a7214..000000000 --- a/apps/editor/lib/mcp/sessions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { SessionUser } from '@/lib/auth/session' -import { getSceneStore } from '@/lib/scene-store-server' -import { ownerScopedStore } from './owner-scoped-store' - -/** - * An MCP conversation is stateful: the agent loads a scene, edits it over - * several calls, then saves. That working scene lives in a `SceneBridge` held - * in memory, so it has to survive between HTTP requests — the transport's - * session id is what ties them together. - * - * The host runs one long-lived Node process, so an in-process map is the right - * shape. Sessions are dropped on idle rather than kept forever: an abandoned - * desktop client would otherwise pin a scene graph in memory indefinitely. - */ - -const IDLE_TIMEOUT_MS = 60 * 60 * 1000 // 1 hour -const MAX_SESSIONS = 64 - -interface McpSession { - userId: string - transport: { handleRequest(req: Request): Promise } - lastSeen: number - close(): Promise -} - -const sessions = new Map() - -function sweep(now: number): void { - for (const [id, session] of sessions) { - if (now - session.lastSeen > IDLE_TIMEOUT_MS) { - sessions.delete(id) - void session.close().catch(() => {}) - } - } - // A hard cap as well as a timeout: many short-lived clients could otherwise - // accumulate faster than the idle sweep retires them. - while (sessions.size > MAX_SESSIONS) { - const oldest = [...sessions.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0] - if (!oldest) break - sessions.delete(oldest[0]) - void oldest[1].close().catch(() => {}) - } -} - -export function getSession(id: string, userId: string): McpSession | null { - const session = sessions.get(id) - if (!session) return null - // A session id is a bearer of its own; refuse to hand one user another's - // in-flight scene even if they somehow learned the id. - if (session.userId !== userId) return null - session.lastSeen = Date.now() - return session -} - -/** - * Builds an MCP server bound to one user and attaches it to a Web-standard - * streamable transport, which takes a `Request` and returns a `Response` — - * exactly what a route handler has, with no Node req/res shim in between. - */ -export async function createSession(user: SessionUser): Promise { - const [{ SceneBridge }, { createPascalMcpServer }, { WebStandardStreamableHTTPServerTransport }] = - await Promise.all([ - import('@pascal-app/mcp/bridge'), - import('@pascal-app/mcp/server'), - import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'), - ]) - - const store = ownerScopedStore(await getSceneStore(), user.id) - const bridge = new SceneBridge() - const server = createPascalMcpServer({ bridge, store }) - - let sessionId: string | null = null - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - onsessioninitialized: (id: string) => { - sessionId = id - const now = Date.now() - sessions.set(id, entry) - entry.lastSeen = now - sweep(now) - }, - }) - - const entry: McpSession = { - userId: user.id, - transport, - lastSeen: Date.now(), - close: async () => { - if (sessionId) sessions.delete(sessionId) - await server.close().catch(() => {}) - }, - } - - await server.connect(transport) - return entry -} - -/** Test seam: drop every session without waiting for the idle sweep. */ -export function clearSessions(): void { - for (const session of sessions.values()) void session.close().catch(() => {}) - sessions.clear() -} diff --git a/apps/editor/lib/mcp/tokens.ts b/apps/editor/lib/mcp/tokens.ts deleted file mode 100644 index 71e675ccd..000000000 --- a/apps/editor/lib/mcp/tokens.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { randomBytes } from 'node:crypto' -import { getAuthPool } from '@/lib/auth/db' -import { hashToken, newToken, type SessionUser } from '@/lib/auth/session' - -/** - * Agent access to the scene API is granted per user from the admin panel and - * carried by a bearer token, because an MCP client is a desktop app rather - * than a browser and has no session cookie to send. - * - * The token identifies a user, so an agent edits as that person: scenes it - * creates are owned, listed and authorized exactly like ones made in the - * editor. Without this the agent would write ownerless scenes that appear in - * nobody's list yet anyone holding the link could change. - */ - -/** Prefixed so a leaked string is recognisable in a log or a paste. */ -const TOKEN_PREFIX = 'dtmcp_' - -export interface McpGrant { - userId: string - createdAt: string - lastUsedAt: string | null -} - -function rows(result: unknown): Record[] { - return Array.isArray(result) ? (result as Record[]) : [] -} - -/** - * Issues a token for a user, replacing any existing one. Returns the raw - * token — the only time it exists in readable form, so the caller must show - * it once and never store it. - */ -export async function grantMcpAccess(userId: string): Promise { - const pool = await getAuthPool() - const token = `${TOKEN_PREFIX}${newToken()}` - const id = randomBytes(16).toString('hex') - const now = new Date().toISOString() - // Replacing rather than adding: a second grant should retire the first, so - // revoking access from one machine cannot be undone by an older token. - await pool.execute('DELETE FROM mcp_tokens WHERE user_id = ?', [userId]) - await pool.execute( - 'INSERT INTO mcp_tokens (id, token_hash, user_id, created_at) VALUES (?, ?, ?, ?)', - [id, hashToken(token), userId, now], - ) - return token -} - -export async function revokeMcpAccess(userId: string): Promise { - const pool = await getAuthPool() - await pool.execute('DELETE FROM mcp_tokens WHERE user_id = ?', [userId]) -} - -/** Which users currently hold agent access, for the admin table. */ -export async function listMcpGrants(): Promise { - const pool = await getAuthPool() - const [result] = await pool.query( - 'SELECT user_id, created_at, last_used_at FROM mcp_tokens ORDER BY created_at DESC', - ) - return rows(result).map((row) => ({ - userId: String(row.user_id), - createdAt: String(row.created_at), - lastUsedAt: row.last_used_at === null ? null : String(row.last_used_at), - })) -} - -/** - * Resolves a bearer token to the user it belongs to, or null. A revoked user - * row cascades the token away, so a deleted account cannot keep agent access. - */ -export async function userForMcpToken(token: string | null): Promise { - if (!token || !token.startsWith(TOKEN_PREFIX)) return null - const pool = await getAuthPool() - const [result] = await pool.query( - `SELECT u.id, u.email, u.role - FROM mcp_tokens t - JOIN users u ON u.id = t.user_id - WHERE t.token_hash = ? - LIMIT 1`, - [hashToken(token)], - ) - const row = rows(result)[0] - if (!row) return null - // Best-effort: a failed timestamp write must not deny a valid request. - void pool - .execute('UPDATE mcp_tokens SET last_used_at = ? WHERE token_hash = ?', [ - new Date().toISOString(), - hashToken(token), - ]) - .catch(() => {}) - return { - id: String(row.id), - email: String(row.email), - role: row.role === 'admin' ? 'admin' : 'user', - } -} - -/** Reads the bearer token from an MCP request. */ -export function bearerToken(request: Request): string | null { - const header = request.headers.get('authorization') - if (!header) return null - const match = header.match(/^Bearer\s+(.+)$/i) - return match?.[1]?.trim() ?? null -} From d4147f9df0fb3fa011a3eba3e65f4cfdd25ecff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 10:52:17 +0000 Subject: [PATCH 056/128] ci: authorize the now-private plugin repository with an ssh key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugin-warehouse was switched to private, so every job dies at `bun install` with a 404 on its tarball — GitHub's answer to an unauthenticated request for a private repository. Back to the git+ssh pin, and a key rather than a token. The `github:` shorthand fetches through api.github.com, and bun has no documented way to authenticate that request — the tracking issue is still open. bun does shell out to `git clone` for a git+ssh spec, so an ssh key works through git's own machinery. Its url-rewriting does not: a `url.insteadOf` rule has no effect on bun's clones, which I verified before abandoning that route. Needs a read-only deploy key on plugin-warehouse, with the private half stored as the PLUGIN_SSH_KEY secret. Without it the step says so and carries on, so the failure names its own cause. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- .github/workflows/ci.yml | 17 +++++++++++++++++ .github/workflows/deploy-bundle.yml | 21 +++++++++++++++------ .github/workflows/mcp-ci.yml | 17 +++++++++++++++++ .github/workflows/release.yml | 17 +++++++++++++++++ apps/editor/package.json | 2 +- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdacf1586..209cb86bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,23 @@ jobs: - uses: oven-sh/setup-bun@v2 + # plugin-warehouse is a private repository, so the runner needs a key to + # clone it. bun shells out to `git clone` for a git+ssh dependency, which + # is why this is an ssh key rather than a token: bun has no documented way + # to authenticate the api.github.com tarball a `github:` spec would use. + - name: Authorize the private plugin repository + env: + PLUGIN_SSH_KEY: ${{ secrets.PLUGIN_SSH_KEY }} + run: | + if [ -z "$PLUGIN_SSH_KEY" ]; then + echo "PLUGIN_SSH_KEY is not set — the private plugin will fail to resolve." + exit 0 + fi + mkdir -p ~/.ssh + printf '%s\n' "$PLUGIN_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/deploy-bundle.yml b/.github/workflows/deploy-bundle.yml index fa630e18f..4ae50f712 100644 --- a/.github/workflows/deploy-bundle.yml +++ b/.github/workflows/deploy-bundle.yml @@ -41,13 +41,22 @@ jobs: with: bun-version: 1.3.0 - # The plugin is pinned over ssh; rewrite it to an authenticated https - # URL so the runner can fetch it whether or not the repo is public. - - name: Allow git dependencies over https + # plugin-warehouse is a private repository, so the runner needs a key to + # clone it. An ssh key rather than a token because bun shells out to + # `git clone` for a git+ssh dependency and ignores git's own url rewriting + # — verified, a `url.insteadOf` rule has no effect on its clones. + - name: Authorize the private plugin repository + env: + PLUGIN_SSH_KEY: ${{ secrets.PLUGIN_SSH_KEY }} run: | - git config --global \ - url."https://x-access-token:${{ secrets.DEPLOY_TOKEN }}@github.com/".insteadOf \ - "ssh://git@github.com/" + if [ -z "$PLUGIN_SSH_KEY" ]; then + echo "PLUGIN_SSH_KEY is not set — the private plugin will fail to resolve." + exit 0 + fi + mkdir -p ~/.ssh + printf '%s\n' "$PLUGIN_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null # The hoisted linker keeps the standalone output free of symlinks, which # break once the host moves the deployed directory. diff --git a/.github/workflows/mcp-ci.yml b/.github/workflows/mcp-ci.yml index c11fed1e5..51d1600e4 100644 --- a/.github/workflows/mcp-ci.yml +++ b/.github/workflows/mcp-ci.yml @@ -33,6 +33,23 @@ jobs: with: bun-version: 1.3.0 + # plugin-warehouse is a private repository, so the runner needs a key to + # clone it. bun shells out to `git clone` for a git+ssh dependency, which + # is why this is an ssh key rather than a token: bun has no documented way + # to authenticate the api.github.com tarball a `github:` spec would use. + - name: Authorize the private plugin repository + env: + PLUGIN_SSH_KEY: ${{ secrets.PLUGIN_SSH_KEY }} + run: | + if [ -z "$PLUGIN_SSH_KEY" ]; then + echo "PLUGIN_SSH_KEY is not set — the private plugin will fail to resolve." + exit 0 + fi + mkdir -p ~/.ssh + printf '%s\n' "$PLUGIN_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + - name: Install run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bae53316..726ac8c9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,23 @@ jobs: node-version: 22 registry-url: "https://registry.npmjs.org" + # plugin-warehouse is a private repository, so the runner needs a key to + # clone it. bun shells out to `git clone` for a git+ssh dependency, which + # is why this is an ssh key rather than a token: bun has no documented way + # to authenticate the api.github.com tarball a `github:` spec would use. + - name: Authorize the private plugin repository + env: + PLUGIN_SSH_KEY: ${{ secrets.PLUGIN_SSH_KEY }} + run: | + if [ -z "$PLUGIN_SSH_KEY" ]; then + echo "PLUGIN_SSH_KEY is not set — the private plugin will fail to resolve." + exit 0 + fi + mkdir -p ~/.ssh + printf '%s\n' "$PLUGIN_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/apps/editor/package.json b/apps/editor/package.json index a20479271..ee0ed9092 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -15,7 +15,7 @@ "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", - "@ovurrsl/plugin-warehouse": "github:ovurrsl/plugin-warehouse#938518f95b3005037ef7eca35c6b4745f56e2a37", + "@ovurrsl/plugin-warehouse": "git+ssh://git@github.com/ovurrsl/plugin-warehouse.git#938518f95b3005037ef7eca35c6b4745f56e2a37", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/ifc-converter": "*", From 2f18cf69b8ef5afd7046cffa21c125f22ce926db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 11:40:41 +0000 Subject: [PATCH 057/128] fix: accept plugin nodes at the scene API boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving any scene containing a plugin node — every warehouse object, for one — failed with 400. The API validates each node against core's AnyNode, and AnyNode is a hand-maintained union of the HOST's kinds: by construction it cannot know a plugin's. The warehouse plugin's own source says exactly this and points at the answer — "the registry validates against def.schema at runtime." Do the same at the boundary: kinds claimed by a plugin manifest are validated against that plugin's schema, and everything else still faces AnyNode, so unknown kinds and malformed plugin nodes are refused as before. The plugin barrels already run server-side during SSR, so importing them in a route adds no new constraint. Proven against the built bundle on MySQL: a pallet built by the plugin's schema saves with 200, loads back, renders in the editor, and the editor's own autosave of that graph succeeds — the exact path that returned 400. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EvTkeoX8srShi7YwB2kZzw --- apps/editor/lib/graph-schema.test.ts | 74 ++++++++++++++++++++++++++++ apps/editor/lib/graph-schema.ts | 34 +++++++++++-- 2 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 apps/editor/lib/graph-schema.test.ts diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts new file mode 100644 index 000000000..e6964037d --- /dev/null +++ b/apps/editor/lib/graph-schema.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test' +import { warehousePlugin } from '@ovurrsl/plugin-warehouse' +import { AnyNode } from '@pascal-app/core/schema' +import { apiGraphSchema } from './graph-schema' + +/** + * A real node of the given plugin kind, built by the plugin's own schema so + * the test breaks if the plugin's contract changes rather than drifting. + */ +function pluginNode(kind: string): Record { + const def = warehousePlugin.nodes?.find((d) => d.kind === kind) + if (!def) throw new Error(`plugin does not register ${kind}`) + // Node ids are branded per kind: the id prefix is the kind's local part + // verbatim (`pallet-rack_x`), so derive it rather than guess. + const local = kind.split(':').pop() ?? kind + const parsed = def.schema.safeParse({ + object: 'node', + id: `${local}_t1`, + type: kind, + name: 'Test node', + parentId: 'level_1', + }) + if (!parsed.success) { + throw new Error(`could not build a valid ${kind}: ${parsed.error.issues[0]?.message}`) + } + return parsed.data as Record +} + +function graphWith(node: Record) { + return { + nodes: { [String(node.id)]: node }, + rootNodeIds: [String(node.id)], + } +} + +describe('apiGraphSchema plugin nodes', () => { + test('a warehouse pallet is NOT part of the host AnyNode union (the 400 bug)', () => { + expect(AnyNode.safeParse(pluginNode('warehouse:pallet')).success).toBe(false) + }) + + test('accepts every node kind the warehouse plugin registers', () => { + for (const def of warehousePlugin.nodes ?? []) { + const result = apiGraphSchema.safeParse(graphWith(pluginNode(def.kind))) + expect( + result.success, + `${def.kind} rejected: ${JSON.stringify(result.error?.issues[0])}`, + ).toBe(true) + } + }) + + test('still accepts built-in nodes', () => { + const wall = { + object: 'node', + id: 'wall_1', + type: 'wall', + name: 'Wall', + visible: true, + thickness: 0.2, + start: [0, 0], + end: [1, 0], + } + expect(apiGraphSchema.safeParse(graphWith(wall)).success).toBe(true) + }) + + test('still rejects unknown node kinds', () => { + const bogus = { object: 'node', id: 'x_1', type: 'not-a-kind', name: 'X' } + expect(apiGraphSchema.safeParse(graphWith(bogus)).success).toBe(false) + }) + + test('still rejects a malformed plugin node', () => { + const broken = { ...pluginNode('warehouse:pallet'), position: 'not-a-vector' } + expect(apiGraphSchema.safeParse(graphWith(broken)).success).toBe(false) + }) +}) diff --git a/apps/editor/lib/graph-schema.ts b/apps/editor/lib/graph-schema.ts index c94c724fa..e82c66421 100644 --- a/apps/editor/lib/graph-schema.ts +++ b/apps/editor/lib/graph-schema.ts @@ -1,17 +1,41 @@ +import { warehousePlugin } from '@ovurrsl/plugin-warehouse' import { AnyNode } from '@pascal-app/core/schema' +import { treesPlugin } from '@pascal-app/plugin-trees' import { z } from 'zod' /** - * Validates a SceneGraph at an untrusted API boundary. Re-runs - * `AnyNode.safeParse` on every node, which enforces the `AssetUrl` - * allowlist in core (closes the Phase 3 SSRF / arbitrary-URL risk on - * scan/guide/item/material fields). + * Validates a SceneGraph at an untrusted API boundary. Re-runs schema + * validation on every node, which enforces the `AssetUrl` allowlist in core + * (closes the Phase 3 SSRF / arbitrary-URL risk on scan/guide/item/material + * fields). * * Shared between `POST /api/scenes` and `PUT /api/scenes/[id]` so neither * route can silently accept malicious URLs via the `graph` payload. * * Phase 8 P4 found the POST bypass; Phase 10 A2 found the PUT bypass. + * + * `AnyNode` is a hand-maintained union of the HOST's kinds — by construction + * it cannot know a plugin's, and validating a `warehouse:*` node against it + * rejects every save containing one. Plugin kinds are therefore validated + * against the plugin's own `def.schema`, exactly as the runtime registry + * does; `AnyNode` remains the verdict for everything unclaimed, so an + * unknown kind is still refused. The plugin barrels are the same modules the + * client bootstrap imports during SSR, so they are server-safe by contract. */ +const pluginNodeSchemas = new Map() +for (const plugin of [treesPlugin, warehousePlugin]) { + for (const def of plugin.nodes ?? []) { + pluginNodeSchemas.set(def.kind, def.schema) + } +} + +function validateNode(node: unknown): z.ZodSafeParseResult { + const kind = (node as { type?: unknown } | null)?.type + const pluginSchema = typeof kind === 'string' ? pluginNodeSchemas.get(kind) : undefined + if (pluginSchema) return pluginSchema.safeParse(node) + return AnyNode.safeParse(node) +} + export const apiGraphSchema = z .object({ nodes: z.record(z.string(), z.unknown()), @@ -21,7 +45,7 @@ export const apiGraphSchema = z }) .superRefine((value, ctx) => { for (const [nodeId, node] of Object.entries(value.nodes)) { - const res = AnyNode.safeParse(node) + const res = validateNode(node) if (!res.success) { for (const issue of res.error.issues) { ctx.addIssue({ From f8d77a5b91e7df8bdc2c4b639b1cac6b9112cd43 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 11:41:15 +0000 Subject: [PATCH 058/128] panel: vendor ovurrsl/panel sources (not yet wired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console app's lib, components and migrations, copied under an isolated @panel alias so they cannot collide with the editor's @/*. Nothing imports them yet — routes, session bridge and the db-env shim land as separate steps. --- .../editor/panel/components/app-providers.tsx | 120 +++ .../panel/components/auth/auth-shell.tsx | 60 ++ .../components/auth/mfa-recovery-screen.tsx | 109 +++ .../components/auth/mfa-setup-screen.tsx | 199 +++++ .../components/auth/mfa-verify-screen.tsx | 132 +++ .../panel/components/auth/otp-input.tsx | 104 +++ .../components/auth/request-access-screen.tsx | 177 ++++ .../components/auth/reset-request-screen.tsx | 96 ++ .../components/auth/set-password-screen.tsx | 279 ++++++ .../panel/components/auth/sign-in-screen.tsx | 479 ++++++++++ .../components/console/assign-dialog.tsx | 141 +++ .../panel/components/console/audit-tab.tsx | 158 ++++ .../components/console/command-palette.tsx | 274 ++++++ .../components/console/console-shell.tsx | 394 +++++++++ .../components/console/integrations-tab.tsx | 463 ++++++++++ .../panel/components/console/invite-form.tsx | 173 ++++ .../panel/components/console/jobs-tab.tsx | 233 +++++ .../panel/components/console/logs-tab.tsx | 258 ++++++ .../panel/components/console/overview-tab.tsx | 206 +++++ .../panel/components/console/roles-tab.tsx | 345 ++++++++ .../panel/components/console/sessions-tab.tsx | 141 +++ .../panel/components/console/settings-tab.tsx | 355 ++++++++ .../panel/components/console/sites-tab.tsx | 293 +++++++ .../panel/components/console/sparkline.tsx | 78 ++ .../panel/components/console/tab-content.tsx | 48 + .../panel/components/console/updates-tab.tsx | 185 ++++ .../panel/components/console/user-drawer.tsx | 473 ++++++++++ .../panel/components/console/users-tab.tsx | 823 ++++++++++++++++++ .../panel/components/error-reporter.tsx | 92 ++ apps/editor/panel/components/ui/backdrop.tsx | 88 ++ apps/editor/panel/components/ui/caps.tsx | 42 + apps/editor/panel/components/ui/controls.tsx | 245 ++++++ apps/editor/panel/components/ui/feedback.tsx | 148 ++++ .../editor/panel/components/ui/modal-focus.ts | 127 +++ .../panel/components/ui/netlog-logo.tsx | 121 +++ apps/editor/panel/lib/api-contract.ts | 554 ++++++++++++ apps/editor/panel/lib/api.ts | 89 ++ apps/editor/panel/lib/audit-events.ts | 107 +++ apps/editor/panel/lib/auth/audit.ts | 43 + apps/editor/panel/lib/auth/crypto.ts | 55 ++ apps/editor/panel/lib/auth/guard.ts | 32 + apps/editor/panel/lib/auth/invitations.ts | 162 ++++ apps/editor/panel/lib/auth/lockout.ts | 76 ++ apps/editor/panel/lib/auth/password.ts | 84 ++ apps/editor/panel/lib/auth/reset.ts | 73 ++ apps/editor/panel/lib/auth/roles.ts | 98 +++ apps/editor/panel/lib/auth/session.ts | 343 ++++++++ apps/editor/panel/lib/auth/totp.ts | 171 ++++ apps/editor/panel/lib/auth/users.ts | 67 ++ apps/editor/panel/lib/casing.ts | 28 + apps/editor/panel/lib/changelog.ts | 237 +++++ apps/editor/panel/lib/client-api.ts | 57 ++ apps/editor/panel/lib/cn.ts | 7 + apps/editor/panel/lib/console-tabs.ts | 109 +++ apps/editor/panel/lib/db.ts | 79 ++ apps/editor/panel/lib/escape-layers.ts | 77 ++ apps/editor/panel/lib/health.ts | 65 ++ apps/editor/panel/lib/hooks/use-breakpoint.ts | 69 ++ apps/editor/panel/lib/i18n/en.ts | 642 ++++++++++++++ apps/editor/panel/lib/i18n/index.ts | 120 +++ apps/editor/panel/lib/i18n/tr.ts | 624 +++++++++++++ apps/editor/panel/lib/integrations.ts | 231 +++++ apps/editor/panel/lib/jobs.ts | 179 ++++ apps/editor/panel/lib/logs.ts | 230 +++++ apps/editor/panel/lib/mail.ts | 147 ++++ apps/editor/panel/lib/password-policy.ts | 57 ++ apps/editor/panel/lib/settings.ts | 95 ++ apps/editor/panel/lib/types.ts | 196 +++++ apps/editor/panel/lib/users.ts | 334 +++++++ apps/editor/panel/migrate.ts | 154 ++++ apps/editor/panel/migrations/001_init.sql | 171 ++++ .../migrations/002_roles_and_requests.sql | 46 + .../panel/migrations/003_password_resets.sql | 22 + apps/editor/panel/seed.ts | 149 ++++ 74 files changed, 13738 insertions(+) create mode 100644 apps/editor/panel/components/app-providers.tsx create mode 100644 apps/editor/panel/components/auth/auth-shell.tsx create mode 100644 apps/editor/panel/components/auth/mfa-recovery-screen.tsx create mode 100644 apps/editor/panel/components/auth/mfa-setup-screen.tsx create mode 100644 apps/editor/panel/components/auth/mfa-verify-screen.tsx create mode 100644 apps/editor/panel/components/auth/otp-input.tsx create mode 100644 apps/editor/panel/components/auth/request-access-screen.tsx create mode 100644 apps/editor/panel/components/auth/reset-request-screen.tsx create mode 100644 apps/editor/panel/components/auth/set-password-screen.tsx create mode 100644 apps/editor/panel/components/auth/sign-in-screen.tsx create mode 100644 apps/editor/panel/components/console/assign-dialog.tsx create mode 100644 apps/editor/panel/components/console/audit-tab.tsx create mode 100644 apps/editor/panel/components/console/command-palette.tsx create mode 100644 apps/editor/panel/components/console/console-shell.tsx create mode 100644 apps/editor/panel/components/console/integrations-tab.tsx create mode 100644 apps/editor/panel/components/console/invite-form.tsx create mode 100644 apps/editor/panel/components/console/jobs-tab.tsx create mode 100644 apps/editor/panel/components/console/logs-tab.tsx create mode 100644 apps/editor/panel/components/console/overview-tab.tsx create mode 100644 apps/editor/panel/components/console/roles-tab.tsx create mode 100644 apps/editor/panel/components/console/sessions-tab.tsx create mode 100644 apps/editor/panel/components/console/settings-tab.tsx create mode 100644 apps/editor/panel/components/console/sites-tab.tsx create mode 100644 apps/editor/panel/components/console/sparkline.tsx create mode 100644 apps/editor/panel/components/console/tab-content.tsx create mode 100644 apps/editor/panel/components/console/updates-tab.tsx create mode 100644 apps/editor/panel/components/console/user-drawer.tsx create mode 100644 apps/editor/panel/components/console/users-tab.tsx create mode 100644 apps/editor/panel/components/error-reporter.tsx create mode 100644 apps/editor/panel/components/ui/backdrop.tsx create mode 100644 apps/editor/panel/components/ui/caps.tsx create mode 100644 apps/editor/panel/components/ui/controls.tsx create mode 100644 apps/editor/panel/components/ui/feedback.tsx create mode 100644 apps/editor/panel/components/ui/modal-focus.ts create mode 100644 apps/editor/panel/components/ui/netlog-logo.tsx create mode 100644 apps/editor/panel/lib/api-contract.ts create mode 100644 apps/editor/panel/lib/api.ts create mode 100644 apps/editor/panel/lib/audit-events.ts create mode 100644 apps/editor/panel/lib/auth/audit.ts create mode 100644 apps/editor/panel/lib/auth/crypto.ts create mode 100644 apps/editor/panel/lib/auth/guard.ts create mode 100644 apps/editor/panel/lib/auth/invitations.ts create mode 100644 apps/editor/panel/lib/auth/lockout.ts create mode 100644 apps/editor/panel/lib/auth/password.ts create mode 100644 apps/editor/panel/lib/auth/reset.ts create mode 100644 apps/editor/panel/lib/auth/roles.ts create mode 100644 apps/editor/panel/lib/auth/session.ts create mode 100644 apps/editor/panel/lib/auth/totp.ts create mode 100644 apps/editor/panel/lib/auth/users.ts create mode 100644 apps/editor/panel/lib/casing.ts create mode 100644 apps/editor/panel/lib/changelog.ts create mode 100644 apps/editor/panel/lib/client-api.ts create mode 100644 apps/editor/panel/lib/cn.ts create mode 100644 apps/editor/panel/lib/console-tabs.ts create mode 100644 apps/editor/panel/lib/db.ts create mode 100644 apps/editor/panel/lib/escape-layers.ts create mode 100644 apps/editor/panel/lib/health.ts create mode 100644 apps/editor/panel/lib/hooks/use-breakpoint.ts create mode 100644 apps/editor/panel/lib/i18n/en.ts create mode 100644 apps/editor/panel/lib/i18n/index.ts create mode 100644 apps/editor/panel/lib/i18n/tr.ts create mode 100644 apps/editor/panel/lib/integrations.ts create mode 100644 apps/editor/panel/lib/jobs.ts create mode 100644 apps/editor/panel/lib/logs.ts create mode 100644 apps/editor/panel/lib/mail.ts create mode 100644 apps/editor/panel/lib/password-policy.ts create mode 100644 apps/editor/panel/lib/settings.ts create mode 100644 apps/editor/panel/lib/types.ts create mode 100644 apps/editor/panel/lib/users.ts create mode 100644 apps/editor/panel/migrate.ts create mode 100644 apps/editor/panel/migrations/001_init.sql create mode 100644 apps/editor/panel/migrations/002_roles_and_requests.sql create mode 100644 apps/editor/panel/migrations/003_password_resets.sql create mode 100644 apps/editor/panel/seed.ts diff --git a/apps/editor/panel/components/app-providers.tsx b/apps/editor/panel/components/app-providers.tsx new file mode 100644 index 000000000..3fa505fbf --- /dev/null +++ b/apps/editor/panel/components/app-providers.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { DEFAULT_LANG, dictionaryFor, type Dictionary } from '@panel/lib/i18n'; +import type { Lang, Theme } from '@panel/lib/types'; + +const THEME_KEY = 'digitaltwin_theme'; +const LANG_KEY = 'digitaltwin_lang'; + +interface AppShell { + theme: Theme; + lang: Lang; + t: Dictionary; + toggleTheme: () => void; + toggleLang: () => void; + setTheme: (theme: Theme) => void; + setLang: (lang: Lang) => void; +} + +const Ctx = createContext(null); + +export function useApp(): AppShell { + const value = useContext(Ctx); + if (!value) throw new Error('useApp must be used inside '); + return value; +} + +/** Convenience for components that only need the dictionary. */ +export function useT(): Dictionary { + return useApp().t; +} + +export function AppProviders({ + initialTheme, + initialLang, + children, +}: { + initialTheme: Theme; + initialLang: Lang; + children: React.ReactNode; +}) { + const [theme, setThemeState] = useState(initialTheme); + const [lang, setLangState] = useState(initialLang); + + /** + * The `lang` attribute drives screen-reader pronunciation, hyphenation and + * font selection, so it tracks the dictionary. + * + * It is deliberately NOT what capitalisation depends on. `text-transform: + * uppercase` is specified to follow the document language, but Chromium still + * renders İŞLEMCİ as ISLEMCI under lang="tr" — so every uppercase label goes + * through , which uses toLocaleUpperCase and cannot silently regress. + */ + useEffect(() => { + document.documentElement.lang = lang; + document.documentElement.dataset.dtTheme = theme; + }, [lang, theme]); + + const setTheme = useCallback((next: Theme) => { + setThemeState(next); + try { + localStorage.setItem(THEME_KEY, next); + document.cookie = `${THEME_KEY}=${next};path=/;max-age=31536000;samesite=lax`; + } catch { + /* private mode — the in-memory state still works for this tab */ + } + }, []); + + const setLang = useCallback((next: Lang) => { + setLangState(next); + try { + localStorage.setItem(LANG_KEY, next); + document.cookie = `${LANG_KEY}=${next};path=/;max-age=31536000;samesite=lax`; + } catch { + /* ignore */ + } + }, []); + + // Reconcile once on mount: the server rendered from the cookie, but a stored + // preference or the OS setting may disagree with it. + useEffect(() => { + let storedTheme: string | null = null; + let storedLang: string | null = null; + try { + storedTheme = localStorage.getItem(THEME_KEY); + storedLang = localStorage.getItem(LANG_KEY); + } catch { + /* ignore */ + } + + if (storedTheme === 'dark' || storedTheme === 'light') { + if (storedTheme !== theme) setTheme(storedTheme); + } else if (!document.cookie.includes(`${THEME_KEY}=`)) { + const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches; + const fallback: Theme = prefersLight ? 'light' : 'dark'; + if (fallback !== theme) setTheme(fallback); + } + + if ((storedLang === 'en' || storedLang === 'tr') && storedLang !== lang) setLang(storedLang); + // Mount-only on purpose — this reconciles the server guess, it does not track changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const value = useMemo( + () => ({ + theme, + lang, + t: dictionaryFor(lang), + toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'), + toggleLang: () => setLang(lang === 'en' ? 'tr' : 'en'), + setTheme, + setLang, + }), + [theme, lang, setTheme, setLang], + ); + + return {children}; +} + +export { THEME_KEY, LANG_KEY, DEFAULT_LANG }; diff --git a/apps/editor/panel/components/auth/auth-shell.tsx b/apps/editor/panel/components/auth/auth-shell.tsx new file mode 100644 index 000000000..bbd39191d --- /dev/null +++ b/apps/editor/panel/components/auth/auth-shell.tsx @@ -0,0 +1,60 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { GlowBlobs, GridBackdrop } from '@panel/components/ui/backdrop'; +import { LangToggle, ThemeToggle } from '@panel/components/ui/controls'; +import { useBreakpoint } from '@panel/lib/hooks/use-breakpoint'; + +/** + * The shared frame for the six single-column auth screens (sign-in has its own + * two-pane layout). Centred column, 400 px card ceiling, grid backdrop behind. + * Below 700 px the card takes the full width and the backdrop grid switches off, + * which is what keeps a phone from paying for a decorative animation. + */ +export function AuthShell({ + children, + label, + paused = false, +}: { + children: ReactNode; + label: string; + paused?: boolean; +}) { + const { isMobile } = useBreakpoint(); + + return ( +
+ {!isMobile ? ( + <> + + + + ) : null} + + {/* A landmark, not a div: the console shell already wraps its content in +
, and without the same here every auth screen offered a screen + reader no way to skip the backdrop and the two toggles. */} +
+
+ + +
+ {children} +
+
+ ); +} + +/** The mono signature line that closes every auth screen. */ +export function AuthFooter({ protectedUpper, signature }: { protectedUpper: string; signature: string }) { + return ( + // No text-transform here: one uppercase pass over mixed Turkish and brand + // text is always wrong for half of it, so the literals carry their own case. +

+ {protectedUpper} · {signature} +

+ ); +} diff --git a/apps/editor/panel/components/auth/mfa-recovery-screen.tsx b/apps/editor/panel/components/auth/mfa-recovery-screen.tsx new file mode 100644 index 000000000..2a7c66f41 --- /dev/null +++ b/apps/editor/panel/components/auth/mfa-recovery-screen.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useApp } from '@panel/components/app-providers'; +import { AuthFooter, AuthShell } from '@panel/components/auth/auth-shell'; +import { AuthCard, Button, Field, FieldLabel } from '@panel/components/ui/controls'; +import { ErrorBox, Kicker, ScreenTitle, SuccessMark } from '@panel/components/ui/feedback'; +import { call } from '@panel/lib/client-api'; +import type { MfaRecoveryResponse } from '@panel/lib/api-contract'; +import { resolveApiMessage } from '@panel/lib/i18n'; + +export function MfaRecoveryScreen() { + const { t } = useApp(); + const router = useRouter(); + + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(false); + const [remaining, setRemaining] = useState(0); + const [nextRoute, setNextRoute] = useState('/console/overview'); + + const submit = useCallback(async () => { + // Client-side shape check first, so an obviously malformed code never costs + // a failed attempt against the account's lockout counter. + if (!/^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/.test(value.trim())) { + setError(t.recErr); + return; + } + + setBusy(true); + setError(null); + const res = await call('/api/mfa/recovery', { body: { code: value.trim().toUpperCase() } }); + setBusy(false); + + if (!res.ok) { + setError(resolveApiMessage(t, res.messageKey, { seconds: Number(res.details.retryAfterSeconds ?? 30) })); + return; + } + + setRemaining(res.data.codesRemaining); + setNextRoute(res.data.state === 'firstSignIn' ? '/welcome' : '/console/overview'); + setDone(true); + }, [value, t]); + + return ( + + + {done ? ( +
+ +
+

{t.recDoneTitle}

+

{t.recDoneLead}

+ + {remaining} {t.muTitleCodes.toLocaleLowerCase()} + +
+ +
+ ) : ( +
+
+ {t.recKick} + +
+ + {error ? {error} : null} + +
+ {t.recLabel} + { + setValue(e.target.value.toUpperCase()); + setError(null); + }} + onKeyDown={(e) => e.key === 'Enter' && void submit()} + className="font-mono uppercase tracking-[0.08em]" + /> +
+ +
+ + +
+
+ )} +
+ + +
+ ); +} diff --git a/apps/editor/panel/components/auth/mfa-setup-screen.tsx b/apps/editor/panel/components/auth/mfa-setup-screen.tsx new file mode 100644 index 000000000..bdfcde9eb --- /dev/null +++ b/apps/editor/panel/components/auth/mfa-setup-screen.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { QrCode } from 'lucide-react'; +import { useApp } from '@panel/components/app-providers'; +import { AuthFooter, AuthShell } from '@panel/components/auth/auth-shell'; +import { OtpInput } from '@panel/components/auth/otp-input'; +import { AuthCard, Button, Checkbox } from '@panel/components/ui/controls'; +import { ErrorBox, Kicker, ScreenTitle } from '@panel/components/ui/feedback'; +import { call } from '@panel/lib/client-api'; +import type { MfaSetupResponse, MfaVerifyResponse } from '@panel/lib/api-contract'; +import { resolveApiMessage } from '@panel/lib/i18n'; +import { Caps } from '@panel/components/ui/caps'; + +type Step = 'scan' | 'verify' | 'codes'; + +export function MfaSetupScreen() { + const { t } = useApp(); + const router = useRouter(); + + const [step, setStep] = useState('scan'); + const [qr, setQr] = useState(null); + const [manualKey, setManualKey] = useState(''); + const [code, setCode] = useState(Array(6).fill('')); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [stored, setStored] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void (async () => { + const res = await call('/api/mfa/setup', { body: {} }); + if (!res.ok) { + if (res.code === 'unauthenticated') { + router.replace('/signin'); + return; + } + // Already enrolled — the code screen is the right place, not setup. + if (res.code === 'conflict') { + router.replace('/mfa'); + return; + } + setError(resolveApiMessage(t, res.messageKey)); + return; + } + setQr(res.data.qrDataUrl); + setManualKey(res.data.manualKey); + })(); + // Runs once: a re-run would mint a new secret mid-enrolment. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const verify = useCallback( + async (joined?: string) => { + const value = joined ?? code.join(''); + if (value.length !== 6) { + setError(t.muCodeErr); + return; + } + + setBusy(true); + setError(null); + const res = await call('/api/mfa/verify', { body: { code: value, trustDevice: false } }); + setBusy(false); + + if (!res.ok) { + setError(resolveApiMessage(t, res.messageKey, { seconds: Number(res.details.retryAfterSeconds ?? 30) })); + setCode(Array(6).fill('')); + return; + } + + setRecoveryCodes(res.data.recoveryCodes ?? []); + setStep('codes'); + }, + [code, t], + ); + + const copyKey = useCallback(async () => { + try { + await navigator.clipboard.writeText(manualKey.replace(/\s/g, '')); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + /* clipboard blocked — the key is on screen to type by hand */ + } + }, [manualKey]); + + const downloadCodes = useCallback(() => { + const body = + `DigitalTwin — recovery codes\n` + + `Each code works once. Store them somewhere safe and offline.\n\n` + + recoveryCodes.map((c) => ` ${c}`).join('\n') + + `\n`; + const url = URL.createObjectURL(new Blob([body], { type: 'text/plain;charset=utf-8' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'digitaltwin-recovery-codes.txt'; + anchor.click(); + URL.revokeObjectURL(url); + }, [recoveryCodes]); + + const title = step === 'scan' ? t.muTitleScan : step === 'verify' ? t.muTitleVerify : t.muTitleCodes; + const lead = step === 'scan' ? t.muLeadScan : step === 'verify' ? t.muLeadVerify : t.muLeadCodes; + + return ( + + +
+ {t.muKick} + +
+ + {error ? {error} : null} + + {step === 'scan' ? ( + <> +
+
+ {qr ? ( + {t.muQr} + ) : ( + <> + + {t.muQr} + + )} +
+
+ + {t.muManual} + + {manualKey || '····'} + +
+
+ + + ) : null} + + {step === 'verify' ? ( + <> + void verify(joined)} invalid={Boolean(error)} /> +
+ + +
+ + ) : null} + + {step === 'codes' ? ( + <> +
+ {recoveryCodes.map((rc) => ( + + {rc} + + ))} +
+ + + {t.muSavedLbl} + + {/* The finish button stays inert until the codes are acknowledged — + the one place in the flow where a checkbox is a real gate. */} +
+ +
+ + ) : null} +
+ + +
+ ); +} diff --git a/apps/editor/panel/components/auth/mfa-verify-screen.tsx b/apps/editor/panel/components/auth/mfa-verify-screen.tsx new file mode 100644 index 000000000..c6f347efa --- /dev/null +++ b/apps/editor/panel/components/auth/mfa-verify-screen.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useApp } from '@panel/components/app-providers'; +import { AuthFooter, AuthShell } from '@panel/components/auth/auth-shell'; +import { OtpInput } from '@panel/components/auth/otp-input'; +import { Button, AuthCard, Checkbox } from '@panel/components/ui/controls'; +import { ErrorBox, Kicker, ScreenTitle } from '@panel/components/ui/feedback'; +import { call } from '@panel/lib/client-api'; +import type { MfaVerifyResponse, SessionResponse } from '@panel/lib/api-contract'; +import { resolveApiMessage } from '@panel/lib/i18n'; +import { Caps } from '@panel/components/ui/caps'; + +export function MfaVerifyScreen() { + const { t } = useApp(); + const router = useRouter(); + + const [code, setCode] = useState(Array(6).fill('')); + const [trustDevice, setTrustDevice] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [resent, setResent] = useState(false); + const [account, setAccount] = useState(''); + + // The half-open session already knows who is signing in — showing the address + // here is what makes "Use another account" a meaningful offer. + useEffect(() => { + void call('/api/auth/session').then((res) => { + if (!res.ok) return; + if (res.data.state === 'anonymous') { + router.replace('/signin'); + return; + } + if (res.data.user) setAccount(res.data.user.email); + }); + }, [router]); + + const verify = useCallback( + async (joined?: string) => { + const value = joined ?? code.join(''); + if (value.length !== 6) { + setError(t.errCode); + return; + } + + setBusy(true); + setError(null); + const res = await call('/api/mfa/verify', { + body: { code: value, trustDevice }, + }); + setBusy(false); + + if (!res.ok) { + setError( + resolveApiMessage(t, res.messageKey, { seconds: Number(res.details.retryAfterSeconds ?? 30) }), + ); + setCode(Array(6).fill('')); + return; + } + + router.push(res.data.state === 'firstSignIn' ? '/welcome' : '/console/overview'); + }, + [code, trustDevice, router, t], + ); + + const signOutAndRestart = useCallback(async () => { + await call('/api/auth/signout', { body: { allDevices: false } }); + router.push('/signin'); + }, [router]); + + return ( + + +
+ {t.step2} + + {account ? {account} : null} +
+ + {error ? {error} : null} + + void verify(joined)} invalid={Boolean(error)} /> + + + {t.trustDevice} + + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+ ); +} diff --git a/apps/editor/panel/components/auth/otp-input.tsx b/apps/editor/panel/components/auth/otp-input.tsx new file mode 100644 index 000000000..70fef3163 --- /dev/null +++ b/apps/editor/panel/components/auth/otp-input.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useEffect, useRef, type ClipboardEvent, type KeyboardEvent } from 'react'; +import { useBreakpoint } from '@panel/lib/hooks/use-breakpoint'; +import { cn } from '@panel/lib/cn'; + +/** + * Six-cell OTP entry with the three ergonomics the design calls out by name: + * paste all six digits into any cell, auto-advance while typing, and Backspace + * on an empty cell steps back instead of doing nothing. + * + * `min-width: 0` on the cells is load-bearing — an 's intrinsic minimum + * width pushed the six cells outside the card without it. + */ +export function OtpInput({ + value, + onChange, + onComplete, + invalid = false, + autoFocus = true, +}: { + value: string[]; + onChange: (next: string[]) => void; + onComplete?: (code: string) => void; + invalid?: boolean; + autoFocus?: boolean; +}) { + const { touch } = useBreakpoint(); + const refs = useRef>([]); + + useEffect(() => { + if (autoFocus) refs.current[0]?.focus(); + }, [autoFocus]); + + const commit = (next: string[]) => { + onChange(next); + const joined = next.join(''); + if (joined.length === 6 && onComplete) onComplete(joined); + }; + + const setCell = (index: number, raw: string) => { + const digit = raw.replace(/\D/g, '').slice(-1); + const next = [...value]; + next[index] = digit; + commit(next); + if (digit && index < 5) refs.current[index + 1]?.focus(); + }; + + const onKeyDown = (index: number, event: KeyboardEvent) => { + if (event.key === 'Backspace' && !value[index] && index > 0) { + event.preventDefault(); + const next = [...value]; + next[index - 1] = ''; + onChange(next); + refs.current[index - 1]?.focus(); + return; + } + if (event.key === 'ArrowLeft' && index > 0) { + event.preventDefault(); + refs.current[index - 1]?.focus(); + } + if (event.key === 'ArrowRight' && index < 5) { + event.preventDefault(); + refs.current[index + 1]?.focus(); + } + }; + + const onPaste = (event: ClipboardEvent) => { + const digits = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6); + if (!digits) return; + event.preventDefault(); + const next = Array.from({ length: 6 }, (_, i) => digits[i] ?? ''); + commit(next); + refs.current[Math.min(digits.length, 5)]?.focus(); + }; + + return ( +
+ {Array.from({ length: 6 }, (_, index) => ( + { + refs.current[index] = el; + }} + type="text" + inputMode="numeric" + autoComplete="one-time-code" + maxLength={1} + aria-label={`Digit ${index + 1} of 6`} + value={value[index] ?? ''} + onChange={(e) => setCell(index, e.target.value)} + onKeyDown={(e) => onKeyDown(index, e)} + onPaste={onPaste} + className={cn( + 'w-0 min-w-0 flex-1 rounded-[8px] bg-field text-center font-mono text-[18px] font-medium text-fg outline-none', + 'border focus:shadow-[0_0_0_3px_var(--dt-hover)]', + invalid ? 'border-destructive' : 'border-input focus:border-ring', + touch ? 'h-[54px]' : 'h-[46px]', + )} + /> + ))} +
+ ); +} diff --git a/apps/editor/panel/components/auth/request-access-screen.tsx b/apps/editor/panel/components/auth/request-access-screen.tsx new file mode 100644 index 000000000..3d84af5b1 --- /dev/null +++ b/apps/editor/panel/components/auth/request-access-screen.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useApp } from '@panel/components/app-providers'; +import { AuthFooter, AuthShell } from '@panel/components/auth/auth-shell'; +import { AuthCard, Button, Field, FieldLabel, SegBar, SegButton } from '@panel/components/ui/controls'; +import { ErrorBox, ScreenTitle, SuccessMark } from '@panel/components/ui/feedback'; +import { call } from '@panel/lib/client-api'; +import type { AccessRequestResponse } from '@panel/lib/api-contract'; +import { resolveApiMessage } from '@panel/lib/i18n'; +import { useBreakpoint } from '@panel/lib/hooks/use-breakpoint'; +import { Caps } from '@panel/components/ui/caps'; + +const DOMAIN = '@netlog.com.tr'; +const DEPARTMENTS = ['Warehouse', 'Operations', 'Engineering', 'IT']; +const ROLES = ['Editor', 'Viewer']; + +export function RequestAccessScreen() { + const { t } = useApp(); + const router = useRouter(); + const { touch } = useBreakpoint(); + + const [fullName, setFullName] = useState(''); + const [username, setUsername] = useState(''); + const [department, setDepartment] = useState(DEPARTMENTS[0]); + const [role, setRole] = useState(ROLES[0]); + const [note, setNote] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [sent, setSent] = useState(false); + + const submit = useCallback(async () => { + if (!fullName.trim() || !username.trim()) { + setError(t.errFields); + return; + } + + setBusy(true); + setError(null); + const res = await call('/api/requests', { + body: { + fullName: fullName.trim(), + // Local part only. The domain is a fixed adornment here and is applied + // server-side, so a foreign address cannot be typed past the form. + username: username.trim().toLowerCase().replace(/@.*$/, ''), + department, + requestedRole: role, + note: note.trim() || undefined, + }, + }); + setBusy(false); + + if (!res.ok) { + setError(resolveApiMessage(t, res.messageKey)); + return; + } + setSent(true); + }, [fullName, username, department, role, note, t]); + + return ( + +
+ + {sent ? ( +
+ +
+

{t.reqSentTitle}

+

{t.reqSentLead}

+ + {username.trim().toLowerCase()} + {DOMAIN} + +
+ +
+ ) : ( +
+
+ +
+ + {error ? {error} : null} + +
+ {t.fullName} + setFullName(e.target.value)} + /> +
+ +
+ {t.workEmail} +
+ setUsername(e.target.value)} + className={`min-w-0 flex-1 bg-transparent px-[11px] text-[13px] text-fg outline-none ${ + touch ? 'h-[46px]' : 'h-[38px]' + }`} + /> + + {DOMAIN} + +
+ {t.usernameHint} +
+ +
+ + {t.department} + + + {DEPARTMENTS.map((d) => ( + setDepartment(d)}> + {d} + + ))} + +
+ +
+ + {t.accessNeeded} + + + {ROLES.map((r) => ( + setRole(r)}> + {r} + + ))} + +
+ +
+ {t.whichSites} +