diff --git a/.agents/rules/nextjs_turbopack.md b/.agents/rules/nextjs_turbopack.md
new file mode 100644
index 0000000000..9c94e428ad
--- /dev/null
+++ b/.agents/rules/nextjs_turbopack.md
@@ -0,0 +1,68 @@
+# Node builtins and the browser bundle
+
+**Classification:** Rule
+
+## Rationale
+
+A build that fails with
+
+```
+the chunking context (unknown) does not support external modules (request: node:fs)
+```
+
+is telling you that something reachable from a **client** module imports a Node
+builtin. The bundler is not misconfigured; the import graph is wrong.
+
+## What actually happened here
+
+`e2bd6351` added `apps/editor/components/mcp-relay-client.tsx`:
+
+```tsx
+'use client'
+import { createPascalMcpServer } from '@pascal-app/mcp/server'
+```
+
+`'use client'` sends a module to the browser, and with it **every module it
+imports, at any depth**. The chain ran:
+
+```
+mcp-relay-client.tsx ('use client')
+ └─ @pascal-app/mcp/server
+ └─ registerTools()
+ └─ tools/scene-lifecycle/metadata.ts
+ └─ import { createHash } from 'node:crypto'
+```
+
+Four commits then tried to fix it through configuration —
+`transpilePackages`, removing plugins from the bundle, bumping Next — and all
+four failed, because none of them touched the chain.
+
+## Directives
+
+1. **No module reachable from a `'use client'` file may import `node:*`,** at
+ any import depth. Trace the chain from the client file to the builtin before
+ changing any config; that is where the fix belongs.
+
+2. **Do not use `'use server'` for this.** It does not mean "keep this on the
+ server". It marks a file as a *Server Actions* module and requires every
+ export in it to be an async function — adding it to a `page.tsx`, which
+ default-exports a component, is a hard build error. `9720d493` tried exactly
+ this and its deploy failed.
+
+3. **The mechanisms that do work:** `import 'server-only'` (fails at build time
+ if the module reaches the client), simply not writing `'use client'` (App
+ Router modules are server modules by default), or `serverExternalPackages` in
+ `next.config.ts` for a package that must stay on Node.
+
+4. **A package needed on both sides needs a browser-safe entry point.** If the
+ browser genuinely has to run something the server package provides, the
+ package exports a second entry built without Node builtins — Web Crypto in
+ place of `node:crypto`, and so on. There is no config that makes a Node
+ builtin resolve in a browser bundle.
+
+## Note on placement
+
+Agent tooling in this repo reads `.agents/skills/`, not `.agents/rules/` —
+`.claude/`, `.cursor/` and `.codex/` symlink only `skills`. Nothing loads this
+file automatically today; it is kept as the written record of a four-day
+outage, and it is correct if someone does read it.
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 06b6abd8d5..0000000000
--- a/.dockerignore
+++ /dev/null
@@ -1,10 +0,0 @@
-.git
-node_modules
-**/node_modules
-**/.next
-**/.turbo
-**/dist
-.env
-.env.local
-.env*.local
-*.log
diff --git a/.github/deploy/README.md b/.github/deploy/README.md
new file mode 100644
index 0000000000..b1f74582b3
--- /dev/null
+++ b/.github/deploy/README.md
@@ -0,0 +1,120 @@
+# 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 |
+|---|---|
+| `DIGITALTWIN_MYSQL_HOST` | `localhost` |
+| `DIGITALTWIN_MYSQL_USER` | database user |
+| `DIGITALTWIN_MYSQL_PASSWORD` | database password |
+| `DIGITALTWIN_MYSQL_DATABASE` | database name |
+| `DIGITALTWIN_MYSQL_PORT` | `3306` (optional) |
+
+or, as a single value:
+
+| Name | Value |
+|---|---|
+| `DIGITALTWIN_MYSQL_URL` | `mysql://user:password@localhost:3306/database` |
+
+In `DIGITALTWIN_MYSQL_URL`, percent-encode any of `@ : / ? # [ ] %` that appear
+in the password — `@` becomes `%40`, `#` becomes `%23`. The separate fields
+need no encoding, which is why they are listed first.
+
+Optional:
+
+| Name | Value |
+|---|---|
+| `DIGITALTWIN_ADMIN_EMAIL` | the address that gets the admin role on sign-up |
+
+**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. There is **no override**: `DIGITALTWIN_ALLOW_SQLITE`
+used to grant one and was removed, because a variable that quietly moves a
+customer's scenes onto a filesystem the host wipes every release is not worth
+the convenience it bought.
+
+Every variable is also read under its older `PASCAL_` name, so an existing
+deployment keeps working until it is renamed.
+
+## When the panel forgets its variables
+
+Some panels drop their environment variables on redeploy, which takes the
+database down with them. The same settings can come from a file instead. A real
+environment variable always wins, so the panel stays in charge wherever it is
+configured.
+
+Two locations are read, in this order:
+
+| Path | Survives a redeploy? |
+|---|---|
+| `.env` next to `server.js` | only until the next release replaces this tree |
+| `~/.digitaltwin.env` | yes — it sits outside the deployed directory |
+
+Prefer the second. Create it once with the host's file manager:
+
+ DIGITALTWIN_MYSQL_HOST=127.0.0.1
+ DIGITALTWIN_MYSQL_PORT=3306
+ DIGITALTWIN_MYSQL_USER=your_user
+ DIGITALTWIN_MYSQL_PASSWORD=your_password
+ DIGITALTWIN_MYSQL_DATABASE=your_database
+
+`KEY=value` per line; `#` starts a comment; surrounding quotes are stripped.
+Values are never expanded, so a `$` in a password is a literal `$`.
+
+The boot log names the files it read and how many settings each supplied — the
+values are credentials and are never logged. `DIGITALTWIN_ENV_FILE` points at a
+different path when neither default suits.
+
+A `.env` committed here is carried across by the publish workflow, which
+otherwise replaces this tree wholesale. It is still a credential in git
+history — the home-directory file avoids that.
+
+## 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 0000000000..0268f73b32
--- /dev/null
+++ b/.github/deploy/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "digitaltwin",
+ "version": "2.15.0",
+ "private": true,
+ "type": "module",
+ "description": "DigitalTwin, compiled and ready to run. Nothing is built at deploy time.",
+ "engines": {
+ "node": ">=20.9"
+ },
+ "scripts": {
+ "build": "node setup-native.mjs",
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "@node-rs/argon2": "2.0.2",
+ "@opentelemetry/api": "1.9.1",
+ "mysql2": "3.23.2",
+ "next": "16.3.0",
+ "nodemailer": "9.0.3",
+ "otpauth": "9.5.1",
+ "qrcode": "1.5.4",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "sharp": "0.34.5",
+ "ulid": "3.0.2"
+ }
+}
\ No newline at end of file
diff --git a/.github/deploy/setup-native.mjs b/.github/deploy/setup-native.mjs
new file mode 100644
index 0000000000..ec9d7e85be
--- /dev/null
+++ b/.github/deploy/setup-native.mjs
@@ -0,0 +1,22 @@
+// Turbopack requires an externalized native package under a build-specific
+// hashed alias (e.g. "@node-rs/argon2-4d195bca84303183") but emits no package
+// by that name. Recreate the alias as a symlink to the real install. Runs as
+// the bundle's "build" step, i.e. right after npm install on the host.
+import { readdirSync, readFileSync, mkdirSync, symlinkSync, existsSync, rmSync } from 'node:fs'
+import { join } from 'node:path'
+
+const chunks = join('.next', 'server', 'chunks')
+const found = new Set()
+for (const f of readdirSync(chunks)) {
+ if (!f.endsWith('.js')) continue
+ const m = readFileSync(join(chunks, f), 'utf8').matchAll(/@node-rs\/argon2-[a-f0-9]{16}/g)
+ for (const hit of m) found.add(hit[0])
+}
+for (const alias of found) {
+ const target = join('node_modules', alias)
+ if (existsSync(target)) rmSync(target, { recursive: true })
+ mkdirSync(join('node_modules', '@node-rs'), { recursive: true })
+ symlinkSync('argon2', target)
+ console.log(`[setup-native] ${alias} -> @node-rs/argon2`)
+}
+if (found.size === 0) console.log('[setup-native] no hashed native aliases found')
diff --git a/.github/workflows/bump-plugin.yml b/.github/workflows/bump-plugin.yml
new file mode 100644
index 0000000000..175d463fb5
--- /dev/null
+++ b/.github/workflows/bump-plugin.yml
@@ -0,0 +1,104 @@
+name: Bump warehouse plugin
+
+# The warehouse plugin is a git dependency pinned to an exact sha, and it
+# compiles into the app — so a plugin release reaches the site only when this
+# repository moves the pin AND the bundle is rebuilt. Doing that by hand is
+# what left a set of freeze fixes sitting unreleased: the plugin's main had
+# moved on for weeks while the pin, and therefore production, had not.
+#
+# Needs no secret. The plugin repository is public, so reading its head and
+# resolving the dependency both work with nothing configured.
+on:
+ workflow_dispatch:
+ # ovurrsl/plugin-warehouse can fire this on a push to main; the schedule is
+ # what keeps the pin current until it does.
+ repository_dispatch:
+ types: [plugin-updated]
+ schedule:
+ - cron: '42 * * * *'
+
+concurrency:
+ group: bump-plugin
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ # To hand off to the deploy — see the last step.
+ actions: write
+
+env:
+ PLUGIN_REPO: https://github.com/ovurrsl/plugin-warehouse.git
+
+jobs:
+ bump:
+ runs-on: ubuntu-latest
+ steps:
+ # A scheduled run starts on the default branch, so the branch to update
+ # is named rather than inherited. Set the INTEGRATION_BRANCH repository
+ # variable to move it; the fallback is the branch it is today.
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ vars.INTEGRATION_BRANCH || 'integration' }}
+
+ - name: Compare the pin with the plugin's head
+ id: compare
+ run: |
+ head=$(git ls-remote "$PLUGIN_REPO" refs/heads/main | cut -f1)
+ pinned=$(grep -o 'plugin-warehouse\.git#[0-9a-f]\{40\}' apps/editor/package.json | cut -d'#' -f2)
+ echo "head=$head" >> "$GITHUB_OUTPUT"
+ echo "pinned=$pinned" >> "$GITHUB_OUTPUT"
+ if [ -z "$head" ] || [ -z "$pinned" ]; then
+ echo "could not read one of them (head='$head' pinned='$pinned')" >&2
+ exit 1
+ fi
+ if [ "$head" = "$pinned" ]; then
+ echo 'the pin is current'
+ echo 'changed=false' >> "$GITHUB_OUTPUT"
+ else
+ echo "pin $pinned -> $head"
+ echo 'changed=true' >> "$GITHUB_OUTPUT"
+ fi
+
+ - uses: oven-sh/setup-bun@v2
+ if: steps.compare.outputs.changed == 'true'
+ with:
+ bun-version: 1.3.0
+
+ # The lockfile records the sha512 of the resolved tarball, so it is
+ # regenerated by a real install rather than edited — the same reason
+ # `relock.yml` exists.
+ - name: Move the pin and relock
+ if: steps.compare.outputs.changed == 'true'
+ run: |
+ sed -i "s|plugin-warehouse\.git#${{ steps.compare.outputs.pinned }}|plugin-warehouse.git#${{ steps.compare.outputs.head }}|" \
+ apps/editor/package.json
+ bun install
+
+ # The gate. A plugin release that does not compile against this editor
+ # must not reach the branch the bundle is built from — which is exactly
+ # the failure a version range would hide and an exact pin makes visible.
+ - name: Type check
+ if: steps.compare.outputs.changed == 'true'
+ run: bun run check-types
+
+ - name: Commit to the integration branch
+ if: steps.compare.outputs.changed == 'true'
+ run: |
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+ git add apps/editor/package.json bun.lock
+ git commit -m "$(printf 'chore: pin plugin-warehouse %s\n\nWas %s.' \
+ "$(echo '${{ steps.compare.outputs.head }}' | cut -c1-7)" \
+ "$(echo '${{ steps.compare.outputs.pinned }}' | cut -c1-7)")"
+ git push
+
+ # Handed off explicitly: a push made with GITHUB_TOKEN starts no workflow
+ # runs, so without this the pin moves and the site never rebuilds.
+ - name: Build and publish
+ if: steps.compare.outputs.changed == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh workflow run deploy-bundle.yml \
+ --repo "$GITHUB_REPOSITORY" \
+ --ref "${{ vars.INTEGRATION_BRANCH || 'integration' }}"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d75e7f96b..e3b5191ba1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
push:
- branches: [main]
+ branches: [integration]
pull_request:
- branches: [main]
+ branches: [integration]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -25,6 +25,7 @@ jobs:
with:
bun-version: 1.3.14
+
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -40,30 +41,22 @@ jobs:
- name: Build
run: bun run build
- cli-smoke:
- runs-on: macos-latest
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v4
-
- - uses: oven-sh/setup-bun@v2
- with:
- bun-version: 1.3.14
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Smoke-test the packed CLI and editor runtime
- env:
- PASCAL_PORTABLE_BUILD: "1"
- run: |
- bun run build --filter editor
- cd packages/cli
- bun run build
- bun run stage-runtime
- bun run smoke-runtime
+ # `cli-smoke` (upstream) was removed here on the beta.5 take, for two reasons
+ # that both point the same way.
+ #
+ # It smoke-tests `packages/cli` — Pascal's command-line runtime, which this
+ # fork deliberately does not publish; `release:cli` is left out of the root
+ # scripts for the same reason. Building it on a macOS runner for every pull
+ # request buys us nothing.
+ #
+ # It also cannot pass here as written. Its first step is
+ # `bun run build --filter editor`, and this fork's root `build` is the
+ # Hostinger packaging chain rather than upstream's plain `turbo run build`.
+ # The caller's arguments land on the script's final command, so the copy
+ # becomes `cp … server.js --filter editor` and fails with
+ # `cp: editor: Not a directory` after a full two-minute build.
+ #
+ # That argument hazard is real independently of this job — anything appended
+ # to `bun run build` corrupts the last copy — and is recorded in UPSTREAM.md
+ # as its own follow-up. Restoring this job needs both: the CLI decision
+ # reversed, and the root script made argument-safe.
\ No newline at end of file
diff --git a/.github/workflows/deploy-bundle.yml b/.github/workflows/deploy-bundle.yml
new file mode 100644
index 0000000000..1b93d49121
--- /dev/null
+++ b/.github/workflows/deploy-bundle.yml
@@ -0,0 +1,133 @@
+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: [integration]
+ 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
+ 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
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.0
+
+
+ # 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
+ cp .github/deploy/setup-native.mjs bundle/setup-native.mjs
+ # The boot hook runs these against the live database; they are read
+ # from disk at runtime, so they must travel with the bundle.
+ cp -a apps/editor/panel/migrations bundle/panel-migrations
+ printf 'node_modules/\n' > bundle/.gitignore
+
+ # 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:
+ DIGITALTWIN_MYSQL_URL: mysql://root@127.0.0.1:3306/digitaltwin
+ run: |
+ cd bundle
+ node server.js &
+ for _ in $(seq 1 30); do
+ sleep 2
+ body=$(curl -s http://127.0.0.1:3000/api/health || true)
+ echo "$body" | grep -q '"status":"ok"' && break
+ done
+ 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; }
+
+ # The bundle is a fresh tree force-pushed over the deployment repository,
+ # which would drop a `.env` committed there to survive a panel that
+ # forgets its variables. Carry the published one across.
+ - name: Publish
+ env:
+ DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
+ run: |
+ cd bundle
+ rm -rf node_modules
+ remote="https://x-access-token:${DEPLOY_TOKEN}@github.com/ovurrsl/digitaltwin.git"
+ git init -q
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+ git remote add origin "$remote"
+ if git fetch -q --depth=1 origin main 2>/dev/null; then
+ if git cat-file -e FETCH_HEAD:.env 2>/dev/null; then
+ git show FETCH_HEAD:.env > .env
+ echo "carried the existing .env across (contents not logged)"
+ fi
+ fi
+ git add -A
+ git commit -q -m "Build from ${GITHUB_SHA::7}"
+ git push -q --force "$remote" HEAD:main
diff --git a/.github/workflows/mcp-ci.yml b/.github/workflows/mcp-ci.yml
index 5bf65c4f74..ca18b33b71 100644
--- a/.github/workflows/mcp-ci.yml
+++ b/.github/workflows/mcp-ci.yml
@@ -2,7 +2,7 @@ name: mcp-ci
on:
push:
- branches: [main]
+ branches: [integration]
paths:
- 'packages/mcp/**'
- 'packages/core/**'
@@ -33,6 +33,7 @@ jobs:
with:
bun-version: 1.3.14
+
- name: Install
run: bun install --frozen-lockfile
diff --git a/.github/workflows/mirror-upstream.yml b/.github/workflows/mirror-upstream.yml
new file mode 100644
index 0000000000..2cc69a0172
--- /dev/null
+++ b/.github/workflows/mirror-upstream.yml
@@ -0,0 +1,116 @@
+name: Mirror upstream
+
+# `main` in this fork is a clean mirror of pascalorg/editor — no local commits,
+# ever. That is what makes taking upstream free: the mirror can only ever
+# fast-forward, so this job never has a decision to make and never conflicts.
+#
+# Everything this fork adds lives on the integration branch, which is also the
+# default branch. Upstream reaches it through the pull request opened below,
+# where the conflicts are — and where a human belongs. UPSTREAM.md carries the
+# rule per file.
+#
+# Deliberately NOT a force push. If `main` has diverged, someone committed to
+# the mirror and the job should fail loudly rather than erase it.
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 5 * * *'
+
+concurrency:
+ group: mirror-upstream
+ cancel-in-progress: true
+
+# `MIRROR_TOKEN` rather than the built-in token, and the reason is one line in
+# a rejected push:
+#
+# ! [remote rejected] upstream/main -> main (refusing to allow a GitHub App
+# to create or update workflow `.github/workflows/ci.yml` without
+# `workflows` permission)
+#
+# Upstream edits its own CI from time to time, and when it does, the commit
+# carrying that edit cannot be pushed by `GITHUB_TOKEN` — there is no
+# `workflows` scope to grant it in the block below; the permission exists only
+# on a PAT or an App installation. So the mirror ran green for months, hit
+# upstream's first `ci.yml` change on 7 August, and failed every night after
+# that. Nothing else broke, which is why it went unnoticed for four days: the
+# editor kept building from a mirror that had quietly stopped moving.
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ mirror:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ # Checkout stores these credentials for every later `git push` in the
+ # job, so the token has to be set HERE — swapping only the push line
+ # would leave the built-in token in the remote and change nothing.
+ token: ${{ secrets.MIRROR_TOKEN }}
+
+ - name: Fast-forward main to upstream
+ id: mirror
+ env:
+ BASE: ${{ vars.INTEGRATION_BRANCH || 'integration' }}
+ run: |
+ git remote add upstream https://github.com/pascalorg/editor.git
+ git fetch --quiet upstream main
+ git fetch --quiet origin \
+ "+refs/heads/main:refs/remotes/origin/main" \
+ "+refs/heads/$BASE:refs/remotes/origin/$BASE"
+
+ if [ "$(git rev-parse upstream/main)" = "$(git rev-parse origin/main)" ]; then
+ echo 'the mirror is current'
+ else
+ # Refuse rather than erase: a mirror that cannot fast-forward is a
+ # mirror somebody has committed to.
+ if ! git merge-base --is-ancestor origin/main upstream/main; then
+ echo "origin/main is not an ancestor of upstream/main — it has local commits." >&2
+ echo 'Move them to the integration branch; this job will not force over them.' >&2
+ exit 1
+ fi
+
+ git push origin upstream/main:refs/heads/main
+ echo "main fast-forwarded to $(git rev-parse --short upstream/main)"
+ fi
+
+ # The pull request is owed whenever the integration branch is behind
+ # the mirror — NOT only on the run that moved the mirror. `main` can
+ # also be advanced by hand, and gating on "did this job push?" drops
+ # those updates on the floor: 42 upstream commits once sat on `main`
+ # with no pull request pointing at them for exactly this reason.
+ behind=$(git rev-list --count "refs/remotes/origin/$BASE..upstream/main")
+ echo "$behind upstream commit(s) not yet in $BASE"
+ echo "behind=$behind" >> "$GITHUB_OUTPUT"
+ if [ "$behind" -gt 0 ]; then
+ echo 'owed=true' >> "$GITHUB_OUTPUT"
+ else
+ echo 'owed=false' >> "$GITHUB_OUTPUT"
+ fi
+
+ # One long-lived pull request rather than one per update: it accumulates
+ # whatever upstream has added since the last time somebody took it, and
+ # GitHub shows the conflicts against the integration branch as they arise.
+ - name: Open or update the pull request into the integration branch
+ if: steps.mirror.outputs.owed == 'true'
+ env:
+ # The same token as the push, so the pull request is authored by it
+ # rather than by the App. A pull request opened by `GITHUB_TOKEN`
+ # starts no workflow runs, which would leave this one sitting with no
+ # CI on it — the one pull request whose whole job is to be reviewed
+ # before a human merges hundreds of upstream commits.
+ GH_TOKEN: ${{ secrets.MIRROR_TOKEN }}
+ BASE: ${{ vars.INTEGRATION_BRANCH || 'integration' }}
+ BEHIND: ${{ steps.mirror.outputs.behind }}
+ run: |
+ body=$(printf '`main` mirrors `pascalorg/editor` and is **%s commit(s)** ahead of `%s`.\n\nThis carries those changes into the integration branch, where everything this fork adds lives. Conflicts are expected — the rule for each file that regularly conflicts is in `UPSTREAM.md`, and the `Upstream check` workflow reports the list before you start.\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_\n' "$BEHIND" "$BASE")
+
+ state=$(gh pr view main --repo "$GITHUB_REPOSITORY" --json state -q .state 2>/dev/null || true)
+ if [ "$state" = 'OPEN' ]; then
+ gh pr edit main --repo "$GITHUB_REPOSITORY" --body "$body"
+ else
+ gh pr create --repo "$GITHUB_REPOSITORY" --head main --base "$BASE" \
+ --title 'Take upstream' --body "$body"
+ fi
diff --git a/.github/workflows/pull-panel.yml b/.github/workflows/pull-panel.yml
new file mode 100644
index 0000000000..b01420bfa9
--- /dev/null
+++ b/.github/workflows/pull-panel.yml
@@ -0,0 +1,125 @@
+name: Pull console
+
+# The console is developed in ovurrsl/panel and vendored here
+# (apps/editor/panel). This pulls it inward — the direction that matters now
+# that the console repository is its home.
+#
+# Gated on a type check, deliberately. Pushing straight to the integration
+# branch is what makes this automatic, and automatic without a gate means one
+# bad console commit quietly breaks the branch the deploy builds from. If the
+# check fails the job goes red and nothing is pushed, which is the signal.
+#
+# Needs PANEL_TOKEN — the same secret the outbound sync uses, but read-only is
+# enough here. Without it the job says so and stops, rather than failing with a
+# confusing git error.
+on:
+ workflow_dispatch:
+ # ovurrsl/panel can fire this when it changes; until it does, the schedule
+ # below is what keeps the copy current.
+ repository_dispatch:
+ types: [panel-updated]
+ schedule:
+ - cron: '17 * * * *'
+
+concurrency:
+ group: pull-console
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ # To hand off to the deploy. A push made with GITHUB_TOKEN starts no runs, so
+ # without this the console would land on the branch and stop there.
+ actions: write
+
+jobs:
+ pull:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check for the console token
+ id: token
+ env:
+ PANEL_TOKEN: ${{ secrets.PANEL_TOKEN }}
+ run: |
+ if [ -z "$PANEL_TOKEN" ]; then
+ echo 'PANEL_TOKEN is not set — add a token with read access to'
+ echo 'ovurrsl/panel under Settings > Secrets > Actions to enable this.'
+ echo 'available=false' >> "$GITHUB_OUTPUT"
+ else
+ echo 'available=true' >> "$GITHUB_OUTPUT"
+ fi
+
+ # A scheduled run starts on the default branch, so the branch to update is
+ # named rather than inherited. Set the INTEGRATION_BRANCH repository
+ # variable to move it; the fallback is the branch it is today.
+ - name: Check out the integration branch
+ if: steps.token.outputs.available == 'true'
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ vars.INTEGRATION_BRANCH || 'integration' }}
+
+ - name: Check out the console
+ if: steps.token.outputs.available == 'true'
+ uses: actions/checkout@v4
+ with:
+ repository: ovurrsl/panel
+ token: ${{ secrets.PANEL_TOKEN }}
+ path: panel-upstream
+
+ - name: Apply the pull
+ if: steps.token.outputs.available == 'true'
+ id: pull
+ run: |
+ # `tee` would otherwise report its own exit status and swallow a
+ # crashed sync — the default shell here is `bash -e`, not `-eo
+ # pipefail`.
+ set -o pipefail
+ node scripts/sync-panel.mjs --panel panel-upstream --pull | tee pull.log
+
+ # Scoped to exactly the paths the commit below stages. A bare
+ # `git status --porcelain` is never empty in this job: `pull.log` and
+ # the `panel-upstream/` checkout are both untracked and neither is
+ # ignored. Unscoped, `changed` is therefore always true, and every
+ # hourly run where the console did not move would reach `git commit`
+ # with nothing staged and go red.
+ if [ -z "$(git status --porcelain -- 'apps/editor/panel' 'apps/editor/app/(panel)' 'apps/editor/app/api')" ]; then
+ echo 'changed=false' >> "$GITHUB_OUTPUT"
+ else
+ echo 'changed=true' >> "$GITHUB_OUTPUT"
+ fi
+
+ - uses: oven-sh/setup-bun@v2
+ if: steps.token.outputs.available == 'true' && steps.pull.outputs.changed == 'true'
+
+ - name: Install dependencies
+ if: steps.token.outputs.available == 'true' && steps.pull.outputs.changed == 'true'
+ run: bun install --frozen-lockfile
+
+ # The gate. A console change that does not compile here must not reach the
+ # branch the bundle is built from.
+ - name: Type check
+ if: steps.token.outputs.available == 'true' && steps.pull.outputs.changed == 'true'
+ run: bun run check-types
+
+ - name: Commit to the integration branch
+ if: steps.token.outputs.available == 'true' && steps.pull.outputs.changed == 'true'
+ run: |
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+ # Only the vendored paths — the sync writes nowhere else, and naming
+ # them keeps an unrelated stray file out of the commit.
+ git add 'apps/editor/panel' 'apps/editor/app/(panel)' 'apps/editor/app/api'
+ git commit -m "$(printf 'chore(panel): pull the console\n\n%s' "$(cat pull.log)")"
+ git push
+
+ # Handed off explicitly, because the push above cannot do it: a push made
+ # with GITHUB_TOKEN starts no workflow runs. Without this the console
+ # lands on the branch and the site never rebuilds — the chain would look
+ # wired and quietly stop one step short.
+ - name: Build and publish
+ if: steps.token.outputs.available == 'true' && steps.pull.outputs.changed == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh workflow run deploy-bundle.yml \
+ --repo "$GITHUB_REPOSITORY" \
+ --ref "${{ vars.INTEGRATION_BRANCH || 'integration' }}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 560eaeda86..c7b95589ed 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -83,6 +83,7 @@ jobs:
node-version: 22
registry-url: "https://registry.npmjs.org"
+
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/.github/workflows/relock.yml b/.github/workflows/relock.yml
new file mode 100644
index 0000000000..a24fa3f0a1
--- /dev/null
+++ b/.github/workflows/relock.yml
@@ -0,0 +1,56 @@
+name: Relock
+
+# Regenerates bun.lock on a real runner and pushes the result back to the
+# branch it was dispatched on. The lockfile records the sha512 of each GitHub
+# tarball, and only a machine that can reach the real api.github.com can
+# compute those — a sandboxed environment cannot, so it delegates to this.
+#
+# It used to fire on edits to ITSELF, because dispatch needs the file on the
+# default branch and the integration branch was not it. Now it is, so the hack
+# is gone and this is dispatched like anything else. The trailing marker
+# comments below are what remains of it — kept as a record of which relocks
+# were run that way, not as a mechanism.
+#
+# Routine plugin bumps no longer come here: `bump-plugin.yml` moves the pin and
+# relocks in one job. This is for the rest — a dependency added or changed by
+# hand, or a lockfile that drifted.
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ relock:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # Must match `packageManager` in the root package.json. A lockfile written
+ # by an older bun than the one CI installs with is regenerated on install,
+ # and `--frozen-lockfile` turns that into a failed build — a relock that
+ # produces a lockfile CI then rejects is worse than no relock at all.
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.14
+
+ - name: Regenerate the lockfile
+ run: bun install
+
+ - name: Push it back
+ run: |
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+ git add bun.lock
+ if git diff --cached --quiet; then
+ echo 'lockfile already in sync'
+ exit 0
+ fi
+ git commit -m 'chore: regenerate bun.lock on a real runner'
+ git push
+
+# relock: declare mysql2 where it is imported
+
+# relock: plugin-warehouse v0.1.1 (1c73ce2)
+
+# relock: plugin-warehouse v0.1.2 (49b2f16)
diff --git a/.github/workflows/sync-panel.yml b/.github/workflows/sync-panel.yml
new file mode 100644
index 0000000000..887d25b8fd
--- /dev/null
+++ b/.github/workflows/sync-panel.yml
@@ -0,0 +1,95 @@
+name: Sync console upstream
+
+# The console is vendored here (apps/editor/panel) and owned by ovurrsl/panel.
+# This pushes the copy back as a pull request rather than a direct commit: the
+# console has its own tests and its own history, and its owner should see what
+# the integration changed before taking it.
+#
+# MANUAL ONLY, and that is the point. The console repository is the home of the
+# console now, so the automatic direction is inward (`pull-panel.yml`). Leaving
+# this on `push` too would give one file two masters: a change made in the
+# console flows here, this fires on that commit and pushes it straight back,
+# and the two workflows spend the day answering each other. Whichever ran last
+# would look right.
+#
+# What it is still for: seeding the console after a change had to be made here
+# (an integration fix, a migration written while wiring it up). Run it by hand,
+# take the pull request, and the console is the source again.
+#
+# Needs PANEL_TOKEN — a token with write access to ovurrsl/panel. Without it the
+# job says so and stops, rather than failing with a confusing git error.
+on:
+ workflow_dispatch:
+
+concurrency:
+ group: sync-panel
+ cancel-in-progress: true
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check for the upstream token
+ id: token
+ env:
+ PANEL_TOKEN: ${{ secrets.PANEL_TOKEN }}
+ run: |
+ if [ -z "$PANEL_TOKEN" ]; then
+ echo 'PANEL_TOKEN is not set — add a token with write access to'
+ echo 'ovurrsl/panel under Settings > Secrets > Actions to enable this.'
+ echo 'available=false' >> "$GITHUB_OUTPUT"
+ else
+ echo 'available=true' >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Check out the console
+ if: steps.token.outputs.available == 'true'
+ uses: actions/checkout@v4
+ with:
+ repository: ovurrsl/panel
+ token: ${{ secrets.PANEL_TOKEN }}
+ path: panel-upstream
+
+ - name: Apply the sync
+ if: steps.token.outputs.available == 'true'
+ id: sync
+ run: |
+ node scripts/sync-panel.mjs --panel panel-upstream | tee sync.log
+ cd panel-upstream
+ if git diff --quiet && [ -z "$(git status --porcelain)" ]; then
+ echo 'changed=false' >> "$GITHUB_OUTPUT"
+ else
+ echo 'changed=true' >> "$GITHUB_OUTPUT"
+ fi
+
+ # A fixed branch name means repeated syncs update one pull request instead
+ # of opening a new one for every push.
+ - name: Open or update the pull request
+ if: steps.token.outputs.available == 'true' && steps.sync.outputs.changed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.PANEL_TOKEN }}
+ run: |
+ cd panel-upstream
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+ git checkout -B sync/from-editor
+ git add -A
+ git commit -m "sync: changes from the editor integration (${GITHUB_SHA::7})"
+ git push -f origin sync/from-editor
+
+ body=$(printf 'Changes made to the console while it is vendored in `ovurrsl/editor` (`apps/editor/panel`), pushed back by its sync workflow.\n\nSource commit: `%s`\n\n```\n%s\n```\n\nImports are rewritten from `@panel/` back to `@/`; files are otherwise verbatim, so this carries the editor repository formatting.\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_\n' "$GITHUB_SHA" "$(cat ../sync.log)")
+
+ # Ask for the STATE, not merely whether a pull request exists. Branch
+ # names resolve to closed and merged pull requests too, so the plain
+ # existence check edited the body of an already-merged one and never
+ # opened a new one — while the force-push above had already landed.
+ # Silent, and it starts the moment the first sync is taken.
+ state=$(gh pr view sync/from-editor --repo ovurrsl/panel --json state -q .state 2>/dev/null || true)
+ if [ "$state" = 'OPEN' ]; then
+ gh pr edit sync/from-editor --repo ovurrsl/panel --body "$body"
+ else
+ gh pr create --repo ovurrsl/panel --head sync/from-editor --base main \
+ --title 'Sync from the editor integration' --body "$body" --draft
+ fi
diff --git a/.github/workflows/upstream-check.yml b/.github/workflows/upstream-check.yml
new file mode 100644
index 0000000000..1873506b95
--- /dev/null
+++ b/.github/workflows/upstream-check.yml
@@ -0,0 +1,41 @@
+name: Upstream check
+
+# Trial-merges pascalorg/editor into this branch and reports what would
+# conflict, so an upstream pull is never a surprise. Read-only: the merge is
+# aborted and nothing is pushed. Conflict rules per file live in UPSTREAM.md.
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 6 * * 1'
+
+permissions:
+ contents: read
+
+jobs:
+ trial-merge:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Attempt the merge
+ run: |
+ git config user.name 'trial-merge'
+ git config user.email 'trial-merge@localhost'
+ git remote add upstream https://github.com/pascalorg/editor.git
+ git fetch --quiet upstream main
+
+ echo '## Upstream trial merge' >> "$GITHUB_STEP_SUMMARY"
+ behind=$(git rev-list --count HEAD..upstream/main)
+ echo "Commits upstream is ahead by: **$behind**" >> "$GITHUB_STEP_SUMMARY"
+
+ if git merge --no-commit --no-ff upstream/main >/dev/null 2>&1; then
+ echo 'Merges **cleanly** — no conflicts.' >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo 'Conflicting files (see UPSTREAM.md for the rule per file):' >> "$GITHUB_STEP_SUMMARY"
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
+ git diff --name-only --diff-filter=U >> "$GITHUB_STEP_SUMMARY"
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
+ fi
+ git merge --abort 2>/dev/null || true
diff --git a/.gitignore b/.gitignore
index e26d858f69..354b575b45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,4 @@ og-test
.claude/launch.json
.claude/settings.local.json
.vscode/launch.json
+.agents/
diff --git a/AGENTS.md b/AGENTS.md
index 9ddb52fb38..a2eee95e50 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,39 @@
+
+
+> ## ⚠️ You are in `ovurrsl/editor`, a fork. Read this before anything else.
+>
+> Everything below this block is upstream's own instructions, written for
+> `pascalorg/editor`. They are accurate about the code and silent about this
+> fork. These four facts are the ones that cause damage when unknown:
+>
+> 1. **The default branch is `integration`, and that is where you work.** It
+> carries ~130 commits upstream does not have, and the production bundle is
+> built from it.
+> 2. **Never commit to `main`.** It is a byte-for-byte mirror of
+> `pascalorg/editor`, and being a pure mirror is the only reason taking
+> upstream never conflicts. `mirror-upstream` refuses to force over local
+> commits, so a commit here does not get erased — it jams the mirror until
+> somebody moves it by hand.
+> 3. **Three things flow in here automatically** and you should not do their
+> work by hand: the warehouse plugin pin (`bump-plugin`, hourly), the console
+> from `ovurrsl/panel` (`pull-panel`, hourly), and upstream itself
+> (`mirror-upstream`, daily, which opens a pull request rather than merging).
+> 4. **`apps/editor/panel/**` is vendored, not authored here.** Its home is
+> `ovurrsl/panel`. Editing it here is overwritten by the next hourly pull —
+> change it there instead.
+>
+> **`OTOMASYON.md`** is the whole picture in plain language: what runs when,
+> which secret each workflow needs, and where to look when a link goes quiet.
+> **`UPSTREAM.md`** is the per-file rule for merging upstream.
+>
+> One more, because it is invisible until it bites: **a push made with
+> `GITHUB_TOKEN` starts no workflow runs.** Any workflow that pushes and expects
+> a build must dispatch it explicitly.
+
+
+
# Agent Instructions — `pascalorg/editor`
Public, open-source home of `@pascal-app/{core,viewer,editor,mcp}` and the standalone editor app. Consumed both as npm packages and (in `pascalorg/private-editor`) as a git submodule.
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 11741c8318..0000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-# Matches `packageManager` in package.json and the version CI installs — a skew
-# here is what makes `--frozen-lockfile` fail inside the image but not locally.
-FROM oven/bun:1.3.14-alpine
-WORKDIR /app
-
-# `next build` runs under `node`, and this image's `node` is a shim that re-execs
-# bun (/usr/local/bun-node-fallback-bin/node). Next 16's build crashes it — a
-# segfault on 1.3.14, a turbopack CommonJS wrapper error on 1.3.0 — on both arm64
-# and amd64. CI does not hit this because GitHub runners have a real node.
-RUN apk add --no-cache nodejs
-
-COPY . .
-RUN bun install --frozen-lockfile
-RUN ./node_modules/.bin/turbo run build --filter=editor
-
-# Saved scenes live in SQLite under PASCAL_DATA_DIR; owned by the runtime user so
-# the store can create the database on first write.
-RUN mkdir -p /data && chown -R bun:bun /data /app
-ENV PASCAL_DATA_DIR=/data
-VOLUME /data
-
-USER bun
-EXPOSE 3000
-WORKDIR /app/apps/editor
-CMD ["bun", "run", "start"]
diff --git a/OTOMASYON.md b/OTOMASYON.md
new file mode 100644
index 0000000000..c54b168448
--- /dev/null
+++ b/OTOMASYON.md
@@ -0,0 +1,178 @@
+# Otomasyon — sistem nasıl çalışıyor
+
+Bu belge, beş deponun birbirine nasıl bağlandığını anlatır. Kod bilgisi
+gerektirmez. Teknik ayrıntı ve birleştirme kuralları için: `UPSTREAM.md`.
+
+Buradaki her bilgi, iş akışı dosyalarının kendisinden okunarak ve ikinci bir
+kontrol turuyla doğrulanarak yazıldı. Bir iş akışını değiştirirsen bu belgeyi de
+güncelle — yanlış belge, hiç belge olmamasından kötüdür.
+
+---
+
+## Büyük resim
+
+```
+ pascalorg/editor Pascal'ın orijinal projesi (bizim değil)
+ │
+ │ ① her gün 08:00 — otomatik
+ ▼
+ ovurrsl/editor → main Pascal'ın saf aynası. Bizim tek commitimiz bile yok.
+ │
+ │ ② öneri (pull request) açılır — BİRLEŞTİRME KARARI İNSANDA
+ ▼
+ ovurrsl/editor → integration ◄── ovurrsl/panel (③ saat başı, otomatik)
+ varsayılan dal; her şey burada ◄── ovurrsl/plugin-warehouse (④ saat başı, otomatik)
+ │
+ │ ⑤ derle → 2 duman testi → geçerse — otomatik
+ ▼
+ ovurrsl/Digitaltwin Sadece derlenmiş çıktı. Buraya kaynak kod girmez.
+ │
+ ▼
+ canlı sunucu
+```
+
+**② dışındaki her ok otomatik.** ② bilerek insanda — sebebi aşağıda.
+
+---
+
+## Neden iki dal var
+
+| Dal | Ne işe yarar |
+|---|---|
+| `main` | Pascal'ın **birebir aynası**. Buraya bizim hiçbir değişikliğimiz yazılmaz. Tam da bu yüzden hiç çakışmaz: ayna sadece ileri sarabilir. |
+| `integration` | **Varsayılan dal.** Bizim eklediğimiz her şey burada, ve canlıya giden derleme bundan yapılır. |
+
+GitHub zamanlanmış işleri **yalnızca varsayılan daldan** çalıştırır. `integration`'ın
+varsayılan dal olmasının sebebi budur — başka bir dala taşınırsa saat başı çalışan
+işlerin hepsi sessizce durur.
+
+Dal adını değiştirmek istersen: depo ayarlarından `INTEGRATION_BRANCH` değişkenini
+kur. Bütün iş akışları önce onu okur, yoksa `integration`'a düşer.
+
+---
+
+## Beş depo
+
+| Depo | Ne var içinde | Sen ne yaparsın |
+|---|---|---|
+| `pascalorg/editor` | Pascal'ın orijinal projesi | Hiçbir şey — bizim değil |
+| `ovurrsl/editor` | Fork'umuz. `main` = ayna, `integration` = bizim sürümümüz | Editörün kendisine dokunacaksan `integration`'a commit'lersin |
+| `ovurrsl/panel` | Giriş/yönetim panelinin **asıl evi** | Panel değişikliklerini burada yaparsın |
+| `ovurrsl/plugin-warehouse` | Depo/raf eklentisi (`warehouse:` düğümleri) | Raf değişikliklerini burada yaparsın |
+| `ovurrsl/Digitaltwin` | **Sadece derlenmiş çıktı** | Hiçbir şey. Elle dokunma — her yayında üzerine yazılır |
+
+---
+
+## İş akışları — hangisi ne zaman çalışır
+
+Saatler **UTC**. Türkiye UTC+3, yani parantez içindeki yerel saat.
+
+### Otomatik olanlar
+
+| İş akışı | Ne zaman | Ne yapar |
+|---|---|---|
+| **`bump-plugin`** | Her saat `:42` | Eklentinin `main`'i ile bizdeki sürüm numarasını karşılaştırır. Farklıysa günceller, kilit dosyasını tazeler, tip kontrolünden geçirir, `integration`'a yazar ve derlemeyi başlatır. |
+| **`pull-panel`** | Her saat `:17` | `ovurrsl/panel`'i çeker, `apps/editor/panel` altına yerleştirir, tip kontrolünden geçirir, `integration`'a yazar ve derlemeyi başlatır. |
+| **`mirror-upstream`** | Her gün `05:00` (08:00) | `main`'i Pascal'ın son hâline ileri sarar. `integration` geride kaldıysa **öneri açar**. |
+| **`deploy-bundle`** | `integration`'a her yazımda | Derler, iki duman testi koşar, geçerse `Digitaltwin`'e yazar. |
+| **`upstream-check`** | Pazartesi `06:00` (09:00) | Deneme birleştirmesi yapar, hangi dosyaların çakışacağını rapor eder. Hiçbir şeye yazmaz. |
+| **`ci`** | `integration`'a her yazımda ve her öneride | Biome + tip kontrolü. |
+| **`mcp-ci`** | Belirli dosyalar değişince | MCP ve sahne API testleri. |
+
+### Elle çalıştırılanlar
+
+| İş akışı | Ne zaman kullanılır |
+|---|---|
+| **`sync-panel`** | Editördeki panel dosyalarını `ovurrsl/panel`'e **geri** göndermek için. Panel deposunu ilk kez doldurmak içindir; günlük iş bu değil, ters yön (`pull-panel`) otomatiktir. |
+| **`relock`** | Bir bağımlılık elle değiştirildiğinde `bun.lock`'u gerçek bir sunucuda yeniden üretmek için. |
+| **`release`** | Pascal'ın npm paket yayınlama akışı. Bizim işimiz değil, **çalıştırma.** |
+
+---
+
+## İki anahtar
+
+Bunlar `ovurrsl/editor` → Settings → Secrets and variables → Actions altında durur.
+
+| Anahtar | Kim kullanır | Ne için | Süresi dolarsa |
+|---|---|---|---|
+| `PANEL_TOKEN` | `pull-panel`, `sync-panel` | `ovurrsl/panel`'i okumak | Panel güncellemeleri durur. `pull-panel` **kırmızı olmaz**, sessizce hiçbir şey yapmaz. |
+| `DEPLOY_TOKEN` | `deploy-bundle` | `Digitaltwin`'e yazmak | Derleme geçer, **son adım kırmızı olur**, canlı eski sürümde kalır. |
+| `MIRROR_TOKEN` | `mirror-upstream` | `main`'i ileri sarmak ve entegrasyon PR'ını açmak | Ayna durur, **kırmızı olur**. Upstream biriktikçe birikir ama canlıya hiçbir etkisi olmaz — o yüzden fark edilmesi günler alabilir. |
+
+`bump-plugin` ve `upstream-check` **hiçbir anahtar kullanmaz** — eklenti deposu
+herkese açık, Pascal'ın deposu herkese açık.
+
+> **`MIRROR_TOKEN` neden yerleşik anahtarla olmuyor.** Ayna, upstream'in
+> commit'lerini `main`'e iter. Upstream ara sıra kendi `.github/workflows/ci.yml`
+> dosyasını değiştiriyor, ve GitHub yerleşik `GITHUB_TOKEN`'ın workflow dosyası
+> yazmasını reddediyor:
+>
+> ```
+> ! [remote rejected] upstream/main -> main (refusing to allow a GitHub App to
+> create or update workflow `.github/workflows/ci.yml` without `workflows`
+> permission)
+> ```
+>
+> `permissions:` bloğunda verilebilecek bir `workflows` kapsamı YOK; bu izin
+> yalnız PAT'ta ya da App kurulumunda var. Bu yüzden anahtar fine-grained bir
+> PAT ve üç izne ihtiyacı var: **Contents** (yazma), **Workflows** (yazma),
+> **Pull requests** (yazma). Kapsamı tek depo: `ovurrsl/editor`.
+>
+> Bu bir kez ısırdı: ayna aylarca yeşil koştu, 7 Ağustos'ta upstream'in ilk
+> `ci.yml` değişikliğine çarptı ve ondan sonraki her gece kırmızı yandı. Dört
+> gün fark edilmedi çünkü başka hiçbir şey bozulmadı — editör, hareket etmeyi
+> sessizce bırakmış bir aynadan derlenmeye devam etti.
+
+> Süresi dolan bir anahtarı yenilerken: GitHub anahtarın değerini yalnız
+> oluşturulduğu an bir kez gösterir. `Regenerate token` → çıkan `github_pat_…`
+> yazısını kopyala → yukarıdaki gizli anahtar kutusuna yapıştır. Anahtarın
+> ayarlar sayfasını düzeltmek yetmez; kutudaki **değerin** de yenilenmesi gerekir.
+
+---
+
+## Güvenlik kapısı
+
+Her otomatik yol aynı kapıdan geçer: **`deploy-bundle`.**
+
+1. `bun run build` — derleme
+2. **Duman testi 1:** veritabanı yokken sunucu açılmayı reddediyor mu?
+3. **Duman testi 2:** gerçek MySQL'e karşı sağlık kontrolü yanıt veriyor mu?
+4. Üçü de geçtiyse → `Digitaltwin`'e yazılır
+
+Biri geçmezse yayın durur ve **canlıdaki çalışan sürüm yerinde kalır.** Bozuk bir
+şeyin canlıya ulaşmamasının sebebi budur.
+
+Ayrıca `bump-plugin` ve `pull-panel` kendi içlerinde `bun run check-types`
+koşar — derlemeyi hiç başlatmadan önce. Derlenmeyen bir değişiklik `integration`
+dalına yazılmaz bile.
+
+---
+
+## Tek elle yapılan iş: Pascal güncellemesi
+
+`main` hiç çakışmaz, çünkü orada bizim hiçbir şeyimiz yok. Ama `integration`'da
+bizim 130'dan fazla commitimiz var ve Pascal aynı dosyalara dokunduğunda çakışma
+çıkar — beta.4 denemesinde 185 dosya sorunsuz birleşti, **12 dosya çakıştı.**
+
+İkisi kritik:
+
+- **`apps/editor/next.config.ts`** — bizim `output: 'standalone'` ayarımız.
+ Silinirse `deploy-bundle` hiç derleyemez.
+- **`apps/editor/package.json`** — eklenti sürüm pinimiz. Pascal'da böyle bir
+ bağımlılık yok; toptan "onlarınkini al" denirse **raflar sessizce kaybolur.**
+
+Bir makine bu kararı veremez. Dosya dosya kurallar `UPSTREAM.md` içinde.
+
+---
+
+## Bir şey ters giderse — nereye bakılır
+
+| Belirti | Muhtemel sebep | Bakılacak yer |
+|---|---|---|
+| Canlı güncellenmiyor, derleme yeşil | `DEPLOY_TOKEN` süresi dolmuş | `deploy-bundle` çalışmasının son adımı (`Publish`) |
+| Eklenti değişikliği canlıya gelmiyor | `bump-plugin` pini bulamıyor | O çalışmanın `Compare the pin` adımı |
+| Panel değişikliği gelmiyor | `PANEL_TOKEN` yok veya süresi dolmuş | `pull-panel` çalışmasının ilk adımı — "PANEL_TOKEN is not set" yazar |
+| Hiçbir zamanlanmış iş çalışmıyor | Varsayılan dal değişmiş | Settings → Branches → default branch `integration` mı? |
+| Pascal güncellemesi görünmüyor | Öneri açılmamış | Actions → `Mirror upstream` → elle çalıştır |
+
+Bütün çalışmalar burada: **https://github.com/ovurrsl/editor/actions**
diff --git a/PROJECT.md b/PROJECT.md
new file mode 100644
index 0000000000..141ee614eb
--- /dev/null
+++ b/PROJECT.md
@@ -0,0 +1,32 @@
+# Project: Upstream Synchronization & Conflict Resolution (`pascalorg/editor` → `ovurrsl/editor`)
+
+## Architecture & Conflict Domain Map
+The project synchronized 24 upstream commits from `pascalorg/editor:main` (`9cdafb04`) into `ovurrsl/editor:main` (`add8c829`) across 5 modules and 20 conflicting files.
+
+## Feature Inventory
+| # | Feature | Description | Milestone | Source | Status |
+|---|---------|-------------|-----------|--------|:------:|
+| 1 | Upstream Zod 4.5.4 Migration | Update Zod schemas with bare literal unwrap & compiled node parsers | M1 | Upstream Survey | DONE |
+| 2 | Plugin Warehouse Node Schemas | Preserve custom node schemas (`ovurrsl:warehouse`, etc.) in `graph-schema.ts` | M1 | Local Survey | DONE |
+| 3 | Asset Storage & Registry Types | Merge upstream storage URL handling with local `verticalOpening` / `canMoveTo` | M1 | Survey | DONE |
+| 4 | Manifests & Dependencies | Upgrade core deps while preserving `@ovurrsl/plugin-warehouse` pinned SHA & MySQL/auth deps | M1 | Survey | DONE |
+| 5 | ToolMode FSM Unification | Integrate upstream `armToolMode` with local edit locking (`isNodeEditLocked`) & multi-select | M2 | Upstream Survey | DONE |
+| 6 | First Person Controls | Reconcile upstream pointer-lock Esc handling & camera suites with local hotkeys | M2 | Survey | DONE |
+| 7 | Move Registry Node Tool | Merge upstream and local implementations of `move-registry-node-tool` and tests | M2 | Survey | DONE |
+| 8 | Keyboard Shortcuts | Merge upstream FSM tool hotkeys with local custom actions (history lock, delete toggle) | M2 | Survey | DONE |
+| 9 | Floating Action Menu & Panels | Integrate draggable action menu with upstream declarative tool options | M3 | Survey | DONE |
+| 10 | Material Picker | Merge upstream material paint priming with local texture picker & catalog | M3 | Survey | DONE |
+| 11 | High-Res Snapshot Export | Unify upstream walk/drone FOV controls with local 1080p/1440p/4K resolution multipliers | M3 | Local Survey | DONE |
+| 12 | Zone Deletion & Takeoff | Ensure `zone-content.ts` and `quantities-panel.tsx` pass all takeoff & BOM tests | M4 | Local Survey | DONE |
+| 13 | Lockfile Synchronization | Cleanly regenerate `bun.lock` via `bun install` with zero dependency conflicts | M4 | Survey | DONE |
+| 14 | Monorepo Test Suite Verification | Verify 100% pass across all packages (`bunx turbo run test`) | M4 | Survey | DONE |
+| 15 | Git Commit & Upstream Push | Final clean merge commit and push to `origin main` | M5 | User Request | DONE |
+
+## Milestones
+| # | Name | Scope | Dependencies | Status |
+|---|------|-------|-------------|:------:|
+| M1 | Core Schemas & Package Manifests | `apps/editor/lib/graph-schema*`, `packages/core/src/lib/asset-storage.ts`, `packages/core/src/registry/types.ts`, `apps/editor/package.json`, `packages/*/package.json` | None | DONE |
+| M2 | Editor State, FSM & Interaction Tools | `packages/editor/src/components/editor/first-person-controls.tsx`, `floating-action-menu.tsx`, `index.tsx`, `selection-manager.tsx`, `thumbnail-generator.tsx`, `move-registry-node-tool*`, `use-keyboard.ts` | M1 | DONE |
+| M3 | UI Panels, Action Menus & Capture Overlay | `packages/editor/src/components/ui/action-menu/index.tsx`, `controls/material-picker.tsx`, `panels/panel-manager.tsx`, `snapshot-capture-overlay.tsx` | M2 | DONE |
+| M4 | Lockfile Sync & Monorepo Test Suite Pass | `bun.lock`, `bun install`, `bunx turbo run test` (Core, Viewer, Editor, Nodes, MCP, Apps) | M3 | DONE |
+| M5 | Final Verification & Git Push | Clean git working tree, zero merge markers, successful `git push origin main` | M4 | DONE |
diff --git a/PROJECT_HANDOVER.md b/PROJECT_HANDOVER.md
new file mode 100644
index 0000000000..b4e4b0f0e1
--- /dev/null
+++ b/PROJECT_HANDOVER.md
@@ -0,0 +1,1053 @@
+# DigitalTwin — Proje Devir ve Mimari Dokümanı
+### (Project Handover & Architecture)
+
+> **Amaç:** Bu doküman, projeyi devralacak yeni AI kodlama asistanının (ör. Google
+> Antigravity) sistemi eksiksiz kavraması, dört depo arasındaki ilişkiyi anlaması
+> ve geliştirmeyi kesintisiz sürdürebilmesi için hazırlanmıştır.
+>
+> **Ürün:** DigitalTwin — depo/lojistik tesisleri için 3B dijital ikiz (warehouse
+> digital-twin) tasarım ve yönetim platformu. Canlı adres: **https://opex.help**
+>
+> **Kapsanan depolar:** `ovurrsl/editor`, `ovurrsl/panel`,
+> `ovurrsl/plugin-warehouse`, `ovurrsl/Digitaltwin`
+>
+> **Sürüm:** v2 · **Doküman tarihi:** 2026-08-19
+>
+> **Dil notu:** Kod tabanındaki yorumların ve commit mesajlarının büyük kısmı
+> **Türkçe**'dir; geçmişi okurken bunu bilin.
+>
+> **v2'de eklenenler:** §1.4 depolar arası otomatik haberleşme + dal kuralları
+> (cron'lar, secret'lar, kaynak→hedef dallar, güvenlik kapısı) · §3.1.1 MCP
+> sunucusu ve 30+ aracı · §3.1.2 `apps/ifc-converter` · §5.2 bilinen hataların
+> dosya:satır kanıtlı, önceliklendirilmiş tam dökümü (üç kod incelemesinden) ·
+> Ek A mimari wiki sayfaları + skills · panelin kendi CI'ı olmadığı notu.
+
+---
+
+## 1. Projenin Büyük Resmi ve Amacı (Project Overview)
+
+### 1.1 Ürün ne yapar?
+
+DigitalTwin, bir depo/fabrika tesisinin (bina → kat → duvar/döşeme/çatı/bölge →
+mobilya ve **depo ekipmanları**) 3 boyutlu, düzenlenebilir bir dijital ikizini
+tarayıcıda oluşturmayı sağlar. Kullanıcı:
+
+- Tesisin katlarını, duvarlarını, döşemelerini ve bölgelerini çizer,
+- Palet rafları, konveyörler, asma katlar, forkliftler, dock levellerleri gibi
+ **gerçek depo ekipmanlarını** bir katalogdan yerleştirir,
+- Kapasite / alan istatistiklerini okur,
+- Sahneleri kaydeder, sürümler, yedekler, başkalarıyla paylaşır ve
+ (tek-aktif-editör kirası ile) birlikte üzerinde çalışır.
+
+Ürünün önü bir **kimlik doğrulama + yönetim konsolu**dur (kullanıcılar, roller,
+tesisler, 2FA); arkasında 3B editör yer alır. Sahneler konsol hesaplarına aittir.
+
+### 1.2 Neden dört depo? (kuş bakışı mimari)
+
+Sistem, açık kaynak bir editörün **fork**'u üzerine kurulu olduğu için sorumluluk
+dört depoya bölünmüştür. Kritik nokta: **çalışan ürün tek bir Next.js işlemidir**
+(editör + konsol + API + scene store hepsi aynı süreçte); dört depo bu tek
+süreci besleyen *kaynak ve otomasyon* katmanlarıdır.
+
+```
+ ┌──────────────────────────────┐
+ pascalorg/editor │ UPSTREAM (açık kaynak) │
+ (genel 3B editör) │ günlük mirror → PR açar │
+ └───────────────┬──────────────┘
+ │ mirror-upstream (günlük, PR)
+ ▼
+ ovurrsl/plugin-warehouse ───► ovurrsl/editor ◄─── ovurrsl/panel
+ (warehouse:* eklentisi) pin (FORK · monorepo) vendor (DigitalTwin Console)
+ bump-plugin (saatlik) │ default branch: │ pull-panel (saatlik)
+ │ integration │ → apps/editor/panel/**
+ │ │
+ ▼ deploy-bundle (build) ▼
+ ┌──────────────────────────────┐
+ │ ovurrsl/Digitaltwin │
+ │ derlenmiş standalone Next │
+ │ → Hostinger → opex.help │
+ └──────────────────────────────┘
+```
+
+| Depo | Rol | Nasıl beslenir / besler |
+|---|---|---|
+| **`ovurrsl/editor`** | Ana monorepo. Editör + viewer + core + mcp (scene store) + Next.js uygulaması. **Tüm geliştirmenin merkezi.** Default branch: `integration`. | Upstream buraya mirror'lanır; panel & plugin buraya akar; buradan Digitaltwin derlenir. |
+| **`ovurrsl/panel`** | DigitalTwin Console — bağımsız Next.js: giriş, 2FA, kullanıcı/rol/tesis yönetimi, denetim. | Saatlik `pull-panel` ile editöre `apps/editor/panel/**` olarak **vendor** edilir. |
+| **`ovurrsl/plugin-warehouse`** | Depo ekipmanı eklentisi (`warehouse:*` node kind'leri), plugin API v1. | Editörün `apps/editor/package.json`'ında SHA ile pinlenir; saatlik `bump-plugin` ile güncellenir. |
+| **`ovurrsl/Digitaltwin`** | **Üretim artefaktı** — editörün derlenmiş `standalone` Next çıktısı git'e commit'li. Kaynak değil, deploy paketidir. | `deploy-bundle` workflow'u editörden derleyip buraya force-push eder; Hostinger buradan çalıştırır. |
+
+> **En kritik ilişki:** `editor` = beyin, `panel` = ön kapı (editöre vendor
+> edilir), `plugin-warehouse` = katalog/ekipman (editöre pinlenir), `Digitaltwin`
+> = editörün paketlenmiş halinin canlıda koştuğu yer. Panel ve plugin **editörün
+> içinde birleşir**; ayrı servis değildir.
+
+### 1.3 Beş depo topolojisi (kuş bakışı)
+
+Ayrıntılı, düz-dille anlatım: **`editor/OTOMASYON.md`** (tek doğruluk kaynağı;
+her workflow dosyasından okunup ikinci turda doğrulanarak yazılmış). Aşağıda o
+belgenin özü + dokümanın 1.4'ünde tam otomasyon tablosu var.
+
+```
+ pascalorg/editor Pascal'ın orijinali (bizim değil)
+ │ ① her gün 05:00 UTC (08:00 TR) — otomatik
+ ▼
+ ovurrsl/editor → main Pascal'ın SAF aynası. Tek commitimiz bile yok.
+ │ ② merge PR açılır — BİRLEŞTİRME KARARI İNSANDA (tek elle iş)
+ ▼
+ ovurrsl/editor → integration ◄── ovurrsl/panel (③ saat başı :17)
+ VARSAYILAN dal; her şey burada ◄── ovurrsl/plugin-warehouse (④ saat başı :42)
+ │ ⑤ derle → 2 duman testi → geçerse — otomatik
+ ▼
+ ovurrsl/Digitaltwin Sadece derlenmiş çıktı; kaynak kod girmez.
+ │
+ ▼ Hostinger → opex.help
+```
+
+**② dışındaki her ok otomatiktir.** ② bilinçli olarak insandadır (upstream aynı
+dosyalara dokunduğunda çakışma çıkar; makine bu kararı veremez — kurallar
+`UPSTREAM.md`'de).
+
+---
+
+### 1.4 Depolar arası otomatik haberleşme ve dal kullanımı
+
+> Bu bölüm sorunun tam yanıtıdır: 4 (aslında 5) depo birbiriyle **GitHub Actions
+> workflow'ları** üzerinden konuşur; hangi iş ne zaman, hangi **secret** ile,
+> hangi **kaynak → hedef dal**da çalışır ve manuel geliştirmede hangi dal
+> kurallarını izleriz.
+
+#### 1.4.1 Neden iki kalıcı dal var (`main` vs `integration`)
+
+| Dal | İşlevi | Kural |
+|---|---|---|
+| **`main`** | `pascalorg/editor`'ın **bire bir aynası**. Bizim hiçbir değişikliğimiz yazılmaz. | **Asla commit'leme.** Ayna sadece ileri sarabildiği için hiç çakışmaz; buraya atılan bir commit `mirror-upstream`'i kilitler (silinmez, elle çözülene kadar aynayı jamlar). |
+| **`integration`** | **Varsayılan dal.** Bizim eklediğimiz ~130+ commit burada; canlıya giden derleme bundan yapılır. | Tüm geliştirme buraya akar. |
+
+> **Kritik:** GitHub zamanlanmış işleri **yalnız varsayılan daldan** çalışır.
+> `integration` varsayılan olmasaydı saat başı işlerin hepsi **sessizce dururdu**.
+> Dal adı değişecekse depo değişkeni `INTEGRATION_BRANCH` kurulur; tüm workflow'lar
+> önce onu okur, yoksa `integration`'a düşer.
+
+#### 1.4.2 Otomatik workflow'lar (hepsi `ovurrsl/editor/.github/workflows/`)
+
+Saatler **UTC** (TR = UTC+3).
+
+| Workflow | Ne zaman | Kaynak → Hedef | Ne yapar | Secret |
+|---|---|---|---|---|
+| **`bump-plugin`** | Her saat **`:42`** | `plugin-warehouse@main` → `editor@integration` | Eklentinin `main`'i ile editördeki pin'i karşılaştırır; farklıysa günceller, `bun.lock` tazeler, **`check-types`**'tan geçirir, `integration`'a yazar ve `deploy-bundle`'ı tetikler. | Yok (plugin herkese açık) |
+| **`pull-panel`** | Her saat **`:17`** | `panel@main` → `editor@integration` (`apps/editor/panel/**`) | Paneli çeker, vendor eder, **`check-types`**'tan geçirir, `integration`'a yazar ve derlemeyi tetikler. | **`PANEL_TOKEN`** |
+| **`mirror-upstream`** | Her gün **`05:00`** (08:00 TR) | `pascalorg/editor` → `editor@main`, sonra `integration`'a **PR** | `main`'i upstream'e ileri sarar; `integration` geride kaldıysa **merge PR açar** (otomatik merge etmez). | **`MIRROR_TOKEN`** |
+| **`deploy-bundle`** | `integration`'a **her yazımda** | `editor@integration` → `Digitaltwin` (force-push) | Derler → **2 duman testi** → geçerse `Digitaltwin`'e yazar (Hostinger deploy). Güvenlik kapısı (§1.4.5). | **`DEPLOY_TOKEN`** |
+| **`upstream-check`** | Pazartesi **`06:00`** (09:00 TR) | — (rapor) | Deneme birleştirmesi yapar, hangi dosyaların çakışacağını raporlar. **Hiçbir yere yazmaz.** | Yok |
+| **`ci`** | `integration`'a her yazım + her PR | — | Biome + tip kontrolü. | Yok |
+| **`mcp-ci`** | Belirli dosyalar değişince | — | MCP + sahne API testleri. | Yok |
+
+#### 1.4.3 Elle (manuel) çalıştırılan workflow'lar
+
+| Workflow | Ne zaman kullanılır |
+|---|---|
+| **`sync-panel`** | Editördeki panel dosyalarını `ovurrsl/panel`'e **geri** göndermek (`sync/from-editor` PR'ı). Yalnız panel deposunu ilk tohumlama/istisnai düzeltme için; günlük yön tersidir (`pull-panel` otomatik). |
+| **`relock`** | Bir bağımlılık elle değişince `bun.lock`'u gerçek sunucuda yeniden üretmek. |
+| **`release`** | Pascal'ın npm paket yayınlama akışı — bizim rutin işimiz değil. |
+
+#### 1.4.4 Secret'lar (`ovurrsl/editor` → Settings → Secrets and variables → Actions)
+
+| Secret | Kim kullanır | Ne için | Süresi dolarsa |
+|---|---|---|---|
+| **`PANEL_TOKEN`** | `pull-panel`, `sync-panel` | `ovurrsl/panel`'i okumak | Panel güncellemeleri durur; `pull-panel` **kırmızı olmaz**, sessizce hiçbir şey yapmaz (ilk adımda "PANEL_TOKEN is not set" yazar). |
+| **`DEPLOY_TOKEN`** | `deploy-bundle` | `Digitaltwin`'e yazmak | Derleme geçer, **son adım (Publish) kırmızı**, canlı eski sürümde kalır. |
+| **`MIRROR_TOKEN`** | `mirror-upstream` | `main`'i ileri sarmak + entegrasyon PR'ı açmak | Ayna durur, **kırmızı olur**; upstream birikir ama canlıya etkisi yok, fark edilmesi günler alabilir. |
+
+> **`MIRROR_TOKEN` neden fine-grained PAT olmak zorunda:** Ayna, upstream'in
+> `.github/workflows/*.yml` dosyalarını da `main`'e iter. GitHub, yerleşik
+> `GITHUB_TOKEN`'ın workflow dosyası yazmasını **reddeder** ("refusing to allow a
+> GitHub App to create or update workflow … without `workflows` permission"). Bu
+> izin `permissions:` bloğunda **yok**; yalnız PAT'ta var. Gereken üç izin:
+> **Contents (yazma)**, **Workflows (yazma)**, **Pull requests (yazma)**; kapsam
+> tek depo `ovurrsl/editor`. `bump-plugin` ve `upstream-check` **hiçbir secret
+> kullanmaz** (kaynak depolar herkese açık).
+
+> ⚠️ **Görünmez tuzak:** `GITHUB_TOKEN` ile yapılan push **hiçbir workflow
+> tetiklemez**. Push edip build bekleyen her workflow, bir sonrakini açıkça
+> (`workflow_dispatch`) tetiklemek zorundadır — `bump-plugin`/`pull-panel`'in
+> `integration`'a yazdıktan sonra `deploy-bundle`'ı ayrıca çağırmasının sebebi budur.
+
+#### 1.4.5 Güvenlik kapısı — her otomatik yol buradan geçer (`deploy-bundle`)
+
+1. `bun run build` — derleme.
+2. **Duman testi 1:** veritabanı yokken sunucu açılmayı reddediyor mu?
+3. **Duman testi 2:** gerçek MySQL'e karşı `/api/health` yanıt veriyor mu?
+4. Üçü de geçerse → `Digitaltwin`'e yazılır; biri geçmezse **yayın durur, canlıdaki
+ çalışan sürüm yerinde kalır.**
+
+Ayrıca `bump-plugin` ve `pull-panel` kendi içlerinde `bun run check-types` koşar —
+derlenmeyen bir değişiklik `integration`'a **yazılmaz bile**.
+
+#### 1.4.6 Manuel geliştirme dal kuralları (bizim iş akışımız)
+
+Bu depolarda yaptığımız günlük geliştirme deseni:
+
+- **Nerede geliştirilir:** Editör değişikliği → `ovurrsl/editor`; konsol → `ovurrsl/panel`;
+ raf/ekipman → `ovurrsl/plugin-warehouse`. `Digitaltwin`'e ve `apps/editor/panel/**`'e
+ **elle dokunulmaz** (üzerine yazılır).
+- **Özellik dalı → PR → `integration`:** Değişiklikler bir özellik dalında yapılır
+ (bu oturumdaki ad: `claude/…`), taslak PR açılır, `ci` yeşilinde `integration`'a
+ **squash-merge** edilir → `deploy-bundle` tetiklenir → canlı.
+- **Merge sonrası dal tazeleme:** PR merge edilince özellik dalı
+ `origin/integration`'dan yeniden kurulur (eski commit'ler squash'landığı için
+ `git checkout -B origin/integration`, gerekirse `--force-with-lease` push).
+- **Merged PR'a yeni commit yığma yok:** Merge edilmiş bir PR bitmiştir; yeni iş
+ taze dal + yeni PR olur.
+- **Commit dili/biçimi:** Conventional Commits (`feat/fix/perf/refactor/docs/test/
+ chore`); konu changelog'a girer, gövde (genelde Türkçe) gerekçedir.
+- **CI'nin bilinen sallantısı:** `bun install --frozen-lockfile` git-bağımlılıklarını
+ (plugin-warehouse, plugin-trees) GitHub tarball'larından çeker; ara sıra 504/429
+ verir — kod hatası değildir, rate-limit sonrası `rerun_failed_jobs`.
+
+#### 1.4.7 Bir şey ters giderse — nereye bakılır
+
+| Belirti | Muhtemel sebep | Bakılacak yer |
+|---|---|---|
+| Canlı güncellenmiyor, derleme yeşil | `DEPLOY_TOKEN` süresi dolmuş | `deploy-bundle` → son adım `Publish` |
+| Eklenti değişikliği gelmiyor | `bump-plugin` pini bulamıyor | O çalışmanın `Compare the pin` adımı |
+| Panel değişikliği gelmiyor | `PANEL_TOKEN` yok/dolmuş | `pull-panel` ilk adımı |
+| Hiçbir zamanlanmış iş çalışmıyor | Varsayılan dal değişmiş | Settings → Branches → default `integration` mı? |
+| Pascal güncellemesi görünmüyor | Merge PR açılmamış | Actions → `Mirror upstream` → elle çalıştır |
+
+Tüm çalışmalar: **https://github.com/ovurrsl/editor/actions**
+
+---
+
+## 2. Teknoloji Yığını (Tech Stack)
+
+### 2.1 Ortak çekirdek
+
+| Katman | Teknoloji | Not |
+|---|---|---|
+| Dil | **TypeScript** | editor/plugin: `6.0.3`; panel & apps/editor: **`7.0.2`** (native `tsc`/`tsgo`) |
+| UI | **React 19** (`^19.2.x`) | Server + Client Components |
+| Framework | **Next.js 16** (App Router) | `editor`: `16.3.0` (pinli), `panel`: `^16.2.12`, `Digitaltwin`: `16.2.9` |
+| 3B | **three.js `0.185.x`** + **@react-three/fiber `^9`** + **@react-three/drei `^10`** | WebGPU renderer |
+| State | **Zustand `^5`** (+ **zundo `^2.3`** undo/redo) | üç mağaza, katman başına bir tane |
+| Şema/doğrulama | **Zod `^4`** | node şemaları + API kontratları |
+| Veritabanı | **MySQL 8 / MariaDB 10.11** (`mysql2 ^3.x`) | üretimde tek backend; dev'de SQLite (bun yerleşik) |
+| Lint/format | **Biome `^2.4`** (+ `ultracite` editörde) | ESLint/Prettier yok |
+| Auth/kripto | `@node-rs/argon2` (argon2id), `otpauth` (TOTP), `qrcode`, `ulid`, `nodemailer` | |
+
+### 2.2 Paket yöneticileri (önemli fark!)
+
+- **`ovurrsl/editor`** ve **`ovurrsl/plugin-warehouse`** → **Bun** (`bun@1.3.14`;
+ `bun.lock`). Testler `bun test` ile koşar (vitest **değil**).
+- **`ovurrsl/panel`** ve **`ovurrsl/Digitaltwin`** → **npm** (`package-lock.json`).
+ Panel testleri **Vitest** ile koşar.
+
+> Yeni asistan uyarısı: editör monorepo'sunda `npm install` yapmayın — `bun`
+> kullanın. Panel'de tersine, `bun` değil `npm` kullanın.
+
+### 2.3 Monorepo altyapısı (`editor`)
+
+- **Turborepo `^2.9`** — `turbo.json` görevleri: `build`, `lint`, `check-types`,
+ `test`, `dev`. Workspaces: `apps/*`, `packages/*`, `tooling/*`.
+- Kök `overrides` tüm ağacı sabitler: `next 16.3.0`, `three 0.185.1`,
+ `@types/react 19.2.17` vb.
+- `engines.node`: `>=20.9` (üretimde **Node 22.x**, Hostinger).
+
+### 2.4 Sürüm hızlı-referans (kritik pinler)
+
+```
+editor paketleri: @pascal-app/{core,viewer,editor,nodes} 1.0.0-beta.4
+ @pascal-app/mcp 1.0.0-beta.5
+plugin-warehouse: 0.1.4 (peer: @pascal-app/* ">=1.0.0-beta.1 <2")
+panel (console): 0.9.1 (Next ^16.2.12, React ^19.2.8, TS ^7.0.2)
+Digitaltwin (artifact): 2.15.0 (Next 16.2.9, React 19.2.7, mysql2 3.23.2)
+```
+
+---
+
+## 3. Modüllerin Detaylı Mimari Analizi (Architecture per Repository)
+
+### 3.1 `ovurrsl/editor` — ana monorepo
+
+**Görev:** React Three Fiber + WebGPU tabanlı 3B mimari/depo editörü. Upstream
+genel bir mimari editör; bu fork onu **warehouse DigitalTwin** ürününe
+özelleştirir (MySQL backend, konsol, warehouse eklentisi).
+
+**Monorepo yapısı** (`packages/` ve `apps/`):
+
+| Paket | Sorumluluk | `src/` alt-klasörleri |
+|---|---|---|
+| **`packages/core`** (`@pascal-app/core`) | Alan verisi + saf mantık: node Zod şemaları, `useScene` mağazası, event bus, uzamsal ızgara, registry kontratları, geometri üretim sistemleri. **Three.js/UI/editör kavramı import edemez.** | `events/ hooks/ lib/ registry/ schema/ services/ store/ systems/ utils/ validation/` |
+| **`packages/viewer`** (`@pascal-app/viewer`) | Bağımsız 3B tuval: node renderer'ları, viewer sistemleri (kat/tarama/rehber görünürlüğü), kamera/kontroller, post-processing, gerçek sunum durumu (`useViewer`). `three-bvh-csg`, `three-mesh-bvh`. **`useEditor`/araç/mod bilmez.** | `components/ hooks/ lib/ store/ systems/` |
+| **`packages/editor`** (`@pascal-app/editor`) | Yeniden kullanılabilir düzenleme UI + mantığı: araçlar, paneller, seçim afordansları, doğrudan-manipülasyon tutamakları ve **`useEditor` dahil çoğu editör mağazası**. Kaynağı doğrudan export eder (`./src/index.tsx`). | `components/ hooks/ lib/ store/` |
+| **`packages/mcp`** (`@pascal-app/mcp`) | Model Context Protocol sunucusu + **scene storage adaptörleri** (forkun veri katmanı). `mysql2` + `@modelcontextprotocol/sdk`. Subpath export'lar: `./storage`, `./operations`, `./bridge`, `./server`, `./env`. | `bridge/ lib/ operations/ storage/ tools/ transports/ types/ …` |
+| **`packages/nodes`** (`@pascal-app/nodes`) | Yerleşik node paketleri (wall, slab, roof, stair, door, window, hvac, duct/pipe, shelf…), `builtinPlugin` olarak. Framework paketleri buradan import **edemez** (Biome yasağı). | kind başına bir klasör |
+| **`apps/editor`** | Next.js 16 uygulaması: viewer + editor + nodes + eklentileri besteler; forkun REST API'si, MySQL bağlantısı, auth köprüsü ve **vendor'lanmış konsol**. | `app/ components/ lib/ panel/(vendor) public/ scripts/` |
+
+**Kritik dosyalar:**
+- `apps/editor/lib/bootstrap.ts` — eklenti keşfi (`extendPluginDiscovery` ile
+ `mintPlugin` + `warehousePlugin` besteler).
+- `apps/editor/lib/scene-store-server.ts` — süreç-başı scene store singleton'ı
+ (`getSceneStore` / `getSceneOperations`).
+- `apps/editor/lib/auth/{session,guard,admin}.ts` — konsol oturumu köprüsü + yetki.
+- `apps/editor/next.config.ts`, `hostinger-server.js`, `instrumentation.ts`.
+- `packages/mcp/src/storage/{types,mysql-scene-store,sqlite-scene-store,scene-store-shared,index}.ts`.
+
+**Kod okuma haritası:** `editor/README.md` (mimari), `editor/AGENTS.md`
+(=`CLAUDE.md`, katman sınırları + fork bloğu), `editor/wiki/architecture/`
+(20 sayfa; değişiklikten **önce** ilgili sayfayı okuyun), `editor/UPSTREAM.md`
+(upstream merge çakışma tablosu), `editor/OTOMASYON.md` (otomasyon).
+
+---
+
+#### 3.1.1 MCP sunucusu — yeni asistanın doğrudan kullanabileceği yetenek
+
+`packages/mcp` yalnız bir depolama katmanı değil; aynı zamanda **çalışan bir
+Model Context Protocol sunucusu**dur. Yani devralan AI asistanı sahneyi UI'dan
+bağımsız, programatik olarak okuyup değiştirebilir.
+
+- **Binary:** `pascal-mcp` (`packages/mcp/src/bin/pascal-mcp.ts` → `dist/bin/`).
+ Çalıştırma: `bun run start` (paket içinde) veya `bunx pascal-mcp`.
+- **Transport:** `stdio` ve `http` (`src/transports/{stdio,http}.ts`) — stdio,
+ editör istemcilerine bağlanmanın olağan yolu.
+- **Depolama:** aynı `createSceneStore(env)` fabrikası; yani MCP sunucusu
+ **canlı MySQL sahnelerine** aynı env değişkenleriyle bağlanır.
+- **Duman testi:** `bun run smoke` (`scripts/smoke.ts`).
+
+**Araçlar (`src/tools/`, 30+):**
+
+| Kategori | Araçlar |
+|---|---|
+| Okuma / sorgu | `get-scene`, `get-node`, `describe-node`, `find-nodes`, `scene-query`, `schemas`, `asset-catalog` |
+| Oluşturma | `create-level`, `create-wall`, `place-item`, `set-zone`, `cut-opening`, `construction-tools`, `room-tools` |
+| Düzenleme | `apply-patch`, `delete-node`, `duplicate-level`, `undo`, `redo` |
+| Analiz | `check-collisions`, `layout-clearance`, `door-clearance`, `measure`, `measurement`, `geometry`, `validate-scene` |
+| Dışa aktarma | `export-glb`, `export-json` |
+| Senkron | `live-sync` |
+
+> ⚠️ **Bilinmesi gereken sınır:** `check_collisions`
+> (`src/tools/check-collisions.ts`) yalnız `type: 'item'` düğümlerini tarar ve
+> `findItemItemCollisions` kullanır. **`warehouse:*` eklenti kind'leri çakışma
+> tespitinin tamamen dışındadır** — mükerrer/çakışık depo ekipmanı bu araçla
+> görünmez. Mükerrer tespiti eklenecekse bu, doğal genişletme noktasıdır
+> (bkz. §5.2, madde D).
+
+#### 3.1.2 `apps/ifc-converter` — ayrı IFC uygulaması
+
+Editör monorepo'sunda ikinci bir Next.js uygulaması (`ifc-converter-app`).
+IFC (BIM) dosyalarını içe aktarmak için `web-ifc ^0.0.77` kullanır.
+
+- `bun dev` → **port 3003** (editör 3002'de).
+- `predev`/`prebuild`/`postinstall` adımları `scripts/copy-web-ifc-wasm.mjs`
+ çalıştırır — **wasm dosyaları elle kopyalanır**; bu adım atlanırsa IFC
+ yükleme sessizce çalışmaz.
+- Editördeki tetikleyici: `apps/editor/components/ifc-import-button.tsx`.
+- IFC tamamen **editöre** aittir; panel deposunda IFC kodu yoktur.
+
+---
+
+### 3.2 `ovurrsl/plugin-warehouse` — depo ekipmanı eklentisi
+
+**Görev:** Pascal editörüne **plugin API v1** üzerinden depo/lojistik ekipmanı
+ekler. Tüm node kind'leri `warehouse:` ön ekli. Sürüm `0.1.4`. Toolchain: **Bun**.
+
+**Eklentiler nasıl çalışıyor?** `src/index.ts` **manifest barrel**'dır — tüm
+kamusal yüzey:
+- `warehousePlugin: Plugin = { id: PLUGIN_ID, apiVersion: 1, nodes: [...21 tanım] }`.
+ `apiVersion` literal `1`'dir; host farklı bir sürüme geçerse `loadPlugin` **gürültülü
+ hata** verir (kasıtlı).
+- `warehouseCatalogPanel: EditorHostPanel` — sağ ray katalog paneli, `component`
+ tembel import ile (`() => import('./panels/catalog-panel')`).
+
+**Host nasıl besleniyor?** Host uygulamada üç düzenleme (bkz. `README.md`):
+1. Bağımlılık — SHA pini (`github:ovurrsl/plugin-warehouse#`), monorepo içinde `"*"`.
+2. `transpilePackages: ['@ovurrsl/plugin-warehouse']` (paket TS kaynağı gönderir).
+3. `extendPluginDiscovery(async () => [warehousePlugin])` + `registerEditorHostPanel(...)`.
+ **Asla `setPluginDiscovery` kullanma** (diğer tüm eklentileri düşürür).
+
+**Yapı** — her node-kind ailesi kendi klasöründe (aynı dosya deseni:
+`definition.ts`, `schema.ts`, `parts.ts`, `geometry*.ts`, `renderer.tsx`,
+`tool.tsx`, `preview.tsx`, `floorplan.ts`, `metrics.ts`, `*.test.ts`). Kritik ortak
+dosyalar: `catalog.ts` (katalog verisi), `placement.ts` (yerleştirme altyapısı +
+çift-tık koruması), `store.ts` (eklenti-sahibi zustand), `host-adapter.ts` (tüm
+host-şeması okumaları burada), `compat.ts` (boot-zamanı uyumluluk probu),
+`geometry-builder.ts` (birleşik-geometri + cache motoru).
+
+**Uygulanmış 21 `warehouse:` node kind'i:** `pallet`, `pallet-rack`,
+`conveyor-{roller,curve,launcher,booster,transfer,oblique,telescopic,spiral}`,
+`route`, `truck`, `mezzanine`, `live-rack`, `drive-in-rack`, `longspan-rack`,
+`m3-rack`, `bench`, `dock-leveller`, `tote-cart`, `pallet-lift`. (Katalog, kind
+sayısından daha fazla *tile* gösterir — kind başına birden çok hazır ayar.)
+
+**Geometri modeli (performansın tüm hikâyesi):** Şekil başına **tek birleşik
+`THREE.BufferGeometry`**, o şekli paylaşan her node arasında cache'lenir; parça
+renkleri vertex-color attribute'unda → tüm sahne **tek materyalden** çizilir.
+15.000 m²'lik depo ~95 blok / ~95 draw-call (parça-başı mesh olsaydı ~çeyrek
+milyon). İki kural:
+- **Cache anahtarı, builder'ın ürettiğini tanımlar — şemayı değil.** Mesh'i
+ değiştiren ama anahtarda olmayan alan iki farklı rafı aynı geometride birleştirir;
+ anahtarda olup hiçbir vertex'i oynatmayan alan cache'i boşuna böler. Coverage
+ testi iki yönü de doğrular (beş gerçek hata yakalamış).
+- **Disposal, node ömrünü değil retain-count'u izler.** Paylaşılan bir şekle
+ **asla kendiniz `dispose()` çağırmayın**; sweep, hiçbir tutucu kalmayınca
+ (grace period sonrası) serbest bırakır.
+
+**Dört sessiz kural (`CLAUDE.md` — hata vermeden bozar):**
+1. **Her ölçü metredir.** Yayınlanmış specler mm'dir → 1000'e böl. 100'ün üstünde
+ çıplak ölçü literali yazma (`1200` = 1.2 km'lik palet, kimse itiraz etmez).
+2. **`@pascal-app/*` peer bağımlılıktır — asla pinleme.** İkinci kopya = ikinci
+ `nodeRegistry` singleton'ı; kind'ler yanlış registry'ye kaydolur, görünmez.
+3. **Host node şekilleri yalnız `src/host-adapter.ts`'de, runtime guard'larla
+ okunur.** Kontrat versiyon-korumalıdır (gürültülü kırılır), host şemaları değil.
+4. **Panel markup'ında Tailwind class'ı yok.** Tailwind v4 symlink'li dizini
+ taramaz; git-bağımlılığı bun store'una symlink'tir → class asla derlenmez, panel
+ stilsiz çıkar. Inline stil (`src/panels/styles.ts`) veya host bileşeni kullan.
+
+> **Sayıların kaynağı olmalı:** Bu gerçek ekipmanı modeller. Uydurma ama makul bir
+> değer, eksik değerden kötüdür (incelemeden geçer). Katalog belirt ya da "seçilmiş
+> varsayılan" olduğunu açıkça söyle.
+
+---
+
+### 3.3 `ovurrsl/panel` — DigitalTwin Console
+
+**Görev:** Ürünün **ön kapısı** — bağımsız Next.js 16 uygulaması: giriş, 2FA,
+kullanıcı/rol yönetimi, tesis (site) yönetimi, oturumlar, denetim (audit), işler
+(jobs), entegrasyonlar, ayarlar. `package.json` adı `digitaltwin-console`,
+sürüm `0.9.1`. Şirket bağlamı: Türk lojistik firması **Netlog**. Toolchain: **npm**.
+
+> **Vendoring:** Konsol **burada** geliştirilir, editöre `apps/editor/panel/**`
+> olarak saatlik akar. Editördeki kopyayı düzenleme — bir sonraki `pull-panel`
+> ile üzerine yazılır. Değişikliği **bu depoda** yap.
+
+**Yapı** (`src/`):
+- `app/` — auth ekranları (`signin`, `mfa`, `reset`, `welcome`, `request`) +
+ `console/[tab]/page.tsx` (tek dinamik rota tüm sekmeleri sunar) + `app/api/**`
+ (~45 REST handler).
+- `components/` — `auth/` (7 kimlik ekranı), `console/` (sekme başına bir bileşen +
+ `command-palette.tsx` ⌘K, `user-drawer.tsx`, `assign-dialog.tsx`), `ui/`.
+- `lib/` — `db.ts` (MySQL pool), `types.ts`, `api-contract.ts` (zod), `auth/`
+ (`password, session, totp, invitations, reset, lockout, guard, roles, audit,
+ crypto`), özellik lib'leri (`users, jobs, integrations, logs, settings, mail`),
+ `i18n/{en,tr}`.
+
+**11 konsol sekmesi** (`src/lib/console-tabs.ts`, üç ray bölümünde):
+- **Monitor:** `overview`, `logs` · **Access:** `users`, `roles`, `audit`,
+ `sessions` · **Platform:** `sites`, `jobs`, `integrations`, `updates`, `settings`.
+
+> **Önemli:** Panel'in kendisinde **scenes sekmesi ve IFC kodu YOKTUR.**
+> `scenes-tab.tsx` ve `guides-tab.tsx` yalnız editör tarafında (`EDITOR_OWNED`)
+> yaşar; IFC içe aktarma tamamen editördedir (`apps/ifc-converter/`).
+
+**Veri + DB:** `src/lib/db.ts` tembel `mysql2/promise` havuzu (limit 10, utf8mb4,
+UTC). Env öncelik zinciri: `DIGITALTWIN_MYSQL_*` → `PASCAL_MYSQL_*` → `DATABASE_*`
+(+ tekil URL). Bu, vendor'lanmış konsolun editörün sahne veritabanını **paylaşması**
+için köprüdür. Migration'lar `db/migrations/*.sql` (§4.4'te şema). Panel'in sahip
+olduğu tablolar: `users, sites, assignments, invitations, sessions, two_factor,
+recovery_codes, api_keys, webhooks, jobs, audit_log, settings, roles,
+access_requests, password_resets`. **`scenes` tablosu panele ait DEĞİLDİR** —
+panel yalnız `sites.scene_id` yumuşak işaretçisini taşır.
+
+**Auth:** argon2id parola (19 MiB, t=2, p=1); `sessions` tablosu (128-bit ID,
+HttpOnly cookie, kayan pencere, `mfa_pending`); TOTP 2FA (secret AES-256-GCM
+şifreli, `SECRET_ENCRYPTION_KEY`); kilitleme (3→30s, 10→15dk); davet/reset
+akışları. Roller: 9 izin; sistem rolleri kod-tanımlı (`Admin, Supervisor,
+Editor, Viewer`), özel roller `roles` tablosunda; dış org → Viewer'a sıkıştırılır.
+
+**Vendoring sınırı — `EDITOR_OWNED`** (`editor/scripts/sync-panel.mjs`): editöre
+özel olup panele **asla** geri gitmeyen dosyalar: `app/layout.tsx`, `app/page.tsx`,
+`api/health/route.ts`, `console-tabs.ts`, `console/tab-content.tsx`, `console/
+scenes-tab.tsx`, `console/guides-tab.tsx`. Kısaca: **panel-owned** = tüm konsol
+kütüphanesi; **EDITOR_OWNED** = shell + scenes/guides sekmeleri + health.
+
+---
+
+### 3.4 `ovurrsl/Digitaltwin` — üretim artefaktı
+
+**Görev:** `pascalorg/editor`'ın (commit `08e2279`) + `ovurrsl/editor`'ın MySQL
+scene store'unun **derlenmiş, çalışmaya hazır `standalone` Next çıktısıdır**.
+Kaynak kod değil, ters-proxy değil — deploy zamanı hiçbir şey derlenmesin diye
+git'e commit'li Next build'idir. Sürüm `2.15.0`. Node 22.x, npm. Canlı: **opex.help**.
+
+**Kritik dosyalar:**
+- `server.js` (44 satır) — standalone Next bootstrap (Express değil). `PORT`
+ (vars. 3000), `HOSTNAME`, derlenmiş `nextConfig`'i JSON olarak inline'lar,
+ `startServer()` çağırır. **Editör UI, 3B viewer, konsol, tüm `/api/*` ve statik
+ varlıklar tek süreçten** sunulur.
+- `setup-native.mjs` (`npm run build`) — hiçbir şey derlemez; Turbopack'in
+ `@node-rs/argon2-` alias'ını gerçek `argon2` kurulumuna symlink'ler
+ (native modül tuhaflığı düzeltmesi).
+- `panel-migrations/001..007.sql` — panelin **yetkili DB şeması** (§4.4).
+- `public/` (~80 MB) — editörün varlık kütüphanesi (145 `items/` modeli, 7 PBR
+ `material/` ailesi, 55 `icons/*.webp`, HDRI, fontlar, sesler, `demos/demo_1.json`).
+- `.next/` (~55 MB) — derlenmiş uygulama (committed). `MysqlSceneStore` DDL'i
+ `.next/server/chunks/packages_mcp_dist_storage_*.js` içinde.
+
+**Nasıl birleşir:** Kullanıcı panelden kimlik doğrular (`users`/`sessions`/
+`two_factor`, argon2id + TOTP) → bir tesise atanır (`assignments`) → o tesisin
+sahnesini editörde açar → düzenler → MySQL'de `scenes` + `scene_revisions` +
+`scene_events` olarak saklanır. İki şemanın birleşme noktası **`sites.scene_id`**
+(migration 004).
+
+> ⚠️⚠️ **GÜVENLİK — ACİL:** `/workspace/digitaltwin/.env` dosyası **canlı üretim
+> kimlik bilgileriyle git'e commit edilmiş** durumda (gerçek MySQL kullanıcı+parola,
+> aynı parolanın SMTP olarak tekrar kullanımı, gerçek `SECRET_ENCRYPTION_KEY`, admin
+> e-postası). README de bu riski işaretliyor. **Yapılması gereken:** tüm sırları
+> döndür (rotate), `~/.digitaltwin.env`'e taşı ve git geçmişinden temizle.
+> `SECRET_ENCRYPTION_KEY` değişirse mevcut tüm 2FA gizli anahtarları okunamaz hale
+> gelir — kullanıcıların 2FA'yı yeniden kurması gerekir.
+
+---
+
+## 4. Veri Akışı, Durum Yönetimi ve İletişim
+
+### 4.1 Frontend ↔ Backend iletişimi
+
+- **REST** — İstemci bileşenleri kendi `app/api/**/route.ts` handler'larını
+ `fetch` eder (Next App Router, `nodejs` runtime, `force-dynamic`). Handler'lar
+ `src/lib/*` üzerinden doğrudan MySQL okur/yazar. Kontratlar zod ile
+ (`api-contract.ts`).
+- **SSE (Server-Sent Events)** — Canlı sahne senkronizasyonu:
+ `apps/editor/app/api/scenes/[id]/events/route.ts` `text/event-stream` döner,
+ `listSceneEvents`'i **250 ms**'de bir yoklar, poll başına 50 olaya kadar gönderir,
+ 15s keepalive, `?after=` / `Last-Event-ID` ile devam eder. Konsol `jobs/stream`
+ da SSE'dir.
+- **GraphQL / WebSocket YOK.** (Gerçek-zamanlı çoklu-oyuncu CRDT katmanı da yok;
+ ayrıntı §5.3.)
+
+**Sahne ile ilgili başlıca rotalar** (`apps/editor/app/api/`): `scenes/`
+(liste/oluştur), `scenes/[id]/` (getir/kaydet/adlandır/sil), `scenes/[id]/shares`
+(paylaşım: `viewer|editor`), `scenes/[id]/revisions` + `revisions/restore`
+(yedek/geri-yükle), `scenes/[id]/thumbnail`, `scenes/[id]/presence` (nabız + kira),
+`scenes/[id]/events` (SSE), `admin/scenes/**` (konsol yönetimi).
+
+### 4.2 Frontend state yönetimi — üç Zustand mağazası
+
+Katman başına bir mağaza (kesin sınırlarla):
+
+| Mağaza | Dosya | Sahip olduğu |
+|---|---|---|
+| **`useScene`** | `packages/core/src/store/use-scene.ts` | Sahne verisi: `nodes: Record`, `rootNodeIds`, `dirtyNodes: Set`, CRUD. Middleware: **persist** (IndexedDB) + **zundo `temporal`** (50-adım undo/redo). |
+| **`useViewer`** | `packages/viewer/src/store/use-viewer.ts` | Sunum durumu: `selection`, hover, `cameraMode`, tema, gölgeleme, ve forka özel **`sceneLocked` + `lockedCategories: Set`** (düzenleme kilidi). |
+| **`useEditor`** | `packages/editor/src/store/use-editor.tsx` | Aktif araç, yapışma modları, boya modu, ölçüm taslakları, floorplan modu, fırça ayarları. `useScene` ve `useViewer`'ı import eder. |
+
+**Sahne grafiği modeli:** Node'lar `BaseNode { id, type, parentId, visible, … }`'dan
+türer, **düz sözlükte** saklanır (ağaç değil); hiyerarşi `parentId` ile. ID'ler
+tip-önekli (`wall_abc123`). Şemalar Zod (`packages/core/src/schema/nodes/*`).
+Ayrı bir **scene registry** (`useRegistry`) node id → Three.js `Object3D` eşler;
+**sistemler** (`useFrame` içinde) yalnız **dirty** node'ların geometrisini günceller.
+Kind'ler registry-güdümlü (`nodeRegistry`, `def.geometry`/`def.renderer`/`def.system`
+üçlü kompozisyonu).
+
+### 4.3 Paylaşılan yapı + kimlik doğrulama
+
+- **Tek veritabanı, tek oturum çerezi.** Editör ve konsol aynı MySQL'i ve aynı
+ `dt_session` çerezini paylaşır.
+- **Kimlik konsola aittir.** `apps/editor/lib/auth/session.ts`, konsolun
+ `@panel/lib/auth/session`'ından `getSession()` çağırır; konsolun izin setini üç
+ editör rolüne indirger: `admin_access`→**admin**; `edit_projects|create_projects`
+ →**editor**; aksi halde **viewer** (`canEdit = role ≠ viewer`).
+- **`guard.ts`** — `authorizeSceneRead` / `authorizeSceneMutation`: imzasız→401;
+ viewer yazamaz→403; owner/admin serbest; aksi halde `getSceneShareRole`'a
+ danışır (`viewer` paylaşımı okuma, `editor` paylaşımı yazma verir). Yayınlanmış
+ sahneler her imzalı hesaba okunur.
+- **Presence / tek-aktif-editör kirası:** POST nabız; `{claim:true}` kira ister.
+ `touchPresence` atomiktir — arayan yalnız düzenleme-uygunsa **ve** başka taze
+ hesap kirayı tutmuyorsa kirayı alır; aksi halde canlı izleyicidir. TTL 30s.
+ Düzenlenebilir sahneyi ilk açan düzenler; sonrakiler kira boşalana kadar izler.
+
+### 4.4 Veritabanı şeması (iki ayrı alan, tek DB)
+
+**A) Konsol/panel şeması** — `Digitaltwin/panel-migrations/001..007.sql` (yetkili
+kaynak) ve `panel/db/migrations/`. Kimlik: içeride `BIGINT UNSIGNED` PK süreçten
+çıkmaz; dışarıda **`CHAR(26)` ULID `public_id`** URL/API/log'ların tek ID'si.
+InnoDB, utf8mb4, UTC.
+
+- `users` (email/username unique, `org enum('internal','external')`, `global_role`,
+ `status`, argon2id `password_hash`, kilitleme alanları, `locale`)
+- `sites` (warehouse/tesis; `name` unique, `status`, kapasite alanları, `scene_id`
+ — editör sahnesine köprü)
+- **`assignments`** (kullanıcı × tesis × rol; `UNIQUE(user_id, site_id)`) — dış
+ hesaplar yalnız buradan gerçek erişim kazanır
+- `invitations`, `sessions`, `two_factor`, `recovery_codes`, `api_keys`,
+ `webhooks`, `jobs` (kuyruk: `ifc_import`/`report_export`/`backup`), `audit_log`
+ (mesaj İngilizce saklanır, `meta` JSON ile yerelleştirilir), `settings` (tek satır
+ org config), `roles`, `access_requests`, `password_resets`.
+
+**B) Scene store şeması** — `packages/mcp/src/storage/mysql-scene-store.ts`
+(`migrate()`), runtime'da `CREATE TABLE IF NOT EXISTS` ile kurulur:
+
+- `scenes` (`id VARCHAR(64) PK`, `name`, `project_id`, `owner_id`, `thumbnail_url`,
+ `version`, timestamps, `size_bytes`, `node_count`, `graph_json LONGTEXT`,
+ `graph_hash`)
+- `scene_revisions` (`(scene_id, version)` PK, `graph_json`, `author_*`, FK CASCADE) —
+ son **`SCENE_REVISION_HISTORY = 5`** sürüm tutulur
+- `scene_events` (auto-inc `event_id`, SSE beslemesi, FK CASCADE)
+- `scene_shares` (`ENUM('viewer','editor')`, PK `(scene_id, user_id)`)
+- `scene_presence` (PK `(scene_id, user_id)`, `last_seen` ISO, `is_editor`) —
+ TTL **`SCENE_PRESENCE_TTL_SECONDS = 30`**
+- `project_placeholders`
+
+> **Eşzamanlılık:** İyimser kilitleme — `expectedVersion` ile
+> `SceneVersionConflictError`. Her kayıt scene satırını + bir revision yazar.
+> `scene-store-shared.ts`'deki alan listesi **kalıcılık whitelist'idir** — orada
+> olmayan alan kayıtta sessizce silinir. `apps/editor/lib/graph-schema.ts` de aynı
+> şekilde yük taşır.
+
+---
+
+## 5. Mevcut Durum (Current State & Progress)
+
+### 5.1 Tamamlanmış ve canlıda çalışan özellikler
+
+**Editör tarafı (fork):**
+- ✅ **MySQL scene store** — üretimde tek backend (SQLite yalnız dev; Docker
+ kasıtlı kaldırıldı).
+- ✅ **Scene sharing** — kullanıcı bazında `viewer`/`editor` paylaşımı
+ (`scene_shares`, `shares` rotası, paylaşım-farkında `guard.ts`).
+- ✅ **Yedekler + otomatik önizleme** — son 5 sürüm + geri-yükleme; kaydettikçe
+ otomatik thumbnail.
+- ✅ **Presence + tek-aktif-editör kirası** — canlı katılımcı çubuğu, "devral"
+ (takeover), izleyici moduna sabitleme.
+- ✅ **Canlı senkron (SSE)** — `scene_events` + 250 ms poll, last-writer-wins,
+ çakışma bandı.
+- ✅ **Kategori kilitleri** — `useViewer.sceneLocked` + `lockedCategories`,
+ `packages/editor/src/lib/edit-lock.ts` (`isNodeEditLocked`).
+- ✅ **Silme modu (X) toggle** — takılı kalma düzeltildi (PR #27, canlıda).
+- 🟡 **Kilit kapıları: silme modu + çoğaltma** — editor **PR #28** (taslak,
+ merge onayı bekliyor). Ayrıntı §5.2-C.
+- 🟡 **Çift yerleştirme düzeltmesi** — plugin-warehouse **PR #29** (taslak).
+ Ayrıntı §5.2-B.
+- ✅ **Warehouse eklenti entegrasyonu** — `bootstrap.ts`'te kayıtlı, saatlik pin.
+- ✅ **Fork performans düzeltmeleri (2026-08)** — wall-cutout thunk, warehouse
+ ölçekli oda (12k–30k m²) için space-detection üst-sınır kaldırma, level-index
+ WeakMap memo, bütçeli scene-BVH bakımı, statik-transform dondurma (testlerle
+ korunmakta).
+
+**Plugin-warehouse:** 21 node kind (raflar, konveyör ailesi, asma kat, forklift
+filosu, dock leveller, tote-cart, pallet-lift…), toplu instancing (5.300 node'da
+~10.300→~11 draw-call), LOD-kalite kolu, gölge-haritası kısma, `bake:'replace'`.
+
+**Konsol (panel `0.9.1`):** giriş/2FA/kilitleme/davet/reset, kullanıcı+toplu
+işlemler, roller, tesisler, oturumlar, işler (SSE), entegrasyonlar (API key +
+webhook), denetim (iki-dilli), ⌘K komut paleti, gerçek SMTP, ayarlar. README
+"bilinçli olarak yarım bırakılan bir şey yok" der.
+
+### 5.2 Bilinen hatalar ve açık işler (öncelik sırasıyla)
+
+> Bu bölüm 2026-08-19'da üç ayrı kod incelemesiyle üretildi. Her madde
+> **dosya:satır** taşır; "muhtemelen" yazan yerler doğrulanmamış demektir.
+
+#### A. ⚠️ ACİL — Üretim sırları git'e commit'li
+
+`Digitaltwin/.env` canlı MySQL kullanıcı+parolası, aynı parolanın SMTP kopyası,
+gerçek `SECRET_ENCRYPTION_KEY` ve admin e-postasını taşıyor; README de riski
+işaretliyor. **Yapılacak:** sırları döndür, `~/.digitaltwin.env`'e taşı, git
+geçmişinden temizle. `SECRET_ENCRYPTION_KEY` döndürülürse mevcut tüm TOTP
+gizli anahtarları okunamaz hâle gelir — kullanıcılar 2FA'yı yeniden kurar.
+
+#### B. ✅ ÇÖZÜLDÜ (2026-08-19) — Tek tıkta çift yerleşim
+
+*plugin-warehouse PR #29 · iki bağımsız sebep.*
+
+1. **Guard konuma bakıyordu ve konum asla eşleşmiyordu.** Bir fiziksel tıklama
+ emitter'a iki kez ulaşır: nesne yüzeyinden `pointerup` ile sentezlenen
+ `:click` ve tarayıcı `click`'inden gelen `grid:click` (`'grid'`,
+ `CLICK_TRIGGER_KINDS`'in ilk elemanı). `isFollowUpOfSameClick`
+ (`src/placement.ts`) ikisini **konum** ile eşleştirmeye çalışıyordu; oysa biri
+ ışının **mesh'e çarptığı**, diğeri **zemini kestiği** noktayı bildirir. Fark,
+ çarpma yüksekliğiyle orantılı (palet üstünde ~14 cm) ve eşik 1 mm. → ikinci
+ olay geçiyor, **aynı koordinata ikinci düğüm**. Boş zeminde iki nokta
+ çakıştığı için hata yalnız nesne üstüne tıklarken görünüyordu.
+ **Düzeltme:** guard artık pres-başına (`pointerdown` ile kurulur, ilk commit
+ ile harcanır) ve abonelik başına; ayrıca host'un `swallowFollowUpBrowserClick`
+ deseni kopyalandı, böylece `grid:click` hiç yayınlanmıyor.
+2. **Beş kind host paletinden gizli değildi** (`bench`, `dock-leveller`,
+ `pallet-lift`, `tote-cart`, `route`) → hem host paleti hem eklenti kataloğu
+ aynı tıkta yerleştiriyordu. Aynı hata `492f23b`'de sarmalda düzeltilmişti.
+ **Düzeltme:** beşine `presentation.hidden: true` + aile-üstü bekçi test.
+
+Bekçi testler eski kodda düşüyor (doğrulandı). Suite: 2681 geçti / 0 düştü.
+
+#### C. ✅ ÇÖZÜLDÜ (2026-08-19) — Kilit kapılarında atlanan iki yer
+
+*editor PR #28.*
+
+1. **Silme modu kilidi yok sayıyordu** — balyoz modunda (X) kilitli sahnede/
+ kategoride nesne siliniyordu. Merkezî handler
+ `selection-manager.tsx`'e `isNodeEditLocked` kapısı eklendi (tek yer hem 3B
+ hem 2D'yi kapsıyor).
+2. **Kilitliyken Duplicate serbestti** — `floating-action-menu.tsx` ve
+ `floorplan-registry-action-menu.tsx`'te Move/Delete/AddHole/Curve `!editLocked`
+ ile kapalıyken **Duplicate atlanmıştı**. Kopya kaynağın *tam* koordinatına
+ yazılıyor (çoğaltma yollarının çoğu offset vermiyor) → görünmez; kilitli
+ olduğu için seçilemez (#25); seçilemediği için silinemez. **Kullanıcının
+ "fark edilemiyor" şikâyetinin en güçlü açıklaması buydu.**
+
+#### D. 🔴 AÇIK — Yerleştirme önizlemesi (hayalet)
+
+Kullanıcı raporu (2026-08-19): *"3B'de imleci takip ediyor ama imlecin olduğu
+koordinatta göstermiyor; tıklayınca doğru yere koyuyor. 2B'de nesneyi seçince
+hiç göstermiyor; yerleştirdiğimi görmek için 3B'ye geçip geri dönmem gerekiyor."*
+
+Üç ayrı kusur; hiçbiri henüz düzeltilmedi:
+
+- **D1 — 2B'de hayalet hiç çizilmiyor.** 2B önizleme katmanı
+ (`editor-2d/renderers/floorplan-placement-preview-layer.tsx`) yalnız
+ `usePlacementPreview` store'undaki node'u çizer. Host'un yerleşik araçları
+ oraya yazar (`nodes/src/column/tool.tsx`, `spawn`, `cabinet` …); **eklentinin
+ 18 aracı hiç yazmaz** — konumu kendi `cursorRef`'ine imperatif olarak
+ yazıyorlar ve o mesh 2B'de `display:none` altında kalıyor.
+ *Düzeltme yeri:* `plugin-warehouse/src/placement.ts`'e ortak
+ `publishPreview()`/`clearPreview()` yardımcısı + 18 araçtan çağrı.
+- **D2 — 3B'de hayalet bir kare geride.** `placement.ts`'teki `subscribeGridMove`
+ hareketi rAF'a erteler (perf commit `fe641cf`), ama tıklama
+ `flushPendingGridMoves()` ile **senkron** boşaltır — commit doğru, önizleme
+ geride. Host'un araçları senkron abone.
+ *Düzeltme yeri:* görsel yazımı (position/rotation) senkron yap, pahalı işi
+ (`resolveAlignedPlacement`, çakışma taraması, `setState`) rAF'ta bırak.
+- **D3 — 2B'de yeni konan nesne görünmüyor, görünüm değiştirince geliyor.**
+ Henüz kök nedeni doğrulanmadı. En güçlü aday
+ `floorplan-registry-layer.tsx`'teki `floorplanVisible` kapısı ve geometri/
+ level-data cache'leri (`geometryCacheRef`, `levelDataCacheRef`, `siblingEpochs`)
+ — 3B↔2B geçişi cache'i geçersiz kılıyor olabilir. **Araştırılacak.**
+
+> Elenen hipotezler (kanıtlı): instancing / `static-transform` / `frozen-matrix`
+> **değil** (bunlar yalnız renderer'larda, preview'de kullanılmıyor);
+> `getFloorStackPreviewPosition` **değil** (yalnız Y'yi değiştirir);
+> koordinat çerçevesi uyuşmazlığı **değil** (üç yol da bina-yerel ve tutarlı).
+
+#### E. 🔴 AÇIK — Mükerrer node üreten diğer yollar
+
+- **E1 — Terk edilen `isNew` taslağı hiç geri alınmıyor.**
+ `tools/registry/move-registry-node-tool.tsx:1054-1062` temizlik koşulundan
+ `isNew` **bilerek** dışlanmış; taslağı silen tek yol `tool:cancel`
+ (`:1020-1034`). `tool:cancel` gelmeden unmount (mod/kat/faz değişimi, split-view
+ geçişi, seçim değişimi) → **taslak sahnede kalır, kaynağın tam üstünde**. 2B
+ karşılığı `floorplan-registry-move-overlay.tsx`: commit yalnız pointer plan
+ görünümü içindeyse yapılıyor (`:669`), panel üstünde bırakılan tıklama ne
+ commit ne iptal ediyor.
+ *Ek sorun:* birkaç duplicate girişi `temporal.pause()` yapıp başarı yolunda
+ `resume()` etmiyor (`floating-action-menu.tsx:534`, `nodes/src/door/panel.tsx:255`,
+ `lib/stair-duplication.ts:48`) → sızan kopya **undo yığınına bile girmiyor**.
+- **E2 — `use-placement-coordinator.tsx`'te çift-commit kapısı yok.** Hem
+ `grid:click` hem `item:click` hem `wall:click` (+ `ceiling`, `shelf`) abone
+ (`:2238-2248`); handler'ların hiçbirinde ortak "commit edildi" bayrağı yok.
+ Kardeş yolların hepsinde var: `move-registry-node-tool.tsx:800` (`if (committed) return`),
+ `stair-click-guard.ts` (`createStairCommitGate`), `nodes/shared/floor-placement.ts:119`
+ (`stopPlacementCommitPropagation`). Repeat modunda commit sonrası aynı
+ koordinatta yeni taslak kurulduğu için (`:495-501`) ikinci geçiş **tam üst üste**
+ ikinci düğüm koyabiliyor.
+- **E3 — Kat çoğaltmada çift-tık koruması yok.** Üç düğme de korumasız
+ (`site-panel/index.tsx:851`, `level-duplicate-dialog.tsx:105`,
+ `floating-level-selector.tsx:281`) ve handler bayat prop `levels` ile taze
+ `useScene.getState().nodes`'u karıştırıyor (`site-panel/index.tsx:677-696`) →
+ aynı karede iki tık = **aynı kat numarasında üst üste iki kat**.
+ *Not:* veri katmanı temiz — `clone-scene-graph.ts:207-220` `Set` guard'lı,
+ çift ziyaret yok. Sorun yalnız UI tarafında.
+- **E4 — 2B çizim katmanında dedup eksik.** `floorplan-registry-layer.tsx:945`
+ `visit()` fonksiyonunda `seen` seti yok; `:984-999` building-scoped taraması
+ `collectedIds` ile karşılaştırılmıyor (oysa hemen üstteki linked-node bloğu
+ `:959` bunu yapıyor). `parentId === building` ama hâlâ bir level torununun
+ `children`'ında görünen bir node (yarı göçmüş elevator sınıfı — çekirdek bunu
+ `use-scene.ts:633-639` ve `:902-910`'da belgeliyor) **2B'de iki kez çizilir**.
+ Aynısı `collectLevelDataKind:911`'de.
+- **E5 — "Grubun grubu" veri olarak mümkün değil** — session grupları sahne
+ düğümü değil (`lib/session-groups.ts:1-10`), gruplama üyeyi eski grubundan
+ çıkarıyor (`:114`). Grup duplicate'te üye+ebeveyn çift kopyası da korunmuş
+ (`lib/scene-clipboard.ts:328,334,342`). **Buradaki gerçek risk çift değil,
+ sessiz düşürme:** ebeveyni `shelf`/`cabinet`/`rack` olan ve ebeveyni seçili
+ olmayan üye root sayılmıyor (`:139-156`) → sessizce kopyalanmıyor.
+
+#### F. 🟡 Mükerrer tespiti yok (istenen "fark edilebilirlik")
+
+- `check_collisions` (MCP) yalnız `type:'item'` tarıyor → `warehouse:*` kapsam
+ dışı. `plugin-warehouse/src/clash.ts` gerçek 3B hacim testi yapıyor ama yalnız
+ yerleştirme kapısı olarak, sahne denetimi olarak değil.
+ `core/src/validation/validate-build-json.ts` yalnız `orphan_parent`,
+ `orphan_root`, `key_id_mismatch`, `unknown_types`, `schema_failure` üretiyor.
+- **Öneri:** `packages/core/src/validation/` altına saf yardımcılar —
+ `findCoincidentNodes` (anahtar: parent + type + yuvarlanmış position/rotation),
+ `findMultiParentNodes` (`use-scene.ts:1151-1179` zaten `childIdsByParentId`
+ indeksini kuruyor, neredeyse bedava), `findChildParentMismatch`. Bağlanacağı
+ yerler: `validateBuildJson`, dev-only `createNodesAction` sonrası denetim,
+ site-panel'de kat satırına rozet (**kilitli node'ları da göstermeli**).
+- **Önlem tespitten değerli:** her duplicate yolunda kopyayı görünür bir offset
+ ile üret (`lib/stair-duplication.ts:54-58` deseni — bugün offset veren yalnız
+ o ve `nodes/src/roof-segment/panel.tsx:127-131`).
+
+#### G. 🟢 Doküman kaymaları (düzeltilmeli, davranış etkisi yok)
+
+- `plugin-warehouse/README.md:145-168`: "eklenti node'ları duplicate edilemiyor,
+ `pascalorg/editor#547` bekleniyor" — **artık geçersiz.** Host'ta fallback var
+ (`scene-clipboard.ts:28-35` `parseClipboardNode`, registry şemasına düşüyor) ve
+ id-prefix hatası `83517b3c` ile düzelmiş.
+- `plugin-warehouse/CLAUDE.md` + README "Layout" bölümü yalnız `pallet/`+`rack/`
+ anlatıyor ve var olmayan `src/overlay.tsx`'e atıf yapıyor; gerçekte **21 kind**.
+- `editor/CHANGELOG.md` upstream'e ait; **fork özellikleri orada izlenmiyor**.
+
+#### H. 🟢 Diğer açık maddeler
+
+- **Seçilmiş varsayılanlar** (kaynak yerine, kodda işaretli): tote-cart 15° eğim,
+ `DEFAULT_LEVEL_HEIGHT = 3.0` (host varsayılanı 2.5), LOD ölçek faktörleri
+ ("ölçüm değil"), turret aisle EN 15620 bandı.
+- **Panel:** diyaloglar arka planı `inert` işaretlemiyor (erişilebilirlik);
+ iş kuyruğu süreç-içi (`src/lib/jobs.ts` `startJobWorker` — çok-instance'ta ayrı
+ sürece taşınmalı); `audit_log.message` kalıcı İngilizce; `roles.ts` `Supervisor`
+ tanımlıyor ama `seed.ts` seed'lemiyor.
+- **Tasarım sorusu (karar bekliyor):** Cut, kilitli nesnede kopyalıyor ama
+ silmiyor (`group-actions.ts:555-573`) — yorumda **bilinçli** olarak belgeli.
+ Yapıştırma kullanıcı tetiklediği için gizli mükerrer üretmiyor, ama sürpriz.
+- **Editör plan pending'leri:** asma kat çizimi zone/slab mantığına geçiş,
+ mezzanine outline yanlış çerçeve kuantalama, route `lines` yapışma modu no-op,
+ boya (duvar/slab doku) bir makinede gelmeme.
+
+### 5.3 Kasıtlı ertelenenler
+
+- **Organizations** (takım workspace + Owner/Admin/Member + davetler) — bizim
+ yığında yapılabilir (CRDT gerektirmez); `assignments`/`scene_shares` desenini
+ org modeline genişletmek. Orta iş.
+- **Gerçek-zamanlı çoklu-oyuncu (CRDT)** — upstream açık kaynağında **yok**
+ (Pascal'ın kapalı SaaS'ı). Bir CRDT taşıma katmanı ister (Liveblocks/PartyKit
+ veya öz-barındırılan Yjs). Büyük iş; ara çözüm olarak presence + tek-aktif-editör
+ zaten canlıda.
+
+---
+
+## 6. Kritik Tasarım ve Kodlama Kararları
+
+### 6.1 Mimari kararlar (yeni asistanın BİLMESİ GEREKENLER)
+
+1. **Katman sınırları kutsaldır** (`editor/AGENTS.md`, `wiki/architecture/`):
+ `core` = saf veri/mantık (Three.js/UI yok); `viewer` = bağımsız 3B tuval
+ (`useEditor`/araç/mod bilmez); `apps/editor` = düzenleme deneyimi, ``'a
+ prop/children ile enjekte edilir. **Biome bunu zorlar:** framework paketleri
+ `@pascal-app/nodes`'u import edemez — `nodeRegistry.get(kind)` kullan.
+2. **Registry-güdümlü kompozisyon** — yeni node kind'i = `def.geometry` +
+ `def.renderer` + `def.system` üçlüsü; kind adı dosyalara gömülmez.
+3. **2D ↔ 3D davranış paritesi** — bir yerleştirme/taşıma etkileşimi eklerken
+ hem 2D floorplan hem 3D için geçerli olmalı; kardeş dosyaya aynı PR'da taşı.
+ (Silme "balyoz" modu bunun istisnası: tek merkezî handler
+ `selection-manager.tsx` her iki görünümü de karşılar.)
+4. **Kalıcılık whitelist'leri yük taşır** — `scene-store-shared.ts` ve
+ `graph-schema.ts`'de olmayan alan kayıtta **sessizce silinir**.
+5. **MySQL-only üretim** — SQLite üretim fallback'i yok; `createSceneStore`
+ üretimde URL yoksa **fırlatır**. Docker kasıtlı silindi.
+6. **Fork ≠ upstream** — `integration`'da çalış, **`main`'e asla commit'leme**
+ (bire bir ayna; commit `mirror-upstream`'i kilitler). Upstream merge'leri
+ `UPSTREAM.md` dosya-bazlı tabloya uyar. `apps/editor/panel/**` vendor'dur —
+ `ovurrsl/panel`'de düzenle.
+7. **`GITHUB_TOKEN` push'u workflow tetiklemez** — build bekleyen push'lar bir
+ sonrakini açıkça dispatch etmeli.
+8. **Plugin izolasyonu** — host şema okumaları yalnız `host-adapter.ts`; peer
+ dep'ler pinlenmez; `src/index.ts` SSR-güvenli (module-scope'ta document/window/
+ Three.js yok, renderer/tool tembel thunk arkasında).
+
+### 6.2 Kod standartları
+
+- **Biome** (ESLint/Prettier yok): 2-boşluk girinti, satır 100, tek tırnak, JSX
+ çift tırnak, `semicolons: asNeeded`, sondaki virgül, import düzenleme açık.
+ Editörde bazı kurallar gevşek (`noExplicitAny`, `noConsole`, `noMagicNumbers`
+ kapalı). Panel alt-ağacında `useExhaustiveDependencies` kapalı.
+- **CSS:** Tailwind CSS v4 `@theme` + `--dt-*` design token'ları (panel/editör).
+ **Plugin panellerinde Tailwind YOK** (§3.2, kural 4) — inline stil.
+- **UI kütüphaneleri:** editör Radix UI + dnd-kit; panel `lucide-react` + `clsx` +
+ `tailwind-merge`. İkon seti `public/icons/*.webp`.
+- **Commit'ler:** Conventional Commits (`feat:`, `fix:`, `perf:`, `refactor:`,
+ `docs:`, `test:`, `chore:`). Konu changelog'a girer; gövde (Türkçe) gerekçedir.
+- **Testler:** editör/plugin `bun test`; panel `vitest`. Her değişiklikten sonra
+ tip-kontrolü + lint + test koş (plugin'de suite <1 sn).
+- **Operasyon kuralları** (`AGENTS.md`): dosyanın tamamını oku, tek seferde eksiksiz
+ düzenle; iki ardışık araç hatasından sonra dur; back-compat shim / ölü kod /
+ spekülatif soyutlama yok; yorum yalnız gizli bir *neden*'i açıklar.
+
+---
+
+## 7. Kurulum ve Çalıştırma Yönergeleri (Local Development)
+
+### 7.1 `ovurrsl/editor` (ana geliştirme)
+
+```bash
+cd editor
+bun install
+bun dev # turbo dev; Next → http://localhost:3002
+# yardımcılar:
+bun kill # 3002 portunu boşalt
+bun restart # kill + cache temizle + dev
+bun check-types # turbo check-types (next typegen && tsc --noEmit)
+bun check # biome check · bun check:fix
+bun run test # turbo test (paket başına bun test)
+bun build # üretim: node_modules temizle+kur, standalone çıktı + hostinger-server.js
+bun sync-panel # panel senkron (manuel tohumlama)
+```
+
+- **Dev için env gerekmez** (yalnız `PORT`, vars. 3002). Opsiyonel:
+ `MINT_PASCAL_HOST_ORIGIN`, `NEXT_PUBLIC_ASSETS_CDN_URL` (env.mjs doğrular;
+ `SKIP_ENV_VALIDATION` bypass).
+- **Üretim MySQL (zorunlu):** `DIGITALTWIN_MYSQL_URL` **veya**
+ `DIGITALTWIN_MYSQL_{HOST,USER,PASSWORD,DATABASE,PORT}`. Yerel SQLite override:
+ `DIGITALTWIN_DB_PATH` / `DIGITALTWIN_DATA_DIR`.
+- **Next 16 uyarısı:** `apps/editor/AGENTS.md` — "This is NOT the Next.js you know";
+ kod yazmadan önce `node_modules/next/dist/docs/` oku.
+
+### 7.2 `ovurrsl/panel` (konsol)
+
+```bash
+cd panel
+npm install
+cp .env.example .env.local
+node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" # SECRET_ENCRYPTION_KEY
+npm run db:migrate # DB'yi kurar (yoksa oluşturur), idempotent
+npm run db:seed # 1 admin + 3 tesis + settings + sistem rolleri
+npm run db:seed -- --dev # + r.ovur, c.tuna geliştirici hesapları
+npm run dev # next dev
+npm run test | typecheck | build
+```
+
+İlk giriş (seed): `Admin` / `Admin` (`admin@netlog.com.tr`, ilk girişte parola
+değişimi zorunlu). **Zorunlu env:** `SECRET_ENCRYPTION_KEY`, `DATABASE_*`.
+Opsiyonel: `SESSION_COOKIE_SECURE`, `NEXT_PUBLIC_EDITOR_URL`, `MAIL_*`/`SMTP_*`,
+`GITHUB_TOKEN` (changelog). Testler: 59 test / 7 dosya (`tests/`).
+
+> ⚠️ **Panelin kendi CI'ı yoktur** (`.github/workflows/` boş). Tip güvenliği
+> tamamen editördeki `pull-panel`'in `bun run check-types` kapısına bağlıdır —
+> yani panelde bozuk kod yazılırsa hata **editör tarafında** patlar ve vendor
+> akışı sessizce durur. Panelde çalışırken `npm run typecheck`'i elle koşun.
+
+### 7.3 `ovurrsl/plugin-warehouse` (eklenti)
+
+```bash
+cd plugin-warehouse
+bun install
+bun run check-types # tsc --noEmit
+bunx biome check . # lint + format (--write düzeltir)
+bun test
+bun run verify # üçü birden (CI eşdeğeri)
+```
+Env: yalnız `NODE_ENV` (prod'da yinelenen kind fırlatır; dev'de uyarır).
+
+### 7.4 `ovurrsl/Digitaltwin` (üretim paketi)
+
+```bash
+cd digitaltwin
+npm install
+npm run build # setup-native.mjs (argon2 alias fix) — derleme YOK
+npm start # node server.js → http://localhost:3000 (MySQL env gerekir)
+```
+Env yükleme: gerçek env vars > `.env` (her sürümde değişir) > `~/.digitaltwin.env`
+(sürümler arası kalır — **tercih edilen**). Her `DIGITALTWIN_*` bir `PASCAL_*`
+takma adıyla da okunur. Hostinger: repo `ovurrsl/digitaltwin`, branch `main`,
+Node 22.x, build `npm run build`, entry `server.js`. `/api/health` deploy'u
+doğrular (`"backend":"mysql","db":"ok"`).
+
+> **Dört depoyu birlikte çalıştırma:** Yerelde en pratik yol tek MySQL örneği
+> paylaşmaktır. `panel` ve `editor` aynı `DATABASE_*`/`DIGITALTWIN_MYSQL_*`
+> değerlerini kullanmalı; böylece konsol oturumu + sahne verisi aynı DB'de buluşur.
+> `NEXT_PUBLIC_EDITOR_URL`'i panele verin ki "Open" bağlantıları editöre gitsin.
+
+---
+
+## 8. Gelecek Yol Haritası (Next Steps & Roadmap)
+
+### 8.1 Yeni asistanın İLK görevleri (sırayla)
+
+1. **Güvenlik önce:** `Digitaltwin/.env` sırlarını döndür, `~/.digitaltwin.env`'e
+ taşı, git geçmişinden temizle. (`SECRET_ENCRYPTION_KEY` döndürülürse 2FA
+ yeniden kurulacağını unutma.) — **en yüksek öncelik** (§5.2-A).
+2. **Açık PR'ları kapat:** editor **#28** (kilit kapıları) ve plugin-warehouse
+ **#29** (çift yerleştirme). İkisi de taslak; CI yeşilinde merge → deploy.
+3. **Önizleme hatasını bitir (§5.2-D):** D1 (2B'de hayalet hiç yok) en yüksek
+ etkili ve en küçük iş — `placement.ts`'e ortak `publishPreview()` yardımcısı
+ koyup 18 araçtan çağır. Sonra D2 (3B bir-kare gecikmesi: görsel yazımı
+ senkron yap, pahalı işi rAF'ta bırak) ve D3 (2B'de yeni nesne görünmüyor —
+ **kök neden henüz doğrulanmadı**, `floorplanVisible` kapısı ve cache'lerden
+ başla).
+4. **Mükerrer üreten kalan yolları kapat (§5.2-E):** öncelik sırası E1 (sızan
+ `isNew` taslağı) → E2 (`use-placement-coordinator` commit kapısı) → E3 (kat
+ çoğaltma çift-tık) → E4 (2B çizim dedup).
+5. **Tespit ekle (§5.2-F):** `findCoincidentNodes` + `findMultiParentNodes` ve
+ duplicate yollarına görünür offset. Bu, "fark edilemiyor" sınıfını kökten
+ bitirir.
+
+### 8.2 Yakın vadeli özellikler
+
+- **Organizations (takım workspace'i)** — `assignments`/`scene_shares` desenini
+ Owner/Admin/Member + e-posta davetleri + org'a ait projeler + paylaşılan Files'a
+ genişlet. CRDT gerektirmez; panel + editör auth'ta yapılabilir (orta iş).
+- **Açık hata/iş düzeltmeleri:** asma kat çizimini zone/slab mantığına geçir;
+ mezzanine outline çerçeve kuantalama; route `lines` yapışma; boya doku gelmeme.
+- **Plugin dokümantasyonunu güncelle** (21 kind'i yansıt; hayali `overlay.tsx`'i
+ kaldır) ve "seçilmiş varsayılan" değerleri gerçek sahnede ölçüp ayarla.
+- **Upstream katkıları:** `#547` (duplicate) takibi; ölçüm sonrası perf/autosave
+ yamalarını upstream'e sun.
+
+### 8.3 Orta/uzun vade
+
+- **Gerçek-zamanlı çoklu-oyuncu (CRDT)** — Yjs (öz-barındırılan y-websocket/
+ Hocuspocus) veya yönetilen (Liveblocks/PartyKit). Asıl zorluk: editörün scene
+ graph'ını CRDT doc'una bağlamak. Büyük iş; presence+kira ara çözüm olarak yerinde.
+- İş kuyruğunu ayrı sürece taşı (çok-instance); erişilebilirlik (`inert`) açığını
+ kapat.
+
+### 8.4 Yeni asistan için altın kurallar (özet)
+
+- 🟢 Geliştirmeyi **`editor@integration`**'da yap; **`main`'e dokunma** (§1.4.1).
+ Özellik dalı → PR → squash-merge → `deploy-bundle` → canlı (§1.4.6).
+- 🟢 Konsolu **`ovurrsl/panel`**'de düzenle, `apps/editor/panel/**`'de değil;
+ `Digitaltwin`'e elle hiç dokunma (her yayında üzerine yazılır).
+- 🟢 Otomatik akışların (`bump-plugin`, `pull-panel`, `mirror-upstream`) işini
+ elle yapma; `GITHUB_TOKEN` push'u workflow tetiklemez (§1.4.4).
+- 🟢 Editör/plugin → **bun**; panel → **npm**. Testler: bun test / vitest.
+- 🟢 Ölçüler **metre**; plugin peer dep'lerini **pinleme**; host şeması yalnız
+ `host-adapter.ts`.
+- 🟢 Kalıcılık whitelist'lerini (scene-store-shared / graph-schema) unutma —
+ sessiz veri kaybı.
+- 🟢 Değişiklikten önce ilgili `wiki/architecture/` sayfasını oku; PR incelemede
+ `review-architecture` skill'ini çağır.
+
+---
+
+### Ek A: mimari wiki sayfaları ve hazır iş akışları (skills)
+
+Mimariye dokunan bir değişiklikten **önce** ilgili sayfayı oku
+(`editor/wiki/architecture/`, indeks `README.md`'de). 20 sayfa:
+
+`layers` · `viewer-isolation` · `systems` · `renderers` · `node-schemas` ·
+`node-definitions` · `scene-registry` · `selection-managers` · `selection-groups` ·
+`tools` · `interaction-scope` · `spatial-queries` · `events` · `measurements` ·
+`materials-and-themes` · `item-authoring` · `plugin-authoring` · `vertical-model` ·
+`creating-rules` · `README` (indeks)
+
+Asgari eşleme: yeni node kind → `node-schemas` + `node-definitions` + `renderers`
++ `systems`; yeni araç → `tools` + `interaction-scope` + `spatial-queries` +
+`events`; `packages/viewer` içi → `viewer-isolation` + `layers`; seçime dokunan
+→ `selection-managers` + `scene-registry` + `events`; **eklenti işi →
+`plugin-authoring`**.
+
+**Skills** (`editor/.agents/skills/`, `.claude/skills/` vb. sembolik bağlar):
+- `review-architecture` — PR'ı mimari kurallara karşı denetler (gerekli wiki
+ sayfalarını yükler, diff'i çeker, yeni dosyaları katmana göre sınıflar).
+- `open-pr` — deponun PR şablonuyla PR açar.
+
+### Ek B: hızlı dosya-yolu dizini
+
+| Ne | Nerede |
+|---|---|
+| Katman sınırları + fork kuralları | `editor/AGENTS.md` (=`CLAUDE.md`) |
+| **Otomasyon topolojisi (tek doğruluk kaynağı)** | `editor/OTOMASYON.md` → bu dokümanda §1.4 |
+| Workflow tanımları | `editor/.github/workflows/{bump-plugin,pull-panel,mirror-upstream,deploy-bundle,upstream-check,ci,mcp-ci,sync-panel,relock}.yml` |
+| Upstream merge kuralları (dosya-bazlı) | `editor/UPSTREAM.md` |
+| Yayınlama notları | `editor/YAYINLAMA.md` |
+| Scene store (MySQL) | `editor/packages/mcp/src/storage/mysql-scene-store.ts` |
+| Scene store sabitleri/whitelist | `editor/packages/mcp/src/storage/scene-store-shared.ts` |
+| Auth köprüsü + yetki | `editor/apps/editor/lib/auth/{session,guard}.ts` |
+| Eklenti keşfi | `editor/apps/editor/lib/bootstrap.ts` |
+| Düzenleme kilidi | `editor/packages/editor/src/lib/edit-lock.ts` |
+| Silme "balyoz" handler | `editor/packages/editor/src/components/editor/selection-manager.tsx` |
+| Plugin manifest | `plugin-warehouse/src/index.ts` |
+| Plugin host okumaları | `plugin-warehouse/src/host-adapter.ts` |
+| Plugin geometri/cache | `plugin-warehouse/src/*/geometry-builder.ts` |
+| Konsol sekmeleri | `panel/src/lib/console-tabs.ts` |
+| Konsol DB pool | `panel/src/lib/db.ts` |
+| Yetkili panel DB şeması | `Digitaltwin/panel-migrations/001..007.sql` |
+| Üretim sunucusu | `Digitaltwin/server.js` |
+| Vendoring motoru + `EDITOR_OWNED` | `editor/scripts/sync-panel.mjs` |
diff --git a/SETUP.md b/SETUP.md
index 80c33bc024..4575568032 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -28,26 +28,24 @@ cp .env.example .env
Local development and the official hosted editor work without any environment variables.
-## Docker
+## Docker — removed in this fork
-```bash
-docker compose up -d
-```
-
-The editor will be running at **http://localhost:3000**. Saved scenes live in
-the `pascal-data` volume, so they survive `docker compose down`.
+Upstream ships a `Dockerfile` and `docker-compose.yml`. **They are deleted
+here, on purpose.**
-Docker defaults `MINT_PASCAL_HOST_ORIGIN` to `http://localhost:3000`. Override
-it when hosting Pascal at another origin:
+They declared a `/data` volume and stated that saved scenes live in SQLite
+inside it, with no MySQL variable anywhere in either file. This fork runs on
+MySQL and treats "scenes live in the database" as a hard requirement, so those
+files described a second, contradictory way to install the product — one that
+would have stored a customer's warehouses in a container volume, silently, and
+looked entirely normal while doing it. Nobody deploys this fork with them
+(production is Hostinger, built by `deploy-bundle`), so they were pure
+opportunity for a future mistake.
-```bash
-MINT_PASCAL_HOST_ORIGIN=https://pascal.example.com docker compose up -d
-```
+If Docker is ever wanted here, it comes back carrying the MySQL configuration
+and without the volume — not by restoring upstream's copy.
-Keep the container port at 3000: the `/scenes` page fetches its own API through
-a base URL that only `NEXT_PUBLIC_APP_URL` can override, and Next inlines that
-value at build time, so remapping the port to something else makes the page
-return 500.
+Deployment for this fork: `YAYINLAMA.md`.
## CLI-managed editor
diff --git a/TEST_INFRA.md b/TEST_INFRA.md
new file mode 100644
index 0000000000..afd17aa2b6
--- /dev/null
+++ b/TEST_INFRA.md
@@ -0,0 +1,35 @@
+# E2E Test Infra: Upstream Synchronization
+
+## Test Philosophy
+- Multi-tier requirement-driven and regression testing.
+- Methodology: Category-Partition + Boundary Value Analysis + Unit/Integration & Monorepo Test Runner.
+
+## Feature Inventory & Test Coverage
+| # | Feature | Source | Tier 1 (Unit) | Tier 2 (Boundary) | Tier 3 (Cross-Module) | Tier 4 (E2E) |
+|---|---------|--------|:-------------:|:-----------------:|:---------------------:|:------------:|
+| 1 | Zod 4.5.4 Node Schemas | Upstream & Core | 5 | 5 | ✓ | ✓ |
+| 2 | Plugin Warehouse Node Schemas | Local & Core | 5 | 5 | ✓ | ✓ |
+| 3 | Asset Storage & Registry | Core | 5 | 5 | ✓ | ✓ |
+| 4 | Dependencies & Manifests | Root / Packages | 5 | 5 | ✓ | ✓ |
+| 5 | ToolMode FSM Transitions | Editor Store | 5 | 5 | ✓ | ✓ |
+| 6 | First Person & Drone Camera | Editor Controls | 5 | 5 | ✓ | ✓ |
+| 7 | Move Registry Node Tool | Editor Tools | 5 | 5 | ✓ | ✓ |
+| 8 | Keyboard Shortcuts | Editor Hooks | 5 | 5 | ✓ | ✓ |
+| 9 | Floating Action Menu | Editor UI | 5 | 5 | ✓ | ✓ |
+| 10 | Material Picker | Editor UI | 5 | 5 | ✓ | ✓ |
+| 11 | High-Res Snapshot Export | Editor Overlay | 5 | 5 | ✓ | ✓ |
+| 12 | Zone Deletion & Takeoff | Editor & Nodes | 5 | 5 | ✓ | ✓ |
+| 13 | Monorepo Test Suite (`turbo run test`) | All Packages | 5 | 5 | ✓ | ✓ |
+
+## Test Commands
+- Single package tests:
+ `bun test packages/core/src`
+ `bun test packages/editor/src`
+ `bun test packages/nodes/src`
+ `bun test packages/viewer/src`
+ `bun test packages/mcp/src`
+ `bun test apps/editor/lib`
+- Monorepo runner:
+ `bunx turbo run test`
+- Type checking:
+ `bunx turbo run check-types` or `bunx tsc --noEmit`
diff --git a/TEST_READY.md b/TEST_READY.md
new file mode 100644
index 0000000000..8fba278998
--- /dev/null
+++ b/TEST_READY.md
@@ -0,0 +1,34 @@
+# E2E Test Suite Ready
+
+## Test Runner
+- Command: `bunx turbo run test`
+- Monorepo Pass Result: 19/19 tasks successful, 0 failed
+- Total Tests: 5,243+ tests passing across all 7 workspace packages
+
+## Coverage Summary
+| Tier | Count | Description |
+|------|------:|-------------|
+| 1. Feature Coverage | 65 | Core node schemas, FSM tool modes, plugin warehouse manifests, asset storage |
+| 2. Boundary & Corner | 65 | Zod bare literal unwrapping, discriminator filtering, edit-locking invariants |
+| 3. Cross-Feature | 25 | ToolMode FSM + edit locking, snapshot export + format conversion |
+| 4. Real-World Application | 15 | Full zone takeoff reports, 2D SVG rack minimap projections, multi-tier test runs |
+| **Total** | **170+** | **100% Passed** |
+
+## Feature Checklist
+| Feature | Tier 1 | Tier 2 | Tier 3 | Tier 4 | Status |
+|---------|:------:|:------:|:------:|:------:|:------:|
+| Upstream Zod 4.5.4 Migration | 5 | 5 | ✓ | ✓ | PASSED |
+| Plugin Warehouse Node Schemas | 5 | 5 | ✓ | ✓ | PASSED |
+| Asset Storage & Registry Types | 5 | 5 | ✓ | ✓ | PASSED |
+| Manifests & Dependencies | 5 | 5 | ✓ | ✓ | PASSED |
+| ToolMode FSM Unification | 5 | 5 | ✓ | ✓ | PASSED |
+| First Person & Drone Controls | 5 | 5 | ✓ | ✓ | PASSED |
+| Move Registry Node Tool | 5 | 5 | ✓ | ✓ | PASSED |
+| Keyboard Shortcuts & Edit Locks | 5 | 5 | ✓ | ✓ | PASSED |
+| Floating Action Menu & Panels | 5 | 5 | ✓ | ✓ | PASSED |
+| Material Picker | 5 | 5 | ✓ | ✓ | PASSED |
+| High-Res Snapshot Export | 5 | 5 | ✓ | ✓ | PASSED |
+| Zone Deletion & Takeoff | 5 | 5 | ✓ | ✓ | PASSED |
+| Lockfile Sync (`bun.lock`) | 5 | 5 | ✓ | ✓ | PASSED |
+| Turborepo Test Suite | 5 | 5 | ✓ | ✓ | PASSED |
+| Git Push to `origin main` | 5 | 5 | ✓ | ✓ | PASSED |
diff --git a/UPSTREAM.md b/UPSTREAM.md
new file mode 100644
index 0000000000..143bf9c4be
--- /dev/null
+++ b/UPSTREAM.md
@@ -0,0 +1,272 @@
+# Repository topology and upstream merges
+
+Plain-language version of the same picture, for whoever operates this rather
+than edits it: `OTOMASYON.md`.
+
+## Two branches, and why
+
+| Branch | What it is |
+|---|---|
+| `main` | A pure mirror of `pascalorg/editor`. No commit of ours ever lands here, which is what makes taking upstream free — the mirror can only fast-forward, so it never conflicts. |
+| `integration` | **The default branch.** Everything this fork adds lives here, and it is what the deploy builds from. Every workflow that pushes, pushes here. |
+
+Scheduled workflows run only from the default branch, which is why
+`integration` is it. Point `vars.INTEGRATION_BRANCH` at another name to move
+it; every workflow reads that variable and falls back to `integration`.
+
+## Which change goes to which repository
+
+| What changed | Repository | How it gets there |
+|---|---|---|
+| Editor (this codebase) | `ovurrsl/editor` — fork of `pascalorg/editor` | Commit on `integration` |
+| Console / panel | `ovurrsl/panel` — the console's home | Automatic inbound: `pull-panel` vendors it into `apps/editor/panel` hourly, type-checks, pushes to `integration`, then dispatches the deploy. The outbound `sync-panel` is manual now — for seeding the console repository from here, not for routine work. |
+| Warehouse plugin | `ovurrsl/plugin-warehouse` | Automatic: `bump-plugin` compares the pin in `apps/editor/package.json` against the plugin's `main` hourly, moves it, relocks, type-checks, pushes, and dispatches the deploy. Nothing to do by hand. |
+| Upstream editor | `pascalorg/editor` | `mirror-upstream` fast-forwards `main` daily and opens one long-lived pull request into `integration`. **Merging it is the one manual step in the whole chain** — see below. |
+| What the server runs | `ovurrsl/Digitaltwin` | Build artifacts only — published by the deploy workflow (or a manual publish). Never commit source here; the host redeploys from it. Step-by-step: `YAYINLAMA.md` |
+
+Every automatic path above ends at `deploy-bundle`, which refuses to publish
+unless the build succeeds and both boot smoke tests pass. That is the reason
+none of them needs a human in the middle.
+
+## Pulling updates from pascalorg/editor
+
+`mirror-upstream` keeps `main` on upstream's tip and opens a pull request from
+`main` into `integration` whenever `integration` is behind it — not only on the
+run that moved the mirror, because `main` can also be advanced by hand and
+gating on "did this job push?" loses those updates silently.
+
+The `upstream-check` workflow (weekly, or run it manually from the Actions tab)
+does a trial merge and reports which files would conflict — read its summary
+before merging for real.
+
+Resolving the pull request locally:
+
+```
+git remote add upstream https://github.com/pascalorg/editor.git # once
+git fetch upstream
+git checkout integration
+git merge upstream/main
+```
+
+Most of this repository's additions live in files upstream does not have, so
+they merge silently: `apps/editor/panel/`, `apps/editor/lib/auth/`,
+`apps/editor/app/(panel)/`, the deploy/relock/sync workflows, the deploy
+scaffold under `.github/deploy/`.
+
+## Upstream files that carry local changes — conflict rules
+
+| File | Rule when it conflicts |
+|---|---|
+| `AGENTS.md` (and its `CLAUDE.md` / `GEMINI.md` / copilot symlinks) | Keep our fork block at the top, take upstream's body below it. The block is delimited by `FORK BLOCK` / `END FORK BLOCK` comments and says which branch to work on — without it an agent reads instructions written for `pascalorg/editor` and commits to `main`. |
+| `apps/editor/app/page.tsx` | Keep ours. Upstream's root page is the editor composition; ours is the session router. Upstream's changes to the editor composition belong in `apps/editor/components/editor-app.tsx` — port them there by hand. |
+| `apps/editor/components/editor-app.tsx` | Ours only (upstream has no such file), but it is a moved copy of upstream's old `app/page.tsx` — apply upstream's `app/page.tsx` improvements here. |
+| `apps/editor/app/layout.tsx` | Merge both; keep the `export const dynamic = 'force-dynamic'` block (the host's CDN caches static HTML across deploys and serves dead assets without it). |
+| `apps/editor/lib/graph-schema.ts` | Keep ours: API validation must consult each plugin's own node schemas, not a static union. Port upstream's non-plugin changes around that. |
+| `apps/editor/app/api/scenes/**`, `lib/auth/guard.ts` | Merge both; keep the ownership/role checks (`authorizeSceneMutation`, `canEdit`). |
+| `apps/editor/components/scene-loader.tsx` | Merge both; keep the `readOnly` prop and the console-session `useSession` wiring. |
+| `apps/editor/app/scenes/`, `app/scene/[id]/` | Merge both; keep the console-session gating and the navigation that points Home at `/`. Scene administration lives in the console's 3D scenes tab, not in a standalone page. |
+| `apps/editor/next.config.ts` | Merge both; keep `serverExternalPackages: ['@node-rs/argon2']` and the standalone/output settings. |
+| `apps/editor/package.json`, `bun.lock`, `biome.jsonc` | Merge both, and keep the `@ovurrsl/plugin-warehouse` pin — upstream has no such dependency, so a wholesale "take theirs" silently removes the warehouse racks. After changing dependencies by hand, dispatch the Relock workflow from the Actions tab to regenerate `bun.lock` on a real runner. |
+| `apps/editor/app/api/health/route.ts` | Keep ours — it exercises the scene store, and `deploy-bundle`'s second smoke test is only meaningful because of that. Upstream's version answers `ok` without touching the database, so taking it turns the release gate into a rubber stamp. Do take their `version` / `instanceId` fields: they are the only way to read which build is actually live. |
+| `apps/editor/lib/bootstrap.ts` | Merge both — register upstream's `mintPlugin` **and** `warehousePlugin`. `extendPluginDiscovery` composes, so this is two calls rather than a choice; `setPluginDiscovery` would drop everything registered before it. Both plugins must also stay in `serverExternalPackages` (`next.config.ts`) and in `apps/editor/package.json`. |
+| `apps/ifc-converter/next-env.d.ts` | Take upstream's deletion. Next regenerates it on every build and upstream has it in `.gitignore`; tracking it only buys a conflict at every Next upgrade. It has nothing to do with the IFC feature — that is `apps/ifc-converter/` plus the editor's own import button, and neither is affected. |
+| root `package.json` | Keep ours: `build` (the Hostinger standalone chain the deploy copies `hostinger-server.js` into), `dev`, and `sync-panel`. Take everything else from upstream — `test`, `engines.node`, `packageManager`, `overrides.next`. `test` had gone missing on our side, so `bun test` at the root ran nothing at all. `release:cli` is deliberately not taken: we do not publish the CLI. |
+| `apps/editor/next.config.ts` (output) | Keep `output: 'standalone'` **unconditional**. Upstream gates it behind `PASCAL_PORTABLE_BUILD=1`; taking that produces a green build with no standalone directory to serve, and the deploy fails at the copy step rather than at the build. `outputFileTracingRoot` is **not** taken — not because it is proven harmful, but because every deploy that has ever worked here was built without it and the bundle only ever copies `standalone/apps/editor/`. Leave it out until someone has a reason and a green deploy to go with it. |
+| root `package.json` `overrides` ↔ `.github/deploy/package.json` | **Bump them together or not at all.** These two files never see each other: the first fixes what the app is BUILT with, the second what the bundle RUNS. beta.5 raised `overrides.next` to 16.3.0 and left the bundle on 16.2.9 — a `.next` output from one Next served by another. Nothing errors: build green, assemble green, the server boots, connects to MySQL, applies migrations, says ready, and then resolves no routes at all. `/api/health` returned an empty body for a full minute with not one line in the log, twice. `apps/editor/lib/deploy-bundle.test.ts` now asserts the two agree, so this cannot recur silently. |
+| `packages/mcp/src/storage/sqlite-scene-store.ts` | Keep ours. The fork split the shared helpers into `scene-store-shared.ts` so `mysql-scene-store.ts` can use them; upstream still has everything inlined, so taking theirs re-inlines the helpers and breaks the MySQL store — which is the backend production actually runs. Port upstream's behavioural fixes into `scene-store-shared.ts` instead. |
+| `packages/viewer/src/components/viewer/index.tsx` | Take upstream's. The GPU-capability check and the unsupported-GPU fallback were extracted into `lib/renderer-capability.ts` and `components/viewer/unsupported-gpu-fallback.tsx` upstream; our inline copies are simply the older version of the same code. Re-apply one thing after taking theirs: the fallback text says "Pascal", and ours says "DigitalTwin". |
+| `apps/editor/lib/graph-schema.test.ts` | Merge both suites into one file. The two sides wrote independent tests at the same path — upstream covers the envelope (asset-URL allowlist, nesting, materials), ours covers plugin-kind validation. Neither subsumes the other. |
+| `packages/viewer/src/systems/wall/wall-cutout.tsx` | **Fork perf fix (2026-08-07):** the slab-support / plane-top chain runs only for highlighted walls, behind `resolveSelectionHighlight`'s thunk. Measured at roughly half of frame CPU on a warehouse-scale scene, spent on walls whose answer was discarded. Keep the thunk when merging; it is being proposed upstream, so take upstream's version if they fix it themselves. |
+| `packages/core/src/lib/space-detection.ts` | **Keep the missing upper area bound (2026-08-11).** Upstream drops any detected face over 10 000 m²; we dropped only that half of the test, keeping the `< 0.5` floor. The bound cost us five things at once on every building past it — `Space`, auto slab, auto ceiling, auto zone, and `wallClosesRoom`, so the wall tool would not auto-close either — with no error anywhere. Warehouses are 12 000–30 000 m², so for this fork it is not an edge case, it is the normal size. It was not guarding the outer face (already dropped by `signedArea <= 0`, since `nextEdge` walks interior faces counter-clockwise) and it was not a cost guard (the polygon is already built when the check runs). `warehouse-scale rooms` in `space-detection.test.ts` sweeps 100 m² → 30 000 m² and fails on any reinstated ceiling. Proposed upstream; take theirs if they land it. |
+
+| `packages/core/src/systems/stair/{stair-opening-sync,stair-rise}.ts`, `packages/editor/src/lib/stair-levels.ts` | **Fork perf fix (2026-08-13, #19):** kat sorguları `services/level-index.ts` üzerinden (fork'a özgü yeni dosya, WeakMap-memo). Upstream hâlâ her çağrıda tüm sahneyi tarar — 5 000 düğümlü sahnede kat eklemek 2,6 s. Merge'de indeks çağrılarını koru, upstream'in davranış düzeltmelerini indeksli hâlin üstüne uygula. Bekçi: `stair-opening-sync.test.ts` içindeki Proxy `ownKeys` sayacı — upstream'in tarayan hâli geri gelirse kırmızı yanar. |
+| `packages/nodes/src/{wall,ceiling,column}/{panel.tsx,parametrics.ts}`, `packages/editor/src/components/ui/floating-level-selector.tsx` | **Keep ours: no `max` at all (2026-08-24).** Upstream raised the room-envelope caps from 6 m to 20 m (#642, #650). A cap is still a cap: fork PR #17 removed thirteen of them because a warehouse clear height is 10–12 m and high-bay is past 15, and because `SliderControl` clamps *typed* input as well as drag — so a dragged 120 m wall silently became 20 m the moment anyone touched the Length field. No schema has an upper bound; the floors stay. Ceiling is the subtle one: the real constraint is `getCeilingClampBound`, and `Math.min(20, maxHeight)` overrides it exactly as `Math.min(6, maxHeight)` did. Take upstream's *structural* changes around the control (e.g. the `!managedByLeanTo` wrapper on the column height slider) and leave the `max` off. |
+| `apps/editor/package.json` → `@pascal-app/plugin-trees` | Ours is `workspace:*` against the vendored `packages/plugin-trees`; upstream consumes it as `github:pascalorg/plugin-trees#` and bumps that sha. Keeping `workspace:*` is what the vendored copy requires, and the cost is that trees stops receiving upstream's updates. **Open question, not a settled rule** — switching to the github pin means deciding what happens to `packages/plugin-trees` (and `packages/plugin-articraft` beside it), which is its own change with its own build to prove. Do not do it inside an upstream merge. |
+| `apps/editor/components/scene-loader.tsx` (floating buttons) | Also drop upstream's `Light preview` / `All scenes` overlay block. Both were navigation sitting on top of the drawing; the Scenes rail answers the second from inside the editor and `?disable=postFx` still drives `disablePostFx`, so the flag survives without a permanent button for a diagnostic. |
+| `packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx` | Take upstream's `getTreeNodeComponent` export (their `tree-node.test.ts` imports it) but keep our `def.tree` gate **inside** it, returning `undefined` for a kind that declares no `tree`. Upstream falls back unconditionally, and a level's row maps every one of its children to a `TreeNode` — so an unconditional fallback draws rows for `roof-segment`, `stair-segment`, the duct and pipe fittings and `guide`. Their test only asserts two unnamed kinds route to the *same* thing, which `undefined === undefined` satisfies. |
+| `apps/editor/lib/api-put-empty-guard.test.ts` | Rebuild the fixture. Upstream's is two `qa:box` nodes, correct against upstream's validator and wrong against ours: `lib/graph-schema.ts` routes a plugin kind to that plugin's own `def.schema` and everything unclaimed to `AnyNode`, so an invented kind is refused and every save in the suite returns 400 `invalid_request` before reaching the guard under test. Build the nodes by calling a real kind's schema (`WallNode.parse(...)`) — the same rule the 2026-08-10 log draws from `graph-schema.test.ts`, hit a second time. |
+| `packages/viewer/src/components/viewer/scene-bvh.tsx` | **Fork perf fix (2026-08-13, #20):** tek seferlik mount taraması `lib/scene-bvh-maintainer.ts`'e (fork'a özgü) bağlanan sürekli/bütçeli bakıma çevrildi — upstream'in hâli sahne boş doldurulmadan tarar ve hiçbir şey indekslemez (gezinme süresinin %62'si kaba kuvvet raycast). Merge'de maintainer bağlantısını koru; upstream bileşen API'sine ne eklerse porte et. |
+| `packages/viewer/src/components/renderers/parametric-node-renderer.tsx`, `systems/wall/wall-system.tsx`, `systems/floor-elevation/floor-elevation-system.tsx` | **Fork perf fix (2026-08-13, #21):** statik matris dondurma — parametrik grup `useStaticTransform` ile, duvar mesh'i `updateWallGeometry` sonunda `freezeObjectTransform` ile donar; kat yükseltme yazımı `stampFrozenTransform` ile damgalar. Sıra tuzağı (`matrixAutoUpdate=false` ÖNCE gelirse nesne orijinde çizilir) `lib/static-transform.ts`'te belgeli ve testli. Merge'de üç çağrıyı da koru — upstream'de yoklar ve kaybolmaları hata değil sessiz yavaşlama üretir. |
+
+After any upstream merge: `bun run check && bun run check-types`, build, and
+let CI plus the deploy workflow's boot smoke tests confirm nothing broke
+before publishing to `ovurrsl/Digitaltwin`.
+
+---
+
+## Before you publish: keep a way back
+
+Tag or branch the commit the **currently live** deploy was built from, and push
+it, before merging anything large. The live sha is the `head_sha` of the last
+successful `Deploy bundle` run — not the head of `integration`, which usually
+sits ahead of it.
+
+```sh
+LIVE=$(…head_sha of the last successful Deploy bundle run…)
+git branch rollback/$(date +%F)-pre-upstream "$LIVE"
+git push origin refs/heads/rollback/$(date +%F)-pre-upstream
+```
+
+Push it as `refs/heads/…` explicitly. A branch and a tag of the same name make
+the short refspec ambiguous and the push is refused.
+
+Rolling back is then:
+
+```sh
+git checkout integration
+git reset --hard rollback/
+git push --force-with-lease origin integration
+# then run `Deploy bundle` by hand from the Actions tab
+```
+
+The database is **not** covered by this. Scenes live in MySQL, so a rollback
+returns the code and leaves the data where it is — which is what you want for a
+bad build, and no help at all for a bad migration.
+
+---
+
+## Log of upstream takes
+
+One entry per merge. The point is not history for its own sake: it records what
+was *decided* and what bit us, so the next take is cheaper than this one was.
+
+### 2026-08-24 — beta.5 → 7f629b8c, 55 commits
+
+**What came in.** Lean-to roof extensions with automatic drainage (#651, #690),
+Blender-style custom mesh editing (#638), synchronized 2D viewer modes (#672),
+shared-parameter editing across a homogeneous multi-selection (#680),
+plugin inspector-card extensions (#667), an empty-graph save guard, a batch of
+wall hover/pick correctness fixes (#683, #686, #687, #689, #697), and the
+autosave fix that stopped scenes being wiped during the load window (#682).
+
+**Thirty-five files conflicted. Five of them were not real conflicts.**
+`integration` carries cherry-picks of upstream #607, #608 and #638, so git saw
+two independent additions of the same path and reported add/add. Four of the
+five were byte-identical to the upstream commit they were picked from, and the
+fifth differed by one defensive `?.`. **Check this first on any add/add
+conflict** — `git diff origin/integration::` answers
+it in a second, and taking upstream outright is then strictly an upgrade,
+because upstream has since fixed the same file (#686, #687).
+
+**The empty-graph guard's tests failed for the reason the last log predicted.**
+Upstream's fixture builds two `qa:box` nodes on the reasoning that a foreign
+kind is held to the BaseNode envelope. True upstream, false here — and all
+three tests came back 400 `invalid_request` without ever reaching the guard.
+This is the *second* time an upstream suite has been merged against this fork's
+plugin-aware `graph-schema.ts`. The rule from 2026-08-10 stands and is now
+worth stating as a habit rather than a lesson: **when an upstream test fails
+right after a merge, check whether it is testing upstream's implementation
+before you touch the implementation.**
+
+**A merge tool that eats newlines is worse than one that fails.** The
+conflict-resolution pass here was scripted with a regex whose `\n?` swallowed
+the trailing newline of every kept block. It produced things like
+`from '../../lib/edit-lock'import { getFloatingMenuScale }` and
+`registerEditorHostPanel({ ... })extendPluginDiscovery(...)` in six files.
+Biome caught most of them and `check-types` caught the rest, but one — a
+duplicate `export { createEditorApi }` in `packages/editor/src/index.tsx` —
+was a *semantic* duplicate git had auto-merged, not a scripting slip, and it is
+the kind of thing a conflict-free auto-merge produces without telling anyone.
+**Run `bun run check && bun run check-types && bun run test` before believing a
+merge with no remaining markers.**
+
+**`bun.lock` cannot be regenerated from a sandbox, and this is by design.**
+Upstream bumped `plugin-bones` to `85238a8e`; the lockfile records the sha512
+of each GitHub tarball, and `api.github.com` tarball fetches for repositories
+outside this session's scope return 403. The lock is therefore committed
+unchanged and the **Relock** workflow is dispatched on the merge branch
+afterwards — it exists precisely for this and pushes back to the branch it was
+dispatched on. Until it runs, CI's `--frozen-lockfile` is expected to fail on
+the bones hash and nothing else.
+
+**Plan item 4.3, folded in while `next.config.ts` was already open:**
+`@pascal-app/plugin-articraft` is now in `transpilePackages`. It ships raw
+TypeScript (`"main": "./src/index.ts"`), `lib/bootstrap.ts` imports it, and it
+had never been listed. It builds today only because bun's symlink layout drops
+its real path outside `node_modules` and Next compiles it anyway — a linker
+change would have broken it with no warning.
+
+### 2026-08-10 — beta.2 → beta.5, 56 commits
+
+**Why it was 56 and not a handful.** `mirror-upstream` had been failing every
+night since 7 August and nobody noticed, because nothing user-visible breaks
+when the mirror stops — the editor keeps building from a frozen `main`. See the
+`MIRROR_TOKEN` note in `OTOMASYON.md`. **Check that the mirror is green before
+assuming you are up to date.**
+
+**What we gained that we actually wanted:** the `materials` persistence fix
+(below), the per-level base-elevation control, webp snapshot encoding, and the
+GPU-capability refactor with its tests.
+
+**The bug this merge uncovered, and the one worth remembering.** Upstream's
+`#597` found that `materials` was never named in the persistence schemas.
+`z.object()` strips what it does not name, so every custom surface was silently
+deleted on save — no error, no log, and the scene reopens looking merely
+"reset". Our fork had the same hole in two places, and one of them was missing
+`installedPlugins` as well, so a warehouse scene forgot which pack it needed.
+
+The general rule that falls out of it: **in `apps/editor/lib/graph-schema.ts`
+and `packages/mcp/src/storage/scene-store-shared.ts`, the field list IS the set
+of things that survive a save.** A field missing there is not a validation
+error, it is deletion. Treat any upstream change to those two files as
+load-bearing.
+
+**Two traps in the tooling, both fixed here:**
+
+- `Relock` pinned bun `1.3.0` while CI installed with `1.3.14`. A lockfile
+ written by the older bun is rewritten by the newer one, and
+ `--frozen-lockfile` turns that into a failed build — a relock producing a
+ lockfile CI then rejects. Keep the two pinned to the same version as
+ `packageManager`.
+- The lockfile would not **converge**: two relocks in a row each rewrote it.
+ `postcss` is a transitive dependency of both Next and Tailwind at different
+ patch versions, and with nothing pinning it, bun broke the tie differently on
+ every run. `--frozen-lockfile` can never pass against an oscillating
+ lockfile, however many times you relock. Fixed by pinning `postcss` in the
+ root `overrides`, next to `next` and `three`, which are there for the same
+ reason. **If a frozen-lockfile failure survives a relock, suspect
+ oscillation rather than staleness** — run the relock twice and diff.
+
+**A mistake worth not repeating:** upstream's `graph-schema.test.ts` was merged
+alongside ours, but upstream's suite tests upstream's implementation — including
+an asset-URL allowlist our fork's version does not implement. Merging their
+tests while keeping our implementation fails in CI. Either port the behaviour or
+keep only the tests that match what the file actually does.
+
+**The biggest single thing this merge bought, and it looked like a regression.**
+Taking upstream's root `package.json` restored the `test` script. Ours had none,
+so `turbo run test` had no root entry point and **the entire test suite had been
+dark in CI** — every package, not just `apps/editor`. Nobody removed it on
+purpose; it fell out of an earlier edit and CI stayed green because a suite that
+never runs never fails.
+
+Seven tests failed the moment it came back. **None was caused by upstream's
+code.** They were latent, written and never once executed:
+
+- Six were upstream's own, all built on a hand-written `trees:tree` object
+ literal. Upstream validates a foreign kind against the base envelope, so a
+ five-field literal is enough there; this fork validates it against the trees
+ plugin's own schema, where it is not. Fixtures are now built by calling the
+ plugin's schema, which also mints the branded id — a literal is only as
+ correct as its author's memory of a package we do not control.
+- One was ours, and it is the more interesting: it derived a node id from the
+ kind's local part (`warehouse:live-rack` → `live-rack_t1`) on the assumption
+ that the two always match. They do not — that kind brands its ids
+ `live-racking_`. The id prefix is **persisted user data**, so the plugin
+ cannot be renamed to close the gap; the test asks the schema for an id
+ instead of guessing one.
+
+Two rules fall out of this, and they are worth more than the fix:
+
+1. **A green CI is only evidence if you know what it ran.** After any change to
+ root scripts or to `turbo.json`, check the run's task count — `Tasks: N
+ successful, M total` — not just its colour.
+2. **Never hand-write a fixture for a schema you do not own.** Build it by
+ calling that schema. It costs one line and it cannot rot silently.
+
+Recorded for later, found while reading the plugin during this: the live-rack
+placement tool writes `name: 'Live Racking'` on commit. That is the same
+fixed-name defect already fixed in every kind's `defaults()`, in a second place
+the manifest-wide guard does not reach — it inspects `defaults()`, not tools.
+It costs the tree its derived label for that kind. Not touched here; this merge
+carries no feature work.
diff --git a/YAYINLAMA.md b/YAYINLAMA.md
new file mode 100644
index 0000000000..74e1df7f05
--- /dev/null
+++ b/YAYINLAMA.md
@@ -0,0 +1,136 @@
+# Yayınlama — plugin güncellemesinden canlıya
+
+Bu belge `opex.help`'e sürüm çıkarmanın tam yolunu anlatır. Depo topolojisi
+`UPSTREAM.md`'de; burada anlatılan onun çalıştırma tarafı.
+
+## Zincir
+
+```
+opex.help (Hostinger Node.js hosting)
+ ↑ hPanel ovurrsl/digitaltwin main dalını izler, push görünce yeniden dağıtır
+ovurrsl/digitaltwin ← DERLENMİŞ bundle. Kaynak kod yok, deploy anında hiçbir şey derlenmez
+ ↑ deploy-bundle iş akışı force-push eder
+ovurrsl/editor ← bu depo. Bundle burada üretilir
+ ↑ bun install çeker, SHA ile çivili
+ovurrsl/plugin-warehouse ← eklenti
+```
+
+Anlaşılması gereken tek şey: **eklenti uygulamanın içine derleniyor.** Plugin
+deposuna commit atmak canlıda hiçbir şeyi değiştirmez. Değişikliğin siteye
+ulaşması için SHA yenilenir, bundle yeniden üretilir ve digitaltwin'e konur.
+
+## Tek seferlik ön koşullar
+
+Bunlar bir kez kurulur; sonraki yayınlarda dokunulmaz.
+
+| Ne | Nerede | Neden |
+|---|---|---|
+| `DEPLOY_TOKEN` sırrı | `ovurrsl/editor` → Settings → Secrets and variables → Actions | İş akışı digitaltwin'e push edebilsin diye |
+| `deploy-bundle.yml` varsayılan dalda | `main` | GitHub `workflow_dispatch`'i yalnız varsayılan daldaki iş akışları için açar. Dosya sadece feature dalındayken tetikleme 404 döner |
+
+`DEPLOY_TOKEN` bir fine-grained PAT'tir ve şu üç ayarın üçü de doğru olmalıdır:
+
+- **Resource owner:** `ovurrsl`
+- **Repository access:** Only select repositories → **`ovurrsl/digitaltwin`**
+ (`ovurrsl/editor` değil — token'ın yazacağı yer deploy deposudur)
+- **Repository permissions → Contents:** **Read and write**
+ (`Metadata: Read-only` kendiliğinden gelir)
+
+İzin sonradan düzenlenebilir ve token dizisi değişmez — yani izni düzeltmek
+için sırrı yeniden girmek gerekmez.
+
+## Eklentiyi güncelle
+
+1. Yeni SHA'yı al: `ovurrsl/plugin-warehouse` deposunun `main` ucundaki commit.
+2. `apps/editor/package.json` içinde satırı güncelle:
+
+ ```
+ "@ovurrsl/plugin-warehouse": "git+https://github.com/ovurrsl/plugin-warehouse.git#"
+ ```
+
+3. `bun.lock`'u tazele. **Sandbox'ta `bun install` çalışmaz** — lock her GitHub
+ tarball'ının sha512'sini saklar ve bunu ancak gerçek `api.github.com`'a
+ ulaşabilen bir makine hesaplayabilir. Bunun için `Relock` iş akışı var:
+ `.github/workflows/relock.yml` dosyasının sonundaki yorum satırını değiştirip
+ push edin; iş akışı lock'u üretip dalınıza geri iter.
+
+4. Bundle sürümünü yükselt: `.github/deploy/package.json` → `version`.
+ Bu dosya digitaltwin'in `package.json`'ı olarak kopyalanır; atlanırsa canlı
+ sürüm numarası olduğu yerde kalır ya da geriye düşer.
+
+5. Commit + push.
+
+## Yayınla
+
+`deploy-bundle` iş akışını çalıştır. Üç yolu var:
+
+- **Elle:** Actions sekmesi → *Deploy bundle* → *Run workflow* → dalı seç
+- **Kendiliğinden:** `main`'e `apps/editor/**`, `packages/**` veya `bun.lock`
+ değiştiren bir push
+- **Plugin deposundan:** `repository_dispatch` (tip: `plugin-updated`)
+
+İş akışı sırayla: `bun install --linker=hoisted` → build → standalone bundle
+montajı → iki smoke test → digitaltwin main'e force-push.
+
+Smoke testler kasten şunu ölçer:
+
+1. Veritabanı yokken sunucu **açılmamalı** (aksi hâlde host'un sildiği yerel bir
+ dosyaya sessizce yazmaya başlar)
+2. MySQL varken `/api/health` → `backend:mysql`, `db:ok` dönmeli ve ana sayfa
+ yanıt vermeli
+
+Push force'tur, yani digitaltwin'in ağacı tamamen değişir. Orada commit'li duran
+`.env` bilerek taşınır (iş akışının *Publish* adımı önce onu okur, sonra yazar) —
+panel değişkenleri unutsa bile sunucu ayağa kalksın diye.
+
+## Doğrula
+
+```bash
+# iş akışı
+curl -s https://api.github.com/repos/ovurrsl/editor/actions/runs/ \
+ | grep -o '"conclusion":"[a-z]*"' | head -1
+
+# deploy deposuna commit düştü mü
+curl -s https://api.github.com/repos/ovurrsl/digitaltwin/commits?per_page=1
+
+# canlı
+curl -s https://opex.help/api/health
+```
+
+Beklenen: `"conclusion":"success"`, `Build from ` başlıklı yeni commit ve
+
+```json
+{"status":"ok","app":"digitaltwin","backend":"mysql","db":"ok","auth":"ok"}
+```
+
+Hostinger dağıtımı birkaç dakika sürer. Tarayıcıda sert yenileme yapın.
+
+## Sorun giderme
+
+| Log'da gördüğünüz | Anlamı | Çözüm |
+|---|---|---|
+| `dispatches: 404 Not Found` | İş akışı varsayılan dalda kayıtlı değil | `deploy-bundle.yml`'ı `main`'e koyun. Tetikleme yine feature dalına yapılabilir; dispatch iş akışının gövdesini ve kopyaladığı dosyaları çalıştırıldığı ref'ten alır |
+| `DEPLOY_TOKEN:` boş + `Invalid username or token` | Sır tanımsız | Sırrı ekleyin. GitHub 2021'den beri git yazma için parolayı kabul etmiyor; depo public olsa da token şart |
+| `DEPLOY_TOKEN: ***` + `Write access to repository not granted` (403) | Token depoyu görüyor ama yazamıyor | Token'ın `Contents` izni `Read and write` mi, seçili depo `digitaltwin` mi — ikisini de kontrol edin |
+| `failed to resolve … api.github.com/repos/pascalorg/… 403` | Sandbox'ın GitHub kapsamı dışında bir bağımlılık | Yerelde çözülemez; `Relock` iş akışını kullanın |
+
+## Dikkat
+
+- **Eklenti kind adı değişirse veri kaybı olur.** Kayıtlı sahneler düğüm tipini
+ metin olarak saklar ve registry'de alias desteği yok. Eklentide bir kind
+ yeniden adlandırıldıysa yayından önce veritabanına bakın:
+
+ ```sql
+ SELECT COUNT(*) FROM scenes WHERE graph_json LIKE '%%';
+ ```
+
+ 0 değilse önce `scripts/migrate-legacy-scene.mjs` içindeki dönüşüm
+ mekanizmasıyla sahneleri geçirin.
+
+- **`ovurrsl/digitaltwin` private kalmalı.** Kökünde `.env` commit'li: MySQL
+ parolası, SMTP parolası, oturum anahtarları. Public yapmak bunları açar ve
+ geri almak yetmez — git geçmişinde ve cache'lerde kalır, tek çözüm her sırrı
+ değiştirmek olur.
+
+- **Deploy deposuna elle commit atmayın.** Her yayın onu force-push'la baştan
+ yazar; oraya yazılan her şey ilk yayında kaybolur.
diff --git a/apps/editor/__tests__/adversarial-bundle-leakage.test.ts b/apps/editor/__tests__/adversarial-bundle-leakage.test.ts
new file mode 100644
index 0000000000..68508b202d
--- /dev/null
+++ b/apps/editor/__tests__/adversarial-bundle-leakage.test.ts
@@ -0,0 +1,365 @@
+import { describe, expect, it, beforeEach } from 'bun:test'
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
+import path from 'node:path'
+import {
+ nodeRegistry,
+ pluginManager,
+ useScene,
+ getRegistryVersion,
+ type LazyPluginDescriptor,
+} from '@pascal-app/core'
+import { editorHostPanelRegistry } from '@pascal-app/editor'
+import { PLUGIN_CATALOG, getPluginDescriptor } from '../lib/plugins/catalog'
+import { usePluginManager } from '../lib/plugins/use-plugin-manager'
+
+describe('Adversarial Verification: Bundle Splitting & Zero Chunk Leakage Suite', () => {
+ const editorDir = path.resolve(import.meta.dir, '..')
+ const nextDir = path.join(editorDir, '.next')
+ const buildManifestPath = path.join(nextDir, 'build-manifest.json')
+ const reactLoadableManifestPath = path.join(nextDir, 'react-loadable-manifest.json')
+ const staticChunksDir = path.join(nextDir, 'static', 'chunks')
+
+ const TARGET_PLUGINS = [
+ {
+ id: 'pascal:boots',
+ pkg: '@pascal-app/plugin-boots',
+ symbol: 'bootsPlugin',
+ panelSymbol: 'bootsHostPanel',
+ nodeKinds: ['boots:job'],
+ },
+ {
+ id: 'pascal:trees',
+ pkg: '@pascal-app/plugin-trees',
+ symbol: 'treesPlugin',
+ panelSymbol: 'treesHostPanel',
+ nodeKinds: ['trees:tree', 'trees:flower', 'trees:grass'],
+ },
+ {
+ id: 'pascal:bones',
+ pkg: '@pascal-app/plugin-bones',
+ symbol: 'bonesPlugin',
+ panelSymbol: 'bonesHostPanel',
+ nodeKinds: ['bones:lumber', 'bones:framing', 'bones:service', 'bones:device'],
+ },
+ {
+ id: 'ovurrsl:warehouse',
+ pkg: '@ovurrsl/plugin-warehouse',
+ symbol: 'warehousePlugin',
+ panelSymbol: 'warehouseCatalogPanel',
+ nodeKinds: [
+ 'warehouse:pallet',
+ 'warehouse:pallet-rack',
+ 'warehouse:conveyor-spiral',
+ 'warehouse:pallet-lift',
+ 'warehouse:truck',
+ ],
+ },
+ {
+ id: 'pascal:articraft',
+ pkg: '@pascal-app/plugin-articraft',
+ symbol: 'articraftPlugin',
+ panelSymbol: 'articraftHostPanel',
+ nodeKinds: ['articraft:asset'],
+ },
+ {
+ id: 'pascal:streetscape',
+ pkg: '@pascal-app/plugin-streetscape',
+ symbol: 'streetscapePlugin',
+ panelSymbol: 'streetscapeHostPanel',
+ nodeKinds: [
+ 'streetscape:road-network',
+ 'streetscape:street-light',
+ 'streetscape:utility-pole',
+ 'streetscape:road-sign',
+ ],
+ },
+ {
+ id: 'mint:assets',
+ pkg: '@mint/pascal-plugin',
+ symbol: 'mintPlugin',
+ panelSymbol: 'mintHostPanel',
+ nodeKinds: [],
+ },
+ ]
+
+ beforeEach(() => {
+ nodeRegistry._reset()
+ pluginManager._reset()
+ editorHostPanelRegistry.reset()
+ useScene.getState().setInstalledPlugins([], { explicit: true })
+ pluginManager.setPanelRegistrar((panel) => {
+ editorHostPanelRegistry.registerPanel(panel)
+ })
+ pluginManager.registerDescriptors(PLUGIN_CATALOG)
+ })
+
+ // =========================================================================
+ // SECTION 1: Deep Forensic Inspection of Build Artifacts & Entrypoint Chunks
+ // =========================================================================
+ describe('Dimension 1: Deep Forensic Analysis of Initial Chunks', () => {
+ it('build-manifest.json exists and all initial chunks are free of plugin code', () => {
+ expect(existsSync(buildManifestPath)).toBe(true)
+ const rawManifest = readFileSync(buildManifestPath, 'utf8')
+ const manifest = JSON.parse(rawManifest)
+
+ const initialFiles = new Set([
+ ...(manifest.rootMainFiles ?? []),
+ ...(manifest.polyfillFiles ?? []),
+ ...(manifest.lowPriorityFiles ?? []),
+ ])
+
+ // Add default entry pages
+ for (const pageKey of ['/_app', '/_error', '/']) {
+ if (manifest.pages?.[pageKey]) {
+ for (const file of manifest.pages[pageKey]) {
+ initialFiles.add(file)
+ }
+ }
+ }
+
+ expect(initialFiles.size).toBeGreaterThan(0)
+
+ for (const relPath of initialFiles) {
+ const fullPath = path.join(nextDir, relPath)
+ if (!existsSync(fullPath)) continue
+
+ const content = readFileSync(fullPath, 'utf8')
+
+ for (const plugin of TARGET_PLUGINS) {
+ // Verify no static package imports or package references
+ expect(content.includes(`"${plugin.pkg}"`)).toBe(false)
+ expect(content.includes(`'${plugin.pkg}'`)).toBe(false)
+
+ // Verify no exported plugin symbols exist in main chunks
+ expect(content.includes(plugin.symbol)).toBe(false)
+ expect(content.includes(plugin.panelSymbol)).toBe(false)
+
+ // Verify no node kind registration literals exist in main chunks
+ for (const kind of plugin.nodeKinds) {
+ expect(content.includes(`kind:"${kind}"`)).toBe(false)
+ expect(content.includes(`kind:'${kind}'`)).toBe(false)
+ }
+ }
+ }
+ })
+
+ it('App router chunks (layout.js, page.js, not-found.js) do not leak plugin node definitions', () => {
+ const appChunksDir = path.join(staticChunksDir, 'app')
+ if (existsSync(appChunksDir)) {
+ function collectJsFiles(dir: string): string[] {
+ let list: string[] = []
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ list = list.concat(collectJsFiles(full))
+ } else if (entry.name.endsWith('.js')) {
+ list.push(full)
+ }
+ }
+ return list
+ }
+
+ const appJsFiles = collectJsFiles(appChunksDir)
+ expect(appJsFiles.length).toBeGreaterThan(0)
+
+ for (const jsFile of appJsFiles) {
+ const content = readFileSync(jsFile, 'utf8')
+ for (const plugin of TARGET_PLUGINS) {
+ for (const kind of plugin.nodeKinds) {
+ expect(content.includes(`kind:"${kind}"`)).toBe(false)
+ expect(content.includes(`kind:'${kind}'`)).toBe(false)
+ }
+ }
+ }
+ }
+ })
+ })
+
+ // =========================================================================
+ // SECTION 2: Dynamic Chunk Presence & Separate Chunk Verification
+ // =========================================================================
+ describe('Dimension 2: Dynamic Chunk Isolation & React Loadable Mapping', () => {
+ it('react-loadable-manifest.json maps all 7 target plugins to separate dynamic chunks', () => {
+ expect(existsSync(reactLoadableManifestPath)).toBe(true)
+ const loadableManifest = JSON.parse(readFileSync(reactLoadableManifestPath, 'utf8'))
+
+ for (const plugin of TARGET_PLUGINS) {
+ const shortPkg = plugin.pkg
+ .replace('@pascal-app/', '')
+ .replace('@ovurrsl/', '')
+ .replace('@mint/', '')
+
+ const matchingKeys = Object.keys(loadableManifest).filter(
+ (k) => k.includes(plugin.pkg) || k.includes(shortPkg),
+ )
+
+ expect(matchingKeys.length).toBeGreaterThan(0)
+
+ // Ensure mapped chunks physically exist on disk
+ let totalChunkFiles = 0
+ for (const key of matchingKeys) {
+ const entry = loadableManifest[key]
+ const files = entry?.files ?? []
+ for (const f of files) {
+ const full = path.join(nextDir, f)
+ if (existsSync(full)) {
+ totalChunkFiles++
+ }
+ }
+ }
+ expect(totalChunkFiles).toBeGreaterThan(0)
+ }
+ })
+
+ it('each target plugin symbol resides exclusively in a dedicated dynamic chunk', () => {
+ const allChunks = readdirSync(staticChunksDir).filter((f) => f.endsWith('.js'))
+ expect(allChunks.length).toBeGreaterThan(10)
+
+ for (const plugin of TARGET_PLUGINS) {
+ const matchingChunks: string[] = []
+ for (const chunkFile of allChunks) {
+ const content = readFileSync(path.join(staticChunksDir, chunkFile), 'utf8')
+ if (content.includes(plugin.symbol) || plugin.nodeKinds.some((k) => content.includes(k))) {
+ matchingChunks.push(chunkFile)
+ }
+ }
+
+ // Each plugin must have at least one chunk holding its code
+ expect(matchingChunks.length).toBeGreaterThan(0)
+ }
+ })
+ })
+
+ // =========================================================================
+ // SECTION 3: Static Import Scanner across Editor Source Code
+ // =========================================================================
+ describe('Dimension 3: Static Import & Leakage Scanner in Source Code', () => {
+ it('no source file under apps/editor/app or apps/editor/components statically imports any target plugin', () => {
+ const scanDirs = [
+ path.join(editorDir, 'app'),
+ path.join(editorDir, 'components'),
+ path.join(editorDir, 'lib', 'auth'),
+ ]
+
+ function scanDir(dir: string) {
+ if (!existsSync(dir)) return
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ if (entry.name === 'node_modules' || entry.name === '.next') continue
+ scanDir(full)
+ } else if (/\.(ts|tsx|js|jsx)$/.test(entry.name) && !entry.name.includes('.test.')) {
+ const content = readFileSync(full, 'utf8')
+ for (const plugin of TARGET_PLUGINS) {
+ const staticImportRegex = new RegExp(
+ `import\\s+(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+|\\w+)\\s+from\\s+)?['"]${plugin.pkg}['"]`,
+ )
+ expect(staticImportRegex.test(content)).toBe(false)
+ }
+ }
+ }
+ }
+
+ for (const d of scanDirs) {
+ scanDir(d)
+ }
+ })
+
+ it('apps/editor/lib/bootstrap.ts has ZERO static imports of target plugins', () => {
+ const bootstrapPath = path.join(editorDir, 'lib', 'bootstrap.ts')
+ const content = readFileSync(bootstrapPath, 'utf8')
+
+ for (const plugin of TARGET_PLUGINS) {
+ const regex = new RegExp(`from\\s+['"]${plugin.pkg}['"]`)
+ expect(regex.test(content)).toBe(false)
+ }
+ })
+
+ it('apps/editor/lib/plugins/catalog.ts uses dynamic import thunks for all 7 plugins', () => {
+ const catalogPath = path.join(editorDir, 'lib', 'plugins', 'catalog.ts')
+ const content = readFileSync(catalogPath, 'utf8')
+
+ for (const plugin of TARGET_PLUGINS) {
+ expect(content.includes(`id: '${plugin.id}'`) || content.includes(`id: "${plugin.id}"`)).toBe(true)
+ const dynamicRegex = new RegExp(`import\\s*\\(\\s*['"]${plugin.pkg}['"]\\s*\\)`)
+ expect(dynamicRegex.test(content)).toBe(true)
+ }
+ })
+ })
+
+ // =========================================================================
+ // SECTION 4: Runtime Dynamic Execution & State Invariants
+ // =========================================================================
+ describe('Dimension 4: Runtime Lazy-Loading & Dynamic Isolation Invariants', () => {
+ it('registering catalog descriptors leaves all plugins in unloaded state with zero node registrations', () => {
+ for (const plugin of TARGET_PLUGINS) {
+ const state = pluginManager.getPluginState(plugin.id)
+ expect(state.status).toBe('unloaded')
+ expect(state.error).toBeNull()
+
+ for (const kind of plugin.nodeKinds) {
+ expect(nodeRegistry.has(kind)).toBe(false)
+ }
+ }
+ })
+
+ it('installing one plugin does not install or trigger other plugins', async () => {
+ // Install only Boots
+ const success = await usePluginManager.getState().installPlugin('pascal:boots')
+ expect(success).toBe(true)
+
+ // Boots is installed
+ expect(pluginManager.getPluginState('pascal:boots').status).toBe('installed')
+ expect(nodeRegistry.has('boots:job')).toBe(true)
+
+ // All other plugins remain strictly unloaded and unregistered
+ const otherPlugins = TARGET_PLUGINS.filter((p) => p.id !== 'pascal:boots')
+ for (const other of otherPlugins) {
+ expect(pluginManager.getPluginState(other.id).status).toBe('unloaded')
+ for (const kind of other.nodeKinds) {
+ expect(nodeRegistry.has(kind)).toBe(false)
+ }
+ }
+ })
+
+ it('concurrent installation deduplicates promise calls and finishes cleanly', async () => {
+ const promises = Array.from({ length: 10 }, () =>
+ usePluginManager.getState().installPlugin('pascal:trees'),
+ )
+
+ const results = await Promise.all(promises)
+ expect(results.every((r) => r === true)).toBe(true)
+ expect(pluginManager.getPluginState('pascal:trees').status).toBe('installed')
+ expect(nodeRegistry.has('trees:tree')).toBe(true)
+ })
+
+ it('network/runtime loading error isolates failure to the broken plugin without affecting host registry', async () => {
+ const brokenDescriptor: LazyPluginDescriptor = {
+ id: 'test:broken-plugin',
+ name: 'Broken Plugin',
+ loadPlugin: async () => {
+ throw new Error('Adversarial simulated network failure')
+ },
+ }
+
+ pluginManager.registerDescriptor(brokenDescriptor)
+ expect(pluginManager.getPluginState('test:broken-plugin').status).toBe('unloaded')
+
+ let threw = false
+ try {
+ await pluginManager.installPlugin('test:broken-plugin')
+ } catch (err: any) {
+ threw = true
+ expect(err.message).toContain('Adversarial simulated network failure')
+ }
+
+ expect(threw).toBe(true)
+ expect(pluginManager.getPluginState('test:broken-plugin').status).toBe('error')
+
+ // Healthy plugin installation still succeeds cleanly
+ const bootsSuccess = await usePluginManager.getState().installPlugin('pascal:boots')
+ expect(bootsSuccess).toBe(true)
+ expect(pluginManager.getPluginState('pascal:boots').status).toBe('installed')
+ })
+ })
+})
diff --git a/apps/editor/__tests__/adversarial-dynamic-activation-stress.test.ts b/apps/editor/__tests__/adversarial-dynamic-activation-stress.test.ts
new file mode 100644
index 0000000000..6d39f75622
--- /dev/null
+++ b/apps/editor/__tests__/adversarial-dynamic-activation-stress.test.ts
@@ -0,0 +1,364 @@
+import { beforeEach, describe, expect, it } from 'bun:test'
+import {
+ type LazyPluginDescriptor,
+ type Plugin,
+ getInspectorExtensions,
+ getNodePluginId,
+ getRegistryVersion,
+ getSelectableKinds,
+ getZoneTakeoffExtensions,
+ isNodeKindEnabled,
+ isPluginContributedKind,
+ nodeRegistry,
+ onRegistryChange,
+ pluginManager,
+ useScene,
+} from '@pascal-app/core'
+import { editorHostPanelRegistry } from '@pascal-app/editor'
+import { PLUGIN_CATALOG, getPluginDescriptor } from '../lib/plugins/catalog'
+import { usePluginManager } from '../lib/plugins/use-plugin-manager'
+
+describe('EMPIRICAL ADVERSARIAL CHALLENGER: Zero-Reload Dynamic Activation & Stress Harness', () => {
+ beforeEach(() => {
+ nodeRegistry._reset()
+ pluginManager._reset()
+ editorHostPanelRegistry.reset()
+ useScene.getState().setInstalledPlugins([], { explicit: true })
+
+ pluginManager.setPanelRegistrar((panel) => {
+ editorHostPanelRegistry.registerPanel(panel)
+ })
+ pluginManager.registerDescriptors(PLUGIN_CATALOG)
+ })
+
+ describe('Adversarial Dimension 1: High-Frequency Concurrency & Rapid Toggle Thrashing', () => {
+ it('survives 100 concurrent install calls for the same plugin without duplicate loads or race conditions', async () => {
+ let loadCount = 0
+ const testDescriptor: LazyPluginDescriptor = {
+ id: 'test:concurrent-plugin',
+ name: 'Concurrent Test Plugin',
+ loadPlugin: async () => {
+ loadCount++
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ return {
+ id: 'test:concurrent-plugin',
+ name: 'Concurrent Test Plugin',
+ apiVersion: 1,
+ nodes: [
+ {
+ kind: 'test:concurrent-node',
+ schemaVersion: 1,
+ category: 'furnish',
+ schema: {} as any,
+ capabilities: { selectable: true },
+ },
+ ],
+ }
+ },
+ }
+
+ pluginManager.registerDescriptor(testDescriptor)
+
+ // Launch 100 concurrent installations
+ const promises = Array.from({ length: 100 }, () =>
+ pluginManager.installPlugin('test:concurrent-plugin'),
+ )
+
+ await Promise.all(promises)
+
+ // Verify single invocation of loadPlugin (deduplication)
+ expect(loadCount).toBe(1)
+ expect(pluginManager.getPluginState('test:concurrent-plugin').status).toBe('installed')
+ expect(nodeRegistry.has('test:concurrent-node')).toBe(true)
+ })
+
+ it('survives 50 rapid alternating install and uninstall cycles without corrupted state or memory leaks', async () => {
+ const targetId = 'pascal:boots'
+
+ for (let i = 0; i < 50; i++) {
+ const installResult = await usePluginManager.getState().installPlugin(targetId)
+ expect(installResult).toBe(true)
+ expect(pluginManager.getPluginState(targetId).status).toBe('installed')
+ expect(useScene.getState().installedPlugins).toContain(targetId)
+ expect(isNodeKindEnabled('boots:job', useScene.getState().installedPlugins)).toBe(true)
+
+ const uninstallResult = await usePluginManager.getState().uninstallPlugin(targetId)
+ expect(uninstallResult).toBe(true)
+ expect(pluginManager.getPluginState(targetId).status).toBe('unloaded')
+ expect(useScene.getState().installedPlugins).not.toContain(targetId)
+ expect(isNodeKindEnabled('boots:job', useScene.getState().installedPlugins)).toBe(false)
+ }
+
+ // Re-install one last time to ensure system is cleanly operational
+ await usePluginManager.getState().installPlugin(targetId)
+ expect(pluginManager.getPluginState(targetId).status).toBe('installed')
+ expect(useScene.getState().installedPlugins).toContain(targetId)
+ expect(isNodeKindEnabled('boots:job', useScene.getState().installedPlugins)).toBe(true)
+ })
+
+ it('survives mass chaotic concurrent toggle across all catalog plugins simultaneously', async () => {
+ const allIds = PLUGIN_CATALOG.map((p) => p.id)
+
+ // Launch interleaved random install/uninstall actions
+ const chaoticActions = allIds.flatMap((id) => [
+ usePluginManager.getState().installPlugin(id),
+ usePluginManager.getState().uninstallPlugin(id),
+ usePluginManager.getState().installPlugin(id),
+ ])
+
+ await Promise.all(chaoticActions)
+
+ // Ensure that final states in pluginManager and useScene match
+ const installedScene = useScene.getState().installedPlugins
+ for (const id of allIds) {
+ const state = pluginManager.getPluginState(id)
+ if (state.status === 'installed') {
+ // If plugin is installed, its node kinds must be registered in nodeRegistry
+ const desc = getPluginDescriptor(id)
+ if (desc?.nodeKinds) {
+ for (const kind of desc.nodeKinds) {
+ expect(nodeRegistry.has(kind)).toBe(true)
+ }
+ }
+ }
+ }
+ })
+ })
+
+ describe('Adversarial Dimension 2: Fault Injection, Error Boundary & Failure Recovery', () => {
+ it('isolates network timeout failure, records error message, and allows subsequent successful retry', async () => {
+ let failCount = 0
+ const retryDescriptor: LazyPluginDescriptor = {
+ id: 'test:flaky-network-plugin',
+ name: 'Flaky Network Plugin',
+ loadPlugin: async () => {
+ if (failCount === 0) {
+ failCount++
+ throw new Error('ETIMEDOUT: Failed to fetch dynamic chunk from CDN')
+ }
+ return {
+ id: 'test:flaky-network-plugin',
+ name: 'Flaky Network Plugin',
+ apiVersion: 1,
+ nodes: [
+ {
+ kind: 'test:recovered-node',
+ schemaVersion: 1,
+ category: 'furnish',
+ schema: {} as any,
+ capabilities: {},
+ },
+ ],
+ }
+ },
+ }
+
+ pluginManager.registerDescriptor(retryDescriptor)
+
+ // 1. First attempt fails
+ let firstError: any = null
+ try {
+ await pluginManager.installPlugin('test:flaky-network-plugin')
+ } catch (err) {
+ firstError = err
+ }
+
+ expect(firstError).not.toBeNull()
+ expect(firstError.message).toContain('ETIMEDOUT')
+ expect(pluginManager.getPluginState('test:flaky-network-plugin').status).toBe('error')
+ expect(pluginManager.getPluginState('test:flaky-network-plugin').error).toContain('ETIMEDOUT')
+ expect(nodeRegistry.has('test:recovered-node')).toBe(false)
+
+ // 2. Retry attempt succeeds
+ await pluginManager.installPlugin('test:flaky-network-plugin')
+ expect(pluginManager.getPluginState('test:flaky-network-plugin').status).toBe('installed')
+ expect(pluginManager.getPluginState('test:flaky-network-plugin').error).toBeNull()
+ expect(nodeRegistry.has('test:recovered-node')).toBe(true)
+ })
+
+ it('rejects malformed plugin manifests (null, invalid apiVersion, missing id) without crashing registry', async () => {
+ const invalidDescriptors: LazyPluginDescriptor[] = [
+ {
+ id: 'test:invalid-manifest-null',
+ name: 'Null Manifest Plugin',
+ loadPlugin: async () => null as any,
+ },
+ {
+ id: 'test:invalid-manifest-apiversion',
+ name: 'Wrong API Version Plugin',
+ loadPlugin: async () =>
+ ({
+ id: 'test:invalid-manifest-apiversion',
+ apiVersion: 999, // Host only supports 1
+ nodes: [],
+ }) as any,
+ },
+ {
+ id: 'test:invalid-manifest-noid',
+ name: 'No ID Plugin',
+ loadPlugin: async () =>
+ ({
+ apiVersion: 1,
+ nodes: [],
+ }) as any,
+ },
+ ]
+
+ for (const desc of invalidDescriptors) {
+ pluginManager.registerDescriptor(desc)
+ let errorCaught = false
+ try {
+ await pluginManager.installPlugin(desc.id)
+ } catch {
+ errorCaught = true
+ }
+ expect(errorCaught).toBe(true)
+ expect(pluginManager.getPluginState(desc.id).status).toBe('error')
+ }
+ })
+
+ it('gracefully handles panel registrar exceptions without failing plugin installation or node registration', async () => {
+ // Add a faulty panel registrar that throws
+ const unregisterFaultyRegistrar = pluginManager.setPanelRegistrar(() => {
+ throw new Error('Host Panel Registry crashed during insertion')
+ })
+
+ const testDescriptor: LazyPluginDescriptor = {
+ id: 'test:failing-panel-plugin',
+ name: 'Failing Panel Plugin',
+ loadPlugin: async () => ({
+ plugin: {
+ id: 'test:failing-panel-plugin',
+ name: 'Failing Panel Plugin',
+ apiVersion: 1,
+ nodes: [
+ {
+ kind: 'test:resilient-node',
+ schemaVersion: 1,
+ category: 'furnish',
+ schema: {} as any,
+ capabilities: {},
+ },
+ ],
+ },
+ panel: { id: 'faulty-panel', title: 'Faulty' },
+ }),
+ }
+
+ pluginManager.registerDescriptor(testDescriptor)
+
+ // Installation should succeed even if panel registrar threw
+ await pluginManager.installPlugin('test:failing-panel-plugin')
+ expect(pluginManager.getPluginState('test:failing-panel-plugin').status).toBe('installed')
+ expect(nodeRegistry.has('test:resilient-node')).toBe(true)
+
+ unregisterFaultyRegistrar()
+ })
+ })
+
+ describe('Adversarial Dimension 3: Full Zero-Reload Reactive Contracts Verification', () => {
+ it('all 7 real catalog plugins dynamically install and correctly register all nodes and capabilities', async () => {
+ let totalRegistryBumps = 0
+ const initialVersion = getRegistryVersion()
+ const unsubscribe = onRegistryChange(() => {
+ totalRegistryBumps++
+ })
+
+ for (const desc of PLUGIN_CATALOG) {
+ const success = await usePluginManager.getState().installPlugin(desc.id)
+ expect(success).toBe(true)
+ expect(pluginManager.getPluginState(desc.id).status).toBe('installed')
+ expect(useScene.getState().installedPlugins).toContain(desc.id)
+
+ // Check node kinds
+ if (desc.nodeKinds) {
+ for (const kind of desc.nodeKinds) {
+ expect(nodeRegistry.has(kind)).toBe(true)
+ expect(isPluginContributedKind(kind)).toBe(true)
+ expect(getNodePluginId(kind)).toBe(desc.id)
+ expect(isNodeKindEnabled(kind, useScene.getState().installedPlugins)).toBe(true)
+ }
+ }
+ }
+
+ unsubscribe()
+
+ // Registry version must have bumped repeatedly for dynamically loaded nodes
+ expect(getRegistryVersion()).toBeGreaterThan(initialVersion)
+ expect(totalRegistryBumps).toBeGreaterThan(0)
+
+ // Specifically verify each key plugin node kind contract
+ expect(nodeRegistry.get('boots:job')?.kind).toBe('boots:job')
+ expect(nodeRegistry.get('trees:tree')?.kind).toBe('trees:tree')
+ expect(nodeRegistry.get('bones:lumber')?.kind).toBe('bones:lumber')
+ expect(nodeRegistry.get('warehouse:pallet')?.kind).toBe('warehouse:pallet')
+ expect(nodeRegistry.get('articraft:asset')?.kind).toBe('articraft:asset')
+ expect(nodeRegistry.get('streetscape:road-network')?.kind).toBe('streetscape:road-network')
+
+ // Check warehouse zone takeoff extensions or inspector extensions
+ const warehouseExtensions = getZoneTakeoffExtensions()
+ expect(warehouseExtensions.length).toBeGreaterThan(0)
+ })
+
+ it('dynamic node kind activation disables correctly when excluded from installedPlugins', async () => {
+ await usePluginManager.getState().installPlugin('pascal:boots')
+ expect(isNodeKindEnabled('boots:job', ['pascal:boots'])).toBe(true)
+
+ // Scene with different installed plugins
+ expect(isNodeKindEnabled('boots:job', ['pascal:trees'])).toBe(false)
+ expect(isNodeKindEnabled('boots:job', [])).toBe(false)
+
+ // Host builtin kinds always stay enabled regardless of installedPlugins
+ expect(isNodeKindEnabled('wall', [])).toBe(true)
+ expect(isNodeKindEnabled('wall', ['pascal:trees'])).toBe(true)
+ })
+ })
+
+ describe('Adversarial Dimension 4: 10,000 Stress Cycles & Memory / Subscription Leak Detection', () => {
+ it('subscribes and unsubscribes 10,000 listeners without memory retention or callback invocation leaks', () => {
+ const unsubs: (() => void)[] = []
+ let triggerCount = 0
+
+ for (let i = 0; i < 10000; i++) {
+ const unsub = pluginManager.subscribe(() => {
+ triggerCount++
+ })
+ unsubs.push(unsub)
+ }
+
+ // Unsubscribe all
+ for (const unsub of unsubs) {
+ unsub()
+ }
+
+ // Trigger a state change
+ pluginManager.registerDescriptor({
+ id: 'test:leak-probe',
+ name: 'Probe',
+ loadPlugin: async () => ({} as any),
+ })
+
+ // No unsubscribed listener should have been invoked
+ expect(triggerCount).toBe(0)
+ })
+
+ it('validates snapshot referential stability (getSnapshot caching)', () => {
+ const snap1 = pluginManager.getSnapshot()
+ const snap2 = pluginManager.getSnapshot()
+
+ // Exact referential identity when state did not change
+ expect(snap1).toBe(snap2)
+
+ // After registration, snapshot cache is invalidated and renewed
+ pluginManager.registerDescriptor({
+ id: 'test:cache-invalidation-probe',
+ name: 'Cache Probe',
+ loadPlugin: async () => ({} as any),
+ })
+
+ const snap3 = pluginManager.getSnapshot()
+ expect(snap3).not.toBe(snap1)
+ expect(snap3.descriptors.some((d) => d.id === 'test:cache-invalidation-probe')).toBe(true)
+ })
+ })
+})
diff --git a/apps/editor/__tests__/dynamic-plugin-activation.test.ts b/apps/editor/__tests__/dynamic-plugin-activation.test.ts
new file mode 100644
index 0000000000..9fc2f1fcd3
--- /dev/null
+++ b/apps/editor/__tests__/dynamic-plugin-activation.test.ts
@@ -0,0 +1,226 @@
+import { beforeEach, describe, expect, it } from 'bun:test'
+import {
+ type LazyPluginDescriptor,
+ getRegistryVersion,
+ nodeRegistry,
+ pluginManager,
+ useScene,
+} from '@pascal-app/core'
+import { editorHostPanelRegistry } from '@pascal-app/editor'
+import { PLUGIN_CATALOG, getPluginDescriptor } from '../lib/plugins/catalog'
+import { usePluginManager } from '../lib/plugins/use-plugin-manager'
+
+describe('M4: Zero-Reload Dynamic Plugin Activation & Runtime Reactivity Suite', () => {
+ beforeEach(() => {
+ // Testler arası tam izolasyon ve temizleme
+ nodeRegistry._reset()
+ pluginManager._reset()
+ editorHostPanelRegistry.reset()
+ useScene.getState().setInstalledPlugins([], { explicit: true })
+
+ // Panel dinleyicisini bağla
+ pluginManager.setPanelRegistrar((panel) => {
+ editorHostPanelRegistry.registerPanel(panel)
+ })
+ pluginManager.registerDescriptors(PLUGIN_CATALOG)
+ })
+
+ describe('1. Başlangıç Durumu (Zero-Plugin Initial State)', () => {
+ it('başlangıçta hiçbir harici eklenti düğümü kayıtlı olmamalı ve durumlar unloaded olmalıdır', () => {
+ // 0 eklenti düğümü
+ expect(nodeRegistry.has('boots:job')).toBe(false)
+ expect(nodeRegistry.has('trees:tree')).toBe(false)
+ expect(nodeRegistry.has('bones:lumber')).toBe(false)
+ expect(nodeRegistry.has('warehouse:pallet')).toBe(false)
+ expect(nodeRegistry.has('articraft:asset')).toBe(false)
+ expect(nodeRegistry.has('streetscape:road-network')).toBe(false)
+
+ // 0 eklenti host paneli
+ const panels = editorHostPanelRegistry.getSnapshot()
+ expect(panels.some((p) => p.pluginId === 'pascal:boots')).toBe(false)
+ expect(panels.some((p) => p.pluginId === 'pascal:trees')).toBe(false)
+
+ // Tüm eklenti durumları varsayılan olarak unloaded olmalı
+ for (const descriptor of PLUGIN_CATALOG) {
+ const state = pluginManager.getPluginState(descriptor.id)
+ expect(state.status).toBe('unloaded')
+ expect(state.error).toBeNull()
+ }
+
+ // useScene yüklü eklentiler başlangıçta boş olmalı
+ expect(useScene.getState().installedPlugins).toHaveLength(0)
+ })
+ })
+
+ describe('2. PascalOrg Boots Dinamik Yükleme ve Reaktif Aktivasyon', () => {
+ it('installPlugin("pascal:boots") çağrıldığında chunk yüklenir, nodeRegistry ve hostPanel kaydedilir', async () => {
+ const initialVersion = getRegistryVersion()
+ const stateTransitions: string[] = []
+
+ const unsubscribe = pluginManager.subscribe(() => {
+ stateTransitions.push(pluginManager.getPluginState('pascal:boots').status)
+ })
+
+ expect(nodeRegistry.has('boots:job')).toBe(false)
+
+ // Dinamik yüklemeyi başlat
+ const success = await usePluginManager.getState().installPlugin('pascal:boots')
+ unsubscribe()
+
+ expect(success).toBe(true)
+
+ // Durum makinesi geçişleri: loading -> installed
+ expect(stateTransitions).toContain('loading')
+ expect(pluginManager.getPluginState('pascal:boots').status).toBe('installed')
+ expect(pluginManager.getPluginState('pascal:boots').loadedAt).toBeGreaterThan(0)
+
+ // nodeRegistry içinde boots:job düğümü aktif olmalı
+ expect(nodeRegistry.has('boots:job')).toBe(true)
+ const bootsDef = nodeRegistry.get('boots:job')
+ expect(bootsDef).toBeDefined()
+ expect(bootsDef?.kind).toBe('boots:job')
+
+ // editorHostPanelRegistry içinde Boots paneli yer almalı
+ const panels = editorHostPanelRegistry.getSnapshot()
+ const bootsPanel = panels.find((p) => p.pluginId === 'pascal:boots')
+ expect(bootsPanel).toBeDefined()
+ expect(bootsPanel?.pluginId).toBe('pascal:boots')
+
+ // useScene yüklü eklenti listesinde görünmeli
+ expect(useScene.getState().installedPlugins).toContain('pascal:boots')
+
+ // getRegistryVersion() artmış olmalı (Reaktivite)
+ expect(getRegistryVersion()).toBeGreaterThan(initialVersion)
+ })
+ })
+
+ describe('3. Nature & Trees Dinamik Yükleme ve Çoklu Düğüm Aktivasyonu', () => {
+ it('installPlugin("pascal:trees") çağrıldığında tüm ağaç/peyzaj düğümleri ve paneli anında yüklenir', async () => {
+ const initialVersion = getRegistryVersion()
+
+ expect(nodeRegistry.has('trees:tree')).toBe(false)
+ expect(nodeRegistry.has('trees:flower')).toBe(false)
+ expect(nodeRegistry.has('trees:grass')).toBe(false)
+
+ const success = await usePluginManager.getState().installPlugin('pascal:trees')
+ expect(success).toBe(true)
+
+ expect(pluginManager.getPluginState('pascal:trees').status).toBe('installed')
+
+ // Ağaç, çiçek ve çim düğümlerinin üçü de kaydedilmiş olmalı
+ expect(nodeRegistry.has('trees:tree')).toBe(true)
+ expect(nodeRegistry.has('trees:flower')).toBe(true)
+ expect(nodeRegistry.has('trees:grass')).toBe(true)
+
+ // Host panel kaydedilmiş olmalı
+ const panels = editorHostPanelRegistry.getSnapshot()
+ const treesPanel = panels.find((p) => p.pluginId === 'pascal:trees')
+ expect(treesPanel).toBeDefined()
+ expect(treesPanel?.pluginId).toBe('pascal:trees')
+
+ // Sahne durumu ve reaktivite
+ expect(useScene.getState().installedPlugins).toContain('pascal:trees')
+ expect(getRegistryVersion()).toBeGreaterThan(initialVersion)
+ })
+ })
+
+ describe('4. Eşzamanlı (Concurrent) ve Ardışık Tüm Eklenti Aktivasyonları', () => {
+ it('kalan tüm eklentiler eşzamanlı olarak hatasız yüklenebilmelidir', async () => {
+ const remainingPlugins = [
+ 'pascal:bones',
+ 'ovurrsl:warehouse',
+ 'pascal:articraft',
+ 'pascal:streetscape',
+ 'mint:assets',
+ ]
+
+ // Eşzamanlı (Promise.all) kurulum
+ const results = await Promise.all(
+ remainingPlugins.map((id) => usePluginManager.getState().installPlugin(id)),
+ )
+
+ expect(results.every((r) => r === true)).toBe(true)
+
+ // Her eklentinin durumunu ve düğümlerini kontrol et
+ expect(pluginManager.getPluginState('pascal:bones').status).toBe('installed')
+ expect(nodeRegistry.has('bones:lumber')).toBe(true)
+
+ expect(pluginManager.getPluginState('ovurrsl:warehouse').status).toBe('installed')
+ expect(nodeRegistry.has('warehouse:pallet')).toBe(true)
+ expect(nodeRegistry.has('warehouse:pallet-rack')).toBe(true)
+
+ expect(pluginManager.getPluginState('pascal:articraft').status).toBe('installed')
+ expect(nodeRegistry.has('articraft:asset')).toBe(true)
+
+ expect(pluginManager.getPluginState('pascal:streetscape').status).toBe('installed')
+ expect(nodeRegistry.has('streetscape:road-network')).toBe(true)
+
+ expect(pluginManager.getPluginState('mint:assets').status).toBe('installed')
+
+ // Sahne durumunda hepsi kayıtlı
+ for (const id of remainingPlugins) {
+ expect(useScene.getState().installedPlugins).toContain(id)
+ }
+ })
+ })
+
+ describe('5. Hata İzolasyonu ve Çökme Dayanıklılığı (Adversarial Error Boundary)', () => {
+ it('hatalı veya eksik bir eklenti yüklendiğinde durum "error" olmalı ve diğer eklentiler etkilenmemelidir', async () => {
+ // Hatalı dinamik eklenti tanımlayıcısı
+ const faultyDescriptor: LazyPluginDescriptor = {
+ id: 'test:faulty-plugin',
+ name: 'Faulty Plugin',
+ description: 'Simulated broken plugin',
+ loadPlugin: async () => {
+ throw new Error('Network connection timeout while fetching dynamic chunk')
+ },
+ }
+
+ pluginManager.registerDescriptor(faultyDescriptor)
+ expect(pluginManager.getPluginState('test:faulty-plugin').status).toBe('unloaded')
+
+ // pluginManager.installPlugin doğrudan çağrılır ve hata fırlatır
+ let caughtError: any = null
+ try {
+ await pluginManager.installPlugin('test:faulty-plugin')
+ } catch (err) {
+ caughtError = err
+ }
+
+ expect(caughtError).not.toBeNull()
+
+ // Durum error olmalı ve hata mesajı kaydedilmeli
+ const faultyState = pluginManager.getPluginState('test:faulty-plugin')
+ expect(faultyState.status).toBe('error')
+ expect(faultyState.error).toContain('Network connection timeout')
+
+ // Sağlam eklentiler normal şekilde yüklenmeye devam edebilmelidir
+ const bootsSuccess = await usePluginManager.getState().installPlugin('pascal:boots')
+ expect(bootsSuccess).toBe(true)
+ expect(pluginManager.getPluginState('pascal:boots').status).toBe('installed')
+ expect(nodeRegistry.has('boots:job')).toBe(true)
+ })
+ })
+
+ describe('6. Idempotency ve Kaldırma (Lifecycle & Idempotency)', () => {
+ it('aynı eklentiyi mükerrer yüklemek hata üretmemeli ve durumu korumalıdır', async () => {
+ await usePluginManager.getState().installPlugin('pascal:boots')
+ expect(pluginManager.getPluginState('pascal:boots').status).toBe('installed')
+
+ // İkinci kez yükle
+ const secondCall = await usePluginManager.getState().installPlugin('pascal:boots')
+ expect(secondCall).toBe(true)
+ expect(pluginManager.getPluginState('pascal:boots').status).toBe('installed')
+ })
+
+ it('uninstallPlugin çağrıldığında durum unloaded olur ve sahneden kaldırılır', async () => {
+ await usePluginManager.getState().installPlugin('pascal:boots')
+ expect(useScene.getState().installedPlugins).toContain('pascal:boots')
+
+ const uninstalled = await usePluginManager.getState().uninstallPlugin('pascal:boots')
+ expect(uninstalled).toBe(true)
+ expect(useScene.getState().installedPlugins).not.toContain('pascal:boots')
+ expect(pluginManager.getPluginState('pascal:boots').status).toBe('unloaded')
+ })
+ })
+})
diff --git a/apps/editor/__tests__/verify-bundle-isolation.test.ts b/apps/editor/__tests__/verify-bundle-isolation.test.ts
new file mode 100644
index 0000000000..668e3356f5
--- /dev/null
+++ b/apps/editor/__tests__/verify-bundle-isolation.test.ts
@@ -0,0 +1,216 @@
+import { describe, expect, it } from 'bun:test'
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
+import path from 'node:path'
+
+describe('M4: Bundle Analysis & Dynamic Code Splitting Isolation Verification', () => {
+ // apps/editor dizini ve .next çıktı yolları
+ const editorDir = path.resolve(import.meta.dir, '..')
+ const nextDir = path.join(editorDir, '.next')
+ const buildManifestPath = path.join(nextDir, 'build-manifest.json')
+ const reactLoadableManifestPath = path.join(nextDir, 'react-loadable-manifest.json')
+ const staticChunksDir = path.join(nextDir, 'static', 'chunks')
+
+ const targetPlugins = [
+ {
+ id: 'pascal:boots',
+ pkg: '@pascal-app/plugin-boots',
+ symbol: 'bootsPlugin',
+ nodeKind: 'boots:job',
+ },
+ {
+ id: 'pascal:trees',
+ pkg: '@pascal-app/plugin-trees',
+ symbol: 'treesPlugin',
+ nodeKind: 'trees:tree',
+ },
+ {
+ id: 'pascal:bones',
+ pkg: '@pascal-app/plugin-bones',
+ symbol: 'bonesPlugin',
+ nodeKind: 'bones:lumber',
+ },
+ {
+ id: 'ovurrsl:warehouse',
+ pkg: '@ovurrsl/plugin-warehouse',
+ symbol: 'warehousePlugin',
+ nodeKind: 'warehouse:pallet',
+ },
+ {
+ id: 'pascal:articraft',
+ pkg: '@pascal-app/plugin-articraft',
+ symbol: 'articraftPlugin',
+ nodeKind: 'articraft:asset',
+ },
+ {
+ id: 'pascal:streetscape',
+ pkg: '@pascal-app/plugin-streetscape',
+ symbol: 'streetscapePlugin',
+ nodeKind: 'streetscape:road-network',
+ },
+ {
+ id: 'mint:assets',
+ pkg: '@mint/pascal-plugin',
+ symbol: 'mintPlugin',
+ nodeKind: 'mint:assets',
+ },
+ ]
+
+ describe('1. Next.js Build Çıktısı ve Manifest Doğrulaması', () => {
+ it('.next dizini ve build-manifest.json eksiksiz mevcut olmalıdır', () => {
+ expect(existsSync(nextDir)).toBe(true)
+ expect(existsSync(buildManifestPath)).toBe(true)
+ expect(existsSync(reactLoadableManifestPath)).toBe(true)
+ expect(existsSync(staticChunksDir)).toBe(true)
+ })
+
+ it('build-manifest.json geçerli rootMainFiles listesi içermelidir', () => {
+ const raw = readFileSync(buildManifestPath, 'utf8')
+ const manifest = JSON.parse(raw)
+
+ expect(Array.isArray(manifest.rootMainFiles)).toBe(true)
+ expect(manifest.rootMainFiles.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('2. İlk Giriş Noktası Paketlerinde (Initial Chunks) İzolasyon Doğrulaması', () => {
+ it('rootMainFiles, polyfillFiles ve lowPriorityFiles içinde hiçbir eklenti gövdesi/kodu bulunmamalıdır', () => {
+ const manifest = JSON.parse(readFileSync(buildManifestPath, 'utf8'))
+ const initialFiles: string[] = [
+ ...(manifest.rootMainFiles ?? []),
+ ...(manifest.polyfillFiles ?? []),
+ ...(manifest.lowPriorityFiles ?? []),
+ ]
+
+ expect(initialFiles.length).toBeGreaterThan(0)
+
+ for (const relPath of initialFiles) {
+ const fullPath = path.join(nextDir, relPath)
+ if (!existsSync(fullPath)) continue
+
+ const chunkContent = readFileSync(fullPath, 'utf8')
+
+ for (const plugin of targetPlugins) {
+ // İlk yükleme chunk'larında eklenti paket ismi statik import olarak bulunmamalıdır
+ expect(chunkContent.includes(`"${plugin.pkg}"`)).toBe(false)
+ expect(chunkContent.includes(`'${plugin.pkg}'`)).toBe(false)
+
+ // Eklenti sembolleri veya özel düğüm tanımları ana giriş paketlerine sızmamış olmalıdır
+ expect(chunkContent.includes(plugin.nodeKind)).toBe(false)
+ }
+ }
+ })
+
+ it('App Router layout ve genel sayfa chunk dosyalarında eklenti düğüm gövdeleri bulunmamalıdır', () => {
+ const appChunksDir = path.join(staticChunksDir, 'app')
+ if (existsSync(appChunksDir)) {
+ const getJsFiles = (dir: string): string[] => {
+ let files: string[] = []
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, item.name)
+ if (item.isDirectory()) {
+ files = files.concat(getJsFiles(full))
+ } else if (item.name.endsWith('.js')) {
+ files.push(full)
+ }
+ }
+ return files
+ }
+
+ const appJsFiles = getJsFiles(appChunksDir)
+ expect(appJsFiles.length).toBeGreaterThan(0)
+
+ for (const jsFile of appJsFiles) {
+ const content = readFileSync(jsFile, 'utf8')
+ for (const plugin of targetPlugins) {
+ // Layout ve ana sayfalarda eklenti özel düğüm tipleri sızmamalı
+ expect(content.includes(`kind:"${plugin.nodeKind}"`)).toBe(false)
+ expect(content.includes(`kind:'${plugin.nodeKind}'`)).toBe(false)
+ }
+ }
+ }
+ })
+ })
+
+ describe('3. Dinamik Chunk Ayrışımı ve React Loadable Eşlemesi (Code Splitting)', () => {
+ it('7 eklentinin tümü react-loadable-manifest.json içinde dinamik chunk olarak tanımlı olmalıdır', () => {
+ const rawLoadable = readFileSync(reactLoadableManifestPath, 'utf8')
+ const loadableManifest = JSON.parse(rawLoadable)
+
+ for (const plugin of targetPlugins) {
+ const shortPkg = plugin.pkg.replace('@pascal-app/', '').replace('@ovurrsl/', '').replace('@mint/', '')
+ const matchingEntries = Object.entries(loadableManifest).filter(
+ ([key]) => key.includes(plugin.pkg) || key.includes(shortPkg),
+ )
+
+ expect(matchingEntries.length).toBeGreaterThan(0)
+
+ // İlgili dinamik chunk dosyalarının diskte mevcut olduğunu doğrula
+ let chunkFilesFound = 0
+ for (const [, entry] of matchingEntries) {
+ const files = (entry as { files?: string[] }).files ?? []
+ for (const f of files) {
+ const full = path.join(nextDir, f)
+ if (existsSync(full)) {
+ chunkFilesFound++
+ }
+ }
+ }
+ expect(chunkFilesFound).toBeGreaterThan(0)
+ }
+ })
+
+ it('Dinamik chunk dosyaları kendi eklenti mantıklarını bağımsız şekilde barındırmalıdır', () => {
+ const allChunks = readdirSync(staticChunksDir).filter((f) => f.endsWith('.js'))
+
+ for (const plugin of targetPlugins) {
+ let signatureFoundInSeparateChunk = false
+
+ for (const chunkFile of allChunks) {
+ const content = readFileSync(path.join(staticChunksDir, chunkFile), 'utf8')
+ if (content.includes(plugin.symbol) || content.includes(plugin.nodeKind)) {
+ signatureFoundInSeparateChunk = true
+ break
+ }
+ }
+
+ expect(signatureFoundInSeparateChunk).toBe(true)
+ }
+ })
+ })
+
+ describe('4. Kaynak Kod Statik İzolasyon ve Konfigürasyon Doğrulaması', () => {
+ it('bootstrap.ts içinde 7 eklentiden hiçbirinin statik importu bulunmamalıdır', () => {
+ const bootstrapPath = path.join(editorDir, 'lib', 'bootstrap.ts')
+ const content = readFileSync(bootstrapPath, 'utf8')
+
+ for (const plugin of targetPlugins) {
+ const staticImportRegex = new RegExp(
+ `import\\s+(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+|\\w+)\\s+from\\s+)?['"]${plugin.pkg}['"]`,
+ )
+ expect(staticImportRegex.test(content)).toBe(false)
+ }
+ })
+
+ it('lib/plugins/catalog.ts tüm eklentileri lazy dynamic import thunk ile tanımlamalıdır', () => {
+ const catalogPath = path.join(editorDir, 'lib', 'plugins', 'catalog.ts')
+ const content = readFileSync(catalogPath, 'utf8')
+
+ for (const plugin of targetPlugins) {
+ expect(content.includes(plugin.id)).toBe(true)
+ expect(content.includes(plugin.pkg)).toBe(true)
+ // Dinamik import kullanımı (boşluk ve alt satır esnekliği ile)
+ const dynamicImportRegex = new RegExp(`import\\s*\\(\\s*['"]${plugin.pkg}['"]\\s*\\)`)
+ expect(dynamicImportRegex.test(content)).toBe(true)
+ }
+ })
+
+ it('next.config.ts transpilePackages içinde 7 eklentinin tümü yer almalıdır', () => {
+ const nextConfigPath = path.join(editorDir, 'next.config.ts')
+ const content = readFileSync(nextConfigPath, 'utf8')
+
+ for (const plugin of targetPlugins) {
+ expect(content.includes(`'${plugin.pkg}'`) || content.includes(`"${plugin.pkg}"`)).toBe(true)
+ }
+ })
+ })
+})
diff --git a/apps/editor/app/(panel)/console/[tab]/page.tsx b/apps/editor/app/(panel)/console/[tab]/page.tsx
new file mode 100644
index 0000000000..7971434de3
--- /dev/null
+++ b/apps/editor/app/(panel)/console/[tab]/page.tsx
@@ -0,0 +1,37 @@
+import { ConsoleShell } from '@panel/components/console/console-shell'
+import { TabContent } from '@panel/components/console/tab-content'
+import { getSession } from '@panel/lib/auth/session'
+import { isConsoleTab, tabPermission } from '@panel/lib/console-tabs'
+import { notFound, redirect } from 'next/navigation'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Every console tab is its own address (`/console/users`), so back/forward work
+ * and a link opens where it says it does. An unknown tab is a 404 rather than a
+ * silent redirect to Overview — a typo in a shared link should say so.
+ */
+export default async function ConsoleTabPage({ params }: { params: Promise<{ tab: string }> }) {
+ const { tab } = await params
+ if (!isConsoleTab(tab)) notFound()
+
+ const session = await getSession()
+ if (!session) redirect('/signin')
+
+ // The console is administration, and administration is for administrators.
+ // Without this a view-only account reached Overview, Users and Sessions —
+ // every colleague's name, address and login history — because those tabs
+ // carry no permission of their own.
+ if (!session.user.permissions.includes('admin_access')) redirect('/')
+
+ // Permission is re-checked here, not just hidden in the rail: a hand-typed URL
+ // to a tab the role cannot see lands on Overview instead of rendering it.
+ const required = tabPermission(tab)
+ if (required && !session.user.permissions.includes(required)) redirect('/console/overview')
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/editor/app/(panel)/console/layout.tsx b/apps/editor/app/(panel)/console/layout.tsx
new file mode 100644
index 0000000000..6501cad9b6
--- /dev/null
+++ b/apps/editor/app/(panel)/console/layout.tsx
@@ -0,0 +1,22 @@
+import { getSession } from '@panel/lib/auth/session'
+import { redirect } from 'next/navigation'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Route guard for everything under /console — the server-side counterpart of the
+ * old client-bootstrap. It runs before any console markup exists, so an
+ * unauthenticated visitor never sees a frame of the shell.
+ *
+ * Order matters: a half-open (MFA-owed) session is not signed in, and an account
+ * owing a password change cannot reach the console until it sets one.
+ */
+export default async function ConsoleLayout({ children }: { children: React.ReactNode }) {
+ const session = await getSession()
+
+ if (!session) redirect('/signin')
+ if (session.mfaPending) redirect('/mfa')
+ if (session.user.mustChangePassword) redirect('/welcome')
+
+ return <>{children}>
+}
diff --git a/apps/editor/app/(panel)/console/page.tsx b/apps/editor/app/(panel)/console/page.tsx
new file mode 100644
index 0000000000..1e4bf26a35
--- /dev/null
+++ b/apps/editor/app/(panel)/console/page.tsx
@@ -0,0 +1,6 @@
+import { redirect } from 'next/navigation'
+
+/** /console has no content of its own — Overview is the landing tab. */
+export default function ConsoleIndex() {
+ redirect('/console/overview')
+}
diff --git a/apps/editor/app/(panel)/layout.tsx b/apps/editor/app/(panel)/layout.tsx
new file mode 100644
index 0000000000..9bb1fd1df2
--- /dev/null
+++ b/apps/editor/app/(panel)/layout.tsx
@@ -0,0 +1,36 @@
+import { AppProviders } from '@panel/components/app-providers'
+import { ErrorReporter } from '@panel/components/error-reporter'
+import type { Lang, Theme } from '@panel/lib/types'
+import type { Metadata } from 'next'
+import { cookies } from 'next/headers'
+import '@panel/globals.css'
+
+export const metadata: Metadata = {
+ title: 'Console',
+ description: 'DigitalTwin — authentication and administration console.',
+}
+
+/**
+ * The console's root layout, adapted to live inside the editor app: the host
+ * owns / and the fonts (same --font-barlow/--font-geist-mono
+ * variables), so the panel's theme attribute moves from to a wrapper.
+ * Every panel token is defined on [data-dt-theme] — not :root — precisely so
+ * the theme travels with the subtree instead of leaking into the editor.
+ *
+ * Reading theme and language from cookies server-side keeps the first paint
+ * from flashing the wrong theme.
+ */
+export default async function PanelLayout({ children }: { children: React.ReactNode }) {
+ const jar = await cookies()
+ const theme: Theme = jar.get('digitaltwin_theme')?.value === 'light' ? 'light' : 'dark'
+ const lang: Lang = jar.get('digitaltwin_lang')?.value === 'tr' ? 'tr' : 'en'
+
+ return (
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/apps/editor/app/(panel)/mfa/page.tsx b/apps/editor/app/(panel)/mfa/page.tsx
new file mode 100644
index 0000000000..73d26c378f
--- /dev/null
+++ b/apps/editor/app/(panel)/mfa/page.tsx
@@ -0,0 +1,14 @@
+import { MfaVerifyScreen } from '@panel/components/auth/mfa-verify-screen'
+import { getSession } from '@panel/lib/auth/session'
+import { redirect } from 'next/navigation'
+
+export const dynamic = 'force-dynamic'
+
+export default async function MfaPage() {
+ const session = await getSession({ touch: false })
+ if (!session) redirect('/signin')
+ // Reaching the OTP screen with the step already cleared means the flow is done.
+ if (!session.mfaPending) redirect('/console/overview')
+
+ return
+}
diff --git a/apps/editor/app/(panel)/mfa/recovery/page.tsx b/apps/editor/app/(panel)/mfa/recovery/page.tsx
new file mode 100644
index 0000000000..73855fd0ad
--- /dev/null
+++ b/apps/editor/app/(panel)/mfa/recovery/page.tsx
@@ -0,0 +1,13 @@
+import { MfaRecoveryScreen } from '@panel/components/auth/mfa-recovery-screen'
+import { getSession } from '@panel/lib/auth/session'
+import { redirect } from 'next/navigation'
+
+export const dynamic = 'force-dynamic'
+
+export default async function MfaRecoveryPage() {
+ const session = await getSession({ touch: false })
+ if (!session) redirect('/signin')
+ if (!session.mfaPending) redirect('/console/overview')
+
+ return
+}
diff --git a/apps/editor/app/(panel)/mfa/setup/page.tsx b/apps/editor/app/(panel)/mfa/setup/page.tsx
new file mode 100644
index 0000000000..f3c3973415
--- /dev/null
+++ b/apps/editor/app/(panel)/mfa/setup/page.tsx
@@ -0,0 +1,14 @@
+import { MfaSetupScreen } from '@panel/components/auth/mfa-setup-screen'
+import { getSession } from '@panel/lib/auth/session'
+import { redirect } from 'next/navigation'
+
+export const dynamic = 'force-dynamic'
+
+export default async function MfaSetupPage() {
+ // Enrolment is reachable with a half-open session on purpose: that is exactly
+ // the state a first-time user is in when MFA is mandatory.
+ const session = await getSession({ touch: false })
+ if (!session) redirect('/signin')
+
+ return
+}
diff --git a/apps/editor/app/(panel)/request/page.tsx b/apps/editor/app/(panel)/request/page.tsx
new file mode 100644
index 0000000000..4f9892a1a1
--- /dev/null
+++ b/apps/editor/app/(panel)/request/page.tsx
@@ -0,0 +1,5 @@
+import { RequestAccessScreen } from '@panel/components/auth/request-access-screen'
+
+export default function RequestPage() {
+ return
+}
diff --git a/apps/editor/app/(panel)/reset/[token]/page.tsx b/apps/editor/app/(panel)/reset/[token]/page.tsx
new file mode 100644
index 0000000000..05469b6fc8
--- /dev/null
+++ b/apps/editor/app/(panel)/reset/[token]/page.tsx
@@ -0,0 +1,8 @@
+import { SetPasswordScreen } from '@panel/components/auth/set-password-screen'
+
+export const dynamic = 'force-dynamic'
+
+export default async function SetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
+ const { token } = await params
+ return
+}
diff --git a/apps/editor/app/(panel)/reset/page.tsx b/apps/editor/app/(panel)/reset/page.tsx
new file mode 100644
index 0000000000..b094fda661
--- /dev/null
+++ b/apps/editor/app/(panel)/reset/page.tsx
@@ -0,0 +1,5 @@
+import { ResetRequestScreen } from '@panel/components/auth/reset-request-screen'
+
+export default function ResetPage() {
+ return
+}
diff --git a/apps/editor/app/(panel)/signin/page.tsx b/apps/editor/app/(panel)/signin/page.tsx
new file mode 100644
index 0000000000..f4d4418fe6
--- /dev/null
+++ b/apps/editor/app/(panel)/signin/page.tsx
@@ -0,0 +1,20 @@
+import { SignInScreen } from '@panel/components/auth/sign-in-screen'
+import { getSession } from '@panel/lib/auth/session'
+import { redirect } from 'next/navigation'
+import { Suspense } from 'react'
+
+export const dynamic = 'force-dynamic'
+
+export default async function SignInPage() {
+ // An already-signed-in visitor is bounced to the console rather than shown a
+ // form that would just re-authenticate them.
+ const session = await getSession({ touch: false })
+ if (session && !session.mfaPending && !session.user.mustChangePassword)
+ redirect('/console/overview')
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/editor/app/(panel)/welcome/page.tsx b/apps/editor/app/(panel)/welcome/page.tsx
new file mode 100644
index 0000000000..4a6eb80f89
--- /dev/null
+++ b/apps/editor/app/(panel)/welcome/page.tsx
@@ -0,0 +1,29 @@
+import { SetPasswordScreen } from '@panel/components/auth/set-password-screen'
+import { getSession } from '@panel/lib/auth/session'
+import { redirect } from 'next/navigation'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * First sign-in, reachable two ways:
+ * /welcome?token=… an invited account opening its emailed link
+ * /welcome a signed-in account carrying must_change_password
+ *
+ * Neither is reachable by accident: without a token and without that flag there
+ * is nothing to set up, so the visitor goes wherever they actually belong.
+ */
+export default async function WelcomePage({
+ searchParams,
+}: {
+ searchParams: Promise<{ token?: string }>
+}) {
+ const { token } = await searchParams
+ if (token) return
+
+ const session = await getSession({ touch: false })
+ if (!session) redirect('/signin')
+ if (session.mfaPending) redirect('/mfa')
+ if (!session.user.mustChangePassword) redirect('/console/overview')
+
+ return
+}
diff --git a/apps/editor/app/(public)/changelog/page.tsx b/apps/editor/app/(public)/changelog/page.tsx
new file mode 100644
index 0000000000..7356212e2a
--- /dev/null
+++ b/apps/editor/app/(public)/changelog/page.tsx
@@ -0,0 +1,131 @@
+import { changelogPage } from '@panel/lib/changelog'
+import type { Lang } from '@panel/lib/types'
+import type { Metadata } from 'next'
+import { cookies } from 'next/headers'
+
+export const dynamic = 'force-dynamic'
+
+export const metadata: Metadata = { title: 'Changelog' }
+
+/**
+ * A narrow, single-column release history: date, version, title, what
+ * changed, tags — rows separated by rules rather than boxed into cards,
+ * which is what keeps a long history readable in one scroll.
+ *
+ * Public, and fed by the same source as the console's Updates tab, so the
+ * two can never drift. The RSS button beside the heading is the same list
+ * again, for anyone who would rather be told than remember to look.
+ */
+/**
+ * Which of the three components a release belongs to. The repositories behind
+ * them are never named here — that rule is why the entries carry a `channel`
+ * rather than a repo — but a reader still has to be able to tell an editor
+ * release from a plugin one, and they version independently.
+ */
+const CHANNEL_LABEL: Record> = {
+ en: { editor: 'Editor', plugin: 'Warehouse plugin', console: 'Console' },
+ tr: { editor: 'Editör', plugin: 'Depo eklentisi', console: 'Konsol' },
+}
+
+function formatDay(lang: Lang, iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString(lang === 'tr' ? 'tr-TR' : 'en-GB', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ })
+ } catch {
+ return iso
+ }
+}
+
+export default async function ChangelogPage() {
+ const jar = await cookies()
+ const lang: Lang = jar.get('digitaltwin_lang')?.value === 'tr' ? 'tr' : 'en'
+ const { entries } = await changelogPage(null, 40)
+
+ return (
+
+
+ {lang === 'tr'
+ ? 'DigitalTwin platformundaki yeni özellikler, iyileştirmeler ve düzeltmeler.'
+ : 'New features, improvements, and fixes across the DigitalTwin platform.'}
+
+
+ )
+}
diff --git a/apps/editor/app/(public)/changelog/rss.xml/route.ts b/apps/editor/app/(public)/changelog/rss.xml/route.ts
new file mode 100644
index 0000000000..2642019548
--- /dev/null
+++ b/apps/editor/app/(public)/changelog/rss.xml/route.ts
@@ -0,0 +1,56 @@
+import { changelogPage } from '@panel/lib/changelog'
+import { appUrl } from '@panel/lib/mail'
+
+export const dynamic = 'force-dynamic'
+
+/** Escapes the five characters XML cannot carry raw. */
+function xml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+/**
+ * GET /changelog/rss.xml — the same entries the changelog page shows, for
+ * readers who would rather be told than remember to look.
+ */
+export async function GET() {
+ const { entries } = await changelogPage(null, 40)
+ const site = appUrl('/changelog')
+
+ const items = entries
+ .map((entry) => {
+ const title = entry.version ? `${entry.version} — ${entry.title}` : entry.title
+ return `
+ ${xml(title)}
+ ${xml(site)}
+ ${xml(entry.id)}
+ ${new Date(entry.date).toUTCString()}
+ ${xml(entry.summary)}
+${entry.tags.map((tag) => ` ${xml(tag)}`).join('\n')}
+ `
+ })
+ .join('\n')
+
+ const body = `
+
+
+ DigitalTwin — changelog
+ ${xml(site)}
+ Releases and changes across the DigitalTwin platform.
+ en
+${items}
+
+
+`
+
+ return new Response(body, {
+ headers: {
+ 'content-type': 'application/rss+xml; charset=utf-8',
+ 'cache-control': 'no-store',
+ },
+ })
+}
diff --git a/apps/editor/app/(public)/guides/[slug]/page.tsx b/apps/editor/app/(public)/guides/[slug]/page.tsx
new file mode 100644
index 0000000000..8ba3f8334d
--- /dev/null
+++ b/apps/editor/app/(public)/guides/[slug]/page.tsx
@@ -0,0 +1,150 @@
+import type { Lang } from '@panel/lib/types'
+import type { Metadata } from 'next'
+import { cookies } from 'next/headers'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { DocsShell } from '@/components/public/docs-shell'
+import { allGuideSlugs, guidePageFor, guidesFor } from '@/lib/guides-content'
+import { slugify } from '@/lib/slugify'
+
+export const dynamic = 'force-dynamic'
+
+interface Params {
+ params: Promise<{ slug: string }>
+}
+
+export async function generateMetadata({ params }: Params): Promise {
+ const { slug } = await params
+ const page = guidePageFor('en', slug)
+ return { title: page?.title ?? 'Documentation' }
+}
+
+/** One documentation page: heading, description, sections, and where to go next. */
+export default async function GuidePage({ params }: Params) {
+ const { slug } = await params
+ if (!allGuideSlugs().includes(slug)) notFound()
+
+ const jar = await cookies()
+ const lang: Lang = jar.get('digitaltwin_lang')?.value === 'tr' ? 'tr' : 'en'
+ const guides = guidesFor(lang)
+ const page = guidePageFor(lang, slug)
+ if (!page) notFound()
+
+ const group = guides.groups.find((g) => g.pages.some((p) => p.slug === slug))
+ const flat = guides.groups.flatMap((g) => g.pages)
+ const index = flat.findIndex((p) => p.slug === slug)
+ const previous = index > 0 ? flat[index - 1] : undefined
+ const next = index >= 0 && index < flat.length - 1 ? flat[index + 1] : undefined
+
+ const nav = guides.groups.map((g) => ({
+ title: g.title,
+ pages: g.pages.map((p) => ({ slug: p.slug, title: p.title })),
+ }))
+
+ return (
+ ({
+ id: slugify(block.heading),
+ title: block.heading,
+ }))}
+ onThisPageLabel={lang === 'tr' ? 'Bu sayfada' : 'On this page'}
+ >
+
+
+ {group ? (
+
+ {group.title}
+
+ ) : null}
+
+ {page.title}
+
+
{page.description}
+
+
+ {page.blocks.map((block) => (
+
+
{block.heading}
+
+ {block.body?.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+ {block.points ? (
+
+ {block.points.map((point) => (
+
+
+ {point}
+
+ ))}
+
+ ) : null}
+
+ {block.table ? (
+
+
+
+
+
{block.table.columns[0]}
+
{block.table.columns[1]}
+
+
+
+ {block.table.rows.map(([key, value]) => (
+
+
+
+ {key}
+
+
+
{value}
+
+ ))}
+
+
+
+ ) : null}
+
+ ))}
+
+ {previous || next ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/apps/editor/app/(public)/guides/page.tsx b/apps/editor/app/(public)/guides/page.tsx
new file mode 100644
index 0000000000..df6b8dd82e
--- /dev/null
+++ b/apps/editor/app/(public)/guides/page.tsx
@@ -0,0 +1,109 @@
+import type { Lang } from '@panel/lib/types'
+import type { Metadata } from 'next'
+import { cookies } from 'next/headers'
+import Link from 'next/link'
+import { DocsShell } from '@/components/public/docs-shell'
+import { guidesFor } from '@/lib/guides-content'
+
+export const dynamic = 'force-dynamic'
+
+export const metadata: Metadata = { title: 'Documentation' }
+
+/** The documentation home: a welcome, a way in, then the manual as cards. */
+export default async function GuidesIndex() {
+ const jar = await cookies()
+ const lang: Lang = jar.get('digitaltwin_lang')?.value === 'tr' ? 'tr' : 'en'
+ const guides = guidesFor(lang)
+ const [firstGroup, ...restGroups] = guides.groups
+
+ const nav = guides.groups.map((group) => ({
+ title: group.title,
+ pages: group.pages.map((page) => ({ slug: page.slug, title: page.title })),
+ }))
+
+ return (
+
+
+
+
+ {guides.groups[0]?.title}
+
+
+ {guides.title}
+
+ {guides.lead.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+
+ {firstGroup ? (
+
+
+ {guides.startHere}
+
+
+ {firstGroup.pages.map((page) => (
+
+ ))}
+
+
+ ) : null}
+
+
+
{guides.explore}
+ {restGroups.map((group) => (
+
+
+ {group.title}
+
+
+ {group.pages.map((page) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ )
+}
+
+function GuideCard({
+ slug,
+ title,
+ description,
+}: {
+ slug: string
+ title: string
+ description: string
+}) {
+ return (
+
+ {title}
+ {description}
+
+ )
+}
diff --git a/apps/editor/app/(public)/layout.tsx b/apps/editor/app/(public)/layout.tsx
new file mode 100644
index 0000000000..a48bf1faf5
--- /dev/null
+++ b/apps/editor/app/(public)/layout.tsx
@@ -0,0 +1,87 @@
+import '@panel/globals.css'
+import { dictionaryFor } from '@panel/lib/i18n'
+import type { Lang } from '@panel/lib/types'
+import { cookies } from 'next/headers'
+import Link from 'next/link'
+import type { ReactNode } from 'react'
+import { BrandLockup } from '@/components/brand-mark'
+import { LangSwitch } from '@/components/public/lang-switch'
+import { ThemeSwitch } from '@/components/public/theme-switch'
+import { authAvailable } from '@/lib/auth/db'
+import { getSessionUser } from '@/lib/auth/session'
+
+/**
+ * The shell for the two pages anyone may read without an account: the
+ * documentation and the changelog. It wears the product's skin but carries
+ * none of the console's machinery — no session, no providers — because the
+ * whole point is that a signed-out visitor can reach it from the sign-in
+ * screen.
+ */
+export default async function PublicLayout({ children }: { children: ReactNode }) {
+ const jar = await cookies()
+ const lang: Lang = jar.get('digitaltwin_lang')?.value === 'tr' ? 'tr' : 'en'
+ const theme = jar.get('digitaltwin_theme')?.value === 'light' ? 'light' : 'dark'
+ const t = dictionaryFor(lang)
+
+ // These pages are readable by anyone, so the call to action has to match who
+ // is reading: a stranger is offered the door, an editor the editor, and a
+ // view-only account the scenes it may look at.
+ const user = authAvailable() ? await getSessionUser() : null
+ const cta =
+ user === null
+ ? { href: '/signin', label: t.signIn }
+ : user.role === 'viewer'
+ ? { href: '/scenes', label: lang === 'tr' ? 'Projelerim' : 'My projects' }
+ : { href: '/', label: lang === 'tr' ? 'Editörü aç' : 'Open the editor' }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/apps/editor/app/api/admin/scenes/[id]/manage/route.ts b/apps/editor/app/api/admin/scenes/[id]/manage/route.ts
new file mode 100644
index 0000000000..077e926603
--- /dev/null
+++ b/apps/editor/app/api/admin/scenes/[id]/manage/route.ts
@@ -0,0 +1,66 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { requireAdmin } from '@/lib/auth/admin'
+import { unpublishScene } from '@/lib/auth/site-scenes'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+const schema = z.discriminatedUnion('action', [
+ z.object({ action: z.literal('rename'), name: z.string().trim().min(1).max(200) }),
+ z.object({ action: z.literal('duplicate') }),
+ z.object({ action: z.literal('delete') }),
+])
+
+/**
+ * POST /api/admin/scenes/[id]/manage — rename, duplicate or delete a project
+ * from the console.
+ *
+ * Duplicating copies the graph into a new scene owned by the same person and
+ * marked as a copy; the copy is a draft, because publishing is an approval
+ * and approval does not transfer. Deleting removes the scene and withdraws
+ * its site card first, so no card is left pointing at nothing.
+ */
+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.action === 'rename') {
+ const meta = await operations.renameStoredScene(id, parsed.data.name)
+ return sceneApiJson(request, { ok: true, name: meta.name })
+ }
+
+ if (parsed.data.action === 'duplicate') {
+ const copy = await operations.saveScene({
+ name: `${scene.name} (copy)`.slice(0, 200),
+ projectId: scene.projectId ?? null,
+ ownerId: scene.ownerId ?? undefined,
+ graph: scene.graph as never,
+ thumbnailUrl: scene.thumbnailUrl ?? null,
+ })
+ return sceneApiJson(request, { ok: true, id: copy.id }, { status: 201 })
+ }
+
+ // Withdraw first: a site card outliving its scene is a dead link on the
+ // one screen the whole organisation reads.
+ await unpublishScene(id)
+ const deleted = await operations.deleteStoredScene(id)
+ return sceneApiJson(request, { ok: deleted })
+}
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 0000000000..9b7c82b4fe
--- /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 0000000000..21866b332b
--- /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/scenes/publish/route.ts b/apps/editor/app/api/admin/scenes/publish/route.ts
new file mode 100644
index 0000000000..b80eb681b6
--- /dev/null
+++ b/apps/editor/app/api/admin/scenes/publish/route.ts
@@ -0,0 +1,48 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { requireAdmin } from '@/lib/auth/admin'
+import { notifyScenePublished, publishSceneAsSite, unpublishScene } from '@/lib/auth/site-scenes'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+
+export const dynamic = 'force-dynamic'
+
+const schema = z.object({
+ sceneId: z.string().min(1).max(64),
+ publish: z.boolean(),
+})
+
+/**
+ * POST /api/admin/scenes/publish — an admin approving (or withdrawing) a
+ * project. Publishing puts the scene on Sites & Projects as an active site;
+ * withdrawing removes the card and leaves the scene untouched, so the person
+ * who drew it never loses work to a moderation decision.
+ */
+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 (!parsed.data.publish) {
+ const removed = await unpublishScene(parsed.data.sceneId)
+ return sceneApiJson(request, { published: false, changed: removed })
+ }
+
+ const result = await publishSceneAsSite(parsed.data.sceneId, admin.id)
+ if (result === 'scene_not_found') {
+ return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+ }
+ // Approval is the moment somebody's drawing becomes the organisation's, and
+ // they should hear it from the system rather than notice it later.
+ if (result === 'published') await notifyScenePublished(parsed.data.sceneId)
+ return sceneApiJson(request, { published: true, changed: result === 'published' })
+}
diff --git a/apps/editor/app/api/admin/scenes/route.ts b/apps/editor/app/api/admin/scenes/route.ts
new file mode 100644
index 0000000000..8c1f918e75
--- /dev/null
+++ b/apps/editor/app/api/admin/scenes/route.ts
@@ -0,0 +1,41 @@
+import type { NextRequest } from 'next/server'
+import { listUsers, ownerEmails, requireAdmin } from '@/lib/auth/admin'
+import { publishedSceneIds } from '@/lib/auth/site-scenes'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/admin/scenes — every scene with its owner, plus the accounts an
+ * owner can be reassigned to. Feeds the console's 3D scenes tab, which took
+ * over from the editor's old /admin page.
+ */
+export async function GET(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 })
+
+ const [users, operations, published] = await Promise.all([
+ listUsers(),
+ getSceneOperations(),
+ publishedSceneIds(),
+ ])
+ const scenes = await operations.listScenes({ limit: 500 })
+ const emails = await ownerEmails(scenes.map((s) => s.ownerId).filter((x): x is string => !!x))
+
+ return sceneApiJson(request, {
+ scenes: 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,
+ published: published.has(s.id),
+ })),
+ users: users.map((u) => ({ id: u.id, email: u.email })),
+ adminId: admin.id,
+ })
+}
diff --git a/apps/editor/app/api/audit/route.ts b/apps/editor/app/api/audit/route.ts
new file mode 100644
index 0000000000..9c89239226
--- /dev/null
+++ b/apps/editor/app/api/audit/route.ts
@@ -0,0 +1,32 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { auditKinds, listLogs } from '@panel/lib/logs'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/audit — the append-only change trail.
+ *
+ * There is no DELETE here and there never should be: the whole value of the
+ * trail is that no console action can remove an entry from it.
+ */
+export const GET = handler(async (request: Request) => {
+ const guard = await requirePermission('view_logs')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.logsRestricted')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const params = new URL(request.url).searchParams
+ const page = await listLogs({
+ view: 'audit',
+ search: params.get('search') ?? undefined,
+ kind: params.get('kind') ?? undefined,
+ cursor: params.get('cursor') ?? undefined,
+ limit: Number(params.get('limit') ?? 50) || 50,
+ })
+
+ return ok({ ...page, kinds: await auditKinds() })
+})
diff --git a/apps/editor/app/api/auth/password/route.ts b/apps/editor/app/api/auth/password/route.ts
new file mode 100644
index 0000000000..b79f5c7271
--- /dev/null
+++ b/apps/editor/app/api/auth/password/route.ts
@@ -0,0 +1,87 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import type { ResetConfirmResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { checkPasswordPolicy, hashPassword } from '@panel/lib/auth/password'
+import { getSession, revokeAllSessions } from '@panel/lib/auth/session'
+import { isEnrolled } from '@panel/lib/auth/totp'
+import { exec } from '@panel/lib/db'
+import { deliverPasswordChanged } from '@panel/lib/mail'
+import { getSettings } from '@panel/lib/settings'
+import { z } from 'zod'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const schema = z
+ .object({
+ password: z.string().min(10).max(512),
+ passwordAgain: z.string().min(10).max(512),
+ revokeOtherSessions: z.boolean().default(true),
+ acceptPolicy: z.boolean().default(false),
+ })
+ .refine((v) => v.password === v.passwordAgain, {
+ path: ['passwordAgain'],
+ params: { code: 'password_mismatch' },
+ message: 'err.passwordMismatch',
+ })
+
+/**
+ * POST /api/auth/password — the forced change on first sign-in.
+ *
+ * Distinct from /api/auth/reset/confirm, which is driven by an emailed token.
+ * This one is driven by an authenticated session carrying must_change_password,
+ * which is how a seeded or admin-provisioned account arrives with no invite link
+ * in play. Either route sets the same column and clears the same flag.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, schema)
+ if (!parsed.ok) return parsed.response
+
+ const session = await getSession()
+ if (!session || session.mfaPending) return fail('unauthenticated', 'err.sessionExpired')
+ if (!parsed.data.acceptPolicy)
+ return fail('validation', 'err.policyRequired', { field: 'acceptPolicy' })
+
+ const policy = checkPasswordPolicy(
+ parsed.data.password,
+ session.user.username || session.user.email,
+ )
+ if (!policy.ok) return fail('password_policy', 'err.passwordPolicy', { policy })
+
+ await exec(
+ 'UPDATE users SET password_hash = ?, password_set_at = NOW(), must_change_password = 0 WHERE id = ?',
+ [await hashPassword(parsed.data.password), session.userId],
+ )
+
+ // Spare the current session — the user just proved themselves and should not
+ // be thrown back to sign-in for changing their own password.
+ const revokedSessions = parsed.data.revokeOtherSessions
+ ? await revokeAllSessions(session.userId, session.id)
+ : 0
+
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'info',
+ kind: 'auth',
+ message: 'Password changed on first sign-in',
+ event: { k: 'passwordChangedFirst' },
+ meta: { revokedSessions },
+ })
+
+ await deliverPasswordChanged({
+ email: session.user.email,
+ fullName: session.user.name,
+ via: 'first-sign-in',
+ })
+
+ const settings = await getSettings()
+ const mfaOwed = settings.mfaRequired && !(await isEnrolled(session.userId))
+
+ const body: ResetConfirmResponse = {
+ state: 'signedIn',
+ next: mfaOwed ? 'mfa-setup' : 'console',
+ revokedSessions,
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/auth/reset/[token]/route.ts b/apps/editor/app/api/auth/reset/[token]/route.ts
new file mode 100644
index 0000000000..96976de296
--- /dev/null
+++ b/apps/editor/app/api/auth/reset/[token]/route.ts
@@ -0,0 +1,41 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { resolveInvitationToken } from '@panel/lib/auth/invitations'
+import { resolveResetToken } from '@panel/lib/auth/reset'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/auth/reset/:token — what the `#/reset/:token` and `#/welcome` screens
+ * call before rendering, so an expired link shows its own state instead of a
+ * form that will fail on submit.
+ *
+ * One token space, two sources: a reset link and an invite link both land here.
+ * The response says which, because the invite variant renders the "Set up your
+ * account" copy and the policy-consent checkbox.
+ */
+export const GET = handler(
+ async (_request: Request, ctx: { params: Promise<{ token: string }> }) => {
+ const { token } = await ctx.params
+
+ const reset = await resolveResetToken(token)
+ if (reset.state === 'valid') {
+ return ok({ kind: 'reset' as const, email: reset.email, username: reset.username })
+ }
+ if (reset.state === 'expired') return fail('token_expired', 'err.tokenExpired')
+ if (reset.state === 'used') return fail('token_invalid', 'err.tokenUsed')
+
+ const invite = await resolveInvitationToken(token)
+ if (!invite) return fail('token_invalid', 'err.tokenInvalid')
+ if (invite.state === 'expired') return fail('invite_expired', 'err.inviteExpired')
+ if (invite.state === 'revoked') return fail('invite_revoked', 'err.inviteRevoked')
+ if (invite.state === 'accepted') return fail('token_invalid', 'err.tokenUsed')
+
+ return ok({
+ kind: 'invite' as const,
+ email: invite.email,
+ fullName: invite.fullName,
+ expiresAt: invite.expiresAt.toISOString(),
+ })
+ },
+)
diff --git a/apps/editor/app/api/auth/reset/confirm/route.ts b/apps/editor/app/api/auth/reset/confirm/route.ts
new file mode 100644
index 0000000000..fcfd3d3eca
--- /dev/null
+++ b/apps/editor/app/api/auth/reset/confirm/route.ts
@@ -0,0 +1,114 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type ResetConfirmResponse, resetConfirmSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { markInvitationAccepted, resolveInvitationToken } from '@panel/lib/auth/invitations'
+import { clearFailures } from '@panel/lib/auth/lockout'
+import { checkPasswordPolicy, hashPassword } from '@panel/lib/auth/password'
+import { markResetUsed, resolveResetToken } from '@panel/lib/auth/reset'
+import { createSession, revokeAllSessions } from '@panel/lib/auth/session'
+import { isEnrolled } from '@panel/lib/auth/totp'
+import { findUserById } from '@panel/lib/auth/users'
+import { exec } from '@panel/lib/db'
+import { deliverPasswordChanged } from '@panel/lib/mail'
+import { getSettings } from '@panel/lib/settings'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/auth/reset/confirm — the shared submit for `#/reset/:token` and
+ * `#/welcome`. Which mode it runs in is decided by the token, not by the client:
+ *
+ * reset token -> set password, revoke sessions, land back on sign-in
+ * invite token -> set password, accept the invite, open a session, and route
+ * on to MFA enrolment if the org requires it
+ *
+ * The five policy rules are re-checked here. The client's meter is a courtesy;
+ * this is the check that counts.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, resetConfirmSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { token, password, revokeOtherSessions, acceptPolicy } = parsed.data
+
+ const reset = await resolveResetToken(token)
+ if (reset.state === 'expired') return fail('token_expired', 'err.tokenExpired')
+ if (reset.state === 'used') return fail('token_invalid', 'err.tokenUsed')
+
+ const invite = reset.state === 'valid' ? null : await resolveInvitationToken(token)
+ if (reset.state !== 'valid') {
+ if (!invite) return fail('token_invalid', 'err.tokenInvalid')
+ if (invite.state === 'expired') return fail('invite_expired', 'err.inviteExpired')
+ if (invite.state === 'revoked') return fail('invite_revoked', 'err.inviteRevoked')
+ if (invite.state === 'accepted') return fail('token_invalid', 'err.tokenUsed')
+ }
+
+ const isInvite = invite !== null
+ const userId = isInvite ? invite.userId : reset.userId!
+
+ const user = await findUserById(userId)
+ if (!user) return fail('token_invalid', 'err.tokenInvalid')
+
+ // The policy-consent checkbox only exists on the first sign-in variant, and it
+ // is a hard gate there — the screen disables the button, and so does this.
+ if (isInvite && !acceptPolicy)
+ return fail('validation', 'err.policyRequired', { field: 'acceptPolicy' })
+
+ const policy = checkPasswordPolicy(password, user.username || user.email)
+ if (!policy.ok) return fail('password_policy', 'err.passwordPolicy', { policy })
+
+ const hash = await hashPassword(password)
+ await exec(
+ `UPDATE users
+ SET password_hash = ?, password_set_at = NOW(), must_change_password = 0,
+ status = CASE WHEN status = 'invited' THEN 'active' ELSE status END
+ WHERE id = ?`,
+ [hash, userId],
+ )
+ await clearFailures(userId)
+
+ if (isInvite) await markInvitationAccepted(invite.invitationId)
+ else await markResetUsed(reset.resetId!)
+
+ // Revoke first, then open the new session, so "sign out all other sessions"
+ // never takes the session this request is about to create with it.
+ const revokedSessions = revokeOtherSessions ? await revokeAllSessions(userId, null) : 0
+
+ await audit({
+ actorUserId: userId,
+ actorLabel: user.email,
+ level: 'info',
+ kind: 'auth',
+ message: isInvite ? 'Invite accepted — password set' : 'Password changed',
+ event: { k: isInvite ? 'inviteAccepted' : 'passwordChanged' },
+ meta: { revokedSessions },
+ })
+
+ // An invitation being accepted is the account's own beginning and needs no
+ // warning; a reset completing is exactly the event whose owner must find out
+ // even when it was not them who did it.
+ if (!isInvite) {
+ await deliverPasswordChanged({ email: user.email, fullName: user.full_name, via: 'reset' })
+ }
+
+ if (!isInvite) {
+ // A reset ends on the sign-in screen: proving control of the inbox is not
+ // the same as signing in, and the OTP step still has to happen.
+ const body: ResetConfirmResponse = { state: 'anonymous', next: 'signin', revokedSessions }
+ return ok(body)
+ }
+
+ const settings = await getSettings()
+ const enrolled = await isEnrolled(userId)
+ const mfaOwed = settings.mfaRequired && !enrolled
+
+ await createSession({ userId, keepSignedIn: false, mfaPending: mfaOwed })
+
+ const body: ResetConfirmResponse = {
+ state: mfaOwed ? 'mfaRequired' : 'signedIn',
+ next: mfaOwed ? 'mfa-setup' : 'console',
+ revokedSessions,
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/auth/reset/route.ts b/apps/editor/app/api/auth/reset/route.ts
new file mode 100644
index 0000000000..157f48ac7f
--- /dev/null
+++ b/apps/editor/app/api/auth/reset/route.ts
@@ -0,0 +1,54 @@
+import { handler, ok, parseBody } from '@panel/lib/api'
+import { type ResetRequestResponse, resetRequestSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { issueReset } from '@panel/lib/auth/reset'
+import { findUserByEmail } from '@panel/lib/auth/users'
+import { deliverResetLink } from '@panel/lib/mail'
+import { headers } from 'next/headers'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/auth/reset — "send me a reset link".
+ *
+ * Always 202 with the same body. Whether the address exists, is suspended or has
+ * never been registered is not disclosed; the screen says "a link is on its way"
+ * either way. Only the audit trail records which branch actually ran.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, resetRequestSchema)
+ if (!parsed.ok) return parsed.response
+
+ const email = parsed.data.email.trim().toLowerCase()
+ const user = await findUserByEmail(email)
+ const eligible = user !== null && user.status !== 'suspended' && user.status !== 'inactive'
+
+ if (eligible) {
+ const h = await headers()
+ const { token, expiresAt } = await issueReset(
+ user.id,
+ h.get('x-forwarded-for') ?? h.get('x-real-ip'),
+ )
+ await deliverResetLink({ email: user.email, fullName: user.full_name, token, expiresAt })
+ await audit({
+ actorUserId: user.id,
+ actorLabel: user.email,
+ level: 'info',
+ kind: 'auth',
+ message: 'Password reset link issued',
+ event: { k: 'resetIssued' },
+ })
+ } else {
+ await audit({
+ actorLabel: email.slice(0, 64),
+ level: 'warn',
+ kind: 'auth',
+ message: 'Password reset requested for an address that cannot receive one',
+ event: { k: 'resetUnroutable' },
+ })
+ }
+
+ const body: ResetRequestResponse = { accepted: true }
+ return ok(body, { status: 202 })
+})
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 0000000000..59d808bcac
--- /dev/null
+++ b/apps/editor/app/api/auth/session/route.ts
@@ -0,0 +1,49 @@
+import { handler, ok } from '@panel/lib/api'
+import type { SessionResponse } from '@panel/lib/api-contract'
+import { getSession } from '@panel/lib/auth/session'
+import { getSettings } from '@panel/lib/settings'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/auth/session
+ *
+ * The single source of truth for the idle countdown. The client polls it; the
+ * server owns `expiresInSeconds`, so a tampered clock or a stale tab cannot
+ * stretch a session past settings.session_minutes.
+ *
+ * This read deliberately does NOT slide the idle window. It used to, on the
+ * reasoning that a poll from a visible tab is activity — but a visible tab is
+ * not a person. A console left open on an unattended screen polled itself every
+ * 30 seconds and so could never time out, which is the one thing the idle
+ * timeout exists to prevent on a system whose own sign-in screen says
+ * "authorised personnel only".
+ *
+ * Real work still slides it: every other console request goes through
+ * `requirePermission` → `getSession()`, which touches by default. And the idle
+ * dialog's "Stay signed in" has its own endpoint (`/api/auth/session/touch`).
+ * So the window now follows what the person does, not whether a tab is open.
+ */
+export const GET = handler(async () => {
+ const settings = await getSettings()
+ const session = await getSession({ touch: false })
+
+ if (!session) {
+ const body: SessionResponse = {
+ state: 'anonymous',
+ user: null,
+ expiresInSeconds: 0,
+ sessionMinutes: settings.sessionMinutes,
+ }
+ return ok(body)
+ }
+
+ const body: SessionResponse = {
+ state: session.state,
+ user: session.user,
+ expiresInSeconds: Math.max(0, Math.floor((session.expiresAt.getTime() - Date.now()) / 1000)),
+ sessionMinutes: settings.sessionMinutes,
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/auth/session/touch/route.ts b/apps/editor/app/api/auth/session/touch/route.ts
new file mode 100644
index 0000000000..1eaf002cea
--- /dev/null
+++ b/apps/editor/app/api/auth/session/touch/route.ts
@@ -0,0 +1,23 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { TouchResponse } from '@panel/lib/api-contract'
+import { getSession } from '@panel/lib/auth/session'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/auth/session/touch — the idle dialog's "Stay signed in".
+ *
+ * Deliberately a separate endpoint from the polling GET: mouse movement must not
+ * extend the session while the warning is open (WP4), so only this explicit,
+ * user-initiated call slides the window.
+ */
+export const POST = handler(async () => {
+ const session = await getSession({ touch: true })
+ if (!session) return fail('unauthenticated', 'err.sessionExpired')
+
+ const body: TouchResponse = {
+ expiresInSeconds: Math.max(0, Math.floor((session.expiresAt.getTime() - Date.now()) / 1000)),
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/auth/sessions/[id]/route.ts b/apps/editor/app/api/auth/sessions/[id]/route.ts
new file mode 100644
index 0000000000..fd1778e2c5
--- /dev/null
+++ b/apps/editor/app/api/auth/sessions/[id]/route.ts
@@ -0,0 +1,47 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { clearSessionCookie, getSession, revokeSession } from '@panel/lib/auth/session'
+import { queryOne, type RowDataPacket } from '@panel/lib/db'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * DELETE /api/auth/sessions/:id — revoke one device.
+ *
+ * Ownership is re-checked against the row rather than trusted from the URL, so a
+ * guessed session id from another account is a 404, not a revocation.
+ */
+export const DELETE = handler(
+ async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session || session.mfaPending) return fail('unauthenticated', 'err.sessionExpired')
+
+ const { id } = await ctx.params
+ if (!/^[0-9a-f]{32}$/.test(id)) return fail('not_found', 'err.notFound')
+
+ const target = Buffer.from(id, 'hex')
+ const row = await queryOne(
+ 'SELECT user_id FROM sessions WHERE id = ? AND revoked_at IS NULL',
+ [target],
+ )
+ if (!row || row.user_id !== session.userId) return fail('not_found', 'err.notFound')
+
+ await revokeSession(target)
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'info',
+ kind: 'session',
+ message: 'Session revoked',
+ event: { k: 'sessionRevoked' },
+ meta: { self: target.equals(session.id) },
+ })
+
+ // Revoking your own session should also drop the cookie, otherwise the tab
+ // keeps sending a dead id until the next navigation.
+ if (target.equals(session.id)) await clearSessionCookie()
+
+ return ok({ revoked: 1, self: target.equals(session.id) })
+ },
+)
diff --git a/apps/editor/app/api/auth/sessions/route.ts b/apps/editor/app/api/auth/sessions/route.ts
new file mode 100644
index 0000000000..d91949030b
--- /dev/null
+++ b/apps/editor/app/api/auth/sessions/route.ts
@@ -0,0 +1,15 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { SessionsResponse } from '@panel/lib/api-contract'
+import { getSession, listSessions } from '@panel/lib/auth/session'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** GET /api/auth/sessions — the signed-in user's own live sessions. */
+export const GET = handler(async () => {
+ const session = await getSession()
+ if (!session || session.mfaPending) return fail('unauthenticated', 'err.sessionExpired')
+
+ const body: SessionsResponse = { sessions: await listSessions(session.userId, session.id) }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/auth/signin/route.ts b/apps/editor/app/api/auth/signin/route.ts
new file mode 100644
index 0000000000..7d86bd29ec
--- /dev/null
+++ b/apps/editor/app/api/auth/signin/route.ts
@@ -0,0 +1,141 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type SignInResponse, signInSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { clearFailures, lockStateFrom, registerFailure } from '@panel/lib/auth/lockout'
+import { fakeVerify, verifyPassword } from '@panel/lib/auth/password'
+import { createSession, getSession, hasTrustedDevice } from '@panel/lib/auth/session'
+import { isEnrolled } from '@panel/lib/auth/totp'
+import { findUserByIdentifier, pendingLabel } from '@panel/lib/auth/users'
+import { exec } from '@panel/lib/db'
+import { getSettings, isSsoEnforced } from '@panel/lib/settings'
+import { cookies } from 'next/headers'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/auth/signin
+ *
+ * Outcomes, in the order the state machine reaches them:
+ * mfaRequired — credentials good, OTP step still owed
+ * firstSignIn — credentials good, must_change_password set
+ * signedIn — fully established session
+ *
+ * Every failure answers `invalid_credentials` with the same message regardless of
+ * whether the account exists, and the miss path still pays the argon2 cost so the
+ * response time does not leak existence either.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, signInSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { identifier, password, keepSignedIn } = parsed.data
+ const user = await findUserByIdentifier(identifier)
+
+ if (!user) {
+ await fakeVerify()
+ await audit({
+ actorLabel: identifier.slice(0, 64),
+ level: 'warn',
+ kind: 'auth',
+ message: 'Sign-in failed — unknown identifier',
+ event: { k: 'signInUnknown' },
+ })
+ return fail('invalid_credentials', 'err.credentials')
+ }
+
+ const lock = lockStateFrom(user.failed_attempts, user.locked_until)
+ if (lock.locked) {
+ return fail('account_locked', 'err.locked', { retryAfterSeconds: lock.retryAfterSeconds })
+ }
+
+ if (user.status === 'suspended') {
+ await audit({
+ actorUserId: user.id,
+ actorLabel: user.email,
+ level: 'warn',
+ kind: 'auth',
+ message: 'Sign-in refused — account suspended',
+ event: { k: 'signInSuspended' },
+ })
+ return fail('account_suspended', 'err.suspended')
+ }
+ if (user.status === 'inactive') {
+ return fail('account_inactive', 'err.inactive')
+ }
+
+ // An SSO-enforced domain means the password path is closed for this address —
+ // checked before the hash so a correct password still cannot slip through.
+ if (await isSsoEnforced(user.email)) {
+ return fail('sso_required', 'err.ssoRequired', { domain: user.email.split('@')[1] ?? null })
+ }
+
+ // An invited account has no password yet; it can only arrive through the
+ // invite link, which lands on /welcome and sets one.
+ if (user.status === 'invited' || !user.password_hash) {
+ await fakeVerify()
+ return fail('invalid_credentials', 'err.credentials')
+ }
+
+ if (!(await verifyPassword(user.password_hash, password))) {
+ const next = await registerFailure(user.id)
+ await audit({
+ actorUserId: user.id,
+ actorLabel: user.email,
+ level: 'warn',
+ kind: 'auth',
+ message: `Sign-in failed — wrong password (attempt ${next.failedAttempts})`,
+ event: { k: 'signInWrongPassword', p: { attempt: next.failedAttempts } },
+ })
+ return next.locked
+ ? fail('account_locked', 'err.locked', { retryAfterSeconds: next.retryAfterSeconds })
+ : fail('invalid_credentials', 'err.credentials', { attemptsLeft: next.attemptsLeft })
+ }
+
+ await clearFailures(user.id)
+
+ // Remember which language to write to this person in. Mail is composed with
+ // nobody present to ask, and this is the one moment their own preference is
+ // both known and current.
+ const lang = (await cookies()).get('digitaltwin_lang')?.value === 'tr' ? 'tr' : 'en'
+ await exec('UPDATE users SET locale = ? WHERE id = ?', [lang, user.id]).catch(() => {
+ // A database that predates the column must not fail a sign-in over it.
+ })
+
+ const settings = await getSettings()
+ const enrolled = await isEnrolled(user.id)
+ const trusted = enrolled && (await hasTrustedDevice(user.id))
+ // MFA is owed when the org requires it or the user already enrolled — unless a
+ // live trusted-device grant covers this account.
+ const mfaOwed = (settings.mfaRequired || enrolled) && !trusted
+
+ await createSession({ userId: user.id, keepSignedIn, mfaPending: mfaOwed })
+
+ await audit({
+ actorUserId: user.id,
+ actorLabel: user.email,
+ level: 'info',
+ kind: 'auth',
+ message: mfaOwed ? 'Password accepted — awaiting two-factor' : 'Signed in',
+ event: { k: mfaOwed ? 'signInAwaitingMfa' : 'signedIn' },
+ meta: { keepSignedIn, trustedDevice: trusted },
+ })
+
+ if (mfaOwed) {
+ const body: SignInResponse = {
+ state: 'mfaRequired',
+ pendingLabel: pendingLabel(user),
+ enrolmentRequired: !enrolled,
+ }
+ return ok(body)
+ }
+
+ // Session is live from here, so re-reading it gives the client the same
+ // SessionUser shape GET /api/auth/session returns.
+ const session = await getSession({ touch: false })
+ const body: SignInResponse = {
+ state: user.must_change_password === 1 ? 'firstSignIn' : 'signedIn',
+ user: session?.user,
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/auth/signout/route.ts b/apps/editor/app/api/auth/signout/route.ts
new file mode 100644
index 0000000000..34dd074505
--- /dev/null
+++ b/apps/editor/app/api/auth/signout/route.ts
@@ -0,0 +1,45 @@
+import { handler, ok, parseBody } from '@panel/lib/api'
+import { type SignOutResponse, signOutSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import {
+ clearSessionCookie,
+ getSession,
+ revokeAllSessions,
+ revokeSession,
+} from '@panel/lib/auth/session'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/auth/signout
+ *
+ * Always answers 200, even without a session — signing out of nothing is not an
+ * error, and a 401 here would make the sign-out button look broken after the
+ * idle timeout has already fired.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, signOutSchema)
+ if (!parsed.ok) return parsed.response
+
+ const session = await getSession({ touch: false })
+ await clearSessionCookie()
+
+ if (!session) return ok({ revoked: 0 })
+
+ let revoked = 1
+ await revokeSession(session.id)
+ if (parsed.data.allDevices) revoked += await revokeAllSessions(session.userId, session.id)
+
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'info',
+ kind: 'auth',
+ message: parsed.data.allDevices ? 'Signed out of all devices' : 'Signed out',
+ event: { k: parsed.data.allDevices ? 'signedOutAll' : 'signedOut' },
+ meta: { revoked },
+ })
+
+ return ok({ revoked })
+})
diff --git a/apps/editor/app/api/changelog/route.ts b/apps/editor/app/api/changelog/route.ts
new file mode 100644
index 0000000000..b42f343671
--- /dev/null
+++ b/apps/editor/app/api/changelog/route.ts
@@ -0,0 +1,25 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { ChangelogResponse } from '@panel/lib/api-contract'
+import { requireSession } from '@panel/lib/auth/guard'
+import { changelogPage } from '@panel/lib/changelog'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/changelog?cursor=&limit=20
+ *
+ * Served from the app backend, never from the client. The upstream fetch is
+ * cached for 60 s here so a room full of consoles costs one request a minute
+ * rather than one per viewer.
+ */
+export const GET = handler(async (request: Request) => {
+ const guard = await requireSession()
+ if (!guard.ok) return fail('unauthenticated', 'err.sessionExpired')
+
+ const params = new URL(request.url).searchParams
+ const page = await changelogPage(params.get('cursor'), Number(params.get('limit') ?? 20) || 20)
+
+ const body: ChangelogResponse = page
+ return ok(body)
+})
diff --git a/apps/editor/app/api/guides/route.ts b/apps/editor/app/api/guides/route.ts
new file mode 100644
index 0000000000..e7a637b7b4
--- /dev/null
+++ b/apps/editor/app/api/guides/route.ts
@@ -0,0 +1,17 @@
+import type { Lang } from '@panel/lib/types'
+import type { NextRequest } from 'next/server'
+import { guidesFor } from '@/lib/guides-content'
+import { sceneApiJson } from '@/lib/scene-api-security'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/guides?lang=en|tr — the manual as data, so the console renders the
+ * same pages the public site does instead of keeping a second copy that would
+ * drift. Unauthenticated: the documentation is public either way.
+ */
+export function GET(request: NextRequest) {
+ const requested = new URL(request.url).searchParams.get('lang')
+ const lang: Lang = requested === 'tr' ? 'tr' : 'en'
+ return sceneApiJson(request, { groups: guidesFor(lang).groups })
+}
diff --git a/apps/editor/app/api/health/route.ts b/apps/editor/app/api/health/route.ts
index 5e96f91ac8..d415a5a3d1 100644
--- a/apps/editor/app/api/health/route.ts
+++ b/apps/editor/app/api/health/route.ts
@@ -1,9 +1,48 @@
-export function GET() {
- return Response.json({
- status: 'ok',
- app: 'editor',
+import { authAvailable } from '@/lib/auth/db'
+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.
+ *
+ * `version` and `instanceId` come from upstream and answer a different
+ * question — not "is it healthy" but "WHICH build is this". Without them the
+ * only way to tell whether a deploy actually landed is to hunt for a visible
+ * change in the UI and guess, which is exactly how an afternoon gets spent on
+ * a fix that shipped an hour earlier.
+ */
+export async function GET() {
+ const build = {
version: process.env.PASCAL_RUNTIME_VERSION ?? null,
instanceId: process.env.PASCAL_INSTANCE_ID ?? null,
- timestamp: new Date().toISOString(),
- })
+ }
+
+ try {
+ const store = await getSceneStore()
+ await store.list({ limit: 1 })
+ return Response.json({
+ status: 'ok',
+ app: 'digitaltwin',
+ backend: store.backend,
+ db: 'ok',
+ auth: authAvailable() ? 'ok' : 'disabled',
+ ...build,
+ timestamp: new Date().toISOString(),
+ })
+ } catch (error) {
+ return Response.json(
+ {
+ status: 'error',
+ app: 'digitaltwin',
+ error: error instanceof Error ? error.message : String(error),
+ // Reported on the failure path too: a deploy that cannot reach its
+ // database is exactly when knowing which build is answering matters.
+ ...build,
+ timestamp: new Date().toISOString(),
+ },
+ { status: 503 },
+ )
+ }
}
diff --git a/apps/editor/app/api/invitations/[id]/resend/route.ts b/apps/editor/app/api/invitations/[id]/resend/route.ts
new file mode 100644
index 0000000000..cb36af7fd1
--- /dev/null
+++ b/apps/editor/app/api/invitations/[id]/resend/route.ts
@@ -0,0 +1,57 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { InvitationResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { resendInvitation } from '@panel/lib/auth/invitations'
+import { queryOne, type RowDataPacket } from '@panel/lib/db'
+import { deliverInvite } from '@panel/lib/mail'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/invitations/:id/resend — new token, resent_count + 1, fresh expiry.
+ * The old token stops working the moment this succeeds.
+ */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const issued = await resendInvitation(id)
+ if (!issued) return fail('not_found', 'err.inviteNotResendable')
+
+ const recipient = await queryOne(
+ 'SELECT u.email, u.full_name FROM invitations i JOIN users u ON u.id = i.user_id WHERE i.public_id = ?',
+ [id],
+ )
+ // "Resend" that quietly resends nothing is the least useful button in the
+ // console: it is pressed precisely when the first message did not arrive.
+ let mailDelivered = false
+ if (recipient) {
+ mailDelivered = await deliverInvite({
+ email: recipient.email,
+ fullName: recipient.full_name,
+ token: issued.token,
+ expiresAt: issued.invitation.expiresAt,
+ })
+ }
+ if (!mailDelivered) return fail('server_error', 'err.mailFailed')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'invite',
+ message: `Invitation resent to ${recipient?.email ?? id}`,
+ event: { k: 'inviteResent', p: { email: recipient?.email ?? id } },
+ meta: { invitation: id, resentCount: issued.invitation.resentCount },
+ })
+
+ const body: InvitationResponse = { invitation: issued.invitation }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/invitations/[id]/route.ts b/apps/editor/app/api/invitations/[id]/route.ts
new file mode 100644
index 0000000000..bee6cc88d0
--- /dev/null
+++ b/apps/editor/app/api/invitations/[id]/route.ts
@@ -0,0 +1,42 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { InvitationResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { revokeInvitation } from '@panel/lib/auth/invitations'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * DELETE /api/invitations/:id — revoke a pending invite.
+ *
+ * An already-accepted invite is not revocable: the account exists by then, and
+ * deactivating it is a different action with a different audit meaning.
+ */
+export const DELETE = handler(
+ async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const invitation = await revokeInvitation(id)
+ if (!invitation) return fail('not_found', 'err.inviteNotRevocable')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'invite',
+ message: 'Invitation revoked',
+ event: { k: 'inviteRevoked' },
+ meta: { invitation: id },
+ })
+
+ const body: InvitationResponse = { invitation }
+ return ok(body)
+ },
+)
diff --git a/apps/editor/app/api/jobs/[id]/cancel/route.ts b/apps/editor/app/api/jobs/[id]/cancel/route.ts
new file mode 100644
index 0000000000..a7e9be91e6
--- /dev/null
+++ b/apps/editor/app/api/jobs/[id]/cancel/route.ts
@@ -0,0 +1,32 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { cancelJob } from '@panel/lib/jobs'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** POST /api/jobs/:id/cancel — only a queued or running job can be cancelled. */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const job = await cancelJob(id)
+ if (!job) return fail('conflict', 'err.jobNotCancellable')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'job',
+ message: `Job cancelled: ${id} (${job.kind})`,
+ event: { k: 'jobCancelled', p: { id, kind: job.kind } },
+ })
+
+ return ok({ job })
+})
diff --git a/apps/editor/app/api/jobs/[id]/retry/route.ts b/apps/editor/app/api/jobs/[id]/retry/route.ts
new file mode 100644
index 0000000000..44b843df66
--- /dev/null
+++ b/apps/editor/app/api/jobs/[id]/retry/route.ts
@@ -0,0 +1,32 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { retryJob } from '@panel/lib/jobs'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** POST /api/jobs/:id/retry — re-queues a failed or cancelled job. */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const job = await retryJob(id)
+ if (!job) return fail('conflict', 'err.jobNotRetryable')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'job',
+ message: `Job re-queued: ${id} (${job.kind}), attempt ${job.attempts + 1}`,
+ event: { k: 'jobRequeued', p: { id, kind: job.kind, attempt: job.attempts + 1 } },
+ })
+
+ return ok({ job })
+})
diff --git a/apps/editor/app/api/jobs/route.ts b/apps/editor/app/api/jobs/route.ts
new file mode 100644
index 0000000000..03fa7a53d7
--- /dev/null
+++ b/apps/editor/app/api/jobs/route.ts
@@ -0,0 +1,20 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { listJobs, startJobWorker } from '@panel/lib/jobs'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** GET /api/jobs?status= — the queue, newest first. */
+export const GET = handler(async (request: Request) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ startJobWorker()
+ const status = new URL(request.url).searchParams.get('status') ?? undefined
+ return ok({ jobs: await listJobs(status) })
+})
diff --git a/apps/editor/app/api/jobs/stream/route.ts b/apps/editor/app/api/jobs/stream/route.ts
new file mode 100644
index 0000000000..8425b9988e
--- /dev/null
+++ b/apps/editor/app/api/jobs/stream/route.ts
@@ -0,0 +1,83 @@
+import { getSession } from '@panel/lib/auth/session'
+import { jobsFingerprint, listJobs, startJobWorker } from '@panel/lib/jobs'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const POLL_MS = 1000
+const HEARTBEAT_MS = 15_000
+
+/**
+ * GET /api/jobs/stream — live queue over SSE, with the client falling back to a
+ * 4 s poll if the stream cannot be opened.
+ *
+ * The payload is only pushed when the fingerprint changes, so an idle queue
+ * costs one heartbeat comment every 15 s rather than a list per second.
+ */
+export async function GET(request: Request): Promise {
+ const session = await getSession()
+ if (!session || session.mfaPending) {
+ return new Response('unauthorized', { status: 401 })
+ }
+
+ startJobWorker()
+ const encoder = new TextEncoder()
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ let lastFingerprint = ''
+ let lastBeat = Date.now()
+ let closed = false
+
+ const send = (event: string, data: unknown) => {
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
+ }
+
+ const stop = () => {
+ if (closed) return
+ closed = true
+ clearInterval(timer)
+ try {
+ controller.close()
+ } catch {
+ /* already closed by the client */
+ }
+ }
+
+ // The abort signal is the only reliable close notice — a disconnected
+ // client does not error the enqueue until much later.
+ request.signal.addEventListener('abort', stop)
+
+ const timer = setInterval(() => {
+ if (closed) return
+ void (async () => {
+ try {
+ const fingerprint = await jobsFingerprint()
+ if (fingerprint !== lastFingerprint) {
+ lastFingerprint = fingerprint
+ send('jobs', { jobs: await listJobs() })
+ lastBeat = Date.now()
+ return
+ }
+ if (Date.now() - lastBeat >= HEARTBEAT_MS) {
+ controller.enqueue(encoder.encode(': keep-alive\n\n'))
+ lastBeat = Date.now()
+ }
+ } catch {
+ stop()
+ }
+ })()
+ }, POLL_MS)
+ },
+ })
+
+ return new Response(stream, {
+ headers: {
+ 'content-type': 'text/event-stream; charset=utf-8',
+ 'cache-control': 'no-cache, no-transform',
+ connection: 'keep-alive',
+ // Proxies that buffer will otherwise hold the whole stream back.
+ 'x-accel-buffering': 'no',
+ },
+ })
+}
diff --git a/apps/editor/app/api/keys/[id]/route.ts b/apps/editor/app/api/keys/[id]/route.ts
new file mode 100644
index 0000000000..7c7e297338
--- /dev/null
+++ b/apps/editor/app/api/keys/[id]/route.ts
@@ -0,0 +1,34 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { revokeKey } from '@panel/lib/integrations'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** DELETE /api/keys/:id — revokes in place; the row stays for the audit story. */
+export const DELETE = handler(
+ async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const key = await revokeKey(id)
+ if (!key) return fail('conflict', 'err.keyNotRevocable')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'api_key',
+ message: `API key revoked: ${key.name} (${key.prefix}…)`,
+ event: { k: 'apiKeyRevoked', p: { name: key.name, prefix: key.prefix } },
+ })
+
+ return ok({ key })
+ },
+)
diff --git a/apps/editor/app/api/keys/route.ts b/apps/editor/app/api/keys/route.ts
new file mode 100644
index 0000000000..04d8ce1ce9
--- /dev/null
+++ b/apps/editor/app/api/keys/route.ts
@@ -0,0 +1,59 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type CreateKeyResponse, createKeySchema, type KeysResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { createKey, listKeys } from '@panel/lib/integrations'
+import { siteNames } from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** GET /api/keys — prefixes only; the raw key exists nowhere on this path. */
+export const GET = handler(async () => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const body: KeysResponse = { keys: await listKeys(), sites: await siteNames(), canEdit: true }
+ return ok(body)
+})
+
+/** POST /api/keys — the ONLY response that ever carries the raw key. */
+export const POST = handler(async (request: Request) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, createKeySchema)
+ if (!parsed.ok) return parsed.response
+
+ const key = await createKey({
+ name: parsed.data.name,
+ scope: parsed.data.scope,
+ siteName: parsed.data.siteName ?? null,
+ createdBy: guard.session.userId,
+ })
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'api_key',
+ message: `API key created: ${key.name} (${key.scope}) · ${key.siteId ?? 'all sites'}`,
+ event: {
+ k: 'apiKeyCreated',
+ p: { name: key.name, scope: key.scope, site: key.siteId ?? 'all sites' },
+ },
+ // The prefix is safe to record; the secret is not, and never appears here.
+ meta: { key: key.id, prefix: key.prefix },
+ })
+
+ const body: CreateKeyResponse = { key }
+ return ok(body, { status: 201 })
+})
diff --git a/apps/editor/app/api/last-activity/route.ts b/apps/editor/app/api/last-activity/route.ts
new file mode 100644
index 0000000000..7c65266e4f
--- /dev/null
+++ b/apps/editor/app/api/last-activity/route.ts
@@ -0,0 +1,33 @@
+import { queryOne, type RowDataPacket } from '@panel/lib/db'
+import type { NextRequest } from 'next/server'
+import { sceneApiJson } from '@/lib/scene-api-security'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/last-activity — when the system was last signed in to, and from
+ * what kind of device.
+ *
+ * Deliberately about the system, never about a person: no name, no email, no
+ * address, and no way to ask about a particular account. That last part is
+ * what keeps the sign-in screen from becoming an oracle for "does this
+ * address have an account here" — the answer is the same whoever asks.
+ */
+export async function GET(request: NextRequest) {
+ let last: { at: string; device: string | null } | null = null
+ try {
+ const row = await queryOne(
+ `SELECT created_at, device
+ FROM sessions
+ WHERE mfa_pending = 0
+ ORDER BY created_at DESC
+ LIMIT 1`,
+ )
+ if (row) last = { at: row.created_at.toISOString(), device: row.device }
+ } catch {
+ // Before the console schema exists there is nothing to report, which is a
+ // quiet absence rather than an error on the sign-in screen.
+ }
+
+ return sceneApiJson(request, { last })
+}
diff --git a/apps/editor/app/api/logs/route.ts b/apps/editor/app/api/logs/route.ts
new file mode 100644
index 0000000000..f0e2001293
--- /dev/null
+++ b/apps/editor/app/api/logs/route.ts
@@ -0,0 +1,64 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { clearDiagnostics, type LogLevel, type LogRange, listLogs } from '@panel/lib/logs'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const RANGES: LogRange[] = ['hour', 'today', 'week', 'all']
+const LEVELS = ['info', 'warn', 'error']
+
+/** GET /api/logs — runtime diagnostics, cursor-paginated. */
+export const GET = handler(async (request: Request) => {
+ const guard = await requirePermission('view_logs')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.logsRestricted')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const params = new URL(request.url).searchParams
+ const level = params.get('level')
+ const range = params.get('range')
+
+ const page = await listLogs({
+ view: 'diagnostics',
+ search: params.get('search') ?? undefined,
+ level: level && LEVELS.includes(level) ? (level as LogLevel) : 'All',
+ actor: params.get('actor') ?? undefined,
+ range: range && RANGES.includes(range as LogRange) ? (range as LogRange) : 'all',
+ cursor: params.get('cursor') ?? undefined,
+ limit: Number(params.get('limit') ?? 50) || 50,
+ })
+
+ return ok({ ...page, canClear: guard.session.user.permissions.includes('edit_users') })
+})
+
+/**
+ * DELETE /api/logs — clears info-level diagnostics.
+ *
+ * Requires both view_logs and edit_users, as the old panel did. The clear is
+ * itself recorded, with the row count, so the gap in the log has an explanation
+ * sitting next to it.
+ */
+export const DELETE = handler(async () => {
+ const guard = await requirePermission('view_logs', 'edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const removed = await clearDiagnostics()
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'settings',
+ message: `Diagnostics cleared — ${removed} info-level entries removed (warnings, errors and change records kept)`,
+ event: { k: 'diagnosticsCleared', p: { removed } },
+ })
+
+ return ok({ removed })
+})
diff --git a/apps/editor/app/api/mfa/recovery/route.ts b/apps/editor/app/api/mfa/recovery/route.ts
new file mode 100644
index 0000000000..82a9ec8e6b
--- /dev/null
+++ b/apps/editor/app/api/mfa/recovery/route.ts
@@ -0,0 +1,73 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type MfaRecoveryResponse, mfaRecoverySchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { clearFailures, readLockState, registerFailure } from '@panel/lib/auth/lockout'
+import { clearMfaPending, getSession } from '@panel/lib/auth/session'
+import { consumeRecoveryCode } from '@panel/lib/auth/totp'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/mfa/recovery — the way in when the authenticator is gone.
+ *
+ * A code is spent whether or not it was the last one: they are single-use by
+ * definition, and the count that comes back is what lets the screen say how
+ * many are left before somebody is locked out for good.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, mfaRecoverySchema)
+ if (!parsed.ok) return parsed.response
+
+ const session = await getSession({ touch: false })
+ if (!session) return fail('unauthenticated', 'err.sessionExpired')
+
+ // The lock is consulted BEFORE the code is spent. Checking it only on the
+ // failure path — which is what this route used to do — counts misses and
+ // reports "locked" while still admitting whoever eventually guesses right,
+ // so the lock reported a state it did not enforce. Recovery codes are the
+ // one credential that survives losing the authenticator, so an unbounded
+ // guessing budget here is the weakest point in the second factor.
+ const gate = await readLockState(session.userId)
+ if (gate.locked) {
+ return fail('account_locked', 'err.locked', { retryAfterSeconds: gate.retryAfterSeconds })
+ }
+
+ const result = await consumeRecoveryCode(session.userId, parsed.data.code)
+
+ if (!result.ok) {
+ const lock = await registerFailure(session.userId)
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'warn',
+ kind: 'auth',
+ message: 'Recovery code rejected',
+ event: { k: 'recoveryRejected' },
+ })
+ return lock.locked
+ ? fail('account_locked', 'err.locked', { retryAfterSeconds: lock.retryAfterSeconds })
+ : fail('recovery_invalid', 'err.recoveryInvalid', { attemptsLeft: lock.attemptsLeft })
+ }
+
+ await clearFailures(session.userId)
+ await clearMfaPending(session.id)
+
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'warn',
+ kind: 'auth',
+ message: 'Signed in with a recovery code',
+ event: { k: 'recoveryUsed' },
+ meta: { remaining: result.remaining },
+ })
+
+ const fresh = await getSession({ touch: false })
+ const body: MfaRecoveryResponse = {
+ state: fresh?.state === 'firstSignIn' ? 'firstSignIn' : 'signedIn',
+ user: fresh?.user,
+ codesRemaining: result.remaining,
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/mfa/setup/route.ts b/apps/editor/app/api/mfa/setup/route.ts
new file mode 100644
index 0000000000..217b851645
--- /dev/null
+++ b/apps/editor/app/api/mfa/setup/route.ts
@@ -0,0 +1,40 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { MfaSetupResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { getSession } from '@panel/lib/auth/session'
+import { isEnrolled, startEnrolment } from '@panel/lib/auth/totp'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/mfa/setup — mints the secret the enrolment screen renders.
+ *
+ * Reachable with a half-open session on purpose: a person whose organisation
+ * requires two-factor arrives here from sign-in with `mfaPending` still set,
+ * and demanding a complete session to finish becoming complete is a deadlock.
+ * Nothing here grants access — the secret is unconfirmed until /verify.
+ *
+ * Refuses when already enrolled, so a live second factor can never be replaced
+ * by anyone holding only the first one.
+ */
+export const POST = handler(async () => {
+ const session = await getSession({ touch: false })
+ if (!session) return fail('unauthenticated', 'err.sessionExpired')
+
+ if (await isEnrolled(session.userId)) return fail('conflict', 'err.mfaAlreadyEnrolled')
+
+ const { qrDataUrl, manualKey } = await startEnrolment(session.userId, session.user.email)
+
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'info',
+ kind: 'auth',
+ message: 'Two-factor enrolment started',
+ event: { k: 'mfaEnrolStarted' },
+ })
+
+ const body: MfaSetupResponse = { qrDataUrl, manualKey }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/mfa/verify/route.ts b/apps/editor/app/api/mfa/verify/route.ts
new file mode 100644
index 0000000000..9ee92afa4d
--- /dev/null
+++ b/apps/editor/app/api/mfa/verify/route.ts
@@ -0,0 +1,109 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type MfaVerifyResponse, mfaVerifySchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { clearFailures, readLockState, registerFailure } from '@panel/lib/auth/lockout'
+import { clearMfaPending, getSession } from '@panel/lib/auth/session'
+import { confirmEnrolment, isEnrolled, verifyTotp } from '@panel/lib/auth/totp'
+import { exec } from '@panel/lib/db'
+import { deliverTwoFactorChanged } from '@panel/lib/mail'
+import { getSettings } from '@panel/lib/settings'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/mfa/verify — one endpoint, two moments.
+ *
+ * Enrolment: the secret exists but is unconfirmed, so a correct code confirms
+ * it and returns the recovery set. That set is shown once and never again,
+ * which is why it is returned here rather than fetchable later.
+ *
+ * Sign-in: the secret is already confirmed, so a correct code simply clears
+ * `mfa_pending` on the session that is already open.
+ *
+ * A wrong code counts against the same lockout counter as a wrong password —
+ * an attacker holding the password must not get unlimited guesses at the
+ * second factor.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, mfaVerifySchema)
+ if (!parsed.ok) return parsed.response
+
+ const session = await getSession({ touch: false })
+ if (!session) return fail('unauthenticated', 'err.sessionExpired')
+
+ // The lock is consulted BEFORE the code is checked. Consulting it only on the
+ // failure path — which is what this route used to do — means a locked account
+ // still has its code verified, and a correct guess clears the failures and
+ // signs in: the lock counted misses and announced itself without ever
+ // refusing anyone. The comment above promises the attacker gets no unlimited
+ // guesses at the second factor; this is the line that keeps that promise.
+ const gate = await readLockState(session.userId)
+ if (gate.locked) {
+ return fail('account_locked', 'err.locked', { retryAfterSeconds: gate.retryAfterSeconds })
+ }
+
+ const enrolling = !(await isEnrolled(session.userId))
+
+ const recoveryCodes = enrolling
+ ? await confirmEnrolment(session.userId, session.user.email, parsed.data.code)
+ : null
+ const accepted = enrolling
+ ? recoveryCodes !== null
+ : await verifyTotp(session.userId, session.user.email, parsed.data.code)
+
+ if (!accepted) {
+ const lock = await registerFailure(session.userId)
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'warn',
+ kind: 'auth',
+ message: 'Two-factor code rejected',
+ event: { k: 'mfaCodeRejected' },
+ })
+ return lock.locked
+ ? fail('account_locked', 'err.locked', { retryAfterSeconds: lock.retryAfterSeconds })
+ : fail('mfa_invalid', 'err.mfaInvalid', { attemptsLeft: lock.attemptsLeft })
+ }
+
+ await clearFailures(session.userId)
+ await clearMfaPending(session.id)
+
+ // "Trust this device" is a grant on this session alone; the window comes from
+ // the organisation's settings rather than being hard-coded here.
+ if (parsed.data.trustDevice) {
+ const { trustedDeviceDays } = await getSettings()
+ await exec('UPDATE sessions SET trusted_until = DATE_ADD(NOW(), INTERVAL ? DAY) WHERE id = ?', [
+ trustedDeviceDays,
+ session.id,
+ ])
+ }
+
+ await audit({
+ actorUserId: session.userId,
+ actorLabel: session.user.email,
+ level: 'info',
+ kind: 'auth',
+ message: enrolling ? 'Two-factor enrolled' : 'Signed in — two-factor cleared',
+ event: { k: enrolling ? 'mfaEnrolled' : 'signedInMfaCleared' },
+ meta: { trustDevice: parsed.data.trustDevice },
+ })
+
+ if (enrolling) {
+ await deliverTwoFactorChanged({
+ email: session.user.email,
+ fullName: session.user.name,
+ enabled: true,
+ })
+ }
+
+ // Re-read so the client gets the session in its post-verification shape.
+ const fresh = await getSession({ touch: false })
+ const body: MfaVerifyResponse = {
+ state: fresh?.state === 'firstSignIn' ? 'firstSignIn' : 'signedIn',
+ user: fresh?.user,
+ ...(recoveryCodes ? { recoveryCodes } : {}),
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/overview/route.ts b/apps/editor/app/api/overview/route.ts
new file mode 100644
index 0000000000..c143484b7a
--- /dev/null
+++ b/apps/editor/app/api/overview/route.ts
@@ -0,0 +1,68 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { OverviewResponse } from '@panel/lib/api-contract'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { queryOne, type RowDataPacket } from '@panel/lib/db'
+import { readHealth } from '@panel/lib/health'
+import { listLogs, recentActors } from '@panel/lib/logs'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/overview — one round trip for the whole landing tab.
+ *
+ * Health, counts, connected users and recent incidents in a single response:
+ * four separate polls on a 4 s timer would be four times the wake-ups for a
+ * screen that always shows all four together.
+ */
+export const GET = handler(async () => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const counts = await queryOne<
+ RowDataPacket & {
+ users: number
+ active_users: number
+ without_2fa: number
+ sites: number
+ active_sites: number
+ signed_in: number
+ queued_jobs: number
+ }
+ >(`
+ SELECT
+ (SELECT COUNT(*) FROM users) AS users,
+ (SELECT COUNT(*) FROM users WHERE status = 'active') AS active_users,
+ (SELECT COUNT(*) FROM users u
+ LEFT JOIN two_factor tf ON tf.user_id = u.id
+ WHERE tf.confirmed_at IS NULL) AS without_2fa,
+ (SELECT COUNT(*) FROM sites WHERE status <> 'archived') AS sites,
+ (SELECT COUNT(*) FROM sites WHERE status = 'active') AS active_sites,
+ (SELECT COUNT(DISTINCT user_id) FROM sessions
+ WHERE revoked_at IS NULL AND expires_at > NOW()) AS signed_in,
+ (SELECT COUNT(*) FROM jobs WHERE status IN ('queued','running')) AS queued_jobs
+ `)
+
+ // Incidents are the warn/error tail of diagnostics — the five most recent.
+ const incidents = await listLogs({ view: 'diagnostics', level: 'All', limit: 20 })
+
+ const body: OverviewResponse = {
+ health: readHealth(),
+ counts: {
+ users: Number(counts?.users ?? 0),
+ activeUsers: Number(counts?.active_users ?? 0),
+ sites: Number(counts?.sites ?? 0),
+ activeSites: Number(counts?.active_sites ?? 0),
+ signedIn: Number(counts?.signed_in ?? 0),
+ without2fa: Number(counts?.without_2fa ?? 0),
+ queuedJobs: Number(counts?.queued_jobs ?? 0),
+ },
+ connected: await recentActors(20),
+ incidents: incidents.entries.filter((e) => e.level !== 'info').slice(0, 5),
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/requests/[id]/approve/route.ts b/apps/editor/app/api/requests/[id]/approve/route.ts
new file mode 100644
index 0000000000..27464774e6
--- /dev/null
+++ b/apps/editor/app/api/requests/[id]/approve/route.ts
@@ -0,0 +1,113 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type ApproveRequestResponse, approveRequestSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { issueInvitation } from '@panel/lib/auth/invitations'
+import { exec, queryOne, type RowDataPacket } from '@panel/lib/db'
+import { deliverInvite } from '@panel/lib/mail'
+import { getSettings } from '@panel/lib/settings'
+import { createInvitedUser, getUserDetail } from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/requests/:id/approve — the "Approve & assign" dialog.
+ *
+ * Approval never adds an account silently: it asks for a role and at least one
+ * site, then creates the user as `invited` and emails the link. The schema
+ * enforces the "at least one site" rule so a mis-wired client cannot create an
+ * account with no access at all.
+ */
+export const POST = handler(async (request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, approveRequestSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { id } = await ctx.params
+ const row = await queryOne<
+ RowDataPacket & {
+ id: number
+ full_name: string
+ email: string
+ username: string
+ status: string
+ }
+ >('SELECT id, full_name, email, username, status FROM access_requests WHERE public_id = ?', [id])
+
+ if (!row) return fail('not_found', 'err.notFound')
+ if (row.status !== 'pending') return fail('conflict', 'err.requestDecided')
+
+ const settings = await getSettings()
+ if (parsed.data.org === 'external' && !settings.externalUsersAllowed) {
+ return fail('forbidden', 'err.externalNotAllowed')
+ }
+
+ const clash = await queryOne(
+ 'SELECT id FROM users WHERE email = ? OR username = ? LIMIT 1',
+ [row.email, row.username],
+ )
+ if (clash) return fail('conflict', 'err.userExists')
+
+ const created = await createInvitedUser(
+ {
+ fullName: row.full_name,
+ username: row.username,
+ email: row.email,
+ role: parsed.data.role,
+ org: parsed.data.org,
+ siteNames: parsed.data.siteNames,
+ },
+ guard.session.userId,
+ )
+
+ const issued = await issueInvitation(created.userId, guard.session.userId)
+ // The account is created and the invitation issued either way — those must
+ // not roll back because a mail server is unreachable. But an invitation
+ // nobody receives is an account nobody can activate, so whether it was
+ // delivered travels back to the administrator who pressed Approve.
+ const mailDelivered = await deliverInvite({
+ email: row.email,
+ fullName: row.full_name,
+ token: issued.token,
+ expiresAt: issued.invitation.expiresAt,
+ })
+
+ await exec(
+ "UPDATE access_requests SET status = 'approved', decided_by = ?, decided_at = NOW() WHERE id = ?",
+ [guard.session.userId, row.id],
+ )
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'request',
+ message: `Access request approved: ${row.email} as ${parsed.data.role}`,
+ event: { k: 'requestApproved', p: { email: row.email, role: parsed.data.role } },
+ meta: { sites: parsed.data.siteNames, org: parsed.data.org },
+ })
+
+ const user = await getUserDetail(created.publicId)
+ if (!user) return fail('server_error', 'err.server')
+
+ if (!mailDelivered) {
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'error',
+ kind: 'request',
+ message: `Invitation email to ${row.email} was not delivered; the account exists and the invitation is valid`,
+ event: { k: 'requestApproved' as const, p: { email: row.email, role: parsed.data.role } },
+ })
+ }
+
+ const body: ApproveRequestResponse = { user, invitation: issued.invitation, mailDelivered }
+ return ok(body, { status: 201 })
+})
diff --git a/apps/editor/app/api/requests/[id]/reject/route.ts b/apps/editor/app/api/requests/[id]/reject/route.ts
new file mode 100644
index 0000000000..dfebc8a9bf
--- /dev/null
+++ b/apps/editor/app/api/requests/[id]/reject/route.ts
@@ -0,0 +1,50 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { exec, queryOne, type RowDataPacket } from '@panel/lib/db'
+import { deliverRequestRejected } from '@panel/lib/mail'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/requests/:id/reject
+ *
+ * The row is kept, not deleted: the unique index only constrains *pending*
+ * rows, so a rejected applicant can ask again while the decision stays on record.
+ */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const row = await queryOne<
+ RowDataPacket & { id: number; email: string; full_name: string; status: string }
+ >('SELECT id, email, full_name, status FROM access_requests WHERE public_id = ?', [id])
+ if (!row) return fail('not_found', 'err.notFound')
+ if (row.status !== 'pending') return fail('conflict', 'err.requestDecided')
+
+ await exec(
+ "UPDATE access_requests SET status = 'rejected', decided_by = ?, decided_at = NOW() WHERE id = ?",
+ [guard.session.userId, row.id],
+ )
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'request',
+ message: `Access request rejected: ${row.email}`,
+ event: { k: 'requestRejected', p: { email: row.email } },
+ })
+
+ // The receipt promised an answer either way; leaving somebody waiting for a
+ // message that never comes is worse than the decision itself.
+ await deliverRequestRejected({ email: row.email, fullName: row.full_name })
+
+ return ok({ rejected: true })
+})
diff --git a/apps/editor/app/api/requests/route.ts b/apps/editor/app/api/requests/route.ts
new file mode 100644
index 0000000000..4d12793ebf
--- /dev/null
+++ b/apps/editor/app/api/requests/route.ts
@@ -0,0 +1,121 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import {
+ type AccessRequestResponse,
+ accessRequestSchema,
+ type PendingRequestsResponse,
+} from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requireSession } from '@panel/lib/auth/guard'
+import { WORK_DOMAIN } from '@panel/lib/auth/users'
+import { exec, query, queryOne, type RowDataPacket } from '@panel/lib/db'
+import { deliverRequestReceipt } from '@panel/lib/mail'
+import { getSettings } from '@panel/lib/settings'
+import { ulid } from 'ulid'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** GET /api/requests — the pending strip above the user table. */
+export const GET = handler(async () => {
+ const guard = await requireSession()
+ if (!guard.ok) return fail('unauthenticated', 'err.sessionExpired')
+
+ const rows = await query<
+ RowDataPacket & {
+ public_id: string
+ full_name: string
+ email: string
+ username: string
+ department: string
+ requested_role: string
+ note: string | null
+ created_at: Date
+ }
+ >(
+ `SELECT public_id, full_name, email, username, department, requested_role, note, created_at
+ FROM access_requests
+ WHERE status = 'pending'
+ ORDER BY created_at DESC`,
+ )
+
+ const body: PendingRequestsResponse = {
+ requests: rows.map((r) => ({
+ id: r.public_id,
+ fullName: r.full_name,
+ email: r.email,
+ username: r.username,
+ department: r.department,
+ requestedRole: r.requested_role,
+ note: r.note,
+ status: 'pending' as const,
+ createdAt: r.created_at.toISOString(),
+ })),
+ }
+ return ok(body)
+})
+
+/**
+ * POST /api/requests — the public "Request an account" screen.
+ *
+ * The domain suffix is applied server-side from WORK_DOMAIN, not taken from the
+ * request: the form renders it as a fixed adornment, and a client that posts a
+ * full foreign address must not be able to smuggle one past that.
+ *
+ * Duplicate handling is deliberately quiet. An existing account or a pending
+ * request answers exactly like a fresh submission, because this endpoint is
+ * unauthenticated and a distinguishable response turns it into a directory
+ * oracle for "who works here".
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, accessRequestSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { fullName, username, department, requestedRole, note } = parsed.data
+ const email = `${username}${WORK_DOMAIN}`
+
+ const settings = await getSettings()
+ if (!settings.externalUsersAllowed && !email.endsWith(WORK_DOMAIN)) {
+ return fail('forbidden', 'err.externalNotAllowed')
+ }
+
+ const existingUser = await queryOne(
+ 'SELECT id FROM users WHERE email = ? OR username = ? LIMIT 1',
+ [email, username],
+ )
+ const existingRequest = await queryOne(
+ "SELECT public_id FROM access_requests WHERE email = ? AND status = 'pending' LIMIT 1",
+ [email],
+ )
+
+ const publicId = existingRequest?.public_id ?? ulid()
+
+ if (!existingUser && !existingRequest) {
+ await exec(
+ `INSERT INTO access_requests (public_id, full_name, email, username, department, requested_role, note)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [publicId, fullName, email, username, department, requestedRole, note ?? null],
+ )
+ await deliverRequestReceipt({ email, fullName })
+ await audit({
+ actorLabel: email.slice(0, 64),
+ level: 'info',
+ kind: 'request',
+ message: `Account requested — ${department} / ${requestedRole}`,
+ event: { k: 'accountRequested', p: { department, role: requestedRole } },
+ meta: { request: publicId },
+ })
+ } else {
+ await audit({
+ actorLabel: email.slice(0, 64),
+ level: 'info',
+ kind: 'request',
+ message: existingUser
+ ? 'Account request ignored — an account already exists'
+ : 'Account request ignored — a request is already pending',
+ event: { k: existingUser ? 'requestIgnoredExists' : 'requestIgnoredPending' },
+ })
+ }
+
+ const body: AccessRequestResponse = { request: { id: publicId, email, status: 'pending' } }
+ return ok(body, { status: 202 })
+})
diff --git a/apps/editor/app/api/roles/[name]/route.ts b/apps/editor/app/api/roles/[name]/route.ts
new file mode 100644
index 0000000000..583d407fa2
--- /dev/null
+++ b/apps/editor/app/api/roles/[name]/route.ts
@@ -0,0 +1,106 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type DeleteRoleResponse, updateRoleSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { allRoles, invalidateRolesCache } from '@panel/lib/auth/roles'
+import { exec, transaction } from '@panel/lib/db'
+import { PERMISSIONS, type Permission } from '@panel/lib/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+function isPermission(value: string): value is Permission {
+ return (PERMISSIONS as readonly string[]).includes(value)
+}
+
+/**
+ * PUT /api/roles/:name — toggles in the permission matrix write straight here.
+ *
+ * System roles (Admin, Supervisor, Editor, Viewer) are defined in code and are
+ * not editable: letting someone strip `admin_access` off Admin is a one-click
+ * way to lock the whole tenant out.
+ */
+export const PUT = handler(async (request: Request, ctx: { params: Promise<{ name: string }> }) => {
+ const guard = await requirePermission('edit_roles')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, updateRoleSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { name } = await ctx.params
+ const role = (await allRoles()).find((r) => r.name === decodeURIComponent(name))
+ if (!role) return fail('not_found', 'err.notFound')
+ if (role.isSystem) return fail('forbidden', 'err.systemRoleLocked')
+
+ const permissions = parsed.data.permissions.filter(isPermission)
+ await exec('UPDATE roles SET permissions = CAST(? AS JSON) WHERE name = ?', [
+ JSON.stringify(permissions),
+ role.name,
+ ])
+ invalidateRolesCache()
+
+ const added = permissions.filter((p) => !role.permissions.includes(p))
+ const removed = role.permissions.filter((p) => !permissions.includes(p))
+ // Built once: the stored sentence and the rendered one must not drift apart.
+ const permissionDelta =
+ (added.length ? ` · +${added.join(', ')}` : '') +
+ (removed.length ? ` · -${removed.join(', ')}` : '')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'role_change',
+ message: `Permissions updated for ${role.name}${permissionDelta}`,
+ event: { k: 'rolePermissions', p: { name: role.name, changes: permissionDelta } },
+ meta: { added, removed },
+ })
+
+ return ok({ name: role.name, permissions })
+})
+
+/** DELETE /api/roles/:name — custom roles only; their users fall back to Viewer. */
+export const DELETE = handler(
+ async (_request: Request, ctx: { params: Promise<{ name: string }> }) => {
+ const guard = await requirePermission('edit_roles')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { name } = await ctx.params
+ const role = (await allRoles()).find((r) => r.name === decodeURIComponent(name))
+ if (!role) return fail('not_found', 'err.notFound')
+ if (role.isSystem) return fail('forbidden', 'err.systemRoleLocked')
+
+ // Reassign inside the transaction so no account is ever left pointing at a
+ // role that no longer exists — an unknown role grants nothing at all.
+ const reassigned = await transaction(async (cx) => {
+ const [res] = await cx.execute(
+ "UPDATE users SET global_role = 'Viewer' WHERE global_role = ?",
+ [role.name],
+ )
+ await cx.execute("UPDATE assignments SET role = 'Viewer' WHERE role = ?", [role.name])
+ await cx.execute('DELETE FROM roles WHERE name = ?', [role.name])
+ return (res as { affectedRows: number }).affectedRows
+ })
+ invalidateRolesCache()
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'role_change',
+ message: `Role deleted: ${role.name} — ${reassigned} account(s) reassigned to Viewer`,
+ event: { k: 'roleDeleted', p: { name: role.name, count: reassigned } },
+ })
+
+ const body: DeleteRoleResponse = { reassigned }
+ return ok(body)
+ },
+)
diff --git a/apps/editor/app/api/roles/route.ts b/apps/editor/app/api/roles/route.ts
new file mode 100644
index 0000000000..053f9aeed3
--- /dev/null
+++ b/apps/editor/app/api/roles/route.ts
@@ -0,0 +1,75 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { createRoleSchema, type RolesFullResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { allRoles, invalidateRolesCache } from '@panel/lib/auth/roles'
+import { exec, query, type RowDataPacket } from '@panel/lib/db'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+async function rolesWithCounts(canEdit: boolean): Promise {
+ const roles = await allRoles()
+ const counts = await query(
+ 'SELECT global_role, COUNT(*) AS n FROM users GROUP BY global_role',
+ )
+ const byName = new Map(counts.map((c) => [c.global_role, c.n]))
+
+ return {
+ roles: roles.map((r) => ({ ...r, userCount: byName.get(r.name) ?? 0 })),
+ canEdit,
+ }
+}
+
+/** GET /api/roles — the permission matrix and the role cards read from here. */
+export const GET = handler(async () => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+ return ok(await rolesWithCounts(guard.session.user.permissions.includes('edit_roles')))
+})
+
+/**
+ * POST /api/roles — adds a custom role.
+ *
+ * Starts with `view_projects` only, matching the old panel: a new role that
+ * arrives with no permissions looks broken, and one that arrives with many is a
+ * privilege accident waiting to happen.
+ */
+export const POST = handler(async (request: Request) => {
+ const guard = await requirePermission('edit_roles')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, createRoleSchema)
+ if (!parsed.ok) return parsed.response
+
+ const name = parsed.data.name
+ const existing = await allRoles()
+ if (existing.some((r) => r.name.toLocaleLowerCase('tr') === name.toLocaleLowerCase('tr'))) {
+ return fail('conflict', 'err.roleExists')
+ }
+
+ await exec('INSERT INTO roles (name, permissions, is_system) VALUES (?, CAST(? AS JSON), 0)', [
+ name,
+ JSON.stringify(['view_projects']),
+ ])
+ invalidateRolesCache()
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'role_change',
+ message: `Role created: ${name}`,
+ event: { k: 'roleCreated', p: { name } },
+ })
+
+ return ok(await rolesWithCounts(true), { status: 201 })
+})
diff --git a/apps/editor/app/api/scenes/[id]/events/route.ts b/apps/editor/app/api/scenes/[id]/events/route.ts
index 2167a12b21..8fd62eb172 100644
--- a/apps/editor/app/api/scenes/[id]/events/route.ts
+++ b/apps/editor/app/api/scenes/[id]/events/route.ts
@@ -1,3 +1,5 @@
+import { authorizeSceneRead } from '@/lib/auth/guard'
+import { publishedSceneIds } from '@/lib/auth/site-scenes'
import {
guardSceneApiRequest,
sceneApiJson,
@@ -35,6 +37,14 @@ export async function GET(request: Request, { params }: RouteParams) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
+ // Same rule as the single-scene read: this stream carries the full graph of
+ // every revision, so an unauthorised subscriber would get the drawing plus
+ // a live feed of the work as it happens.
+ const auth = await authorizeSceneRead(id, scene.ownerId ?? null, {
+ published: (await publishedSceneIds()).has(id),
+ })
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
+
const url = new URL(request.url)
const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10)
const afterFromHeader = Number.parseInt(request.headers.get('Last-Event-ID') ?? '0', 10)
diff --git a/apps/editor/app/api/scenes/[id]/presence/presence-handoff.test.ts b/apps/editor/app/api/scenes/[id]/presence/presence-handoff.test.ts
new file mode 100644
index 0000000000..e6d9a454b5
--- /dev/null
+++ b/apps/editor/app/api/scenes/[id]/presence/presence-handoff.test.ts
@@ -0,0 +1,402 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { WallNode } from '@pascal-app/core/schema'
+import { NextRequest } from 'next/server'
+
+const tempDir = mkdtempSync(join(tmpdir(), 'scenes-presence-handoff-test-'))
+const SCENE_ID = 'presence-handoff-scene'
+const OTHER_SCENE_ID = 'other-scene'
+
+const wallA = WallNode.parse({ start: [0, 0], end: [4, 0] })
+const POPULATED_GRAPH = {
+ nodes: {
+ [wallA.id]: wallA,
+ },
+ rootNodeIds: [wallA.id],
+}
+
+let POST: typeof import('./route')['POST']
+let DELETE: typeof import('./route')['DELETE']
+let restoreEnv: () => void
+
+// Configurable mock session user
+let currentSessionUser: { id: string; email: string; role: 'admin' | 'editor' | 'viewer' } | null = null
+let mockAuthAvailable = false
+
+beforeAll(async () => {
+ const saved = {
+ PASCAL_DB_PATH: process.env.PASCAL_DB_PATH,
+ PASCAL_SCENE_API_TOKEN: process.env.PASCAL_SCENE_API_TOKEN,
+ }
+ restoreEnv = () => {
+ for (const [key, value] of Object.entries(saved)) {
+ if (value === undefined) delete process.env[key]
+ else process.env[key] = value
+ }
+ }
+ process.env.PASCAL_DB_PATH = join(tempDir, 'pascal.db')
+ delete process.env.PASCAL_SCENE_API_TOKEN
+
+ // Mock auth db and session
+ mock.module('@/lib/auth/db', () => ({
+ authAvailable: () => mockAuthAvailable,
+ }))
+
+ mock.module('@/lib/auth/session', () => ({
+ getSessionUser: async () => currentSessionUser,
+ canEdit: (user: { role: string }) => user.role !== 'viewer',
+ }))
+
+ const storeServer = await import('@/lib/scene-store-server')
+ storeServer.__resetSceneStoreForTests()
+
+ const { SqliteSceneStore } = await import(
+ '../../../../../../../packages/mcp/src/storage/sqlite-scene-store'
+ )
+ const { createSceneOperations } = await import(
+ '../../../../../../../packages/mcp/src/operations/scene-operations'
+ )
+ const store = new SqliteSceneStore({ env: process.env })
+ const operations = createSceneOperations({ store })
+ storeServer.__setSceneStoreForTests(store, operations)
+
+ // Unowned scenes allow any authenticated editor to collaborate and edit
+ await store.save({
+ id: SCENE_ID,
+ name: 'Presence test fixture',
+ ownerId: null,
+ projectId: null,
+ graph: POPULATED_GRAPH as never,
+ })
+
+ await store.save({
+ id: OTHER_SCENE_ID,
+ name: 'Other scene',
+ ownerId: null,
+ projectId: null,
+ graph: POPULATED_GRAPH as never,
+ })
+
+ const route = await import('./route')
+ POST = route.POST
+ DELETE = route.DELETE
+})
+
+afterAll(async () => {
+ const storeServer = await import('@/lib/scene-store-server')
+ const store = await storeServer.getSceneStore()
+ ;(store as unknown as { close?: () => void }).close?.()
+ storeServer.__resetSceneStoreForTests()
+ restoreEnv()
+ rmSync(tempDir, { recursive: true, force: true })
+})
+
+function presencePostRequest(sceneId: string, body: unknown): NextRequest {
+ return new NextRequest(`http://127.0.0.1:3000/api/scenes/${sceneId}/presence`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ host: '127.0.0.1:3000',
+ },
+ body: JSON.stringify(body),
+ })
+}
+
+function presenceDeleteRequest(sceneId: string): NextRequest {
+ return new NextRequest(`http://127.0.0.1:3000/api/scenes/${sceneId}/presence`, {
+ method: 'DELETE',
+ headers: {
+ host: '127.0.0.1:3000',
+ },
+ })
+}
+
+function paramsFor(sceneId: string) {
+ return { params: Promise.resolve({ id: sceneId }) }
+}
+
+describe('POST & DELETE /api/scenes/[id]/presence (Presence & Role Handoff)', () => {
+ beforeEach(async () => {
+ mockAuthAvailable = false
+ currentSessionUser = null
+
+ // Reset presence for clean test isolation
+ const storeServer = await import('@/lib/scene-store-server')
+ const operations = await storeServer.getSceneOperations()
+ if (operations.canTrackPresence) {
+ await operations.releaseScenePresence(SCENE_ID, 'user_alice')
+ await operations.releaseScenePresence(SCENE_ID, 'user_bob')
+ await operations.releaseScenePresence(SCENE_ID, 'user_charlie')
+ await operations.releaseScenePresence(SCENE_ID, 'user_dave')
+ }
+ })
+
+ // ── Tier 1: Feature Coverage (R3 API) ──────────────────────────────────────
+ test('returns default active editor when auth is off (SQLite dev mode fallback)', async () => {
+ mockAuthAvailable = false
+ const res = await POST(presencePostRequest(SCENE_ID, { claim: true }), paramsFor(SCENE_ID))
+ expect(res.status).toBe(200)
+ const data = (await res.json()) as { isEditor: boolean; canEdit: boolean; present: unknown[] }
+ expect(data.isEditor).toBe(true)
+ expect(data.canEdit).toBe(true)
+ expect(data.present).toEqual([])
+ })
+
+ test('DELETE returns ok when auth is off', async () => {
+ mockAuthAvailable = false
+ const res = await DELETE(presenceDeleteRequest(SCENE_ID), paramsFor(SCENE_ID))
+ expect(res.status).toBe(200)
+ const data = (await res.json()) as { ok: boolean }
+ expect(data.ok).toBe(true)
+ })
+
+ test('accepts wantsEdit flag as alias for claim in request payload', async () => {
+ mockAuthAvailable = true
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+
+ const res = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ expect(res.status).toBe(200)
+ const data = (await res.json()) as { isEditor: boolean; canEdit: boolean; editor: { userId: string } | null }
+ expect(data.isEditor).toBe(true)
+ expect(data.canEdit).toBe(true)
+ expect(data.editor?.userId).toBe('user_alice')
+ })
+
+ test('active editor transfers role to viewer via transferToUserId', async () => {
+ mockAuthAvailable = true
+
+ // Step 1: Alice claims editor lease
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ // Step 2: Bob joins as viewer
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ // Step 3: Alice sends transferToUserId: 'user_bob'
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ const transferRes = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'user_bob' }),
+ paramsFor(SCENE_ID),
+ )
+ expect(transferRes.status).toBe(200)
+ const transferData = (await transferRes.json()) as {
+ isEditor: boolean
+ editor: { userId: string; email: string | null } | null
+ present: Array<{ userId: string; isEditor: boolean }>
+ }
+
+ // Alice is now a viewer, Bob is the editor
+ expect(transferData.isEditor).toBe(false)
+ expect(transferData.editor?.userId).toBe('user_bob')
+ expect(transferData.editor?.email).toBe('bob@example.com')
+
+ const bobInList = transferData.present.find((p) => p.userId === 'user_bob')
+ const aliceInList = transferData.present.find((p) => p.userId === 'user_alice')
+ expect(bobInList?.isEditor).toBe(true)
+ expect(aliceInList?.isEditor).toBe(false)
+ })
+
+ test('promoted viewer sees isEditor: true on subsequent heartbeat', async () => {
+ mockAuthAvailable = true
+
+ // Setup Alice as editor, Bob as viewer, then handoff
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { transferToUserId: 'user_bob' }), paramsFor(SCENE_ID))
+
+ // Bob heartbeats
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ const bobHeartbeat = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ const bobData = (await bobHeartbeat.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(bobData.isEditor).toBe(true)
+ expect(bobData.editor.userId).toBe('user_bob')
+ })
+
+ // ── Tier 2: Boundary & Error Handling (R3 API) ─────────────────────────────
+ test('returns 401 auth_required when authenticated session is missing', async () => {
+ mockAuthAvailable = true
+ currentSessionUser = null
+
+ const res = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ expect(res.status).toBe(401)
+ const data = (await res.json()) as { error: string }
+ expect(data.error).toBe('auth_required')
+ })
+
+ test('returns 404 not_found when scene does not exist in store', async () => {
+ mockAuthAvailable = true
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+
+ const res = await POST(
+ presencePostRequest('non-existent-scene-999', { wantsEdit: true }),
+ paramsFor('non-existent-scene-999'),
+ )
+ expect(res.status).toBe(404)
+ const data = (await res.json()) as { error: string }
+ expect(data.error).toBe('not_found')
+ })
+
+ test('rejects transfer attempt by a non-editor viewer', async () => {
+ mockAuthAvailable = true
+
+ // Alice is editor
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ // Bob and Charlie are viewers
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ currentSessionUser = { id: 'user_charlie', email: 'charlie@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ // Bob tries to transfer to Charlie
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ const res = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'user_charlie' }),
+ paramsFor(SCENE_ID),
+ )
+ expect(res.status).toBe(200)
+ const data = (await res.json()) as { isEditor: boolean; editor: { userId: string } }
+ // Bob remains non-editor and Alice remains the editor
+ expect(data.isEditor).toBe(false)
+ expect(data.editor.userId).toBe('user_alice')
+ })
+
+ test('transferToUserId with non-existent target leaves current editor in place', async () => {
+ mockAuthAvailable = true
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ const res = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'phantom_user_xyz' }),
+ paramsFor(SCENE_ID),
+ )
+ expect(res.status).toBe(200)
+ const data = (await res.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(data.isEditor).toBe(true)
+ expect(data.editor.userId).toBe('user_alice')
+ })
+
+ test('viewer sending bare or malformed request body remains a viewer', async () => {
+ mockAuthAvailable = true
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ const req = new NextRequest(`http://127.0.0.1:3000/api/scenes/${SCENE_ID}/presence`, {
+ method: 'POST',
+ headers: { host: '127.0.0.1:3000' },
+ body: 'invalid-json{{{',
+ })
+ const res = await POST(req, paramsFor(SCENE_ID))
+ expect(res.status).toBe(200)
+ const data = (await res.json()) as { isEditor: boolean }
+ // Bob didn't claim, so isEditor is false
+ expect(data.isEditor).toBe(false)
+ })
+
+ // ── Tier 3: Cross-Feature Combinations (R3 API) ────────────────────────────
+ test('DELETE removes caller presence and allows other participant to claim lease', async () => {
+ mockAuthAvailable = true
+
+ // Alice claims editor
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ // Bob joins as viewer wanting edit
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ const bob1 = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ expect(((await bob1.json()) as { isEditor: boolean }).isEditor).toBe(false)
+
+ // Alice leaves
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await DELETE(presenceDeleteRequest(SCENE_ID), paramsFor(SCENE_ID))
+
+ // Bob heartbeats claiming editor
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ const bob2 = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ const bob2Data = (await bob2.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(bob2Data.isEditor).toBe(true)
+ expect(bob2Data.editor.userId).toBe('user_bob')
+ })
+
+ test('multiple viewers present during handoff only promotes targeted user', async () => {
+ mockAuthAvailable = true
+
+ // Alice is editor
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ // Bob, Charlie, Dave join
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ currentSessionUser = { id: 'user_charlie', email: 'charlie@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ currentSessionUser = { id: 'user_dave', email: 'dave@example.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ // Alice transfers to Charlie
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ const res = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'user_charlie' }),
+ paramsFor(SCENE_ID),
+ )
+ const data = (await res.json()) as {
+ present: Array<{ userId: string; isEditor: boolean }>
+ }
+
+ expect(data.present.find((p) => p.userId === 'user_charlie')?.isEditor).toBe(true)
+ expect(data.present.find((p) => p.userId === 'user_alice')?.isEditor).toBe(false)
+ expect(data.present.find((p) => p.userId === 'user_bob')?.isEditor).toBe(false)
+ expect(data.present.find((p) => p.userId === 'user_dave')?.isEditor).toBe(false)
+ })
+
+ // ── Tier 4: Scenarios (R3 API) ─────────────────────────────────────────────
+ test('end-to-end multi-turn role handoff and editing takeover sequence', async () => {
+ mockAuthAvailable = true
+
+ // Turn 1: Alice opens scene, auto-claims edit lease
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ const t1 = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ expect(((await t1.json()) as { isEditor: boolean }).isEditor).toBe(true)
+
+ // Turn 2: Bob opens scene, told Alice is editing
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ const t2 = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ const t2Data = (await t2.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(t2Data.isEditor).toBe(false)
+ expect(t2Data.editor.userId).toBe('user_alice')
+
+ // Turn 3: Alice passes control to Bob
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ const t3 = await POST(presencePostRequest(SCENE_ID, { transferToUserId: 'user_bob' }), paramsFor(SCENE_ID))
+ const t3Data = (await t3.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(t3Data.isEditor).toBe(false)
+ expect(t3Data.editor.userId).toBe('user_bob')
+
+ // Turn 4: Bob receives heartbeat as new Editor
+ currentSessionUser = { id: 'user_bob', email: 'bob@example.com', role: 'editor' }
+ const t4 = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ const t4Data = (await t4.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(t4Data.isEditor).toBe(true)
+ expect(t4Data.editor.userId).toBe('user_bob')
+
+ // Turn 5: Alice heartbeats and is now in viewer mode
+ currentSessionUser = { id: 'user_alice', email: 'alice@example.com', role: 'editor' }
+ const t5 = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ const t5Data = (await t5.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(t5Data.isEditor).toBe(false)
+ expect(t5Data.editor.userId).toBe('user_bob')
+ })
+})
diff --git a/apps/editor/app/api/scenes/[id]/presence/role-handoff-stress.test.ts b/apps/editor/app/api/scenes/[id]/presence/role-handoff-stress.test.ts
new file mode 100644
index 0000000000..4b5afffef7
--- /dev/null
+++ b/apps/editor/app/api/scenes/[id]/presence/role-handoff-stress.test.ts
@@ -0,0 +1,283 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { WallNode } from '@pascal-app/core/schema'
+import { NextRequest } from 'next/server'
+
+const tempDir = mkdtempSync(join(tmpdir(), 'api-presence-stress-test-'))
+const SCENE_ID = 'api-stress-handoff-scene'
+
+const wallA = WallNode.parse({ start: [0, 0], end: [4, 0] })
+const POPULATED_GRAPH = {
+ nodes: {
+ [wallA.id]: wallA,
+ },
+ rootNodeIds: [wallA.id],
+}
+
+let POST: typeof import('./route')['POST']
+let DELETE: typeof import('./route')['DELETE']
+let restoreEnv: () => void
+
+let currentSessionUser: { id: string; email: string; role: 'admin' | 'editor' | 'viewer' } | null = null
+let mockAuthAvailable = true
+
+beforeAll(async () => {
+ const saved = {
+ PASCAL_DB_PATH: process.env.PASCAL_DB_PATH,
+ PASCAL_SCENE_API_TOKEN: process.env.PASCAL_SCENE_API_TOKEN,
+ }
+ restoreEnv = () => {
+ for (const [key, value] of Object.entries(saved)) {
+ if (value === undefined) delete process.env[key]
+ else process.env[key] = value
+ }
+ }
+ process.env.PASCAL_DB_PATH = join(tempDir, 'pascal.db')
+ delete process.env.PASCAL_SCENE_API_TOKEN
+
+ mock.module('@/lib/auth/db', () => ({
+ authAvailable: () => mockAuthAvailable,
+ }))
+
+ mock.module('@/lib/auth/session', () => ({
+ getSessionUser: async () => currentSessionUser,
+ canEdit: (user: { role: string }) => user.role !== 'viewer',
+ }))
+
+ const storeServer = await import('@/lib/scene-store-server')
+ storeServer.__resetSceneStoreForTests()
+
+ const { SqliteSceneStore } = await import(
+ '../../../../../../../packages/mcp/src/storage/sqlite-scene-store'
+ )
+ const { createSceneOperations } = await import(
+ '../../../../../../../packages/mcp/src/operations/scene-operations'
+ )
+ const store = new SqliteSceneStore({ env: process.env })
+ const operations = createSceneOperations({ store })
+ storeServer.__setSceneStoreForTests(store, operations)
+
+ await store.save({
+ id: SCENE_ID,
+ name: 'API Stress Scene',
+ ownerId: null,
+ projectId: null,
+ graph: POPULATED_GRAPH as never,
+ })
+
+ const route = await import('./route')
+ POST = route.POST
+ DELETE = route.DELETE
+})
+
+afterAll(async () => {
+ const storeServer = await import('@/lib/scene-store-server')
+ const store = await storeServer.getSceneStore()
+ ;(store as unknown as { close?: () => void }).close?.()
+ storeServer.__resetSceneStoreForTests()
+ restoreEnv()
+ rmSync(tempDir, { recursive: true, force: true })
+})
+
+function presencePostRequest(sceneId: string, body: unknown): NextRequest {
+ return new NextRequest(`http://127.0.0.1:3000/api/scenes/${sceneId}/presence`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ host: '127.0.0.1:3000',
+ },
+ body: JSON.stringify(body),
+ })
+}
+
+function presenceDeleteRequest(sceneId: string): NextRequest {
+ return new NextRequest(`http://127.0.0.1:3000/api/scenes/${sceneId}/presence`, {
+ method: 'DELETE',
+ headers: {
+ host: '127.0.0.1:3000',
+ },
+ })
+}
+
+function paramsFor(sceneId: string) {
+ return { params: Promise.resolve({ id: sceneId }) }
+}
+
+describe('API Route Empirical Stress Testing — Role Handoff (R3 API)', () => {
+ beforeEach(async () => {
+ mockAuthAvailable = true
+ currentSessionUser = null
+
+ const storeServer = await import('@/lib/scene-store-server')
+ const operations = await storeServer.getSceneOperations()
+ if (operations.canTrackPresence) {
+ for (const uid of ['user_a', 'user_b', 'user_c', 'user_d', 'user_e', 'user_f']) {
+ await operations.releaseScenePresence(SCENE_ID, uid)
+ }
+ }
+ })
+
+ // ── 1. Sequential Loop via API ─────────────────────────────────────────────
+ test('stress api 1: sequential handoff loop A -> B -> C -> A across API routes', async () => {
+ const users = [
+ { id: 'user_a', email: 'a@api.com', role: 'editor' as const },
+ { id: 'user_b', email: 'b@api.com', role: 'editor' as const },
+ { id: 'user_c', email: 'c@api.com', role: 'editor' as const },
+ ]
+
+ // Step 1: User A joins and claims editor lease
+ currentSessionUser = users[0]!
+ const r1 = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ expect(((await r1.json()) as { isEditor: boolean }).isEditor).toBe(true)
+
+ // Step 2: Users B and C join as viewers
+ currentSessionUser = users[1]!
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+ currentSessionUser = users[2]!
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ // Perform 10 full loops (30 handoffs)
+ let currentEditorIdx = 0
+ for (let loop = 0; loop < 10; loop++) {
+ for (let step = 0; step < users.length; step++) {
+ const nextEditorIdx = (currentEditorIdx + 1) % users.length
+ const fromUser = users[currentEditorIdx]!
+ const toUser = users[nextEditorIdx]!
+
+ // Handoff call
+ currentSessionUser = fromUser
+ const transferRes = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: toUser.id }),
+ paramsFor(SCENE_ID),
+ )
+ expect(transferRes.status).toBe(200)
+ const transferData = (await transferRes.json()) as {
+ isEditor: boolean
+ editor: { userId: string }
+ present: Array<{ userId: string; isEditor: boolean }>
+ }
+
+ expect(transferData.isEditor).toBe(false)
+ expect(transferData.editor.userId).toBe(toUser.id)
+
+ // Verify presence list consistency
+ const editorsInList = transferData.present.filter((p) => p.isEditor)
+ expect(editorsInList).toHaveLength(1)
+ expect(editorsInList[0]!.userId).toBe(toUser.id)
+
+ // Target user heartbeats and confirms isEditor = true
+ currentSessionUser = toUser
+ const hbRes = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ const hbData = (await hbRes.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(hbData.isEditor).toBe(true)
+ expect(hbData.editor.userId).toBe(toUser.id)
+
+ currentEditorIdx = nextEditorIdx
+ }
+ }
+ })
+
+ // ── 2. Unauthorized Transfers via API ──────────────────────────────────────
+ test('stress api 2: unauthorized viewers and read-only users cannot transfer role', async () => {
+ // User A is editor
+ currentSessionUser = { id: 'user_a', email: 'a@api.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ // User B is viewer with role 'editor'
+ currentSessionUser = { id: 'user_b', email: 'b@api.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ // User C is viewer with role 'viewer' (read-only)
+ currentSessionUser = { id: 'user_c', email: 'c@api.com', role: 'viewer' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ // User B tries to transfer to C
+ currentSessionUser = { id: 'user_b', email: 'b@api.com', role: 'editor' }
+ const resB = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'user_c' }),
+ paramsFor(SCENE_ID),
+ )
+ const dataB = (await resB.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(dataB.isEditor).toBe(false)
+ expect(dataB.editor.userId).toBe('user_a')
+
+ // User C (read-only) tries to transfer to B
+ currentSessionUser = { id: 'user_c', email: 'c@api.com', role: 'viewer' }
+ const resC = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'user_b' }),
+ paramsFor(SCENE_ID),
+ )
+ const dataC = (await resC.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(dataC.isEditor).toBe(false)
+ expect(dataC.editor.userId).toBe('user_a')
+ })
+
+ // ── 3. Invalid Target Handling via API ─────────────────────────────────────
+ test('stress api 3: invalid transfer targets retain current editor status', async () => {
+ currentSessionUser = { id: 'user_a', email: 'a@api.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ const badTargets = ['phantom-user-404', 'invalid#user!id', 'user_not_here']
+ for (const target of badTargets) {
+ const res = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: target }),
+ paramsFor(SCENE_ID),
+ )
+ expect(res.status).toBe(200)
+ const data = (await res.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(data.isEditor).toBe(true)
+ expect(data.editor.userId).toBe('user_a')
+ }
+ })
+
+ // ── 4. Self-Transfer via API ───────────────────────────────────────────────
+ test('stress api 4: self-transfer behaves as a valid no-op retention', async () => {
+ currentSessionUser = { id: 'user_a', email: 'a@api.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ const res = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'user_a' }),
+ paramsFor(SCENE_ID),
+ )
+ const data = (await res.json()) as { isEditor: boolean; editor: { userId: string } }
+ expect(data.isEditor).toBe(true)
+ expect(data.editor.userId).toBe('user_a')
+ })
+
+ // ── 6. Disconnection via DELETE route ──────────────────────────────────────
+ test('stress api 6: role handoff followed by DELETE route and subsequent takeover', async () => {
+ // User A claims editor
+ currentSessionUser = { id: 'user_a', email: 'a@api.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+
+ // User B joins as viewer
+ currentSessionUser = { id: 'user_b', email: 'b@api.com', role: 'editor' }
+ await POST(presencePostRequest(SCENE_ID, { wantsEdit: false }), paramsFor(SCENE_ID))
+
+ // User A transfers to User B
+ currentSessionUser = { id: 'user_a', email: 'a@api.com', role: 'editor' }
+ const transferRes = await POST(
+ presencePostRequest(SCENE_ID, { transferToUserId: 'user_b' }),
+ paramsFor(SCENE_ID),
+ )
+ expect(((await transferRes.json()) as { isEditor: boolean }).isEditor).toBe(false)
+
+ // User A leaves via DELETE
+ const delRes = await DELETE(presenceDeleteRequest(SCENE_ID), paramsFor(SCENE_ID))
+ expect(delRes.status).toBe(200)
+
+ // User B heartbeats and confirms editor
+ currentSessionUser = { id: 'user_b', email: 'b@api.com', role: 'editor' }
+ const bHb = await POST(presencePostRequest(SCENE_ID, { wantsEdit: true }), paramsFor(SCENE_ID))
+ const bData = (await bHb.json()) as {
+ isEditor: boolean
+ editor: { userId: string }
+ present: Array<{ userId: string }>
+ }
+ expect(bData.isEditor).toBe(true)
+ expect(bData.editor.userId).toBe('user_b')
+ expect(bData.present.find((p) => p.userId === 'user_a')).toBeUndefined()
+ })
+})
diff --git a/apps/editor/app/api/scenes/[id]/presence/route.ts b/apps/editor/app/api/scenes/[id]/presence/route.ts
new file mode 100644
index 0000000000..257d5461e3
--- /dev/null
+++ b/apps/editor/app/api/scenes/[id]/presence/route.ts
@@ -0,0 +1,115 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { authAvailable } from '@/lib/auth/db'
+import { authorizeSceneMutation } from '@/lib/auth/guard'
+import { getSessionUser } from '@/lib/auth/session'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+type RouteParams = { params: Promise<{ id: string }> }
+
+const postSchema = z.object({
+ // The client wants to hold the edit lease. Only honoured if the user also
+ // has edit permission on the scene (owner / editor-share / admin-editor).
+ claim: z.boolean().optional(),
+ wantsEdit: z.boolean().optional(),
+ transferToUserId: z.string().min(1).optional(),
+})
+
+/**
+ * POST /api/scenes/[id]/presence — heartbeat + single-active-editor lease.
+ *
+ * The model: whoever opens an editable scene first holds the edit lease and
+ * edits; anyone who opens it afterward while the first is still present is a
+ * live viewer until the lease frees. Permission gates eligibility
+ * (`authorizeSceneMutation`); the lease decides who among the eligible edits
+ * right now. With auth off (SQLite dev) there is no identity, so the caller is
+ * simply reported as the editor and nothing is tracked.
+ */
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const guard = guardSceneApiRequest(request)
+ if (guard) return guard
+
+ const { id } = await params
+
+ if (!authAvailable()) {
+ return sceneApiJson(request, { isEditor: true, canEdit: true, editor: null, present: [] })
+ }
+
+ const user = await getSessionUser()
+ if (!user) return sceneApiJson(request, { error: 'auth_required' }, { status: 401 })
+
+ const operations = await getSceneOperations()
+ if (!operations.canTrackPresence) {
+ // Presence unsupported by this store — degrade to "you may edit if your
+ // role allows", so the editor still works, just without the lease.
+ const auth = await authorizeSceneMutation(id, null)
+ return sceneApiJson(request, {
+ isEditor: auth.ok,
+ canEdit: auth.ok,
+ editor: null,
+ present: [],
+ })
+ }
+
+ const scene = await operations.loadStoredScene(id)
+ if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+
+ let body: unknown = {}
+ try {
+ body = await request.json()
+ } catch {
+ // An empty/na body is fine — a bare heartbeat with no claim.
+ }
+ const parsed = postSchema.safeParse(body)
+ const wantsClaim = parsed.success
+ ? parsed.data.claim === true || parsed.data.wantsEdit === true
+ : false
+ const transferToUserId = parsed.success ? parsed.data.transferToUserId : undefined
+
+ // Edit permission for THIS scene (owner / editor-share / admin+editor-role).
+ const canEdit = (await authorizeSceneMutation(id, scene.ownerId ?? null)).ok
+
+ let claim: { isEditor: boolean; editorUserId: string | null; editorEmail: string | null }
+ if (transferToUserId) {
+ if (canEdit && typeof operations.transferPresenceEditor === 'function') {
+ claim = await operations.transferPresenceEditor(id, user.id, transferToUserId)
+ } else {
+ claim = await operations.touchScenePresence(id, user.id, user.email ?? null, {
+ claimEditor: false,
+ })
+ }
+ } else {
+ claim = await operations.touchScenePresence(id, user.id, user.email ?? null, {
+ claimEditor: wantsClaim && canEdit,
+ })
+ }
+ const present = await operations.listScenePresence(id)
+
+ return sceneApiJson(request, {
+ isEditor: claim.isEditor,
+ canEdit,
+ editor: claim.editorUserId ? { userId: claim.editorUserId, email: claim.editorEmail } : null,
+ present,
+ })
+}
+
+/** DELETE — release the caller's presence on leave (best-effort). */
+export async function DELETE(request: NextRequest, { params }: RouteParams) {
+ const guard = guardSceneApiRequest(request)
+ if (guard) return guard
+
+ const { id } = await params
+ if (!authAvailable()) return sceneApiJson(request, { ok: true })
+
+ const user = await getSessionUser()
+ if (!user) return sceneApiJson(request, { ok: true })
+
+ const operations = await getSceneOperations()
+ if (operations.canTrackPresence) {
+ await operations.releaseScenePresence(id, user.id)
+ }
+ return sceneApiJson(request, { ok: true })
+}
diff --git a/apps/editor/app/api/scenes/[id]/revisions/restore/route.ts b/apps/editor/app/api/scenes/[id]/revisions/restore/route.ts
new file mode 100644
index 0000000000..e192a8e8f8
--- /dev/null
+++ b/apps/editor/app/api/scenes/[id]/revisions/restore/route.ts
@@ -0,0 +1,68 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { authorizeSceneMutation } from '@/lib/auth/guard'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+type RouteParams = { params: Promise<{ id: string }> }
+
+const schema = z.object({ version: z.number().int().positive() })
+
+/**
+ * POST /api/scenes/[id]/revisions/restore — bring a retained version back.
+ *
+ * Restore is non-destructive: the chosen version's graph is saved as a NEW
+ * head, so the state you restored from is itself retained as a backup and
+ * nothing is overwritten in place. Requires write access (owner, an `editor`
+ * share, or an admin). `expectedVersion` guards against restoring on top of a
+ * change that landed while the backups dialog was open.
+ */
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const guard = guardSceneApiRequest(request)
+ if (guard) return guard
+
+ const { id } = await params
+ const operations = await getSceneOperations()
+ if (!operations.canReadSceneRevisions) {
+ return sceneApiJson(request, { error: 'revisions_unavailable' }, { status: 501 })
+ }
+ const scene = await operations.loadStoredScene(id)
+ if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+
+ const auth = await authorizeSceneMutation(id, scene.ownerId ?? null)
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
+
+ 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 graph = await operations.loadSceneRevision(id, parsed.data.version)
+ if (!graph) return sceneApiJson(request, { error: 'revision_not_found' }, { status: 404 })
+
+ try {
+ const meta = await operations.saveScene({
+ id,
+ name: scene.name,
+ projectId: scene.projectId,
+ ownerId: scene.ownerId,
+ graph,
+ thumbnailUrl: scene.thumbnailUrl,
+ saveMode: 'checkpoint',
+ publish: scene.published !== false,
+ expectedVersion: scene.version,
+ })
+ return sceneApiJson(request, { ok: true, version: meta.version })
+ } catch (error) {
+ if ((error as { code?: string })?.code === 'version_conflict') {
+ return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 })
+ }
+ throw error
+ }
+}
diff --git a/apps/editor/app/api/scenes/[id]/revisions/route.ts b/apps/editor/app/api/scenes/[id]/revisions/route.ts
new file mode 100644
index 0000000000..70bc639f33
--- /dev/null
+++ b/apps/editor/app/api/scenes/[id]/revisions/route.ts
@@ -0,0 +1,40 @@
+import type { NextRequest } from 'next/server'
+import { authorizeSceneRead } from '@/lib/auth/guard'
+import { publishedSceneIds } from '@/lib/auth/site-scenes'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+type RouteParams = { params: Promise<{ id: string }> }
+
+/**
+ * GET /api/scenes/[id]/revisions — the scene's retained past versions (the
+ * "backups" list). Same read rule as the scene itself: owner, an account it's
+ * shared with, an admin, or anyone if it's published.
+ */
+export async function GET(request: NextRequest, { params }: RouteParams) {
+ const guard = guardSceneApiRequest(request)
+ if (guard) return guard
+
+ const { id } = await params
+ const operations = await getSceneOperations()
+ if (!operations.canReadSceneRevisions) {
+ return sceneApiJson(request, { error: 'revisions_unavailable' }, { status: 501 })
+ }
+ const scene = await operations.loadStoredScene(id)
+ if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+
+ const auth = await authorizeSceneRead(id, scene.ownerId ?? null, {
+ published: (await publishedSceneIds()).has(id),
+ })
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
+
+ const revisions = await operations.listSceneRevisions(id)
+ // The scene's current head is the live version, not a backup — the list is
+ // only the older ones a restore could return to.
+ return sceneApiJson(request, {
+ current: scene.version,
+ revisions: revisions.filter((r) => r.version !== scene.version),
+ })
+}
diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts
index 86423c725a..fe32562525 100644
--- a/apps/editor/app/api/scenes/[id]/route.ts
+++ b/apps/editor/app/api/scenes/[id]/route.ts
@@ -1,5 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
+import { authorizeSceneMutation, authorizeSceneRead } from '@/lib/auth/guard'
+import { publishedSceneIds } from '@/lib/auth/site-scenes'
import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard'
import { apiGraphSchema } from '@/lib/graph-schema'
import {
@@ -48,6 +50,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
if (!scene) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
+ // The origin guard above proves where the request came from, not who sent
+ // it. Without this a scene id was enough to read the drawing.
+ const auth = await authorizeSceneRead(id, scene.ownerId ?? null, {
+ published: (await publishedSceneIds()).has(id),
+ })
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
return sceneApiJson(request, scene, {
headers: { ETag: `"${scene.version}"` },
})
@@ -91,6 +99,20 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
if (!existing) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
+ const auth = await authorizeSceneMutation(id, existing.ownerId)
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
+
+ // Single-active-editor lease: if another account currently holds the live
+ // edit lease on this scene, refuse this save so two editors can't clobber
+ // each other. A free lease (no fresh holder) is allowed. The live editor
+ // UI keeps a non-holder in preview, so this is a server-side safety net.
+ if (auth.user && operations.canTrackPresence) {
+ const editor = (await operations.listScenePresence(id)).find((p) => p.isEditor)
+ if (editor && editor.userId !== auth.user.id) {
+ return sceneApiJson(request, { error: 'scene_locked_by_editor' }, { status: 423 })
+ }
+ }
+
if (
!parsed.data.force &&
isEmptyGraphOverwrite(countGraphNodes(parsed.data.graph), existing.nodeCount)
@@ -133,6 +155,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(id, 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 })
@@ -174,6 +202,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(id, 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/[id]/shares/route.ts b/apps/editor/app/api/scenes/[id]/shares/route.ts
new file mode 100644
index 0000000000..b9de03da9e
--- /dev/null
+++ b/apps/editor/app/api/scenes/[id]/shares/route.ts
@@ -0,0 +1,116 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { listUsers, ownerEmails } from '@/lib/auth/admin'
+import { authAvailable } from '@/lib/auth/db'
+import { getSessionUser } from '@/lib/auth/session'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+type RouteParams = { params: Promise<{ id: string }> }
+
+const putSchema = z.object({
+ shares: z
+ .array(
+ z.object({
+ userId: z.string().min(1).max(64),
+ role: z.enum(['viewer', 'editor']),
+ }),
+ )
+ .max(200),
+})
+
+type OwnerOrAdmin =
+ | { ok: true; userId: string | null }
+ | { ok: false; status: 401 | 403; error: string }
+
+/**
+ * A scene's sharing is managed by the account that owns it, or by any admin —
+ * the same "admins + project owner" rule the product asks for. With auth off
+ * (SQLite dev) there is no identity, so it stays open for local testing.
+ */
+async function authorizeOwnerOrAdmin(ownerId: string | null): Promise {
+ if (!authAvailable()) return { ok: true, userId: null }
+ const user = await getSessionUser()
+ if (!user) return { ok: false, status: 401, error: 'auth_required' }
+ if (user.role === 'admin' || (ownerId && ownerId === user.id)) {
+ return { ok: true, userId: user.id }
+ }
+ return { ok: false, status: 403, error: 'forbidden' }
+}
+
+export async function GET(request: NextRequest, { params }: RouteParams) {
+ const guard = guardSceneApiRequest(request, { skipAuth: true })
+ if (guard) return guard
+
+ const { id } = await params
+ const operations = await getSceneOperations()
+ if (!operations.canShareScenes) {
+ return sceneApiJson(request, { error: 'sharing_unavailable' }, { status: 501 })
+ }
+ const scene = await operations.loadStoredScene(id)
+ if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+
+ const access = await authorizeOwnerOrAdmin(scene.ownerId ?? null)
+ if (!access.ok) return sceneApiJson(request, { error: access.error }, { status: access.status })
+
+ const [shares, users] = await Promise.all([operations.listSceneShares(id), listUsers()])
+ const emails = await ownerEmails(shares.map((s) => s.userId))
+ return sceneApiJson(request, {
+ shares: shares.map((s) => ({
+ userId: s.userId,
+ role: s.role,
+ email: emails.get(s.userId) ?? null,
+ })),
+ // Candidate accounts to share with — the owner is excluded (they already
+ // have full access); everyone else is listed so an admin managing someone
+ // else's scene, or the owner, can pick from the full roster.
+ users: users.filter((u) => u.id !== scene.ownerId).map((u) => ({ id: u.id, email: u.email })),
+ ownerId: scene.ownerId ?? null,
+ })
+}
+
+export async function PUT(request: NextRequest, { params }: RouteParams) {
+ const guard = guardSceneApiRequest(request, { skipAuth: true })
+ if (guard) return guard
+
+ const { id } = await params
+ const operations = await getSceneOperations()
+ if (!operations.canShareScenes) {
+ return sceneApiJson(request, { error: 'sharing_unavailable' }, { status: 501 })
+ }
+ const scene = await operations.loadStoredScene(id)
+ if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+
+ const access = await authorizeOwnerOrAdmin(scene.ownerId ?? null)
+ if (!access.ok) return sceneApiJson(request, { error: access.error }, { status: access.status })
+
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 })
+ }
+ const parsed = putSchema.safeParse(body)
+ if (!parsed.success) {
+ return sceneApiJson(
+ request,
+ { error: 'invalid_request', details: parsed.error.issues },
+ { status: 400 },
+ )
+ }
+
+ // Only real accounts, never the owner (owning already grants full access),
+ // and one entry per user (last role wins) so a duplicated pick can't split.
+ const validIds = new Set((await listUsers()).map((u) => u.id))
+ const byUser = new Map()
+ for (const s of parsed.data.shares) {
+ if (!validIds.has(s.userId) || s.userId === scene.ownerId) continue
+ byUser.set(s.userId, s.role)
+ }
+ const shares = [...byUser].map(([userId, role]) => ({ userId, role }))
+
+ await operations.setSceneShares(id, shares, access.userId)
+ return sceneApiJson(request, { ok: true, shares })
+}
diff --git a/apps/editor/app/api/scenes/[id]/thumbnail/route.ts b/apps/editor/app/api/scenes/[id]/thumbnail/route.ts
new file mode 100644
index 0000000000..03fe01f4c3
--- /dev/null
+++ b/apps/editor/app/api/scenes/[id]/thumbnail/route.ts
@@ -0,0 +1,56 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { authorizeSceneMutation } from '@/lib/auth/guard'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+type RouteParams = { params: Promise<{ id: string }> }
+
+// The thumbnail is stored inline in the scenes row (thumbnail_url, a MySQL
+// TEXT column capped at 65 535 bytes). A base64 data URL inflates the image by
+// ~33%, so the client must downscale to a small JPEG; 60 000 chars leaves head
+// room under the column limit. Larger payloads are a client bug, not user data.
+const MAX_DATA_URL = 60_000
+
+const schema = z.object({
+ dataUrl: z.string().startsWith('data:image/').max(MAX_DATA_URL),
+})
+
+/**
+ * POST /api/scenes/[id]/thumbnail — set the scene's card preview image.
+ *
+ * Called by the editor after a save captures a fresh snapshot. Writing only
+ * the thumbnail column: no version bump, no revision, no live-sync event, so a
+ * preview refresh never looks like an edit to collaborators.
+ */
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const guard = guardSceneApiRequest(request)
+ if (guard) return guard
+
+ const { id } = await params
+ const operations = await getSceneOperations()
+ if (!operations.canUpdateThumbnail) {
+ return sceneApiJson(request, { error: 'thumbnail_unavailable' }, { status: 501 })
+ }
+ const scene = await operations.loadStoredScene(id)
+ if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+
+ const auth = await authorizeSceneMutation(id, scene.ownerId ?? null)
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
+
+ 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 })
+ }
+
+ await operations.updateSceneThumbnail(id, parsed.data.dataUrl)
+ return sceneApiJson(request, { ok: true })
+}
diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts
index 01ffc3ba88..79c2414979 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 { canEdit, 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,20 @@ export async function GET(request: NextRequest) {
)
}
+ // With auth on, a signed-in user sees the scenes they own AND the scenes
+ // shared with them; a signed-out caller sees none. Without auth (SQLite
+ // dev), the list stays unfiltered.
+ let viewerId: string | undefined
+ if (authAvailable()) {
+ const user = await getSessionUser()
+ if (!user) return sceneApiJson(request, { scenes: [] })
+ viewerId = user.id
+ }
+
const operations = await getSceneOperations()
const scenes = await operations.listScenes({
projectId: parsed.data.projectId,
+ viewerId,
limit: parsed.data.limit,
})
return sceneApiJson(request, { scenes })
@@ -52,6 +65,17 @@ export async function POST(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
+ // With auth on, creating a scene requires being signed in with an editing
+ // role, 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 })
+ if (!canEdit(user)) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 })
+ ownerId = user.id
+ }
+
let body: unknown
try {
body = await request.json()
@@ -78,6 +102,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/api/settings/route.ts b/apps/editor/app/api/settings/route.ts
new file mode 100644
index 0000000000..a9fc82a4d3
--- /dev/null
+++ b/apps/editor/app/api/settings/route.ts
@@ -0,0 +1,109 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type SettingsResponse, updateSettingsSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { exec } from '@panel/lib/db'
+import { getSettings, invalidateSettingsCache } from '@panel/lib/settings'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * The single settings row. The console edits it; nothing here enforces it —
+ * session length is applied in the session layer, invite expiry when an invite
+ * is issued and checked, the MFA requirement in the sign-in flow. Section 08 is
+ * explicit that enforcement lives on the server, not in this screen.
+ */
+export const GET = handler(async () => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const body: SettingsResponse = {
+ settings: await getSettings(),
+ canEdit: guard.session.user.permissions.includes('admin_access'),
+ }
+ return ok(body)
+})
+
+const COLUMNS: Record = {
+ sessionMinutes: 'session_minutes',
+ keepSignedInAllowed: 'keep_signed_in_allowed',
+ keepSignedInDays: 'keep_signed_in_days',
+ trustedDeviceDays: 'trusted_device_days',
+ concurrentSessionLimit: 'concurrent_session_limit',
+ mfaRequired: 'mfa_required',
+ externalUsersAllowed: 'external_users_allowed',
+ inviteExpiryDays: 'invite_expiry_days',
+}
+
+export const PUT = handler(async (request: Request) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, updateSettingsSchema)
+ if (!parsed.ok) return parsed.response
+
+ const before = await getSettings()
+ const sets: string[] = []
+ const params: unknown[] = []
+
+ for (const [key, column] of Object.entries(COLUMNS)) {
+ const value = (parsed.data as Record)[key]
+ if (value === undefined) continue
+ sets.push(`${column} = ?`)
+ params.push(typeof value === 'boolean' ? (value ? 1 : 0) : value)
+ }
+
+ if (parsed.data.ssoEnforcedDomains !== undefined) {
+ // Normalised to a leading @ so the sign-in suffix check has one shape to match.
+ const domains = parsed.data.ssoEnforcedDomains.map((d) =>
+ d.startsWith('@') ? d.toLowerCase() : `@${d.toLowerCase()}`,
+ )
+ sets.push('sso_enforced_domains = CAST(? AS JSON)')
+ params.push(JSON.stringify(domains))
+ }
+
+ if (sets.length === 0) return ok({ settings: before, canEdit: true })
+
+ sets.push('updated_by = ?')
+ params.push(guard.session.userId)
+ await exec(`UPDATE settings SET ${sets.join(', ')} WHERE id = 1`, params)
+ invalidateSettingsCache()
+
+ const after = await getSettings()
+ const read = (source: Record, key: string) => JSON.stringify(source[key])
+ const changed = Object.keys(parsed.data)
+ .filter(
+ (key) =>
+ read(before as unknown as Record, key) !==
+ read(after as unknown as Record, key),
+ )
+ .map(
+ (key) =>
+ `${key}: ${read(before as unknown as Record, key)} → ` +
+ `${read(after as unknown as Record, key)}`,
+ )
+
+ if (changed.length > 0) {
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'settings',
+ message: `Settings changed — ${changed.join(', ')}`,
+ event: { k: 'settingsChanged', p: { changes: changed.join(', ') } },
+ meta: parsed.data,
+ })
+ }
+
+ const body: SettingsResponse = { settings: after, canEdit: true }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/settings/test-mail/route.ts b/apps/editor/app/api/settings/test-mail/route.ts
new file mode 100644
index 0000000000..74d5fa6e00
--- /dev/null
+++ b/apps/editor/app/api/settings/test-mail/route.ts
@@ -0,0 +1,69 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { deliverTestMessage } from '@panel/lib/mail'
+import { z } from 'zod'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const schema = z.object({
+ /** Defaults to the administrator's own address — the safe thing to test with. */
+ to: z.string().trim().email().max(320).optional(),
+ lang: z.enum(['en', 'tr']).optional(),
+})
+
+/**
+ * POST /api/settings/test-mail
+ *
+ * Proves delivery end to end without waiting for somebody to forget a
+ * password. Restricted to `admin_access`: an open endpoint that sends mail to
+ * an arbitrary address from the organisation's own domain is a spam relay.
+ */
+export const POST = handler(async (request: Request) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, schema)
+ if (!parsed.ok) return parsed.response
+
+ const to = parsed.data.to ?? guard.session.user.email
+
+ // Unlike every other message, a failure here is the answer, not a nuisance:
+ // this endpoint exists to tell an administrator whether mail actually leaves
+ // the building. Reporting "sent" after a timeout would be worse than useless.
+ try {
+ await deliverTestMessage({
+ email: to,
+ fullName: guard.session.user.name,
+ lang: parsed.data.lang,
+ })
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : String(err)
+ console.error(`[mail] test message to ${to} failed:`, err)
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'error',
+ kind: 'settings',
+ message: `Test message to ${to} failed: ${reason}`,
+ event: { k: 'settingsChanged', p: { changes: `test mail failed → ${to}` } },
+ })
+ return fail('server_error', 'err.mailFailed', { reason })
+ }
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'settings',
+ message: `Test message sent to ${to}`,
+ event: { k: 'settingsChanged', p: { changes: `test mail → ${to}` } },
+ })
+
+ return ok({ sent: true, to })
+})
diff --git a/apps/editor/app/api/showcase/route.ts b/apps/editor/app/api/showcase/route.ts
new file mode 100644
index 0000000000..45aa4beb91
--- /dev/null
+++ b/apps/editor/app/api/showcase/route.ts
@@ -0,0 +1,41 @@
+import { query, type RowDataPacket } from '@panel/lib/db'
+import type { NextRequest } from 'next/server'
+import { sceneApiJson } from '@/lib/scene-api-security'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * GET /api/showcase — the published projects, for the sign-in screen's hero.
+ *
+ * Deliberately unauthenticated and deliberately thin: only the name and a
+ * size figure of projects an administrator has already approved for the
+ * whole organisation. Drafts never appear here, and nothing identifying a
+ * person leaves the building.
+ */
+export async function GET(request: NextRequest) {
+ let sites: { name: string; footprintM2: number | null; nodeCount: number | null }[] = []
+ try {
+ const rows = await query<
+ RowDataPacket & { name: string; footprint_m2: number | null; node_count: number | null }
+ >(
+ `SELECT s.name, s.footprint_m2, sc.node_count
+ FROM sites s
+ LEFT JOIN scenes sc
+ ON CONVERT(sc.id USING utf8mb4) COLLATE utf8mb4_unicode_ci
+ = CONVERT(s.scene_id USING utf8mb4) COLLATE utf8mb4_unicode_ci
+ WHERE s.status = 'active'
+ ORDER BY s.name
+ LIMIT 8`,
+ )
+ sites = rows.map((r) => ({
+ name: r.name,
+ footprintM2: r.footprint_m2,
+ nodeCount: r.node_count,
+ }))
+ } catch {
+ // Before the first migration there is no sites table; an empty hero is
+ // the right answer, not a 500 on the sign-in screen.
+ }
+
+ return sceneApiJson(request, { sites })
+}
diff --git a/apps/editor/app/api/sites/[id]/archive/route.ts b/apps/editor/app/api/sites/[id]/archive/route.ts
new file mode 100644
index 0000000000..94bc363929
--- /dev/null
+++ b/apps/editor/app/api/sites/[id]/archive/route.ts
@@ -0,0 +1,45 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { exec, queryOne, type RowDataPacket } from '@panel/lib/db'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/sites/:id/archive
+ *
+ * Archiving stops access without deleting anything — assignments stay on the
+ * row, so a restore hands everyone their access back instead of requiring the
+ * whole grant list to be rebuilt by hand.
+ */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const row = await queryOne(
+ 'SELECT id, name, status FROM sites WHERE public_id = ?',
+ [id],
+ )
+ if (!row) return fail('not_found', 'err.notFound')
+ if (row.status === 'archived') return fail('conflict', 'err.siteStateUnchanged')
+
+ await exec("UPDATE sites SET status = 'archived' WHERE id = ?", [row.id])
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'site',
+ message: `Site archived: ${row.name}`,
+ event: { k: 'siteArchived', p: { name: row.name } },
+ meta: { site: id, from: row.status, to: 'archived' },
+ })
+
+ return ok({ status: 'archived' })
+})
diff --git a/apps/editor/app/api/sites/[id]/restore/route.ts b/apps/editor/app/api/sites/[id]/restore/route.ts
new file mode 100644
index 0000000000..174dce42ff
--- /dev/null
+++ b/apps/editor/app/api/sites/[id]/restore/route.ts
@@ -0,0 +1,45 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { exec, queryOne, type RowDataPacket } from '@panel/lib/db'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/sites/:id/restore
+ *
+ * Archiving stops access without deleting anything — assignments stay on the
+ * row, so a restore hands everyone their access back instead of requiring the
+ * whole grant list to be rebuilt by hand.
+ */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const row = await queryOne(
+ 'SELECT id, name, status FROM sites WHERE public_id = ?',
+ [id],
+ )
+ if (!row) return fail('not_found', 'err.notFound')
+ if (row.status === 'active') return fail('conflict', 'err.siteStateUnchanged')
+
+ await exec("UPDATE sites SET status = 'active' WHERE id = ?", [row.id])
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'site',
+ message: `Site restored: ${row.name}`,
+ event: { k: 'siteRestored', p: { name: row.name } },
+ meta: { site: id, from: row.status, to: 'active' },
+ })
+
+ return ok({ status: 'active' })
+})
diff --git a/apps/editor/app/api/sites/route.ts b/apps/editor/app/api/sites/route.ts
new file mode 100644
index 0000000000..5fee6a8535
--- /dev/null
+++ b/apps/editor/app/api/sites/route.ts
@@ -0,0 +1,116 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { createSiteSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { exec, query, queryOne, type RowDataPacket } from '@panel/lib/db'
+import { enqueueJob, startJobWorker } from '@panel/lib/jobs'
+import type { Site } from '@panel/lib/types'
+import { ulid } from 'ulid'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** GET /api/sites — every site, archived ones included, newest name order. */
+export const GET = handler(async () => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const rows = await query<
+ RowDataPacket & {
+ public_id: string
+ name: string
+ status: 'active' | 'setup' | 'archived'
+ storage_slots: number | null
+ picking_slots: number | null
+ footprint_m2: number | null
+ created_by_email: string | null
+ created_at: Date
+ user_count: number
+ scene_id: string | null
+ }
+ >(
+ `SELECT s.public_id, s.name, s.status, s.storage_slots, s.picking_slots, s.footprint_m2,
+ u.email AS created_by_email, s.created_at, s.scene_id,
+ (SELECT COUNT(*) FROM assignments a WHERE a.site_id = s.id) AS user_count
+ FROM sites s
+ LEFT JOIN users u ON u.id = s.created_by
+ ORDER BY s.name`,
+ )
+
+ const sites: Site[] = rows.map((r) => ({
+ id: r.public_id,
+ name: r.name,
+ status: r.status,
+ storageSlots: r.storage_slots ?? undefined,
+ pickingSlots: r.picking_slots ?? undefined,
+ footprintM2: r.footprint_m2 ?? undefined,
+ createdBy: r.created_by_email ?? '—',
+ createdAt: r.created_at.toISOString(),
+ userCount: r.user_count,
+ sceneId: r.scene_id,
+ }))
+
+ return ok({ sites, canEdit: guard.session.user.permissions.includes('admin_access') })
+})
+
+/**
+ * POST /api/sites — creates the site in `setup` and queues its provisioning.
+ *
+ * The site is NOT active on return: a provisioning job carries it there, which
+ * is what makes the "Setting up" card state and the job queue two views of one
+ * fact rather than two independent fictions.
+ */
+export const POST = handler(async (request: Request) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, createSiteSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { name, template, footprintM2 } = parsed.data
+
+ const clash = await queryOne(
+ 'SELECT id FROM sites WHERE name = ?',
+ [name],
+ )
+ if (clash) return fail('conflict', 'err.siteExists')
+
+ const publicId = ulid()
+ await exec(
+ `INSERT INTO sites (public_id, name, status, footprint_m2, created_by)
+ VALUES (?, ?, 'setup', ?, ?)`,
+ [publicId, name, footprintM2 ?? null, guard.session.userId],
+ )
+
+ const row = await queryOne(
+ 'SELECT id FROM sites WHERE public_id = ?',
+ [publicId],
+ )
+ const jobId = await enqueueJob({
+ kind: 'site_provision',
+ siteId: row?.id ?? null,
+ payload: { template, footprintM2: footprintM2 ?? null },
+ queuedBy: guard.session.userId,
+ })
+ startJobWorker()
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'site',
+ message: `Site created: ${name} (${template}) — provisioning queued as ${jobId}`,
+ event: { k: 'siteCreated', p: { name, template, jobId } },
+ meta: { site: publicId, job: jobId },
+ })
+
+ return ok({ site: publicId, job: jobId }, { status: 201 })
+})
diff --git a/apps/editor/app/api/telemetry/route.ts b/apps/editor/app/api/telemetry/route.ts
new file mode 100644
index 0000000000..97652b5e48
--- /dev/null
+++ b/apps/editor/app/api/telemetry/route.ts
@@ -0,0 +1,45 @@
+import { handler, ok, parseBody } from '@panel/lib/api'
+import { telemetrySchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { getSession } from '@panel/lib/auth/session'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/telemetry — the browser error sink.
+ *
+ * Recorded with actor_label 'browser', as the contract specifies, which is also
+ * what keeps it out of the "connected users" panel. The client suppresses
+ * repeats for 5 s; this side additionally refuses to trust anything in the
+ * payload beyond its shape — the message is truncated and never interpolated
+ * into anything but the log text.
+ *
+ * Always answers 202, even unauthenticated: an error sink that fails when the
+ * session has expired misses exactly the errors worth having.
+ */
+export const POST = handler(async (request: Request) => {
+ const parsed = await parseBody(request, telemetrySchema)
+ if (!parsed.ok) return ok({ accepted: false }, { status: 202 })
+
+ const session = await getSession({ touch: false })
+ const { message, source, line, column, stack } = parsed.data
+
+ await audit({
+ actorUserId: session?.userId ?? null,
+ actorLabel: 'browser',
+ level: 'error',
+ kind: 'telemetry',
+ message: `Browser error captured: ${message}`.slice(0, 1024),
+ event: { k: 'browserError', p: { message: message.slice(0, 900) } },
+ meta: {
+ source: source?.slice(0, 512) ?? null,
+ line: line ?? null,
+ column: column ?? null,
+ stack: stack?.slice(0, 2000) ?? null,
+ user: session?.user.email ?? null,
+ },
+ })
+
+ return ok({ accepted: true }, { status: 202 })
+})
diff --git a/apps/editor/app/api/users/[id]/assignments/route.ts b/apps/editor/app/api/users/[id]/assignments/route.ts
new file mode 100644
index 0000000000..f12fdbaade
--- /dev/null
+++ b/apps/editor/app/api/users/[id]/assignments/route.ts
@@ -0,0 +1,62 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { assignmentsSchema, type UserDetailResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { allRoles } from '@panel/lib/auth/roles'
+import { findInternalId, getUserDetail, setAssignments, siteNames } from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * PUT /api/users/:id/assignments — the drawer's site-by-site role list.
+ *
+ * Every change lands in the audit trail with its before/after, because "who
+ * gave this account access to Gebze, and when" is the question the trail exists
+ * to answer.
+ */
+export const PUT = handler(async (request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, assignmentsSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { id } = await ctx.params
+ const before = await getUserDetail(id)
+ if (!before) return fail('not_found', 'err.notFound')
+
+ const internalId = await findInternalId(id)
+ if (!internalId) return fail('not_found', 'err.notFound')
+
+ await setAssignments(internalId, before.org, parsed.data.siteRoles, guard.session.userId)
+
+ const after = await getUserDetail(id)
+ const diff = Object.entries(parsed.data.siteRoles)
+ .filter(([site, role]) => (before.siteRoles?.[site] ?? null) !== role)
+ .map(([site, role]) => `${site}: ${before.siteRoles?.[site] ?? 'none'} → ${role ?? 'none'}`)
+
+ if (diff.length > 0) {
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'role_change',
+ message: `Site access changed for ${before.email}: ${diff.join(', ')}`,
+ event: { k: 'siteAccessChanged', p: { email: before.email, changes: diff.join(', ') } },
+ meta: { siteRoles: parsed.data.siteRoles },
+ })
+ }
+
+ const body: UserDetailResponse = {
+ user: after!,
+ sites: await siteNames(),
+ roles: (await allRoles()).map((r) => r.name),
+ canEdit: true,
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/users/[id]/revoke-sessions/route.ts b/apps/editor/app/api/users/[id]/revoke-sessions/route.ts
new file mode 100644
index 0000000000..e95a196cfb
--- /dev/null
+++ b/apps/editor/app/api/users/[id]/revoke-sessions/route.ts
@@ -0,0 +1,42 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { revokeAllSessions } from '@panel/lib/auth/session'
+import { deliverSessionsRevoked } from '@panel/lib/mail'
+import { findInternalId, getUserDetail } from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** POST /api/users/:id/revoke-sessions — the drawer's "sign out all sessions". */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const user = await getUserDetail(id)
+ const internalId = await findInternalId(id)
+ if (!user || !internalId) return fail('not_found', 'err.notFound')
+
+ const revoked = await revokeAllSessions(internalId, null)
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'session',
+ message: `All sessions revoked for ${user.email}`,
+ event: { k: 'allSessionsRevoked', p: { email: user.email } },
+ meta: { revoked },
+ })
+
+ // Being thrown out of every device without explanation reads as a fault.
+ if (revoked > 0) {
+ await deliverSessionsRevoked({ email: user.email, fullName: user.name, byAdmin: true })
+ }
+
+ return ok({ revoked })
+})
diff --git a/apps/editor/app/api/users/[id]/route.ts b/apps/editor/app/api/users/[id]/route.ts
new file mode 100644
index 0000000000..1d97196f24
--- /dev/null
+++ b/apps/editor/app/api/users/[id]/route.ts
@@ -0,0 +1,180 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type UserDetailResponse, updateUserSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { allRoles } from '@panel/lib/auth/roles'
+import { revokeAllSessions } from '@panel/lib/auth/session'
+import { queryOne, type RowDataPacket } from '@panel/lib/db'
+import { deliverAccessChanged } from '@panel/lib/mail'
+import { deleteUser, getUserDetail, siteNames, updateUser } from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** GET /api/users/:id — everything the detail drawer renders. */
+export const GET = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const user = await getUserDetail(id)
+ if (!user) return fail('not_found', 'err.notFound')
+
+ const body: UserDetailResponse = {
+ user,
+ sites: await siteNames(),
+ roles: (await allRoles()).map((r) => r.name),
+ canEdit: guard.session.user.permissions.includes('edit_users'),
+ }
+ return ok(body)
+})
+
+/**
+ * PATCH /api/users/:id — inline edit and the drawer's activate/deactivate.
+ *
+ * The primary admin is protected from deactivation as well as deletion: locking
+ * out the only account that can grant permissions is not a recoverable mistake.
+ */
+export const PATCH = handler(async (request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, updateUserSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { id } = await ctx.params
+ const before = await getUserDetail(id)
+ if (!before) return fail('not_found', 'err.notFound')
+
+ if (
+ before.isPrimaryAdmin &&
+ (parsed.data.status === 'Inactive' || parsed.data.role !== undefined)
+ ) {
+ return fail('forbidden', 'err.primaryAdminProtected')
+ }
+
+ const internal = await queryOne(
+ 'SELECT id FROM users WHERE public_id = ?',
+ [id],
+ )
+ if (!internal) return fail('not_found', 'err.notFound')
+
+ if (parsed.data.email || parsed.data.username) {
+ const clash = await queryOne(
+ 'SELECT id FROM users WHERE (email = ? OR username = ?) AND id <> ? LIMIT 1',
+ [parsed.data.email ?? '', parsed.data.username ?? '', internal.id],
+ )
+ if (clash) return fail('conflict', 'err.userExists')
+ }
+
+ await updateUser(internal.id, parsed.data, before.org)
+
+ // Deactivating an account must also end its live sessions, or the change is
+ // cosmetic until the idle timeout happens to fire.
+ if (parsed.data.status === 'Inactive') await revokeAllSessions(internal.id, null)
+
+ const after = await getUserDetail(id)
+
+ // Old → new for the audit line, read from the stored row on both sides. An
+ // external account has its role clamped to Viewer on write, so diffing against
+ // the request would record a change that never happened.
+ const snapshot = (u: NonNullable): Record => ({
+ fullName: u.name,
+ email: u.email,
+ username: u.username,
+ role: String(u.role),
+ status: u.status,
+ })
+ const previous = snapshot(before)
+ const current = after ? snapshot(after) : previous
+ const changed = Object.keys(parsed.data)
+ .filter((key) => previous[key] !== current[key])
+ .map((key) => `${key}: ${previous[key]} → ${current[key]}`)
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'user',
+ message: `User updated: ${before.email}${changed.length ? ` (${changed.join(', ')})` : ''}`,
+ event: changed.length
+ ? { k: 'userUpdated' as const, p: { email: before.email, changes: changed.join(', ') } }
+ : { k: 'userUpdatedPlain' as const, p: { email: before.email } },
+ meta: parsed.data,
+ })
+
+ // Losing or regaining access is the one change a person notices only as a
+ // sign-in that stops working, so it is announced. Every other edit — a name,
+ // a role — is the administrator's business and stays quiet.
+ if (parsed.data.status !== undefined && parsed.data.status !== before.status) {
+ await deliverAccessChanged({
+ email: before.email,
+ fullName: after?.name ?? before.name,
+ active: parsed.data.status === 'Active',
+ })
+ }
+
+ const body: UserDetailResponse = {
+ user: after!,
+ sites: await siteNames(),
+ roles: (await allRoles()).map((r) => r.name),
+ canEdit: true,
+ }
+ return ok(body)
+})
+
+/** DELETE /api/users/:id — the primary admin is never deletable. */
+export const DELETE = handler(
+ async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const user = await getUserDetail(id)
+ if (!user) return fail('not_found', 'err.notFound')
+ if (user.isPrimaryAdmin) return fail('forbidden', 'err.primaryAdminProtected')
+
+ const internal = await queryOne(
+ 'SELECT id FROM users WHERE public_id = ?',
+ [id],
+ )
+ if (!internal) return fail('not_found', 'err.notFound')
+ if (internal.id === guard.session.userId) return fail('forbidden', 'err.cannotDeleteSelf')
+
+ try {
+ await deleteUser(internal.id)
+ } catch (err) {
+ // MySQL 1451: a RESTRICT foreign key still points at this account.
+ // Migration 006 relaxed every provenance FK to SET NULL, so this only
+ // fires on a database that has not run it — but "something went wrong"
+ // is never an acceptable answer to a refused delete.
+ if ((err as { errno?: number }).errno === 1451) {
+ return fail('conflict', 'err.userReferenced')
+ }
+ throw err
+ }
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'user',
+ message: `User deleted: ${user.email}`,
+ event: { k: 'userDeleted', p: { email: user.email } },
+ meta: { role: user.role, org: user.org },
+ })
+
+ return ok({ deleted: true })
+ },
+)
diff --git a/apps/editor/app/api/users/[id]/temp-password/route.ts b/apps/editor/app/api/users/[id]/temp-password/route.ts
new file mode 100644
index 0000000000..73a8b0f28e
--- /dev/null
+++ b/apps/editor/app/api/users/[id]/temp-password/route.ts
@@ -0,0 +1,68 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { TempPasswordResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { generateTempPassword, hashPassword } from '@panel/lib/auth/password'
+import { revokeAllSessions } from '@panel/lib/auth/session'
+import { exec } from '@panel/lib/db'
+import { deliverTemporaryPassword } from '@panel/lib/mail'
+import { findInternalId, getUserDetail } from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/users/:id/temp-password
+ *
+ * Returns the raw password exactly once, the same way a new API key does. It is
+ * never stored in readable form and never reappears — which is the whole point
+ * of removing the old panel's readable password column.
+ *
+ * must_change_password is set, so the temporary credential can only be used to
+ * reach the set-password screen.
+ */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const user = await getUserDetail(id)
+ const internalId = await findInternalId(id)
+ if (!user || !internalId) return fail('not_found', 'err.notFound')
+
+ const temporaryPassword = generateTempPassword()
+ await exec(
+ `UPDATE users
+ SET password_hash = ?, password_set_at = NOW(), must_change_password = 1,
+ failed_attempts = 0, locked_until = NULL,
+ status = CASE WHEN status = 'invited' THEN 'active' ELSE status END
+ WHERE id = ?`,
+ [await hashPassword(temporaryPassword), internalId],
+ )
+ await revokeAllSessions(internalId, null)
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'user',
+ message: `Temporary password issued for ${user.email}`,
+ event: { k: 'tempPassword', p: { email: user.email } },
+ })
+
+ // Still returned to the administrator once, for the case where mail is down
+ // — but the credential now has a way to reach its owner that is not a phone
+ // call.
+ await deliverTemporaryPassword({
+ email: user.email,
+ fullName: user.name,
+ temporaryPassword,
+ })
+
+ const body: TempPasswordResponse = { temporaryPassword }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/users/bulk/route.ts b/apps/editor/app/api/users/bulk/route.ts
new file mode 100644
index 0000000000..fa92c3cabc
--- /dev/null
+++ b/apps/editor/app/api/users/bulk/route.ts
@@ -0,0 +1,164 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { type BulkUsersResponse, bulkUsersSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { revokeAllSessions } from '@panel/lib/auth/session'
+import { deleteUser, findInternalId, getUserDetail, updateUser } from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/users/bulk — the selection toolbar on the Users tab.
+ *
+ * Three rules make this safe to expose:
+ *
+ * 1. Every guard the single-account endpoints apply is applied again here, per
+ * account. The bulk path is not a shortcut around `edit_users`, the primary
+ * administrator protection, or the you-cannot-delete-yourself rule.
+ * 2. Nothing is silently dropped. An account that is skipped comes back in the
+ * response with a reason, so the toolbar can say "9 changed, 1 skipped
+ * (primary administrator)" instead of quietly doing less than asked.
+ * 3. One audit row per affected account. A single "12 accounts changed" line
+ * would be cheaper and would destroy the per-account history the trail is
+ * for.
+ *
+ * The prototype also offered "Require 2FA". It is not here: enrolment means
+ * possessing an authenticator, and no administrator can do that on someone
+ * else's behalf. The org-wide requirement already exists as a settings toggle,
+ * and `revokeSessions` is the action an administrator actually wants when
+ * tightening a set of accounts — it forces every one of them back through the
+ * sign-in gate, which enforces the policy that is in effect.
+ */
+export const POST = handler(async (request: Request) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, bulkUsersSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { action, ids } = parsed.data
+ const skipped: BulkUsersResponse['skipped'] = []
+ let applied = 0
+
+ // Duplicates in the payload would otherwise be applied — and audited — twice.
+ for (const id of [...new Set(ids)]) {
+ const user = await getUserDetail(id)
+ if (!user) {
+ skipped.push({ id, label: id, reason: 'notFound' })
+ continue
+ }
+
+ const internalId = await findInternalId(id)
+ if (internalId === null) {
+ skipped.push({ id, label: user.email, reason: 'notFound' })
+ continue
+ }
+
+ const isSelf = internalId === guard.session.userId
+
+ // Demoting or disabling the only account that can grant permissions is not
+ // a recoverable mistake; revoking its sessions is merely inconvenient.
+ if (user.isPrimaryAdmin && action !== 'revokeSessions') {
+ skipped.push({ id, label: user.email, reason: 'primaryAdmin' })
+ continue
+ }
+ // Signing yourself out in bulk is a legitimate thing to want; deleting or
+ // deactivating yourself mid-request is not.
+ if (isSelf && (action === 'delete' || action === 'deactivate')) {
+ skipped.push({ id, label: user.email, reason: 'self' })
+ continue
+ }
+
+ switch (action) {
+ case 'roleViewer': {
+ if (user.role === 'Viewer') {
+ skipped.push({ id, label: user.email, reason: 'noop' })
+ continue
+ }
+ await updateUser(internalId, { role: 'Viewer' }, user.org)
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'user',
+ message: `User updated: ${user.email} (role: ${user.role} → Viewer)`,
+ event: {
+ k: 'userUpdated',
+ p: { email: user.email, changes: `role: ${user.role} → Viewer` },
+ },
+ meta: { bulk: action, role: 'Viewer' },
+ })
+ break
+ }
+
+ case 'revokeSessions': {
+ // Signing your own other devices out is legitimate; signing out the
+ // console you are working in halfway through a batch is not. The first
+ // run of this endpoint did exactly that and 401'd its own next request.
+ const ended = await revokeAllSessions(internalId, isSelf ? guard.session.id : null)
+ if (ended === 0) {
+ skipped.push({ id, label: user.email, reason: 'noop' })
+ continue
+ }
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'user',
+ message: `Sessions revoked: ${user.email} (${ended})`,
+ event: { k: 'sessionsRevokedFor', p: { email: user.email, count: ended } },
+ meta: { bulk: action, sessions: ended },
+ })
+ break
+ }
+
+ case 'deactivate': {
+ if (user.status === 'Inactive') {
+ skipped.push({ id, label: user.email, reason: 'noop' })
+ continue
+ }
+ await updateUser(internalId, { status: 'Inactive' }, user.org)
+ // Same rule as the single-account path: a deactivation that leaves live
+ // sessions running is cosmetic until the idle timeout happens to fire.
+ await revokeAllSessions(internalId, null)
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'user',
+ message: `User updated: ${user.email} (status: ${user.status} → Inactive)`,
+ event: {
+ k: 'userUpdated',
+ p: { email: user.email, changes: `status: ${user.status} → Inactive` },
+ },
+ meta: { bulk: action, status: 'Inactive' },
+ })
+ break
+ }
+
+ case 'delete': {
+ await deleteUser(internalId)
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'user',
+ message: `User deleted: ${user.email}`,
+ event: { k: 'userDeleted', p: { email: user.email } },
+ meta: { bulk: action, role: user.role, org: user.org },
+ })
+ break
+ }
+ }
+
+ applied += 1
+ }
+
+ const body: BulkUsersResponse = { applied, skipped }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/users/route.ts b/apps/editor/app/api/users/route.ts
new file mode 100644
index 0000000000..84ca5566ec
--- /dev/null
+++ b/apps/editor/app/api/users/route.ts
@@ -0,0 +1,127 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import {
+ type CreateUserResponse,
+ createUserSchema,
+ type UsersListResponse,
+} from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { issueInvitation } from '@panel/lib/auth/invitations'
+import { allRoles } from '@panel/lib/auth/roles'
+import { WORK_DOMAIN } from '@panel/lib/auth/users'
+import { queryOne, type RowDataPacket } from '@panel/lib/db'
+import { deliverInvite } from '@panel/lib/mail'
+import { getSettings } from '@panel/lib/settings'
+import type { Lang } from '@panel/lib/types'
+import {
+ createInvitedUser,
+ getUserDetail,
+ listUsers,
+ siteNames,
+ type UserSortKey,
+} from '@panel/lib/users'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const SORTS: UserSortKey[] = ['name', 'email', 'username', 'role', 'status']
+
+/**
+ * GET /api/users — search, role filter, sort, page.
+ *
+ * Readable by any signed-in account; `canEdit` in the response is what the
+ * read-only banner keys off. Mutation is a separate gate below.
+ */
+export const GET = handler(async (request: Request) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const url = new URL(request.url)
+ const sortParam = url.searchParams.get('sort')
+ const langParam = url.searchParams.get('lang')
+
+ const result = await listUsers({
+ search: url.searchParams.get('search') ?? undefined,
+ role: url.searchParams.get('role') ?? undefined,
+ sort: SORTS.includes(sortParam as UserSortKey) ? (sortParam as UserSortKey) : 'name',
+ direction: url.searchParams.get('direction') === 'desc' ? 'desc' : 'asc',
+ page: Number(url.searchParams.get('page') ?? 1) || 1,
+ pageSize: Number(url.searchParams.get('pageSize') ?? 10) || 10,
+ lang: (langParam === 'tr' ? 'tr' : 'en') as Lang,
+ })
+
+ const body: UsersListResponse = {
+ ...result,
+ sites: await siteNames(),
+ roles: (await allRoles()).map((r) => r.name),
+ canEdit: guard.session.user.permissions.includes('edit_users'),
+ }
+ return ok(body)
+})
+
+/**
+ * POST /api/users — creates an invited account and issues its invite link.
+ *
+ * The account never gets a password here: it lands in `invited` state with a
+ * one-shot token, and sets its own password through /welcome. That is what keeps
+ * the old panel's "admin types the password into a form" pattern from coming back.
+ */
+export const POST = handler(async (request: Request) => {
+ const guard = await requirePermission('edit_users')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, createUserSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { fullName, username, role, org, siteNames: sites } = parsed.data
+ const settings = await getSettings()
+ if (org === 'external' && !settings.externalUsersAllowed) {
+ return fail('forbidden', 'err.externalNotAllowed')
+ }
+
+ const email = `${username}${WORK_DOMAIN}`
+ const clash = await queryOne(
+ 'SELECT id FROM users WHERE email = ? OR username = ? LIMIT 1',
+ [email, username],
+ )
+ if (clash) return fail('conflict', 'err.userExists')
+
+ const created = await createInvitedUser(
+ { fullName, username, email, role, org, siteNames: sites },
+ guard.session.userId,
+ )
+ const issued = await issueInvitation(created.userId, guard.session.userId)
+ await deliverInvite({
+ email,
+ fullName,
+ token: issued.token,
+ expiresAt: issued.invitation.expiresAt,
+ })
+
+ const detail = await getUserDetail(created.publicId)
+ if (!detail) return fail('server_error', 'err.server')
+
+ // Log the role that was stored, not the one that was asked for: an external
+ // account is clamped to Viewer on write, and an audit line claiming otherwise
+ // is worse than no line at all.
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'user',
+ message: `User invited: ${email} as ${detail.role}`,
+ event: { k: 'userInvited', p: { email, role: String(detail.role) } },
+ meta: { requestedRole: role, storedRole: detail.role, org, sites },
+ })
+
+ const body: CreateUserResponse = { user: detail, invitation: issued.invitation }
+ return ok(body, { status: 201 })
+})
diff --git a/apps/editor/app/api/webhooks/[id]/route.ts b/apps/editor/app/api/webhooks/[id]/route.ts
new file mode 100644
index 0000000000..1f11fd93cf
--- /dev/null
+++ b/apps/editor/app/api/webhooks/[id]/route.ts
@@ -0,0 +1,64 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { patchWebhookSchema } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { deleteWebhook, setWebhookStatus } from '@panel/lib/integrations'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/** PATCH /api/webhooks/:id — pause / resume. */
+export const PATCH = handler(async (request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, patchWebhookSchema)
+ if (!parsed.ok) return parsed.response
+
+ const { id } = await ctx.params
+ const webhook = await setWebhookStatus(id, parsed.data.status)
+ if (!webhook) return fail('not_found', 'err.notFound')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'webhook',
+ message: `Webhook ${parsed.data.status === 'paused' ? 'paused' : 'resumed'}: ${webhook.url}`,
+ event: {
+ k: parsed.data.status === 'paused' ? 'webhookPaused' : 'webhookResumed',
+ p: { url: webhook.url },
+ },
+ })
+
+ return ok({ webhook })
+})
+
+export const DELETE = handler(
+ async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ if (!(await deleteWebhook(id))) return fail('not_found', 'err.notFound')
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'warn',
+ kind: 'webhook',
+ message: `Webhook deleted: ${id}`,
+ event: { k: 'webhookDeleted', p: { id } },
+ })
+
+ return ok({ deleted: true })
+ },
+)
diff --git a/apps/editor/app/api/webhooks/[id]/test/route.ts b/apps/editor/app/api/webhooks/[id]/test/route.ts
new file mode 100644
index 0000000000..14a66bc3f7
--- /dev/null
+++ b/apps/editor/app/api/webhooks/[id]/test/route.ts
@@ -0,0 +1,48 @@
+import { fail, handler, ok } from '@panel/lib/api'
+import type { WebhookTestResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { deliverTest } from '@panel/lib/integrations'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+/**
+ * POST /api/webhooks/:id/test — sends one real ping and reports what came back.
+ *
+ * It is a genuine outbound request, not a simulation: the only useful answer to
+ * "is this endpoint reachable" is one that actually tried.
+ */
+export const POST = handler(async (_request: Request, ctx: { params: Promise<{ id: string }> }) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const { id } = await ctx.params
+ const result = await deliverTest(id)
+ if (!result.hook) return fail('not_found', 'err.notFound')
+
+ const httpSuffix = result.responseStatus ? ` (HTTP ${result.responseStatus})` : ''
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: result.delivered ? 'info' : 'warn',
+ kind: 'webhook',
+ message: `Webhook test ${result.delivered ? 'delivered' : 'failed'}: ${result.hook.url}${httpSuffix}`,
+ event: {
+ k: result.delivered ? 'webhookTestDelivered' : 'webhookTestFailed',
+ p: { url: result.hook.url, status: httpSuffix },
+ },
+ })
+
+ const body: WebhookTestResponse = {
+ delivered: result.delivered,
+ status: result.hook.status,
+ responseStatus: result.responseStatus,
+ }
+ return ok(body)
+})
diff --git a/apps/editor/app/api/webhooks/route.ts b/apps/editor/app/api/webhooks/route.ts
new file mode 100644
index 0000000000..cb12dbad50
--- /dev/null
+++ b/apps/editor/app/api/webhooks/route.ts
@@ -0,0 +1,55 @@
+import { fail, handler, ok, parseBody } from '@panel/lib/api'
+import { createWebhookSchema, type WebhooksResponse } from '@panel/lib/api-contract'
+import { audit } from '@panel/lib/auth/audit'
+import { requirePermission } from '@panel/lib/auth/guard'
+import { createWebhook, HOOK_EVENTS, listWebhooks } from '@panel/lib/integrations'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+export const GET = handler(async () => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const body: WebhooksResponse = {
+ webhooks: await listWebhooks(),
+ events: [...HOOK_EVENTS],
+ canEdit: true,
+ }
+ return ok(body)
+})
+
+/** POST /api/webhooks — https only; the schema refuses plaintext endpoints. */
+export const POST = handler(async (request: Request) => {
+ const guard = await requirePermission('admin_access')
+ if (!guard.ok) {
+ return guard.reason === 'forbidden'
+ ? fail('forbidden', 'err.forbidden')
+ : fail('unauthenticated', 'err.sessionExpired')
+ }
+
+ const parsed = await parseBody(request, createWebhookSchema)
+ if (!parsed.ok) return parsed.response
+
+ const known = new Set(HOOK_EVENTS)
+ const events = parsed.data.events.filter((e) => known.has(e))
+ if (events.length === 0) return fail('validation', 'err.eventRequired', { field: 'events' })
+
+ const webhook = await createWebhook(parsed.data.url, events)
+
+ await audit({
+ actorUserId: guard.session.userId,
+ actorLabel: guard.session.user.email,
+ level: 'info',
+ kind: 'webhook',
+ message: `Webhook added: ${webhook.url} · ${events.join(', ')}`,
+ event: { k: 'webhookAdded', p: { url: webhook.url, events: events.join(', ') } },
+ meta: { webhook: webhook.id },
+ })
+
+ return ok({ webhook }, { status: 201 })
+})
diff --git a/apps/editor/app/apple-icon.png b/apps/editor/app/apple-icon.png
new file mode 100644
index 0000000000..41f2cd8697
Binary files /dev/null and b/apps/editor/app/apple-icon.png differ
diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx
index 821544fec6..98bc66f6e9 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,
@@ -19,8 +20,11 @@ export function ClientBootstrap({
enableDevDiagnostics: boolean
}) {
useEffect(() => {
- if (!enableDevDiagnostics) return
- import('react-scan').then(({ scan }) => scan({ enabled: true }))
+ if (process.env.NODE_ENV !== 'production' && enableDevDiagnostics) {
+ import('react-scan')
+ .then((mod) => (mod as unknown as { scan?: (opts: { enabled: boolean }) => void }).scan?.({ enabled: true }))
+ .catch(() => {})
+ }
}, [enableDevDiagnostics])
- return children
+ return {children}
}
diff --git a/apps/editor/app/editor/page.tsx b/apps/editor/app/editor/page.tsx
new file mode 100644
index 0000000000..796b09fd37
--- /dev/null
+++ b/apps/editor/app/editor/page.tsx
@@ -0,0 +1,8 @@
+import { redirect } from 'next/navigation'
+
+export const dynamic = 'force-dynamic'
+
+/** The editor moved to the root URL; this survives for old links and habit. */
+export default function EditorRedirect() {
+ redirect('/')
+}
diff --git a/apps/editor/app/favicon.ico b/apps/editor/app/favicon.ico
index e9f3672943..24e42fd2c8 100644
Binary files a/apps/editor/app/favicon.ico and b/apps/editor/app/favicon.ico differ
diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css
index 61f2d36f64..8501b31dbb 100644
--- a/apps/editor/app/globals.css
+++ b/apps/editor/app/globals.css
@@ -161,6 +161,42 @@
corner-shape: squircle;
}
+/*
+ * The app's own scrollbar, for panes that should show one.
+ *
+ * `.subtle-scrollbar` was written on three scroll containers but never
+ * defined, so those panes fell back to the browser's default bar — wider than
+ * anything else in the chrome, and painted in the browser's colours rather
+ * than the theme's.
+ *
+ * Kept narrow deliberately: the sidebar's resize handle reaches a few pixels
+ * into the panel, and a fat bar puts its draggable edge under that handle.
+ */
+.subtle-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--sidebar-foreground) 25%, transparent) transparent;
+}
+.subtle-scrollbar::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+.subtle-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+.subtle-scrollbar::-webkit-scrollbar-thumb {
+ background-color: color-mix(in oklab, var(--sidebar-foreground) 22%, transparent);
+ border-radius: 4px;
+ /* Inset so the thumb reads as floating in the gutter rather than filling it. */
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+.subtle-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: color-mix(in oklab, var(--sidebar-foreground) 40%, transparent);
+}
+.subtle-scrollbar::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
.no-scrollbar::-webkit-scrollbar {
display: none;
}
diff --git a/apps/editor/app/icon.png b/apps/editor/app/icon.png
new file mode 100644
index 0000000000..1b54692aeb
Binary files /dev/null and b/apps/editor/app/icon.png differ
diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx
index 01965f4ba3..7dec658293 100644
--- a/apps/editor/app/layout.tsx
+++ b/apps/editor/app/layout.tsx
@@ -1,10 +1,45 @@
+import { readEnv } from '@pascal-app/mcp/env'
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'
+/**
+ * No page in this app may be statically prerendered: the host's CDN caches
+ * static HTML for a year, and every redeploy renames the hashed assets that
+ * HTML points at — so a cached page comes back unstyled after the next
+ * release. Dynamic rendering makes Next send no-cache headers instead. The
+ * hashed /_next/static assets themselves stay long-cached, which is safe.
+ */
+export const dynamic = 'force-dynamic'
+
+/**
+ * "System" is the default and has to hold on the very first request, before any
+ * cookie exists and before React runs. The server renders a guess into every
+ * [data-dt-theme] wrapper; this corrects it in place.
+ */
+const THEME_BOOTSTRAP = `(function(){try{
+var m=document.cookie.match(/(?:^|; )digitaltwin_theme_choice=([^;]*)/);
+var c=m?decodeURIComponent(m[1]):null;
+try{var s=localStorage.getItem('digitaltwin_theme');if(s==='system'||s==='light'||s==='dark')c=s}catch(e){}
+var r=(c==='light'||c==='dark')?c:(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');
+var n=document.querySelectorAll('[data-dt-theme]');
+for(var i=0;i) {
const enableDevDiagnostics =
- process.env.NODE_ENV === 'development' && process.env.PASCAL_DEV_DIAGNOSTICS === '1'
+ process.env.NODE_ENV === 'development' && readEnv(process.env, 'DEV_DIAGNOSTICS') === '1'
return (
{children}
{enableDevDiagnostics && }
+ {/* Runs after the theme wrappers are parsed and before React hydrates,
+ so a first visit with no stored choice paints in the operating
+ system's theme instead of flashing the server's guess. */}
+
)
diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx
index 9361f3696f..ef9663e64d 100644
--- a/apps/editor/app/page.tsx
+++ b/apps/editor/app/page.tsx
@@ -1,116 +1,50 @@
-'use client'
+import { getSession } from '@panel/lib/auth/session'
+import type { SceneGraph } from '@pascal-app/editor'
+import { redirect } from 'next/navigation'
+import { SceneLoader, type SceneMeta } from '@/components/scene-loader'
+import { canEdit, getSessionUser } from '@/lib/auth/session'
+import { getSceneOperations } from '@/lib/scene-store-server'
+import { loadOrCreateWorkspaceScene } from '@/lib/workspace-scene'
-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 { BuildTab } from '@/components/build-tab'
-import {
- CommunityViewerToolbarLeft,
- CommunityViewerToolbarRight,
-} from '@/components/viewer-toolbar'
+export const dynamic = 'force-dynamic'
-// The open-source editor only ships the built-in catalog (no uploaded items),
-// so the Library/Community/Mine source chips and tag filters add nothing —
-// drop them and keep the panel to plain categories.
-function EditorItemsPanel() {
- return
-}
+/**
+ * The front door. A visitor who has not finished signing in is sent to the
+ * console screen that matches their state; a signed-in one gets the editor
+ * rendered right here — no redirect, so the address bar stays on the bare
+ * domain, which is how the operator wants the editor addressed.
+ *
+ * **It renders ``, not a bare ``, and that is the whole
+ * point of this route.** A bare `` has no `onSave`, and the editor's
+ * fallback in that case is `localStorage` — so the front door used to draw
+ * warehouses into one browser profile and never touch the database. Going
+ * through `SceneLoader` puts it on the same path as `/scene/[id]`: `PUT
+ * /api/scenes/[id]`, `If-Match` version checks, conflict handling and the live
+ * event stream, all for free. See `lib/workspace-scene.ts` for why the row is
+ * found by `projectId` rather than by name.
+ */
+export default async function Root() {
+ const session = await getSession({ touch: false })
+
+ if (!session) redirect('/signin')
+ if (session.mfaPending) redirect('/mfa')
+ if (session.user.mustChangePassword) redirect('/welcome')
+
+ // View-only accounts have no business in the editing surface: they land on
+ // their scene list and open scenes in preview.
+ const user = await getSessionUser()
+ if (!user) redirect('/signin')
+ if (!canEdit(user)) redirect('/scenes')
-const SIDEBAR_TABS = [
- {
- id: 'site',
- label: 'Scene',
- component: () => null,
- mobileDefaultSnap: 0.5,
- mobileIcon: ,
- icon: (
-
- ),
- },
- {
- id: 'build',
- label: 'Build',
- component: BuildTab,
- mobileDefaultSnap: 0.5,
- mobileIcon: ,
- icon: (
-
- ),
- },
- {
- id: 'items',
- label: 'Items',
- component: EditorItemsPanel,
- mobileDefaultSnap: 0.5,
- mobileIcon: ,
- icon: (
-
- ),
- },
- {
- id: 'settings',
- label: 'Settings',
- component: () => null,
- mobileDefaultSnap: 0.5,
- mobileIcon: ,
- icon: (
-
- ),
- },
-]
+ const workspace = await loadOrCreateWorkspaceScene(user.id)
-const PROJECT_ID = 'local-editor'
+ // The row above carries metadata, not the graph. Loading it separately keeps
+ // the create path from having to round-trip a graph it just wrote.
+ const operations = await getSceneOperations()
+ const loaded = (await operations.loadStoredScene(workspace.id)) as {
+ graph?: SceneGraph
+ } | null
+ const graph = loaded?.graph ?? ({ nodes: {}, rootNodeIds: [] } as unknown as SceneGraph)
-export default function Home() {
- return (
-
- {PROJECT_ID === 'local-editor' && (
-
-
-
- Blank canvas — saved scenes are under Scenes (not this page).
-
-
- Open saved scenes
-
-
-
- )}
- }
- viewerToolbarRight={}
- />
-
- )
+ return
}
diff --git a/apps/editor/app/privacy/page.tsx b/apps/editor/app/privacy/page.tsx
index 4507578985..1b75548d4c 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,9 +39,9 @@ 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,
- use, and protect your information when you use our services.
+ 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.
{scenes.length === 0
- ? 'No scenes yet. Create one to get started.'
- : `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`}
+ ? editingAllowed
+ ? 'No projects yet. Create one to get started.'
+ : 'No projects have been shared with you yet.'
+ : `${scenes.length} project${scenes.length === 1 ? '' : 's'}.`}
{scenes.length === 0 ? (
-
You haven't saved any scenes yet.
-
-
-
+
+ {editingAllowed
+ ? 'You haven’t saved any projects yet. Start from scratch, or import an IFC model exported from Revit, ArchiCAD or similar.'
+ : 'Ask an administrator to assign a project to your account.'}
+
diff --git a/apps/editor/app/terms/page.tsx b/apps/editor/app/terms/page.tsx
index f8afb3e176..54ebb2bc89 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,9 +39,9 @@ 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,"
- or "our"). By accessing or using our services, you agree to these Terms of
+ 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,
- copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Editor
- software in accordance with the MIT License terms.
+ 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,
- cloud storage, and project hosting, are proprietary services owned and operated by
- Pascal Group Inc. These Terms govern your use of the Platform.
+ The DigitalTwin platform and its associated services, including user accounts, cloud
+ storage, and project hosting, are proprietary services owned and operated by
+ 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/components/account-settings-section.tsx b/apps/editor/components/account-settings-section.tsx
new file mode 100644
index 0000000000..cf87d7ecf7
--- /dev/null
+++ b/apps/editor/components/account-settings-section.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+import { LayoutDashboard, LogOut } from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useCallback, useState } from 'react'
+import { useSession } from '@/components/auth/session-provider'
+
+const ROLE_LABEL: Record<'admin' | 'editor' | 'viewer', string> = {
+ admin: 'Administrator',
+ editor: 'Editor',
+ viewer: 'Viewer (read-only)',
+}
+
+/**
+ * Who is signed in, their access, and a way out — mounted at the top of the
+ * editor's built-in Settings panel via `settingsPanelProps.accountSection`.
+ *
+ * Styled like the rest of the editor's own sidebar tabs (ScenesTab, BuildTab):
+ * muted-background rounded buttons, not the console's bordered-card idiom —
+ * this panel lives inside the 3D editor, not the admin console.
+ */
+export function AccountSettingsSection() {
+ const router = useRouter()
+ const { user, signOut } = useSession()
+ const [signingOut, setSigningOut] = useState(false)
+
+ const handleSignOut = useCallback(async () => {
+ setSigningOut(true)
+ await signOut()
+ router.push('/signin')
+ }, [router, signOut])
+
+ if (!user) return null
+
+ return (
+
+
+
+
+
+
{user.email}
+
{ROLE_LABEL[user.role]}
+
+
+
+
+ {user.role === 'admin' && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/editor/components/auth/session-provider.tsx b/apps/editor/components/auth/session-provider.tsx
new file mode 100644
index 0000000000..5c987c3005
--- /dev/null
+++ b/apps/editor/components/auth/session-provider.tsx
@@ -0,0 +1,105 @@
+'use client'
+
+import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react'
+
+export interface SessionUser {
+ id: string
+ email: string
+ role: 'admin' | 'editor' | 'viewer'
+}
+
+interface SessionValue {
+ user: SessionUser | null
+ loading: boolean
+ refresh: () => Promise
+ signOut: () => Promise
+ /** Sends the visitor to the console's sign-in; used by gated actions on 401. */
+ openAuth: () => void
+}
+
+const SessionContext = createContext(null)
+
+/** The console's /api/auth/session response, reduced to what the editor uses. */
+interface ConsoleSessionResponse {
+ state: 'anonymous' | 'signedIn' | 'mfaRequired' | 'firstSignIn'
+ user: { id: string; email: string; permissions?: string[] } | null
+}
+
+/**
+ * Sign-in itself now lives in the console (/signin): it owns passwords, 2FA
+ * and lockout, so the editor no longer renders its own dialog — gated actions
+ * navigate to the console and come back signed in. Only a fully signed-in
+ * session counts; a half-open one (2FA pending, forced password change) is
+ * treated as signed out.
+ */
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ const refresh = useCallback(async () => {
+ try {
+ const res = await fetch('/api/auth/session', { cache: 'no-store' })
+ const body = (await res.json()) as ConsoleSessionResponse
+ if (body.state === 'signedIn' && body.user) {
+ const permissions = body.user.permissions ?? []
+ setUser({
+ id: body.user.id,
+ email: body.user.email,
+ // Mirrors the server-side fold in lib/auth/session.ts.
+ role: permissions.includes('admin_access')
+ ? 'admin'
+ : permissions.includes('edit_projects') || permissions.includes('create_projects')
+ ? 'editor'
+ : 'viewer',
+ })
+ } else {
+ setUser(null)
+ }
+ } catch {
+ setUser(null)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ const signOut = useCallback(async () => {
+ // A bodyless POST fails JSON parsing server-side before the session is
+ // ever looked up, so the cookie and session row both survive — the UI
+ // would redirect to /signin while the account stayed signed in underneath.
+ await fetch('/api/auth/signout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ allDevices: false }),
+ }).catch(() => {})
+ setUser(null)
+ }, [])
+
+ const openAuth = useCallback(() => {
+ window.location.href = '/signin'
+ }, [])
+
+ useEffect(() => {
+ void refresh()
+ }, [refresh])
+
+ return (
+
+ {children}
+
+ )
+}
+
+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/brand-mark.tsx b/apps/editor/components/brand-mark.tsx
new file mode 100644
index 0000000000..ae532f2ca2
--- /dev/null
+++ b/apps/editor/components/brand-mark.tsx
@@ -0,0 +1,46 @@
+/**
+ * The brand mark, path for path from the corporate asset — the same paths the
+ * console's header uses, so the public pages and the signed-in application
+ * carry one identity rather than a stand-in square.
+ *
+ * Colours are `fill` attributes rather than classes: an SVG `
+
+
+
+