diff --git a/.github/deploy/README.md b/.github/deploy/README.md
new file mode 100644
index 000000000..234643afe
--- /dev/null
+++ b/.github/deploy/README.md
@@ -0,0 +1,119 @@
+# 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. Setting `DIGITALTWIN_ALLOW_SQLITE=1` overrides the
+requirement, writing scenes to a local file the host discards on every
+release — never set it here.
+
+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 000000000..35a87e096
--- /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.2.9",
+ "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 000000000..ec9d7e85b
--- /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 000000000..175d463fb
--- /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 fdacf1586..2574fdc2e 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 }}
@@ -18,6 +18,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
+
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/.github/workflows/deploy-bundle.yml b/.github/workflows/deploy-bundle.yml
new file mode 100644
index 000000000..1b93d4912
--- /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 c11fed1e5..606ff9c73 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.0
+
- 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 000000000..03a1e5537
--- /dev/null
+++ b/.github/workflows/mirror-upstream.yml
@@ -0,0 +1,93 @@
+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
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ mirror:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - 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:
+ GH_TOKEN: ${{ github.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 000000000..b01420bfa
--- /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 0bae53316..0330129f2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -49,6 +49,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 000000000..5f89ced22
--- /dev/null
+++ b/.github/workflows/relock.yml
@@ -0,0 +1,52 @@
+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
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.0
+
+ - 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 000000000..887d25b8f
--- /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 000000000..1873506b9
--- /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/AGENTS.md b/AGENTS.md
index 9ddb52fb3..a2eee95e5 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/OTOMASYON.md b/OTOMASYON.md
new file mode 100644
index 000000000..390ecab96
--- /dev/null
+++ b/OTOMASYON.md
@@ -0,0 +1,156 @@
+# 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. |
+
+`bump-plugin`, `mirror-upstream` ve `upstream-check` **hiçbir anahtar kullanmaz** —
+eklenti deposu herkese açık, Pascal'ın deposu herkese açık.
+
+> 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/UPSTREAM.md b/UPSTREAM.md
new file mode 100644
index 000000000..3d80dc29d
--- /dev/null
+++ b/UPSTREAM.md
@@ -0,0 +1,74 @@
+# 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/lib/bootstrap.ts`, `apps/ifc-converter/next-env.d.ts`, root `package.json`, `packages/mcp/src/storage/sqlite-scene-store.ts`, `packages/viewer/src/components/viewer/index.tsx` | No rule written yet — these conflicted in the beta.4 trial merge. Decide, then record the rule here so the next merge is cheaper. |
+
+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`.
diff --git a/YAYINLAMA.md b/YAYINLAMA.md
new file mode 100644
index 000000000..74e1df7f0
--- /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/app/(panel)/console/[tab]/page.tsx b/apps/editor/app/(panel)/console/[tab]/page.tsx
new file mode 100644
index 000000000..7971434de
--- /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 000000000..6501cad9b
--- /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 000000000..1e4bf26a3
--- /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 000000000..9bb1fd1df
--- /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 000000000..73d26c378
--- /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 000000000..73855fd0a
--- /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 000000000..f3c397341
--- /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 000000000..4f9892a1a
--- /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 000000000..05469b6fc
--- /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 000000000..b094fda66
--- /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 000000000..f4d4418fe
--- /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 000000000..4a6eb80f8
--- /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 000000000..7356212e2
--- /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 000000000..264201954
--- /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 000000000..8ba3f8334
--- /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 000000000..df6b8dd82
--- /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 000000000..28f43289a
--- /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' ? 'Sahnelerim' : 'My scenes' }
+ : { 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 000000000..077e92660
--- /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 000000000..9b7c82b4f
--- /dev/null
+++ b/apps/editor/app/api/admin/scenes/[id]/owner/route.ts
@@ -0,0 +1,39 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { reassignScene, requireAdmin, userExists } from '@/lib/auth/admin'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+import { getSceneOperations } from '@/lib/scene-store-server'
+
+export const dynamic = 'force-dynamic'
+
+const schema = z.object({ ownerId: z.string().min(1).max(64).nullable() })
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const guard = guardSceneApiRequest(request, { skipAuth: true })
+ if (guard) return guard
+ const admin = await requireAdmin()
+ if (!admin) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 })
+
+ const { id } = await params
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 })
+ }
+ const parsed = schema.safeParse(body)
+ if (!parsed.success) {
+ return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 })
+ }
+
+ const operations = await getSceneOperations()
+ const scene = await operations.loadStoredScene(id)
+ if (!scene) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+
+ if (parsed.data.ownerId && !(await userExists(parsed.data.ownerId))) {
+ return sceneApiJson(request, { error: 'owner_not_found' }, { status: 400 })
+ }
+
+ await reassignScene(id, parsed.data.ownerId)
+ return sceneApiJson(request, { ok: true })
+}
diff --git a/apps/editor/app/api/admin/scenes/adopt-unowned/route.ts b/apps/editor/app/api/admin/scenes/adopt-unowned/route.ts
new file mode 100644
index 000000000..21866b332
--- /dev/null
+++ b/apps/editor/app/api/admin/scenes/adopt-unowned/route.ts
@@ -0,0 +1,33 @@
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { adoptUnownedScenes, requireAdmin, userExists } from '@/lib/auth/admin'
+import { guardSceneApiRequest, sceneApiJson } from '@/lib/scene-api-security'
+
+export const dynamic = 'force-dynamic'
+
+const schema = z.object({ ownerId: z.string().min(1).max(64) })
+
+/** Adopts every legacy null-owner scene to one user. */
+export async function POST(request: NextRequest) {
+ const guard = guardSceneApiRequest(request, { skipAuth: true })
+ if (guard) return guard
+ const admin = await requireAdmin()
+ if (!admin) return sceneApiJson(request, { error: 'forbidden' }, { status: 403 })
+
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 })
+ }
+ const parsed = schema.safeParse(body)
+ if (!parsed.success) {
+ return sceneApiJson(request, { error: 'invalid_request' }, { status: 400 })
+ }
+ if (!(await userExists(parsed.data.ownerId))) {
+ return sceneApiJson(request, { error: 'owner_not_found' }, { status: 400 })
+ }
+
+ const adopted = await adoptUnownedScenes(parsed.data.ownerId)
+ return sceneApiJson(request, { ok: true, adopted })
+}
diff --git a/apps/editor/app/api/admin/scenes/publish/route.ts b/apps/editor/app/api/admin/scenes/publish/route.ts
new file mode 100644
index 000000000..b80eb681b
--- /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 000000000..8c1f918e7
--- /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 000000000..9c8923922
--- /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 000000000..b79f5c727
--- /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 000000000..96976de29
--- /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 000000000..fcfd3d3ec
--- /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 000000000..157f48ac7
--- /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 000000000..59d808bca
--- /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 000000000..1eaf002ce
--- /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 000000000..fd1778e2c
--- /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 000000000..d91949030
--- /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 000000000..7d86bd29e
--- /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 000000000..34dd07450
--- /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 000000000..b42f34367
--- /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 000000000..e7a637b7b
--- /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 300230ba3..55a4086ef 100644
--- a/apps/editor/app/api/health/route.ts
+++ b/apps/editor/app/api/health/route.ts
@@ -1,3 +1,33 @@
-export function GET() {
- return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() })
+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.
+ */
+export async function GET() {
+ try {
+ const store = await getSceneStore()
+ await store.list({ limit: 1 })
+ return Response.json({
+ status: 'ok',
+ app: 'digitaltwin',
+ backend: store.backend,
+ db: 'ok',
+ auth: authAvailable() ? 'ok' : 'disabled',
+ timestamp: new Date().toISOString(),
+ })
+ } catch (error) {
+ return Response.json(
+ {
+ status: 'error',
+ app: 'digitaltwin',
+ error: error instanceof Error ? error.message : String(error),
+ timestamp: new Date().toISOString(),
+ },
+ { status: 503 },
+ )
+ }
}
diff --git a/apps/editor/app/api/invitations/[id]/resend/route.ts b/apps/editor/app/api/invitations/[id]/resend/route.ts
new file mode 100644
index 000000000..cb36af7fd
--- /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 000000000..bee6cc88d
--- /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 000000000..a7e9be91e
--- /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 000000000..44b843df6
--- /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 000000000..03fa7a53d
--- /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 000000000..8425b9988
--- /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 000000000..7c7e29733
--- /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 000000000..04d8ce1ce
--- /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 000000000..7c65266e4
--- /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 000000000..f0e200129
--- /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 000000000..82a9ec8e6
--- /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 000000000..217b85164
--- /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 000000000..9ee92afa4
--- /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 000000000..c143484b7
--- /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 000000000..27464774e
--- /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 000000000..dfebc8a9b
--- /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 000000000..4d12793eb
--- /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 000000000..583d407fa
--- /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 000000000..053f9aeed
--- /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 2167a12b2..9b043f90e 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(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]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts
index 1712ad4ad..1b795f0ad 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 { apiGraphSchema } from '@/lib/graph-schema'
import {
guardSceneApiRequest,
@@ -40,6 +42,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(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}"` },
})
@@ -83,6 +91,8 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
if (!existing) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
+ const auth = await authorizeSceneMutation(existing.ownerId)
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
const meta = await operations.saveScene({
id,
name: parsed.data.name ?? existing.name,
@@ -110,6 +120,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
const operations = await getSceneOperations()
try {
+ const existing = await operations.loadStoredScene(id)
+ if (!existing) {
+ return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+ }
+ const auth = await authorizeSceneMutation(existing.ownerId)
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch })
if (!removed) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
@@ -151,6 +167,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const operations = await getSceneOperations()
try {
+ const existing = await operations.loadStoredScene(id)
+ if (!existing) {
+ return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
+ }
+ const auth = await authorizeSceneMutation(existing.ownerId)
+ if (!auth.ok) return sceneApiJson(request, { error: auth.error }, { status: auth.status })
const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion })
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },
diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts
index 01ffc3ba8..5f2be1aa3 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,19 @@ export async function GET(request: NextRequest) {
)
}
+ // With auth on, a signed-in user sees only their own scenes and a signed-out
+ // caller sees none. Without auth (SQLite dev), the list stays unfiltered.
+ let ownerId: string | undefined
+ if (authAvailable()) {
+ const user = await getSessionUser()
+ if (!user) return sceneApiJson(request, { scenes: [] })
+ ownerId = user.id
+ }
+
const operations = await getSceneOperations()
const scenes = await operations.listScenes({
projectId: parsed.data.projectId,
+ ownerId,
limit: parsed.data.limit,
})
return sceneApiJson(request, { scenes })
@@ -52,6 +64,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 +101,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 000000000..a9fc82a4d
--- /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 000000000..74d5fa6e0
--- /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 000000000..45aa4beb9
--- /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 000000000..94bc36392
--- /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 000000000..174dce42f
--- /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 000000000..5fee6a853
--- /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 000000000..97652b5e4
--- /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 000000000..f12fdbaad
--- /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 000000000..e95a196cf
--- /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 000000000..1d97196f2
--- /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 000000000..73a8b0f28
--- /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 000000000..fa92c3cab
--- /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 000000000..84ca5566e
--- /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 000000000..1f11fd93c
--- /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 000000000..14a66bc3f
--- /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 000000000..cb12dbad5
--- /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 000000000..41f2cd869
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 821544fec..70a7018f5 100644
--- a/apps/editor/app/client-bootstrap.tsx
+++ b/apps/editor/app/client-bootstrap.tsx
@@ -10,6 +10,7 @@
// idempotent under HMR.
import '../lib/bootstrap'
import { type ReactNode, useEffect } from 'react'
+import { SessionProvider } from '@/components/auth/session-provider'
export function ClientBootstrap({
children,
@@ -22,5 +23,5 @@ export function ClientBootstrap({
if (!enableDevDiagnostics) return
import('react-scan').then(({ scan }) => scan({ enabled: true }))
}, [enableDevDiagnostics])
- return children
+ return {children}
}
diff --git a/apps/editor/app/editor/page.tsx b/apps/editor/app/editor/page.tsx
new file mode 100644
index 000000000..796b09fd3
--- /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 e9f367294..24e42fd2c 100644
Binary files a/apps/editor/app/favicon.ico and b/apps/editor/app/favicon.ico differ
diff --git a/apps/editor/app/icon.png b/apps/editor/app/icon.png
new file mode 100644
index 000000000..1b54692ae
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 01965f4ba..7dec65829 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 e4ccf7ed3..b75272c7f 100644
--- a/apps/editor/app/page.tsx
+++ b/apps/editor/app/page.tsx
@@ -1,117 +1,27 @@
-'use client'
+import { getSession } from '@panel/lib/auth/session'
+import { redirect } from 'next/navigation'
+import { EditorApp } from '@/components/editor-app'
+import { canEdit, getSessionUser } from '@/lib/auth/session'
-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.
+ */
+export default async function Root() {
+ const session = await getSession({ touch: false })
-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: (
-
- ),
- },
-]
+ if (!session) redirect('/signin')
+ if (session.mfaPending) redirect('/mfa')
+ if (session.user.mustChangePassword) redirect('/welcome')
-const PROJECT_ID = 'local-editor'
+ // 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 && !canEdit(user)) redirect('/scenes')
-export default function Home() {
- return (
-
- {PROJECT_ID === 'local-editor' && (
-
-
- Local editor — scenes are not saved.
-
- Open recent scenes
-
-
- ·
-
-
- Create new
-
-
-
- )}
- }
- viewerToolbarRight={}
- />
-
- )
+ return
}
diff --git a/apps/editor/app/privacy/page.tsx b/apps/editor/app/privacy/page.tsx
index 450757898..1b75548d4 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.
@@ -66,16 +76,25 @@ export default async function ScenesPage() {
Your scenes
{scenes.length === 0
- ? 'No scenes yet. Create one to get started.'
+ ? editingAllowed
+ ? 'No scenes yet. Create one to get started.'
+ : 'No scenes have been shared with you yet.'
: `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`}
{scenes.length === 0 ? (
-
You haven't saved any scenes yet.
-
-
-
+
+ {editingAllowed
+ ? 'You haven’t saved any scenes yet. Start from scratch, or import an IFC model exported from Revit, ArchiCAD or similar.'
+ : 'Ask an administrator to assign a scene to your account.'}
+
+ {editingAllowed && (
+
+
+
+
+ )}
) : (
diff --git a/apps/editor/app/terms/page.tsx b/apps/editor/app/terms/page.tsx
index f8afb3e17..54ebb2bc8 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 000000000..cf87d7ecf
--- /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 000000000..5c987c300
--- /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 000000000..ae532f2ca
--- /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 `
+
+
+
+