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' ? 'Sürüm notları' : 'Changelog'} +

+

+ {lang === 'tr' + ? 'DigitalTwin platformundaki yeni özellikler, iyileştirmeler ve düzeltmeler.' + : 'New features, improvements, and fixes across the DigitalTwin platform.'} +

+ +
+ +
+ {entries.map((entry, index) => ( +
+
+ + {entry.version ? ( + <> + · + {entry.version} + + ) : null} + · + + {CHANNEL_LABEL[lang][entry.channel]} + +
+ +

+ {entry.title} +

+ +

{entry.summary}

+ + {entry.tags.length > 0 ? ( +
+ {entry.tags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null} +
+ ))} +
+
+
+ ) +} 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.rows.map(([key, value]) => ( + + + + + ))} + +
{block.table.columns[0]}{block.table.columns[1]}
+ + {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} + +
+
+ DigitalTwin — {t.internalOnly} + + + {lang === 'tr' ? 'Şartlar' : 'Terms'} + + + {lang === 'tr' ? 'Gizlilik' : 'Privacy'} + + +
+
+
+ ) +} 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. */} + & co')).toBe( + '<script>alert("x")</script> & co', + ) +}) + +test('a name from the database cannot inject markup', () => { + const html = renderMail({ ...base, intro: ' Övür, hello.' }) + expect(html).not.toContain(' { + const url = 'https://opex.help/reset/abc123' + const html = renderMail({ ...base, action: { label: 'Set a new password', url } }) + // The VML rectangle Outlook uses, the ordinary anchor, and the fallback box. + expect(html.split(url).length - 1).toBe(3) + expect(html).toContain('href="https://opex.help/reset/abc123"') + expect(html).toContain('v:roundrect') +}) + +test('omitting the action drops the button and its fallback box', () => { + const html = renderMail(base) + expect(html).not.toContain('Or paste this address') + expect(html).not.toContain(' { + const html = renderMail(base) + expect(html).toContain('DigitalTwin
https://opex.help/') +}) + +test('the frame speaks the recipient’s language', () => { + const tr = renderMail({ + ...base, + lang: 'tr', + action: { label: 'Aç', url: 'https://opex.help/x' }, + }) + expect(tr).toContain('') + expect(tr).toContain('Ya da bu adresi tarayıcınıza yapıştırın') + expect(tr).toContain('Bu ileti otomatik olarak gönderildi') + + const en = renderMail({ ...base, action: { label: 'Open', url: 'https://opex.help/x' } }) + expect(en).toContain('') + expect(en).toContain('Or paste this address into your browser') +}) + +test('a callout value carries its own dark-mode class', () => { + // Without dt-fg the inline near-black colour wins in dark mode and a + // temporary password renders invisible — the one failure nobody can report, + // because the reader cannot see there is anything to report. + const html = renderMail({ ...base, callout: { label: 'Temporary password', value: 'S3cret!x' } }) + expect(html).toMatch(/class="dt-fg"[^>]*>S3cret!x { + const html = renderMail({ + ...base, + facts: [ + { label: 'Account', value: 'a@b.c' }, + { label: 'When', value: '01/08/2026' }, + ], + }) + expect(html).toContain('Account') + expect(html).toContain('a@b.c') + expect(html).toContain('01/08/2026') +}) diff --git a/apps/editor/panel/lib/mail-template.ts b/apps/editor/panel/lib/mail-template.ts new file mode 100644 index 000000000..6d85bdeb2 --- /dev/null +++ b/apps/editor/panel/lib/mail-template.ts @@ -0,0 +1,305 @@ +import type { Lang } from './types' + +/** + * The house style for transactional mail, as one function. + * + * Mail clients are not browsers: no external stylesheet survives, Outlook lays + * out with Word, and Gmail drops anything it does not recognise. So this is + * table-based with inline styles — the two things every client agrees on. The + * only ` + + + +
${escapeHtml(page.preheader)}
+ + + + + + +` +} diff --git a/apps/editor/panel/lib/mail.ts b/apps/editor/panel/lib/mail.ts new file mode 100644 index 000000000..044252481 --- /dev/null +++ b/apps/editor/panel/lib/mail.ts @@ -0,0 +1,740 @@ +import nodemailer, { type Transporter } from 'nodemailer' +import { queryOne, type RowDataPacket } from './db' +import { formatDate } from './i18n' +import { type MailFact, type MailPage, renderMail } from './mail-template' +import type { Lang } from './types' + +/** + * Delivery for every transactional message the system owes a person. + * + * Two transports, chosen by `MAIL_TRANSPORT`: + * + * - `console` (default) prints the message with its link, so every flow is + * exercisable end to end without a mail server. + * - `smtp` sends through SMTP. + * + * Raw tokens and temporary passwords appear here and nowhere else: not in the + * API response, not in the audit trail, not in the database. + * + * Two rules the set follows: + * + * 1. Every promise the copy makes is kept by code. The access-request receipt + * says another message will follow — so approval AND rejection both send + * one. + * 2. Anything that changes how an account can be signed into tells its owner, + * unprompted. A password change, a new second factor, a forced sign-out and + * a suspension are all things a person must be able to notice. + */ + +export function appUrl(path: string): string { + const base = process.env.APP_URL ?? 'http://localhost:3000' + return new URL(path, base).toString() +} + +function origin(): string { + return appUrl('/').replace(/\/$/, '') +} + +/** + * Signature under every message, mirroring the sign-in screen's notice. Read per + * send rather than at import: the deploy bundle loads its .env in the boot hook, + * which can run after this module is first evaluated. + */ +function footer(lang: Lang): string { + const line = + lang === 'tr' + ? 'DigitalTwin — kurum içi sistem, yalnızca yetkili personel' + : 'DigitalTwin — internal system, authorised personnel only' + return `${line}\n${appUrl('/')}` +} + +/** + * The language to write to somebody in. + * + * Recorded from their own session when they sign in. Anyone who has never + * signed in — an invitation's recipient, an outside access request — has no + * preference yet, and English is the system default. + */ +export async function localeFor(email: string): Promise { + try { + const row = await queryOne( + 'SELECT locale FROM users WHERE email = ? LIMIT 1', + [email], + ) + return row?.locale === 'tr' ? 'tr' : 'en' + } catch { + // A console-transport development database may predate the column. + return 'en' + } +} + +interface Envelope { + to: string + subject: string + /** text/plain part. Always sent — it is what the console transport prints. */ + body: string + html?: string +} + +let transporter: Transporter | null = null + +/** + * One connection pool for the process. Built on first use rather than at import + * so a console-transport deployment never opens a socket, and so a missing + * SMTP_HOST is reported when someone actually asks for SMTP. + */ +function smtp(): Transporter { + if (transporter) return transporter + + const host = process.env.SMTP_HOST + if (!host) throw new Error('MAIL_TRANSPORT="smtp" needs SMTP_HOST.') + + const port = Number(process.env.SMTP_PORT ?? 587) + const user = process.env.SMTP_USER + const pass = process.env.SMTP_PASSWORD + + transporter = nodemailer.createTransport({ + host, + port, + // Implicit TLS on 465, STARTTLS elsewhere — the usual split, overridable. + secure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === '1' : port === 465, + auth: user ? { user, pass } : undefined, + // Deliberately unpooled (nodemailer's default; `pool: true` was removed). + // This system sends a handful of messages a day, and a pooled socket kept + // open between them is the classic shared-hosting failure: the provider + // drops the idle connection, the pool does not notice, and the next send + // fails on a socket that looks alive — which is exactly the shape of "the + // receipt arrived but the invitation never did". A fresh connection per + // message costs a TLS handshake nobody will ever feel. + // + // Without these timeouts a hung mail server holds the HTTP request open until the + // platform's own timeout kills it, turning a slow mail server into a slow + // console. + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 20_000, + }) + + return transporter +} + +/** + * Sends, and never throws. + * + * Every caller awaits this inline in a request handler, and two of them must not + * be able to fail: `POST /api/auth/reset` deliberately answers the same way + * whether or not the address exists, so letting a dead mail server turn one case + * into a 500 would hand out an account-enumeration oracle. A failure is loud in + * the logs and invisible to the caller — which is the same thing a queue would + * do, one retry later. + */ +async function send(envelope: Envelope, opts: { rethrow?: boolean } = {}): Promise { + const transport = process.env.MAIL_TRANSPORT ?? 'console' + + if (transport === 'console') { + console.info( + `\n─── mail ───────────────────────────────────\n` + + `To: ${envelope.to}\n` + + `Subject: ${envelope.subject}\n\n` + + `${envelope.body}\n` + + `────────────────────────────────────────────\n`, + ) + return true + } + + if (transport !== 'smtp') { + console.error(`[mail] MAIL_TRANSPORT="${transport}" is not a transport; nothing was sent.`) + return false + } + + try { + await smtp().sendMail({ + from: process.env.MAIL_FROM ?? 'DigitalTwin ', + to: envelope.to, + subject: envelope.subject, + text: envelope.body, + html: envelope.html, + }) + return true + } catch (err) { + // The address is logged, the body is not — it carries a single-use token. + console.error(`[mail] delivery to ${envelope.to} failed:`, err) + // Transactional mail swallows this on purpose: a mail server having a bad + // afternoon must not fail the password reset or invitation that triggered + // it. The test message is the exception — it exists only to prove delivery, + // so reporting success when nothing was delivered is the one answer it + // must never give. + if (opts.rethrow) throw err + return false + } +} + +/** + * One composer for every message: the plain-text part is derived from the same + * fields as the HTML, so the two can never drift into saying different things. + */ +async function compose( + to: string, + lang: Lang, + subject: string, + page: Omit, + opts: { rethrow?: boolean } = {}, +): Promise { + const lines: string[] = [page.heading, '', page.intro] + if (page.facts?.length) { + lines.push('') + for (const fact of page.facts) lines.push(`${fact.label}: ${fact.value}`) + } + if (page.callout) { + lines.push('', `${page.callout.label}: ${page.callout.value}`) + } + if (page.action) { + lines.push('', page.action.label, page.action.url) + } + if (page.note) lines.push('', page.note) + lines.push('', footer(lang)) + + return send( + { + to, + subject, + body: lines.join('\n'), + html: renderMail({ ...page, lang, origin: origin(), footer: footer(lang) }), + }, + opts, + ) +} + +/** "DigitalTwin — ", the shape every subject line takes. */ +function subject(what: string): string { + return `DigitalTwin — ${what}` +} + +function when(lang: Lang, at: Date = new Date()): MailFact { + return { label: lang === 'tr' ? 'Zaman' : 'When', value: formatDate(lang, at) } +} + +function account(lang: Lang, email: string): MailFact { + return { label: lang === 'tr' ? 'Hesap' : 'Account', value: email } +} + +/* ── Access ──────────────────────────────────────────────────────────────── */ + +export async function deliverResetLink(opts: { + email: string + fullName: string + token: string + expiresAt: Date + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + const minutes = Math.max(1, Math.round((opts.expiresAt.getTime() - Date.now()) / 60_000)) + const url = appUrl(`/reset/${opts.token}`) + + const copy = + lang === 'tr' + ? { + subject: 'parola sıfırlama', + label: 'Parola sıfırlama', + heading: 'Yeni bir parola belirleyin', + intro: `${opts.fullName}, DigitalTwin hesabınız için yeni bir parola seçmek üzere aşağıdaki düğmeyi kullanın.`, + action: 'Yeni parola belirle', + note: `Bağlantı tek kullanımlıktır ve ${minutes} dakika sonra geçersiz olur. Bu isteği siz yapmadıysanız bu iletiyi yok sayın; parolanız değişmez ve istek yöneticilere kaydedilir.`, + preheader: `Sıfırlama bağlantınız ${minutes} dakika geçerli.`, + } + : { + subject: 'password reset', + label: 'Password reset', + heading: 'Set a new password', + intro: `${opts.fullName}, use the button below to choose a new password for your DigitalTwin account.`, + action: 'Set a new password', + note: `The link is single-use and expires in ${minutes} minutes. If you did not ask for it, ignore this message — your password stays as it is, and the request is recorded for the administrators.`, + preheader: `Your reset link is valid for ${minutes} minutes.`, + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), when(lang)], + action: { label: copy.action, url }, + note: copy.note, + preheader: copy.preheader, + }) +} + +/** + * Returns whether the message actually reached the mail server. An invitation + * that is not delivered leaves an account nobody can activate, so the caller + * has to be able to say so rather than reporting a silent success. + */ +export async function deliverInvite(opts: { + email: string + fullName: string + token: string + expiresAt: string + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + const days = Math.max( + 1, + Math.ceil((new Date(opts.expiresAt).getTime() - Date.now()) / 86_400_000), + ) + const url = appUrl(`/welcome?token=${opts.token}`) + + const copy = + lang === 'tr' + ? { + subject: 'hesabınız hazır', + label: 'Hesap oluşturuldu', + heading: 'Hesabınız hazır', + intro: `${opts.fullName}, bir yönetici sizin için bir DigitalTwin hesabı oluşturdu. Başlamak için parolanızı belirleyin ve iki adımlı doğrulamayı kurun.`, + action: 'Hesabımı etkinleştir', + note: `Bağlantı ${days} gün geçerlidir. İlk girişte kendi parolanızı belirlemeniz istenecek.`, + preheader: 'Parolanızı belirleyin ve iki adımlı doğrulamayı kurun.', + } + : { + subject: 'your account is ready', + label: 'Account created', + heading: 'Your account is ready', + intro: `${opts.fullName}, an administrator created a DigitalTwin account for you. Set your password and enrol two-factor authentication to get started.`, + action: 'Activate my account', + note: `The link is valid for ${days} day(s). You will be asked to set your own password on first sign-in.`, + preheader: 'Set your password and enrol two-factor authentication.', + } + + return compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email)], + action: { label: copy.action, url }, + note: copy.note, + preheader: copy.preheader, + }) +} + +export async function deliverRequestReceipt(opts: { + email: string + fullName: string + lang?: Lang +}): Promise { + const lang = opts.lang ?? 'en' + + const copy = + lang === 'tr' + ? { + subject: 'hesap talebiniz alındı', + label: 'Talep alındı', + heading: 'Talebiniz inceleniyor', + intro: `${opts.fullName}, erişim talebiniz yöneticilere iletildi. Karar verildiğinde — olumlu ya da olumsuz — size bir ileti daha göndereceğiz.`, + preheader: 'Bir yönetici erişim talebinizi inceleyecek.', + } + : { + subject: 'account request received', + label: 'Request received', + heading: 'Your request is under review', + intro: `${opts.fullName}, your access request is with the administrators. You will get another message once it has been decided, either way.`, + preheader: 'An administrator will review your access request.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), when(lang)], + preheader: copy.preheader, + }) +} + +/** The other half of the receipt's promise. Silence is not an answer. */ +export async function deliverRequestRejected(opts: { + email: string + fullName?: string + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + const name = opts.fullName ? `${opts.fullName}, ` : '' + + const copy = + lang === 'tr' + ? { + subject: 'hesap talebiniz hakkında', + label: 'Talep sonuçlandı', + heading: 'Erişim talebiniz onaylanmadı', + intro: `${name}DigitalTwin erişim talebiniz şu an için onaylanmadı. Bu bir hata olduğunu düşünüyorsanız kurum içinde ilgili yöneticiyle görüşün; talebiniz yeniden değerlendirilebilir.`, + preheader: 'Erişim talebiniz bu sefer onaylanmadı.', + } + : { + subject: 'about your account request', + label: 'Request closed', + heading: 'Your access request was not approved', + intro: `${name}your request for DigitalTwin access was not approved at this time. If you believe that is a mistake, speak to the administrator responsible for your team — a request can be reconsidered.`, + preheader: 'Your access request was not approved this time.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), when(lang)], + preheader: copy.preheader, + }) +} + +/* ── Security notices ────────────────────────────────────────────────────── */ + +/** + * A password changed — by a reset link, or on a forced first sign-in. The + * point is that the owner finds out even when it was not them who did it. + */ +export async function deliverPasswordChanged(opts: { + email: string + fullName: string + via: 'reset' | 'first-sign-in' + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + + const howTr = { + reset: 'Sıfırlama bağlantısı', + self: 'Hesap ayarları', + 'first-sign-in': 'İlk giriş', + } + const howEn = { reset: 'Reset link', self: 'Account settings', 'first-sign-in': 'First sign-in' } + + const copy = + lang === 'tr' + ? { + subject: 'parolanız değiştirildi', + label: 'Güvenlik bildirimi', + heading: 'Parolanız değiştirildi', + intro: `${opts.fullName}, DigitalTwin hesabınızın parolası az önce değiştirildi.`, + howLabel: 'Yöntem', + how: howTr[opts.via], + note: 'Bunu siz yaptıysanız yapmanız gereken bir şey yok. Yapmadıysanız hemen parolanızı sıfırlayın ve bir yöneticiye haber verin.', + preheader: 'Hesabınızın parolası değiştirildi.', + } + : { + subject: 'your password was changed', + label: 'Security notice', + heading: 'Your password was changed', + intro: `${opts.fullName}, the password on your DigitalTwin account has just been changed.`, + howLabel: 'Method', + how: howEn[opts.via], + note: 'If that was you, there is nothing to do. If it was not, reset your password immediately and tell an administrator.', + preheader: 'The password on your account was changed.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), { label: copy.howLabel, value: copy.how }, when(lang)], + note: copy.note, + preheader: copy.preheader, + }) +} + +/** + * An administrator issued a temporary password. Without this message the + * password only exists on the administrator's screen, and gets read out over + * a phone — which is the worst way to move a credential. + */ +export async function deliverTemporaryPassword(opts: { + email: string + fullName: string + temporaryPassword: string + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + const url = appUrl('/signin') + + const copy = + lang === 'tr' + ? { + subject: 'geçici parolanız', + label: 'Geçici parola', + heading: 'Geçici bir parola verildi', + intro: `${opts.fullName}, bir yönetici hesabınıza geçici bir parola tanımladı. Bununla giriş yapın; sistem hemen kendi parolanızı belirlemenizi isteyecek.`, + calloutLabel: 'Geçici parola', + action: 'Giriş yap', + note: 'Bu parola yalnızca bir kez, kendi parolanızı belirlemeniz için geçerlidir. Böyle bir talebiniz olmadıysa bir yöneticiye haber verin.', + preheader: 'Giriş yapın ve kendi parolanızı belirleyin.', + } + : { + subject: 'your temporary password', + label: 'Temporary password', + heading: 'A temporary password was issued', + intro: `${opts.fullName}, an administrator set a temporary password on your account. Sign in with it and you will be asked to choose your own straight away.`, + calloutLabel: 'Temporary password', + action: 'Sign in', + note: 'This password works once, to let you set your own. If you did not ask for it, tell an administrator.', + preheader: 'Sign in and choose your own password.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), when(lang)], + callout: { label: copy.calloutLabel, value: opts.temporaryPassword }, + action: { label: copy.action, url }, + note: copy.note, + preheader: copy.preheader, + }) +} + +/** Two-factor authentication was enrolled — or taken away. */ +export async function deliverTwoFactorChanged(opts: { + email: string + fullName: string + enabled: boolean + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + + const copy = + lang === 'tr' + ? { + subject: opts.enabled ? 'iki adımlı doğrulama açıldı' : 'iki adımlı doğrulama kapatıldı', + label: 'Güvenlik bildirimi', + heading: opts.enabled ? 'İki adımlı doğrulama açıldı' : 'İki adımlı doğrulama kapatıldı', + intro: opts.enabled + ? `${opts.fullName}, hesabınızda iki adımlı doğrulama kuruldu. Bundan sonra her girişte doğrulayıcı uygulamanızdaki kod istenecek.` + : `${opts.fullName}, hesabınızdaki iki adımlı doğrulama kaldırıldı. Girişte artık yalnızca parolanız istenecek.`, + note: 'Bunu siz yapmadıysanız hemen bir yöneticiye haber verin.', + preheader: opts.enabled + ? 'Hesabınıza ikinci bir doğrulama adımı eklendi.' + : 'Hesabınızdaki ikinci doğrulama adımı kaldırıldı.', + } + : { + subject: opts.enabled + ? 'two-factor authentication enabled' + : 'two-factor authentication removed', + label: 'Security notice', + heading: opts.enabled + ? 'Two-factor authentication is on' + : 'Two-factor authentication was removed', + intro: opts.enabled + ? `${opts.fullName}, a second step was added to your account. From now on every sign-in asks for the code from your authenticator app.` + : `${opts.fullName}, the second step was removed from your account. Sign-in now asks for your password alone.`, + note: 'If that was not you, tell an administrator immediately.', + preheader: opts.enabled + ? 'A second sign-in step was added to your account.' + : 'The second sign-in step was removed from your account.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), when(lang)], + note: copy.note, + preheader: copy.preheader, + }) +} + +/** Every session was ended — by the owner, or by an administrator. */ +export async function deliverSessionsRevoked(opts: { + email: string + fullName: string + byAdmin: boolean + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + const url = appUrl('/signin') + + const copy = + lang === 'tr' + ? { + subject: 'tüm oturumlarınız kapatıldı', + label: 'Güvenlik bildirimi', + heading: 'Tüm cihazlardan çıkış yapıldı', + intro: opts.byAdmin + ? `${opts.fullName}, bir yönetici hesabınızın açık tüm oturumlarını kapattı. Devam etmek için yeniden giriş yapmanız gerekir.` + : `${opts.fullName}, hesabınızın açık tüm oturumları kapatıldı. Devam etmek için yeniden giriş yapmanız gerekir.`, + action: 'Yeniden giriş yap', + note: 'Bunu beklemiyorduysanız parolanızı değiştirin ve bir yöneticiye haber verin.', + preheader: 'Açık tüm oturumlarınız sonlandırıldı.', + } + : { + subject: 'you were signed out everywhere', + label: 'Security notice', + heading: 'Signed out on every device', + intro: opts.byAdmin + ? `${opts.fullName}, an administrator ended every open session on your account. You will need to sign in again to carry on.` + : `${opts.fullName}, every open session on your account has been ended. You will need to sign in again to carry on.`, + action: 'Sign in again', + note: 'If you were not expecting this, change your password and tell an administrator.', + preheader: 'Every open session on your account was ended.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), when(lang)], + action: { label: copy.action, url }, + note: copy.note, + preheader: copy.preheader, + }) +} + +/** Access suspended or restored. A sign-in that simply fails explains nothing. */ +export async function deliverAccessChanged(opts: { + email: string + fullName: string + active: boolean + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + + const copy = + lang === 'tr' + ? { + subject: opts.active ? 'erişiminiz yeniden açıldı' : 'erişiminiz durduruldu', + label: 'Hesap durumu', + heading: opts.active ? 'Erişiminiz yeniden açıldı' : 'Erişiminiz durduruldu', + intro: opts.active + ? `${opts.fullName}, DigitalTwin hesabınız yeniden etkin. Her zamanki gibi giriş yapabilirsiniz.` + : `${opts.fullName}, DigitalTwin hesabınız bir yönetici tarafından devre dışı bırakıldı. Şu an giriş yapamazsınız; çizimleriniz olduğu gibi duruyor.`, + note: opts.active + ? undefined + : 'Bunun nedenini öğrenmek için ekibinizden sorumlu yöneticiyle görüşün.', + preheader: opts.active ? 'Hesabınız yeniden etkin.' : 'Hesabınız şu an devre dışı.', + } + : { + subject: opts.active ? 'your access was restored' : 'your access was suspended', + label: 'Account status', + heading: opts.active ? 'Your access was restored' : 'Your access was suspended', + intro: opts.active + ? `${opts.fullName}, your DigitalTwin account is active again. You can sign in as usual.` + : `${opts.fullName}, an administrator has deactivated your DigitalTwin account. You cannot sign in for now; your work is untouched.`, + note: opts.active + ? undefined + : 'Speak to the administrator responsible for your team to find out why.', + preheader: opts.active ? 'Your account is active again.' : 'Your account is inactive.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [account(lang, opts.email), when(lang)], + note: copy.note, + preheader: copy.preheader, + }) +} + +/* ── Product ─────────────────────────────────────────────────────────────── */ + +/** An administrator published somebody's project as a site. */ +export async function deliverScenePublished(opts: { + email: string + fullName: string + sceneName: string + sceneId: string + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + const url = appUrl(`/scene/${opts.sceneId}`) + + const copy = + lang === 'tr' + ? { + subject: 'projeniz yayınlandı', + label: 'Proje yayınlandı', + heading: 'Projeniz yayınlandı', + intro: `${opts.fullName}, çiziminiz bir yönetici tarafından onaylandı ve saha olarak yayınlandı. Artık erişimi olan herkes tarafından görülebilir.`, + projectLabel: 'Proje', + action: 'Projeyi aç', + preheader: 'Çiziminiz saha olarak yayınlandı.', + } + : { + subject: 'your project was published', + label: 'Project published', + heading: 'Your project was published', + intro: `${opts.fullName}, an administrator approved your drawing and published it as a site. It is now visible to everyone with access.`, + projectLabel: 'Project', + action: 'Open the project', + preheader: 'Your drawing was published as a site.', + } + + await compose(opts.email, lang, subject(copy.subject), { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts: [{ label: copy.projectLabel, value: opts.sceneName }, when(lang)], + action: { label: copy.action, url }, + preheader: copy.preheader, + }) +} + +/* ── Diagnostics ─────────────────────────────────────────────────────────── */ + +/** + * A message with nothing behind it, for checking that delivery and rendering + * work from the console's Settings screen. It carries every element the set + * uses — facts, a callout, a button, a note — so one send proves the lot. + */ +export async function deliverTestMessage(opts: { + email: string + fullName?: string + lang?: Lang +}): Promise { + const lang = opts.lang ?? (await localeFor(opts.email)) + const name = opts.fullName ?? opts.email + + const copy = + lang === 'tr' + ? { + subject: 'deneme', + label: 'Deneme', + heading: 'Bu bir deneme iletisidir', + intro: `${name}, bu ileti DigitalTwin'in posta ayarlarını denemek için gönderildi. Arkasında bir işlem yok ve yapmanız gereken bir şey yok.`, + calloutLabel: 'Örnek kod', + action: 'Konsolu aç', + note: 'Bu iletiyi düzgün okuyabiliyorsanız — logo, başlık, alanlar ve düğme yerli yerindeyse — posta ayarları çalışıyor demektir.', + preheader: 'Posta ayarlarını denemek için gönderilen örnek ileti.', + facts: [ + { label: 'Ortam', value: origin() }, + { label: 'Aktarım', value: process.env.MAIL_TRANSPORT ?? 'console' }, + ], + } + : { + subject: 'test message', + label: 'Test', + heading: 'This is a test message', + intro: `${name}, this message was sent to check DigitalTwin's mail settings. Nothing happened behind it and there is nothing for you to do.`, + calloutLabel: 'Sample code', + action: 'Open the console', + note: 'If this reads properly — logo, heading, fields and button all in place — mail delivery is working.', + preheader: 'A sample message, sent to check mail delivery.', + facts: [ + { label: 'Environment', value: origin() }, + { label: 'Transport', value: process.env.MAIL_TRANSPORT ?? 'console' }, + ], + } + + const facts: MailFact[] = [...copy.facts, when(lang)] + + await compose( + opts.email, + lang, + subject(copy.subject), + { + label: copy.label, + heading: copy.heading, + intro: copy.intro, + facts, + callout: { label: copy.calloutLabel, value: '482 913' }, + action: { label: copy.action, url: appUrl('/console/overview') }, + note: copy.note, + preheader: copy.preheader, + }, + // The one message whose whole job is to prove delivery: a failure has to + // reach the administrator who pressed the button. + { rethrow: true }, + ) +} diff --git a/apps/editor/panel/lib/password-policy.ts b/apps/editor/panel/lib/password-policy.ts new file mode 100644 index 000000000..7eac43c8a --- /dev/null +++ b/apps/editor/panel/lib/password-policy.ts @@ -0,0 +1,57 @@ +import type { PasswordPolicyResult } from './api-contract' + +/** + * The five policy rules, as one pure function shared by both sides. + * + * The client renders it live under the field; the server re-runs it on submit. + * Keeping a single implementation here — rather than a copy in each — is what + * stops the meter from saying "Strong" on a password the API then rejects. + * This module must stay free of Node imports so the client can pull it in. + * + * `identity` is the username or local part; rule 5 rejects a password that + * contains it, or the word "netlog". + */ +export function checkPasswordPolicy(password: string, identity = ''): PasswordPolicyResult { + const parts = identityParts(identity) + const lower = password.toLowerCase() + + const minLength = password.length >= 10 + const mixedCase = /[a-z]/.test(password) && /[A-Z]/.test(password) + const digit = /\d/.test(password) + const symbol = /[^A-Za-z0-9]/.test(password) + const noIdentity = + password.length > 0 && !lower.includes('netlog') && !parts.some((part) => lower.includes(part)) + + const passed = [minLength, mixedCase, digit, symbol, noIdentity].filter(Boolean).length + + return { + minLength, + mixedCase, + digit, + symbol, + noIdentity, + ok: passed === 5, + // Same mapping as the prototype: ceil(passed * 4 / 5) - 1, clamped to 0..3. + strength: Math.max(0, Math.min(3, Math.ceil((passed * 4) / 5) - 1)) as 0 | 1 | 2 | 3, + } +} + +/** + * Every meaningful piece of the identity, lowercased. + * + * This used to be `identity.split('@')[0].split('.')[0]` — the first segment + * only. That is exactly wrong for this product's username format: "r.ovur" + * reduced to "r", which is under the three-character floor, so rule 5 was + * skipped altogether and "R.ovur-2026!" scored full marks. Both segments count + * now, plus the joined form, so neither "ovur" nor "rovur" gets through. + * + * The three-character floor stays: a user named "ab" must not be barred from + * every password containing those two letters. + */ +function identityParts(identity: string): string[] { + const local = (identity.split('@')[0] ?? '').toLowerCase() + const segments = local.split(/[^a-z0-9]+/i).filter((part) => part.length >= 3) + const joined = local.replace(/[^a-z0-9]+/gi, '') + + return joined.length >= 3 ? [...new Set([...segments, joined])] : segments +} diff --git a/apps/editor/panel/lib/settings.ts b/apps/editor/panel/lib/settings.ts new file mode 100644 index 000000000..6d8290d69 --- /dev/null +++ b/apps/editor/panel/lib/settings.ts @@ -0,0 +1,95 @@ +import { exec, queryOne, type RowDataPacket } from './db' +import type { OrgSettings } from './types' + +interface SettingsRow extends RowDataPacket { + session_minutes: number + keep_signed_in_allowed: number + keep_signed_in_days: number + trusted_device_days: number + concurrent_session_limit: number + mfa_required: number + sso_enforced_domains: string[] | string | null + external_users_allowed: number + invite_expiry_days: number + updated_by: number | null + updated_at: Date +} + +/** Defaults mirror the DDL, so a missing row degrades to the documented values. */ +const FALLBACK: OrgSettings = { + sessionMinutes: 20, + keepSignedInAllowed: true, + keepSignedInDays: 14, + trustedDeviceDays: 30, + concurrentSessionLimit: 3, + mfaRequired: true, + ssoEnforcedDomains: [], + externalUsersAllowed: true, + inviteExpiryDays: 7, + updatedBy: null, + updatedAt: new Date(0).toISOString(), +} + +let cache: { value: OrgSettings; at: number } | null = null +const TTL_MS = 5_000 + +function parseDomains(raw: SettingsRow['sso_enforced_domains']): string[] { + if (Array.isArray(raw)) return raw + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + return [] +} + +/** + * Reads the single settings row. Enforcement lives on the server — session + * length, invite expiry and the MFA requirement are all read from here, never + * from anything the client sends. + */ +export async function getSettings(): Promise { + if (cache && Date.now() - cache.at < TTL_MS) return cache.value + + const row = await queryOne('SELECT * FROM settings WHERE id = 1') + const value: OrgSettings = row + ? { + sessionMinutes: row.session_minutes, + keepSignedInAllowed: row.keep_signed_in_allowed === 1, + keepSignedInDays: row.keep_signed_in_days, + trustedDeviceDays: row.trusted_device_days, + concurrentSessionLimit: row.concurrent_session_limit, + mfaRequired: row.mfa_required === 1, + ssoEnforcedDomains: parseDomains(row.sso_enforced_domains), + externalUsersAllowed: row.external_users_allowed === 1, + inviteExpiryDays: row.invite_expiry_days, + updatedBy: row.updated_by ? String(row.updated_by) : null, + updatedAt: row.updated_at.toISOString(), + } + : FALLBACK + + cache = { value, at: Date.now() } + return value +} + +export function invalidateSettingsCache(): void { + cache = null +} + +/** True when the address falls under an SSO-enforced domain (password sign-in off). */ +export async function isSsoEnforced(email: string): Promise { + const { ssoEnforcedDomains } = await getSettings() + const lower = email.toLowerCase() + return ssoEnforcedDomains.some((domain) => { + const suffix = domain.startsWith('@') ? domain.toLowerCase() : `@${domain.toLowerCase()}` + return lower.endsWith(suffix) + }) +} + +export async function touchSettingsUpdatedBy(userId: number): Promise { + await exec('UPDATE settings SET updated_by = ? WHERE id = 1', [userId]) + invalidateSettingsCache() +} diff --git a/apps/editor/panel/lib/types.ts b/apps/editor/panel/lib/types.ts new file mode 100644 index 000000000..13cbcb2c8 --- /dev/null +++ b/apps/editor/panel/lib/types.ts @@ -0,0 +1,198 @@ +/** + * Core domain types — section 08 of the rebuild plan, verbatim where the document + * spells them out. These are the types every later step compiles against. + * + * At the API boundary `id` is always the CHAR(26) ULID `public_id`; the internal + * BIGINT primary key never leaves the server. Timestamps are ISO-8601 UTC + * strings and are formatted in the i18n layer, never in the query. + */ + +export type AuthState = + | 'anonymous' + | 'verifying' + | 'mfaRequired' + | 'mfaLocked' + | 'firstSignIn' + | 'passwordExpired' + | 'signedIn' + | 'idleWarning' + | 'expired' + | 'suspended' + +export type Role = 'Admin' | 'Supervisor' | 'Editor' | 'Viewer' | (string & {}) +export type UserStatus = 'Active' | 'Inactive' | 'Invited' + +export interface User { + name: string + email: string + username: string + role: Role + mfa: 'On' | 'Off' + status: UserStatus + lastSeen: string + siteRoles?: Record +} + +export interface Assignment { + user: string + site: string + role: Role + grantedBy: string + grantedAt: string +} + +export type Scenario = 'happy' | 'locked' | 'sso' | 'error' | 'empty' | 'slow' + +/* ——— v3 types · one-to-one with the tables in section 10 ——— */ + +export type Org = 'internal' | 'external' +export type SiteStatus = 'active' | 'setup' | 'archived' +export type JobStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled' +export type KeyScope = 'read' | 'read_write' +export type HookStatus = 'active' | 'paused' | 'failing' + +export interface Site { + id: string + name: string + status: SiteStatus + storageSlots?: number + pickingSlots?: number + footprintM2?: number + createdBy: string + createdAt: string + userCount?: number + /** The 3D scene representing this site, once the editor has created it. */ + sceneId?: string | null +} + +export interface Invitation { + id: string + userId: string + invitedBy: string + expiresAt: string + resentCount: number + acceptedAt?: string | null + revokedAt?: string | null + state: 'pending' | 'expired' | 'accepted' | 'revoked' +} + +export interface Job { + id: string + kind: string + siteId?: string | null + status: JobStatus + progress: number + errorText?: string | null + attempts: number + queuedBy: string + queuedAt: string + startedAt?: string | null + finishedAt?: string | null +} + +export interface ApiKey { + id: string + name: string + prefix: string + scope: KeyScope + siteId?: string | null // null = every site + createdBy: string + createdAt: string + lastUsedAt?: string | null + revokedAt?: string | null + secret?: string // populated ONLY in the POST /api/keys response +} + +export interface Webhook { + id: string + url: string + events: string[] + status: HookStatus + failCount: number + lastDeliveryAt?: string | null + createdAt: string +} + +export interface OrgSettings { + sessionMinutes: number + keepSignedInAllowed: boolean + keepSignedInDays: number + trustedDeviceDays: number + concurrentSessionLimit: number + mfaRequired: boolean + ssoEnforcedDomains: string[] + externalUsersAllowed: boolean + inviteExpiryDays: number + updatedBy?: string | null + updatedAt: string +} + +export interface UserV3 extends User { + id: string + org: Org + invitation?: Invitation | null +} + +/* ——— supporting types the screens in steps 3–5 need ——— */ + +export const PERMISSIONS = [ + 'admin_access', + 'edit_projects', + 'create_projects', + 'delete_projects', + 'access_settings', + 'view_projects', + 'edit_users', + 'edit_roles', + 'view_logs', +] as const + +export type Permission = (typeof PERMISSIONS)[number] + +export interface RoleDefinition { + name: Role + permissions: Permission[] + isSystem: boolean +} + +export interface SessionInfo { + id: string + device: string | null + ip: string | null + current: boolean + createdAt: string + lastActivityAt: string + expiresAt: string + trustedUntil?: string | null +} + +/** What `GET /api/auth/session` hands the client. Never carries a password hash. */ +export interface SessionUser { + id: string + name: string + email: string + username: string + role: Role + org: Org + status: UserStatus + mfa: 'On' | 'Off' + permissions: Permission[] + mustChangePassword: boolean + siteRoles: Record +} + +export interface AccessRequest { + id: string + fullName: string + email: string + username: string + department: string + requestedRole: Role + note?: string | null + status: 'pending' | 'approved' | 'rejected' + createdAt: string +} + +export type ListState = 'ready' | 'loading' | 'error' | 'empty' +export type Lang = 'en' | 'tr' +export type Theme = 'dark' | 'light' diff --git a/apps/editor/panel/lib/users.ts b/apps/editor/panel/lib/users.ts new file mode 100644 index 000000000..c3759956a --- /dev/null +++ b/apps/editor/panel/lib/users.ts @@ -0,0 +1,359 @@ +import { ulid } from 'ulid' +import { invitationForUser } from './auth/invitations' +import { permissionsForRole } from './auth/roles' +import { exec, query, queryOne, type RowDataPacket, transaction } from './db' +import { collator } from './i18n' +import type { Lang, Permission, Role, UserStatus, UserV3 } from './types' + +/** + * The account the console must never let anyone delete or deactivate. The old + * panel hard-coded `admin@netlog.com.tr`; the rule survives, but it is anchored + * on the seeded username so a renamed address cannot orphan the tenant. + */ +export const PRIMARY_ADMIN_USERNAME = process.env.SEED_ADMIN_USERNAME ?? 'Admin' + +const DB_TO_UI: Record<'invited' | 'active' | 'inactive' | 'suspended', UserStatus> = { + invited: 'Invited', + active: 'Active', + inactive: 'Inactive', + suspended: 'Inactive', +} + +const UI_TO_DB: Record = { + Invited: 'invited', + Active: 'active', + Inactive: 'inactive', +} + +interface UserListRow extends RowDataPacket { + id: number + public_id: string + email: string + username: string + full_name: string + org: 'internal' | 'external' + global_role: string + status: 'invited' | 'active' | 'inactive' | 'suspended' + last_seen_at: Date | null + mfa_confirmed_at: Date | null +} + +const LIST_SELECT = ` + SELECT u.id, u.public_id, u.email, u.username, u.full_name, u.org, + u.global_role, u.status, u.last_seen_at, tf.confirmed_at AS mfa_confirmed_at + FROM users u + LEFT JOIN two_factor tf ON tf.user_id = u.id +` + +export type UserSortKey = 'name' | 'email' | 'username' | 'role' | 'status' + +export interface UserQuery { + search?: string + role?: string + sort?: UserSortKey + direction?: 'asc' | 'desc' + page?: number + pageSize?: number + lang?: Lang +} + +export interface UserListResult { + users: UserV3[] + total: number + /** Total before search/role filtering — drives "no users at all" vs "no matches". */ + totalUnfiltered: number + page: number + pageSize: number + without2fa: number +} + +function toUser(row: UserListRow, siteRoles: Record = {}): UserV3 { + return { + id: row.public_id, + name: row.full_name, + email: row.email, + username: row.username, + role: row.global_role as Role, + org: row.org, + mfa: row.mfa_confirmed_at ? 'On' : 'Off', + status: DB_TO_UI[row.status], + lastSeen: row.last_seen_at ? row.last_seen_at.toISOString() : '', + siteRoles, + } +} + +/** + * Lists users with search, role filter, sorting and paging. + * + * Sorting is done in the application, not in SQL: MySQL's utf8mb4_0900_ai_ci + * gets Turkish İ/ı wrong, and section 10 is explicit that the collation stays + * out of the database. `Intl.Collator('tr')` is the only place that rule lives. + * The row count here is small enough (an internal tenant) that reading the set + * and slicing it in memory is honest; if it ever is not, the fix is a generated + * sort-key column, not a collation change. + */ +export async function listUsers(opts: UserQuery = {}): Promise { + const rows = await query(LIST_SELECT) + + const totalUnfiltered = rows.length + const without2fa = rows.filter((r) => !r.mfa_confirmed_at).length + + const search = opts.search?.trim().toLocaleLowerCase(opts.lang === 'tr' ? 'tr' : 'en') ?? '' + const filtered = rows.filter((row) => { + if (opts.role && opts.role !== 'All' && row.global_role !== opts.role) return false + if (!search) return true + const haystack = + `${row.full_name} ${row.email} ${row.username} ${row.global_role}`.toLocaleLowerCase( + opts.lang === 'tr' ? 'tr' : 'en', + ) + return haystack.includes(search) + }) + + const compare = collator(opts.lang ?? 'en') + const key = opts.sort ?? 'name' + const sign = opts.direction === 'desc' ? -1 : 1 + + filtered.sort((a, b) => { + const pick = (row: UserListRow) => + key === 'email' + ? row.email + : key === 'username' + ? row.username + : key === 'role' + ? row.global_role + : key === 'status' + ? row.status + : row.full_name + return sign * compare.compare(pick(a), pick(b)) + }) + + const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 10)) + const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize)) + const page = Math.min(Math.max(1, opts.page ?? 1), pageCount) + const slice = filtered.slice((page - 1) * pageSize, page * pageSize) + + // Site roles and invitations are only loaded for the visible page — the list + // shows a badge, not the full assignment set. + const users = await Promise.all( + slice.map(async (row) => { + const user = toUser(row, await siteRolesFor(row.id)) + return row.status === 'invited' + ? { ...user, invitation: await invitationForUser(row.id) } + : user + }), + ) + + return { users, total: filtered.length, totalUnfiltered, page, pageSize, without2fa } +} + +export async function siteRolesFor(userId: number): Promise> { + const rows = await query( + `SELECT s.name, a.role + FROM assignments a + JOIN sites s ON s.id = a.site_id + WHERE a.user_id = ?`, + [userId], + ) + return Object.fromEntries(rows.map((r) => [r.name, r.role as Role])) +} + +export interface UserDetail extends UserV3 { + /** Widest permission set the account actually holds, global ∪ site roles. */ + effectivePermissions: Permission[] + isPrimaryAdmin: boolean + createdAt: string + passwordSetAt: string | null + activeSessions: number +} + +export async function getUserDetail(publicId: string): Promise { + // Its own SELECT, not LIST_SELECT plus a WHERE: the detail view needs two + // columns the list does not, and asserting them onto the list row type is how + // `created_at.toISOString()` ends up called on undefined at runtime. + const row = await queryOne( + `SELECT u.id, u.public_id, u.email, u.username, u.full_name, u.org, + u.global_role, u.status, u.last_seen_at, u.created_at, u.password_set_at, + tf.confirmed_at AS mfa_confirmed_at + FROM users u + LEFT JOIN two_factor tf ON tf.user_id = u.id + WHERE u.public_id = ?`, + [publicId], + ) + if (!row) return null + + const siteRoles = await siteRolesFor(row.id) + const granted = new Set() + for (const name of new Set([row.global_role, ...Object.values(siteRoles)])) { + for (const perm of await permissionsForRole(name)) granted.add(perm) + } + + const sessions = await queryOne( + 'SELECT COUNT(*) AS n FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()', + [row.id], + ) + + return { + ...toUser(row, siteRoles), + invitation: row.status === 'invited' ? await invitationForUser(row.id) : null, + effectivePermissions: [...granted], + isPrimaryAdmin: row.username === PRIMARY_ADMIN_USERNAME, + createdAt: row.created_at.toISOString(), + passwordSetAt: row.password_set_at ? row.password_set_at.toISOString() : null, + activeSessions: sessions?.n ?? 0, + } +} + +export async function findInternalId(publicId: string): Promise { + const row = await queryOne( + 'SELECT id FROM users WHERE public_id = ?', + [publicId], + ) + return row?.id ?? null +} + +/** + * External accounts cap out at Viewer globally (section 08). Real access for a + * 3PL partner arrives through site assignments, never through the global role — + * so this clamp is applied on write, not merely hidden in the UI. + */ +export function clampRole(org: 'internal' | 'external', role: string): string { + return org === 'external' && role !== 'Viewer' ? 'Viewer' : role +} + +export interface CreateUserInput { + fullName: string + username: string + email: string + role: string + org: 'internal' | 'external' + siteNames: string[] +} + +/** Creates an invited account plus its site assignments in one transaction. */ +export async function createInvitedUser( + input: CreateUserInput, + actorId: number, +): Promise<{ userId: number; publicId: string }> { + const publicId = ulid() + + return transaction(async (cx) => { + await cx.execute( + `INSERT INTO users (public_id, email, username, full_name, org, global_role, status, must_change_password) + VALUES (?, ?, ?, ?, ?, ?, 'invited', 1)`, + [ + publicId, + input.email, + input.username, + input.fullName, + input.org, + clampRole(input.org, input.role), + ], + ) + + const [rows] = await cx.execute>( + 'SELECT id FROM users WHERE public_id = ?', + [publicId], + ) + const userId = rows[0]?.id + if (userId === undefined) throw new Error('user row vanished after insert') + + for (const siteName of input.siteNames) { + const [siteRows] = await cx.execute>( + 'SELECT id FROM sites WHERE name = ?', + [siteName], + ) + if (!siteRows[0]) continue + await cx.execute( + `INSERT INTO assignments (user_id, site_id, role, granted_by) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE role = VALUES(role), granted_by = VALUES(granted_by)`, + [userId, siteRows[0].id, clampRole(input.org, input.role), actorId], + ) + } + + return { userId, publicId } + }) +} + +/** Replaces a user's site assignments wholesale. `null` role means no access. */ +export async function setAssignments( + userId: number, + org: 'internal' | 'external', + siteRoles: Record, + actorId: number, +): Promise { + await transaction(async (cx) => { + for (const [siteName, role] of Object.entries(siteRoles)) { + const [siteRows] = await cx.execute>( + 'SELECT id FROM sites WHERE name = ?', + [siteName], + ) + const siteId = siteRows[0]?.id + if (!siteId) continue + + if (role === null) { + await cx.execute('DELETE FROM assignments WHERE user_id = ? AND site_id = ?', [ + userId, + siteId, + ]) + continue + } + await cx.execute( + `INSERT INTO assignments (user_id, site_id, role, granted_by) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE role = VALUES(role), granted_by = VALUES(granted_by), granted_at = NOW()`, + [userId, siteId, clampRole(org, role), actorId], + ) + } + }) +} + +export async function updateUser( + userId: number, + patch: { + fullName?: string + email?: string + username?: string + role?: string + status?: UserStatus + }, + org: 'internal' | 'external', +): Promise { + const sets: string[] = [] + const params: unknown[] = [] + + if (patch.fullName !== undefined) { + sets.push('full_name = ?') + params.push(patch.fullName) + } + if (patch.email !== undefined) { + sets.push('email = ?') + params.push(patch.email.toLowerCase()) + } + if (patch.username !== undefined) { + sets.push('username = ?') + params.push(patch.username) + } + if (patch.role !== undefined) { + sets.push('global_role = ?') + params.push(clampRole(org, patch.role)) + } + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(UI_TO_DB[patch.status]) + } + if (sets.length === 0) return + + params.push(userId) + await exec(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, params) +} + +export async function deleteUser(userId: number): Promise { + // assignments, sessions, invitations, two_factor and recovery_codes all cascade. + await exec('DELETE FROM users WHERE id = ?', [userId]) +} + +export async function siteNames(): Promise { + const rows = await query( + "SELECT name FROM sites WHERE status <> 'archived' ORDER BY name", + ) + return rows.map((r) => r.name) +} diff --git a/apps/editor/panel/migrate.ts b/apps/editor/panel/migrate.ts new file mode 100644 index 000000000..ff2e9ed14 --- /dev/null +++ b/apps/editor/panel/migrate.ts @@ -0,0 +1,160 @@ +/** + * Applies every db/migrations/*.sql not yet recorded in schema_migrations. + * Creates the database if it is missing, so a fresh MySQL 8 needs no manual step. + * + * node --experimental-strip-types scripts/migrate.ts + */ +import { readdir, readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import mysql from 'mysql2/promise' +import { loadEnv } from './env' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const MIGRATIONS_DIR = join(HERE, 'migrations') + +/** + * Splits a migration into statements on semicolons that sit outside string + * literals, comments and BEGIN...END blocks. Naive `split(';')` breaks on the + * functional index in 002, whose CASE expression contains no semicolon but whose + * surrounding parentheses matter for readability of the error if it ever does. + */ +function splitStatements(sql: string): string[] { + const out: string[] = [] + let buf = '' + let quote: string | null = null + let lineComment = false + let blockComment = false + + for (let i = 0; i < sql.length; i++) { + const ch = sql[i] + const next = sql[i + 1] + + if (lineComment) { + if (ch === '\n') lineComment = false + buf += ch + continue + } + if (blockComment) { + if (ch === '*' && next === '/') { + blockComment = false + buf += '*/' + i++ + continue + } + buf += ch + continue + } + if (quote) { + buf += ch + if (ch === '\\') { + if (next !== undefined) { + buf += next + i++ + } + continue + } + if (ch === quote) quote = null + continue + } + if (ch === '-' && next === '-') { + lineComment = true + buf += '--' + i++ + continue + } + if (ch === '/' && next === '*') { + blockComment = true + buf += '/*' + i++ + continue + } + if (ch === "'" || ch === '"' || ch === '`') { + quote = ch + buf += ch + continue + } + if (ch === ';') { + out.push(buf) + buf = '' + continue + } + buf += ch + } + out.push(buf) + + return out + .map((s) => + s + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n') + .trim(), + ) + .filter((s) => s.length > 0) +} + +async function main() { + loadEnv() + + const host = process.env.DATABASE_HOST ?? '127.0.0.1' + const port = Number(process.env.DATABASE_PORT ?? 3306) + const user = process.env.DATABASE_USER ?? 'root' + const password = process.env.DATABASE_PASSWORD ?? '' + const database = process.env.DATABASE_NAME ?? 'digitaltwin' + + const bootstrap = await mysql.createConnection({ + host, + port, + user, + password, + multipleStatements: false, + }) + await bootstrap.query( + `CREATE DATABASE IF NOT EXISTS \`${database.replace(/`/g, '')}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, + ) + await bootstrap.end() + + const cx = await mysql.createConnection({ host, port, user, password, database }) + await cx.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name VARCHAR(190) PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + + const [appliedRows] = await cx.query('SELECT name FROM schema_migrations') + const applied = new Set(appliedRows.map((r) => r.name as string)) + + const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith('.sql')).sort() + let ran = 0 + + for (const file of files) { + if (applied.has(file)) continue + const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf8') + const statements = splitStatements(sql) + + await cx.beginTransaction() + try { + for (const statement of statements) await cx.query(statement) + await cx.query('INSERT INTO schema_migrations (name) VALUES (?)', [file]) + await cx.commit() + } catch (err) { + // MySQL 8 commits DDL implicitly, so a rollback here cannot undo half a + // migration. Report loudly instead of pretending it was clean. + await cx.rollback().catch(() => {}) + console.error(`\n ✗ ${file} failed. DDL already executed in this file is NOT rolled back.`) + throw err + } + console.log(` ✓ ${file} (${statements.length} statements)`) + ran++ + } + + await cx.end() + console.log(ran === 0 ? 'Schema already up to date.' : `Applied ${ran} migration(s).`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/editor/panel/migrations/001_init.sql b/apps/editor/panel/migrations/001_init.sql new file mode 100644 index 000000000..9c0dc32a6 --- /dev/null +++ b/apps/editor/panel/migrations/001_init.sql @@ -0,0 +1,171 @@ +-- 001_init — DigitalTwin core schema +-- Contract: "DigitalTwin Rebuild Plan" section 10 (MySQL 8, InnoDB, utf8mb4). +-- Two-layer identity: internal BIGINT UNSIGNED PK never leaves the process; +-- CHAR(26) ULID public_id is the only id that appears in URLs, APIs and logs. +-- Collation utf8mb4_unicode_ci; Turkish name ordering is done in the app layer +-- with Intl.Collator('tr') so the I/ı trap never reaches the database. +-- All timestamps are stored UTC and formatted per locale on display. + +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(26) NOT NULL UNIQUE, + email VARCHAR(320) NOT NULL UNIQUE, + username VARCHAR(64) NOT NULL UNIQUE, + full_name VARCHAR(160) NOT NULL, + org ENUM('internal','external') NOT NULL DEFAULT 'internal', + global_role VARCHAR(48) NOT NULL DEFAULT 'Viewer', + status ENUM('invited','active','inactive','suspended') NOT NULL, + password_hash VARBINARY(255) NULL, -- argon2id; NULL while invited + password_set_at TIMESTAMP NULL, + must_change_password TINYINT(1) NOT NULL DEFAULT 0, + failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0, + locked_until TIMESTAMP NULL, + last_seen_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_users_status (status), KEY idx_users_org (org) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE sites ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(26) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL UNIQUE, -- 'Sakarya LM1' + status ENUM('active','setup','archived') NOT NULL DEFAULT 'setup', + storage_slots INT UNSIGNED NULL, picking_slots INT UNSIGNED NULL, + footprint_m2 INT UNSIGNED NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_sites_creator FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE assignments ( -- user x site x role + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + site_id BIGINT UNSIGNED NOT NULL, + role VARCHAR(48) NOT NULL, + granted_by BIGINT UNSIGNED NOT NULL, + granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_user_site (user_id, site_id), + CONSTRAINT fk_asg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_asg_site FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE, + CONSTRAINT fk_asg_by FOREIGN KEY (granted_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE invitations ( -- invite lifecycle + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(26) NOT NULL UNIQUE, + user_id BIGINT UNSIGNED NOT NULL, + token_hash BINARY(32) NOT NULL UNIQUE, -- SHA-256; raw token only in the email + invited_by BIGINT UNSIGNED NOT NULL, + expires_at TIMESTAMP NOT NULL, -- default +7 days + resent_count TINYINT UNSIGNED NOT NULL DEFAULT 0, + accepted_at TIMESTAMP NULL, revoked_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_inv_user (user_id), + CONSTRAINT fk_inv_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_inv_by FOREIGN KEY (invited_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE sessions ( + id BINARY(16) PRIMARY KEY, -- random 128 bit + user_id BIGINT UNSIGNED NOT NULL, + device VARCHAR(160) NULL, ip VARBINARY(16) NULL, + trusted_until TIMESTAMP NULL, -- trusted device + keep_signed_in TINYINT(1) NOT NULL DEFAULT 0, + mfa_pending TINYINT(1) NOT NULL DEFAULT 0, -- set until the OTP step clears + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_activity_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + KEY idx_sess_user (user_id), KEY idx_sess_exp (expires_at), + CONSTRAINT fk_sess_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE two_factor ( + user_id BIGINT UNSIGNED PRIMARY KEY, + totp_secret VARBINARY(255) NOT NULL, -- encrypted in the app layer + confirmed_at TIMESTAMP NULL, + CONSTRAINT fk_2fa_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE recovery_codes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + code_hash BINARY(32) NOT NULL, + used_at TIMESTAMP NULL, + KEY idx_rc_user (user_id), + CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE api_keys ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(26) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + prefix CHAR(8) NOT NULL, -- the part shown in the list + key_hash BINARY(32) NOT NULL UNIQUE, -- raw key returned once, at creation + scope ENUM('read','read_write') NOT NULL DEFAULT 'read', + site_id BIGINT UNSIGNED NULL, -- NULL = all sites + created_by BIGINT UNSIGNED NOT NULL, + last_used_at TIMESTAMP NULL, revoked_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_key_site FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE, + CONSTRAINT fk_key_by FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE webhooks ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(26) NOT NULL UNIQUE, + url VARCHAR(2048) NOT NULL, + events JSON NOT NULL, -- ['user.invited','site.created',...] + secret VARBINARY(255) NOT NULL, -- signing secret, encrypted + status ENUM('active','paused','failing') NOT NULL DEFAULT 'active', + fail_count INT UNSIGNED NOT NULL DEFAULT 0, + last_delivery_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE jobs ( -- job queue + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(26) NOT NULL UNIQUE, + kind VARCHAR(64) NOT NULL, -- ifc_import, report_export, backup... + payload JSON NULL, + site_id BIGINT UNSIGNED NULL, + status ENUM('queued','running','done','failed','cancelled') NOT NULL DEFAULT 'queued', + progress TINYINT UNSIGNED NOT NULL DEFAULT 0, + error_text TEXT NULL, + attempts TINYINT UNSIGNED NOT NULL DEFAULT 0, + queued_by BIGINT UNSIGNED NOT NULL, + queued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP NULL, finished_at TIMESTAMP NULL, + KEY idx_jobs_status (status, queued_at), + CONSTRAINT fk_jobs_site FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE SET NULL, + CONSTRAINT fk_jobs_by FOREIGN KEY (queued_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + actor_user_id BIGINT UNSIGNED NULL, -- NULL = system / browser + actor_label VARCHAR(64) NOT NULL, -- 'system', 'browser', email + level ENUM('info','warn','error') NOT NULL, + kind VARCHAR(48) NULL, -- auth, role_change, invite... + message VARCHAR(1024) NOT NULL, + meta JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_audit_time (created_at), KEY idx_audit_actor (actor_label), + CONSTRAINT fk_audit_user FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE settings ( -- single-row org settings + id TINYINT UNSIGNED PRIMARY KEY DEFAULT 1, + session_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 20, + keep_signed_in_allowed TINYINT(1) NOT NULL DEFAULT 1, + keep_signed_in_days TINYINT UNSIGNED NOT NULL DEFAULT 14, + trusted_device_days TINYINT UNSIGNED NOT NULL DEFAULT 30, + concurrent_session_limit TINYINT UNSIGNED NOT NULL DEFAULT 3, + mfa_required TINYINT(1) NOT NULL DEFAULT 1, + sso_enforced_domains JSON NULL, -- ['@netlog.com.tr'] + external_users_allowed TINYINT(1) NOT NULL DEFAULT 1, + invite_expiry_days TINYINT UNSIGNED NOT NULL DEFAULT 7, + updated_by BIGINT UNSIGNED NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/editor/panel/migrations/002_roles_and_requests.sql b/apps/editor/panel/migrations/002_roles_and_requests.sql new file mode 100644 index 000000000..21acaa792 --- /dev/null +++ b/apps/editor/panel/migrations/002_roles_and_requests.sql @@ -0,0 +1,50 @@ +-- 002_roles_and_requests +-- +-- Two additions on top of the contract's 12 tables. Both are called for by the +-- document but left without a DDL block there, so they are kept in their own +-- migration rather than smuggled into 001. +-- +-- roles — section 10 closing note: "system roles (Admin, Editor, +-- Viewer) are constants in code; custom roles can move into +-- roles(name, permissions JSON)". This is the counterpart of +-- the prototype's `perms` dictionary and backs the Roles tab. +-- access_requests — the `#/request` screen (section 03, "Account request") and +-- the request -> invite loop (WP7) need somewhere to park a +-- self-service request until an administrator approves it. +-- Approval writes users + invitations; this table only holds +-- the pending ask. + +CREATE TABLE roles ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(48) NOT NULL UNIQUE, + permissions JSON NOT NULL, -- ['view_projects','edit_users',...] + is_system TINYINT(1) NOT NULL DEFAULT 0, -- system roles cannot be deleted + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE access_requests ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(26) NOT NULL UNIQUE, + full_name VARCHAR(160) NOT NULL, + email VARCHAR(320) NOT NULL, + username VARCHAR(64) NOT NULL, + department VARCHAR(64) NOT NULL, + requested_role VARCHAR(48) NOT NULL, + note VARCHAR(1024) NULL, + status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending', + decided_by BIGINT UNSIGNED NULL, + decided_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_req_status (status, created_at), + CONSTRAINT fk_req_decider FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- One pending request per address; a rejected/approved row must not block a +-- retry. Written as a generated column + unique prefix key rather than a +-- functional index: MariaDB (what shared hosting runs) has no expression +-- indexes, and the 191 prefix keeps the key under the 767-byte row cap. +ALTER TABLE access_requests + ADD COLUMN pending_email VARCHAR(320) + GENERATED ALWAYS AS (CASE WHEN status = 'pending' THEN email ELSE NULL END) STORED, + ADD UNIQUE KEY uq_req_pending_email (pending_email(191)); diff --git a/apps/editor/panel/migrations/003_password_resets.sql b/apps/editor/panel/migrations/003_password_resets.sql new file mode 100644 index 000000000..7b7068b0c --- /dev/null +++ b/apps/editor/panel/migrations/003_password_resets.sql @@ -0,0 +1,22 @@ +-- 003_password_resets +-- +-- Section 08 lists `reset` among the step-4 endpoints but section 10 has no +-- table behind it. The `invitations` table is the obvious near-fit — same +-- token_hash / expires_at / user_id shape — but its columns mean invite things +-- (resent_count, accepted_at) and overloading them would make "how many invites +-- are pending" unanswerable. A reset link is its own object, so it gets its own +-- table with the same hashing discipline: SHA-256 stored, raw token only ever in +-- the email. + +CREATE TABLE password_resets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + token_hash BINARY(32) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, -- 30 minutes, per the reset screen copy + used_at TIMESTAMP NULL, + requested_ip VARBINARY(16) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_pwr_user (user_id), + KEY idx_pwr_expiry (expires_at), + CONSTRAINT fk_pwr_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/editor/panel/migrations/004_site_scene.sql b/apps/editor/panel/migrations/004_site_scene.sql new file mode 100644 index 000000000..8fd970e8c --- /dev/null +++ b/apps/editor/panel/migrations/004_site_scene.sql @@ -0,0 +1,4 @@ +-- A site is represented in the editor by a 3D scene. The column stays NULL in +-- a standalone console deployment (nothing there creates scenes); in the +-- combined app the editor's site-scene worker fills it in after provisioning. +ALTER TABLE sites ADD COLUMN scene_id VARCHAR(64) NULL; diff --git a/apps/editor/panel/migrations/005_user_locale.sql b/apps/editor/panel/migrations/005_user_locale.sql new file mode 100644 index 000000000..813ee203a --- /dev/null +++ b/apps/editor/panel/migrations/005_user_locale.sql @@ -0,0 +1,7 @@ +-- Which language to write to a person in. +-- +-- The console already renders in English or Turkish per reader, but mail is +-- composed with nobody present to ask — so the preference has to be stored. +-- It is recorded from the reader's own cookie when they sign in, which keeps +-- it true without anyone having to maintain it. +ALTER TABLE users ADD COLUMN locale VARCHAR(5) NOT NULL DEFAULT 'en'; diff --git a/apps/editor/panel/migrations/006_provenance_survives_deletion.sql b/apps/editor/panel/migrations/006_provenance_survives_deletion.sql new file mode 100644 index 000000000..b6c89dc1e --- /dev/null +++ b/apps/editor/panel/migrations/006_provenance_survives_deletion.sql @@ -0,0 +1,37 @@ +-- Deleting a user must not be blocked by what they once did. +-- +-- Five columns record who performed an action — created a site, granted an +-- assignment, sent an invitation, created an API key, queued a job. All five +-- were NOT NULL with a bare FOREIGN KEY, whose default rule is RESTRICT: the +-- moment an account had created anything, DELETE FROM users failed with a +-- constraint error the UI could only report as "something went wrong". +-- +-- Provenance is history, not ownership. The site outlives its creator; the +-- record of *who* becomes NULL, exactly as audit_log.actor_user_id already +-- does. (The readers were flipped to LEFT JOIN in the same change, so rows +-- with a deleted actor keep appearing in every list.) + +ALTER TABLE sites DROP FOREIGN KEY fk_sites_creator; +ALTER TABLE sites MODIFY created_by BIGINT UNSIGNED NULL; +ALTER TABLE sites ADD CONSTRAINT fk_sites_creator + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE assignments DROP FOREIGN KEY fk_asg_by; +ALTER TABLE assignments MODIFY granted_by BIGINT UNSIGNED NULL; +ALTER TABLE assignments ADD CONSTRAINT fk_asg_by + FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE invitations DROP FOREIGN KEY fk_inv_by; +ALTER TABLE invitations MODIFY invited_by BIGINT UNSIGNED NULL; +ALTER TABLE invitations ADD CONSTRAINT fk_inv_by + FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE api_keys DROP FOREIGN KEY fk_key_by; +ALTER TABLE api_keys MODIFY created_by BIGINT UNSIGNED NULL; +ALTER TABLE api_keys ADD CONSTRAINT fk_key_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; + +ALTER TABLE jobs DROP FOREIGN KEY fk_jobs_by; +ALTER TABLE jobs MODIFY queued_by BIGINT UNSIGNED NULL; +ALTER TABLE jobs ADD CONSTRAINT fk_jobs_by + FOREIGN KEY (queued_by) REFERENCES users(id) ON DELETE SET NULL; diff --git a/apps/editor/panel/migrations/007_indexes_for_the_hot_paths.sql b/apps/editor/panel/migrations/007_indexes_for_the_hot_paths.sql new file mode 100644 index 000000000..276d3a0a5 --- /dev/null +++ b/apps/editor/panel/migrations/007_indexes_for_the_hot_paths.sql @@ -0,0 +1,44 @@ +-- Indexes for the three lookups that run on pages everyone opens. +-- +-- `sites.scene_id` was added by 004 with no index, so `publishedSceneIds()` +-- and `unpublishScene()` — which run on the console's scenes tab, on every +-- publish and on every scene delete — scan the whole table. It is also the +-- column the sign-in showcase joins on. +-- +-- `users.created_at` is the ORDER BY of the account list and had no index, so +-- every listing filesorted the table. +-- +-- `audit_log.created_at` orders the audit trail, which is the fastest-growing +-- table in the schema and the one most likely to be read with a LIMIT. +-- +-- CREATE INDEX is not idempotent on MariaDB 10.11 (no IF NOT EXISTS for +-- indexes in every version), so each one is guarded against information_schema +-- and executed only when missing. The runner splits on semicolons outside +-- strings, so each guard is one statement. + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sites' AND INDEX_NAME = 'idx_sites_scene') = 0, + 'ALTER TABLE sites ADD KEY idx_sites_scene (scene_id)', + 'DO 0'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_created') = 0, + 'ALTER TABLE users ADD KEY idx_users_created (created_at)', + 'DO 0'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_log' AND INDEX_NAME = 'idx_audit_created') = 0, + 'ALTER TABLE audit_log ADD KEY idx_audit_created (created_at)', + 'DO 0'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/apps/editor/panel/seed.ts b/apps/editor/panel/seed.ts new file mode 100644 index 000000000..9e1dc2944 --- /dev/null +++ b/apps/editor/panel/seed.ts @@ -0,0 +1,169 @@ +/** + * Seeds the acceptance baseline named in the handover order, step 1: + * "1 admin, 3 sites, the single settings row" — plus the three system roles, + * which the Roles tab reads and which no other step creates. + * + * node --experimental-strip-types scripts/seed.ts + * node --experimental-strip-types scripts/seed.ts --dev # + the two supervisors + * + * Idempotent: every insert is guarded, so re-running never duplicates a row. + */ + +import { hash as argon2Hash } from '@node-rs/argon2' +import mysql from 'mysql2/promise' +import { ulid } from 'ulid' +import { loadEnv } from './env' + +const ARGON2ID = 2 +const ARGON2_OPTS = { algorithm: ARGON2ID, memoryCost: 19456, timeCost: 2, parallelism: 1 } as const + +const PERMISSIONS = [ + 'admin_access', + 'edit_projects', + 'create_projects', + 'delete_projects', + 'access_settings', + 'view_projects', + 'edit_users', + 'edit_roles', + 'view_logs', +] as const + +const SYSTEM_ROLES: Array<{ name: string; permissions: string[] }> = [ + { name: 'Admin', permissions: [...PERMISSIONS] }, + { + name: 'Editor', + permissions: [ + 'edit_projects', + 'create_projects', + 'delete_projects', + 'access_settings', + 'view_projects', + ], + }, + { name: 'Viewer', permissions: ['view_projects'] }, +] + +// Figures lifted from the prototype's SITES constant. The contract asks for +// three; the remaining two (Torbalı CX, Kocaeli LM2) are created in the console. +const SITES = [ + { name: 'Sakarya LM1', status: 'active', storage: 12480, picking: 1840, footprint: 42000 }, + { name: 'Esenyurt DC2', status: 'active', storage: 8120, picking: 2260, footprint: 28400 }, + { name: 'Gebze LM3', status: 'setup', storage: 19650, picking: 1120, footprint: 61800 }, +] as const + +const DEV_USERS = [ + { + name: 'Resul Övür', + username: 'r.ovur', + email: 'resul.ovur@netlog.com.tr', + password: 'ro12345', + }, + { + name: 'Cengiz Tuna', + username: 'c.tuna', + email: 'cengiz.tuna@netlog.com.tr', + password: 'ct12345', + }, +] as const + +async function main() { + loadEnv() + const withDev = process.argv.includes('--dev') + + const cx = await mysql.createConnection({ + host: process.env.DATABASE_HOST ?? '127.0.0.1', + port: Number(process.env.DATABASE_PORT ?? 3306), + user: process.env.DATABASE_USER ?? 'root', + password: process.env.DATABASE_PASSWORD ?? '', + database: process.env.DATABASE_NAME ?? 'digitaltwin', + charset: 'utf8mb4_unicode_ci', + timezone: 'Z', + }) + + for (const role of SYSTEM_ROLES) { + await cx.execute( + `INSERT INTO roles (name, permissions, is_system) VALUES (?, ?, 1) + ON DUPLICATE KEY UPDATE permissions = VALUES(permissions), is_system = 1`, + [role.name, JSON.stringify(role.permissions)], + ) + } + console.log(` ✓ roles (${SYSTEM_ROLES.map((r) => r.name).join(', ')})`) + + // The prototype signs the primary admin in as username `Admin` with no email + // address. The schema makes email NOT NULL UNIQUE and the document wins over + // the prototype, so the account carries the old panel's protected address. + // The password is a bootstrap credential: must_change_password forces the + // first sign-in through /welcome before anything else is reachable. + const adminUsername = process.env.SEED_ADMIN_USERNAME ?? 'Admin' + const adminEmail = process.env.SEED_ADMIN_EMAIL ?? 'admin@netlog.com.tr' + const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? 'Admin' + const adminHash = await argon2Hash(adminPassword, ARGON2_OPTS) + + await cx.execute( + `INSERT INTO users (public_id, email, username, full_name, org, global_role, status, + password_hash, password_set_at, must_change_password) + VALUES (?, ?, ?, ?, 'internal', 'Admin', 'active', ?, NOW(), 1) + ON DUPLICATE KEY UPDATE id = id`, + [ulid(), adminEmail, adminUsername, 'System Administrator', Buffer.from(adminHash, 'utf8')], + ) + + const [adminRows] = await cx.execute( + 'SELECT id FROM users WHERE username = ?', + [adminUsername], + ) + const adminId = adminRows[0]?.id as number | undefined + if (!adminId) throw new Error('admin seed did not resolve an id') + console.log( + ` ✓ admin (${adminUsername} / ${adminEmail}) — must change password on first sign-in`, + ) + + for (const site of SITES) { + await cx.execute( + `INSERT INTO sites (public_id, name, status, storage_slots, picking_slots, footprint_m2, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE id = id`, + [ulid(), site.name, site.status, site.storage, site.picking, site.footprint, adminId], + ) + } + console.log(` ✓ sites (${SITES.map((s) => s.name).join(', ')})`) + + // sso_enforced_domains starts empty on purpose. The DDL comment shows the + // shape (['@netlog.com.tr']), not a default — seeding the domain would switch + // password sign-in off for the whole organisation before anyone can sign in. + await cx.execute( + `INSERT INTO settings (id, sso_enforced_domains, updated_by) + VALUES (1, '[]', ?) + ON DUPLICATE KEY UPDATE id = id`, + [adminId], + ) + console.log(' ✓ settings (single row, defaults from section 10)') + + if (withDev) { + for (const user of DEV_USERS) { + const pwd = await argon2Hash(user.password, ARGON2_OPTS) + await cx.execute( + `INSERT INTO users (public_id, email, username, full_name, org, global_role, status, + password_hash, password_set_at, must_change_password) + VALUES (?, ?, ?, ?, 'internal', 'Supervisor', 'active', ?, NOW(), 1) + ON DUPLICATE KEY UPDATE id = id`, + [ulid(), user.email, user.username, user.name, Buffer.from(pwd, 'utf8')], + ) + } + console.log(` ✓ dev users (${DEV_USERS.map((u) => u.username).join(', ')})`) + } + + await cx.execute( + `INSERT INTO audit_log (actor_user_id, actor_label, level, kind, message) + VALUES (?, 'system', 'info', 'seed', ?)`, + [adminId, withDev ? 'Database seeded (with dev users)' : 'Database seeded'], + ) + + await cx.end() + console.log('\nSeed complete.') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/editor/public/.gitignore b/apps/editor/public/.gitignore new file mode 100644 index 000000000..681508c03 --- /dev/null +++ b/apps/editor/public/.gitignore @@ -0,0 +1,4 @@ +# Copied from node_modules/web-ifc/ by scripts/copy-web-ifc-wasm.mjs +# (runs on predev / prebuild). +web-ifc.wasm +web-ifc-mt.wasm diff --git a/apps/editor/public/brand/digitaltwin-mark-small.png b/apps/editor/public/brand/digitaltwin-mark-small.png new file mode 100644 index 000000000..585d87eba Binary files /dev/null and b/apps/editor/public/brand/digitaltwin-mark-small.png differ diff --git a/apps/editor/public/brand/digitaltwin-mark.png b/apps/editor/public/brand/digitaltwin-mark.png new file mode 100644 index 000000000..f91985097 Binary files /dev/null and b/apps/editor/public/brand/digitaltwin-mark.png differ diff --git a/apps/editor/public/pascal-logo-full.svg b/apps/editor/public/pascal-logo-full.svg deleted file mode 100644 index 313883578..000000000 --- a/apps/editor/public/pascal-logo-full.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/editor/public/pascal-logo-shape.svg b/apps/editor/public/pascal-logo-shape.svg deleted file mode 100644 index 94b8e41e6..000000000 --- a/apps/editor/public/pascal-logo-shape.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/editor/public/pascal.svg b/apps/editor/public/pascal.svg deleted file mode 100644 index c50259df1..000000000 --- a/apps/editor/public/pascal.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/editor/scripts/copy-web-ifc-wasm.mjs b/apps/editor/scripts/copy-web-ifc-wasm.mjs new file mode 100644 index 000000000..3d589c269 --- /dev/null +++ b/apps/editor/scripts/copy-web-ifc-wasm.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// web-ifc ships its WASM binaries inside node_modules. Next.js needs to +// serve them at the app root URL (the library hardcodes `/web-ifc.wasm` +// when no `wasmPath` override is set), so copy the three blobs into +// `public/` so they're served from /web-ifc*.wasm. +// +// Run on `postinstall` and again on `predev` / `prebuild` so a forgotten +// install step doesn't leave the dev server with a stale or missing +// copy. Idempotent: skips files that already match by size. + +import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +// web-ifc's package.json doesn't expose subpath exports, so we can't use +// require.resolve('web-ifc/package.json'). Walk up the script directory +// looking for the package folder inside any node_modules along the way. +function findWebIfcDir(startDir) { + let dir = startDir + while (dir && dir !== '/') { + const candidate = join(dir, 'node_modules', 'web-ifc') + if (existsSync(join(candidate, 'web-ifc.wasm'))) return candidate + dir = resolve(dir, '..') + } + return null +} + +const webIfcDir = findWebIfcDir(import.meta.dirname) +if (!webIfcDir) { + console.warn('[editor] web-ifc package not found — wasm copy skipped.') + process.exit(0) +} +const publicDir = join(import.meta.dirname, '..', 'public') + +mkdirSync(publicDir, { recursive: true }) + +// Browser blobs only — `web-ifc-node.wasm` is never fetched by a page and +// would add a megabyte of dead weight to the deployed bundle. +const files = ['web-ifc.wasm', 'web-ifc-mt.wasm'] +for (const name of files) { + const src = join(webIfcDir, name) + const dst = join(publicDir, name) + try { + const srcSize = statSync(src).size + let dstSize = 0 + try { + dstSize = statSync(dst).size + } catch { + /* not present yet */ + } + if (srcSize === dstSize) { + continue + } + copyFileSync(src, dst) + console.log(`[editor] copied ${name} (${(srcSize / 1024).toFixed(0)} KB)`) + } catch (err) { + console.warn(`[editor] could not copy ${name}:`, err.message) + } +} diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json index 70924e110..85141bb2e 100644 --- a/apps/editor/tsconfig.json +++ b/apps/editor/tsconfig.json @@ -7,7 +7,12 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ], + "@panel/*": [ + "./panel/*" + ] } }, "include": [ @@ -17,9 +22,17 @@ "next.config.js", ".next/types/**/*.ts" ], - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"], + "exclude": [ + "node_modules", + "**/*.test.ts", + "**/*.test.tsx" + ], "references": [ - { "path": "../../packages/core" }, - { "path": "../../packages/viewer" } + { + "path": "../../packages/core" + }, + { + "path": "../../packages/viewer" + } ] -} +} \ No newline at end of file diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/biome.jsonc b/biome.jsonc index c72578720..6f522c22c 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -111,6 +111,19 @@ ] }, "overrides": [ + { + // Vendored console sources (synced from ovurrsl/panel). Its hooks + // intentionally pin effect dependencies; re-deriving them here would + // change runtime behavior, so the rule is off for this subtree only. + "includes": ["apps/editor/panel/**"], + "linter": { + "rules": { + "correctness": { + "useExhaustiveDependencies": "off" + } + } + } + }, { "includes": ["packages/editor/components/debug/react-scan.tsx"], "assist": { diff --git a/bun.lock b/bun.lock index d8ac1e9fe..27430400a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "editor", @@ -28,9 +27,12 @@ "version": "0.1.0", "dependencies": { "@iconify/react": "^6.0.2", + "@node-rs/argon2": "^2.0.2", "@number-flow/react": "^0.6.0", + "@ovurrsl/plugin-warehouse": "git+https://github.com/ovurrsl/plugin-warehouse.git#b53d9dee7623039a89fe3390716e96ad95af7164", "@pascal-app/core": "*", "@pascal-app/editor": "*", + "@pascal-app/ifc-converter": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", @@ -42,19 +44,27 @@ "clsx": "^2.1.1", "geist": "^1.7.0", "lucide-react": "^1.7.0", + "mysql2": "^3.15.4", "next": "16.2.9", + "nodemailer": "^9.0.3", + "otpauth": "^9.5.1", "postcss": "^8.5.6", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "three": "^0.185.0", + "ulid": "^3.0.2", + "web-ifc": "^0.0.77", "zod": "^4.3.5", }, "devDependencies": { "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", + "@types/nodemailer": "^8.0.1", + "@types/qrcode": "^1.5.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^3.0.2", @@ -98,7 +108,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.9.2", + "version": "1.0.0-beta.1", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -124,7 +134,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.9.2", + "version": "1.0.0-beta.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -161,8 +171,8 @@ "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@pascal/typescript-config": "*", "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", @@ -174,8 +184,8 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -203,7 +213,7 @@ }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", - "version": "0.1.2", + "version": "1.0.0-beta.1", "dependencies": { "@pascal-app/core": "*", "nanoid": "^5.1.6", @@ -217,32 +227,33 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.3.2", + "version": "1.0.0-beta.1", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@pascal-app/lingo": "^0.2.0", + "mysql2": "^3.15.4", "zod": "^4.3.5", }, "devDependencies": { - "@pascal-app/core": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", }, }, "packages/nodes": { "name": "@pascal-app/nodes", - "version": "0.1.1", + "version": "1.0.0-beta.1", "devDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/editor": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/editor": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", @@ -251,9 +262,9 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", - "@pascal-app/editor": "^0.9.2", - "@pascal-app/viewer": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", + "@pascal-app/editor": "^1.0.0-beta.1", + "@pascal-app/viewer": "^1.0.0-beta.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "lucide-react": "^1", @@ -285,7 +296,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.9.2", + "version": "1.0.0-beta.1", "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", @@ -299,7 +310,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.2", + "@pascal-app/core": "^1.0.0-beta.1", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", @@ -391,11 +402,11 @@ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.70", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.70" } }, "sha512-3VXuL63IDmq13We+ApRKn2JW3Rb9g5gj1YEmfb8u2b73norur1VsIJ/pRE4qjShevg19dQYi2JsLawSZ6gApug=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -519,7 +530,7 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="], @@ -545,6 +556,36 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@node-rs/argon2": ["@node-rs/argon2@2.0.2", "", { "optionalDependencies": { "@node-rs/argon2-android-arm-eabi": "2.0.2", "@node-rs/argon2-android-arm64": "2.0.2", "@node-rs/argon2-darwin-arm64": "2.0.2", "@node-rs/argon2-darwin-x64": "2.0.2", "@node-rs/argon2-freebsd-x64": "2.0.2", "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", "@node-rs/argon2-linux-arm64-gnu": "2.0.2", "@node-rs/argon2-linux-arm64-musl": "2.0.2", "@node-rs/argon2-linux-x64-gnu": "2.0.2", "@node-rs/argon2-linux-x64-musl": "2.0.2", "@node-rs/argon2-wasm32-wasi": "2.0.2", "@node-rs/argon2-win32-arm64-msvc": "2.0.2", "@node-rs/argon2-win32-ia32-msvc": "2.0.2", "@node-rs/argon2-win32-x64-msvc": "2.0.2" } }, "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg=="], + + "@node-rs/argon2-android-arm-eabi": ["@node-rs/argon2-android-arm-eabi@2.0.2", "", { "os": "android", "cpu": "arm" }, "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw=="], + + "@node-rs/argon2-android-arm64": ["@node-rs/argon2-android-arm64@2.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg=="], + + "@node-rs/argon2-darwin-arm64": ["@node-rs/argon2-darwin-arm64@2.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA=="], + + "@node-rs/argon2-darwin-x64": ["@node-rs/argon2-darwin-x64@2.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw=="], + + "@node-rs/argon2-freebsd-x64": ["@node-rs/argon2-freebsd-x64@2.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg=="], + + "@node-rs/argon2-linux-arm-gnueabihf": ["@node-rs/argon2-linux-arm-gnueabihf@2.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww=="], + + "@node-rs/argon2-linux-arm64-gnu": ["@node-rs/argon2-linux-arm64-gnu@2.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew=="], + + "@node-rs/argon2-linux-arm64-musl": ["@node-rs/argon2-linux-arm64-musl@2.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA=="], + + "@node-rs/argon2-linux-x64-gnu": ["@node-rs/argon2-linux-x64-gnu@2.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA=="], + + "@node-rs/argon2-linux-x64-musl": ["@node-rs/argon2-linux-x64-musl@2.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw=="], + + "@node-rs/argon2-wasm32-wasi": ["@node-rs/argon2-wasm32-wasi@2.0.2", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.5" }, "cpu": "none" }, "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ=="], + + "@node-rs/argon2-win32-arm64-msvc": ["@node-rs/argon2-win32-arm64-msvc@2.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ=="], + + "@node-rs/argon2-win32-ia32-msvc": ["@node-rs/argon2-win32-ia32-msvc@2.0.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ=="], + + "@node-rs/argon2-win32-x64-msvc": ["@node-rs/argon2-win32-x64-msvc@2.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -567,6 +608,8 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@ovurrsl/plugin-warehouse": ["@ovurrsl/plugin-warehouse@github:ovurrsl/plugin-warehouse#b53d9de", { "peerDependencies": { "@iconify/react": "^6", "@pascal-app/core": ">=1.0.0-beta.1 <2", "@pascal-app/editor": ">=1.0.0-beta.1 <2", "@pascal-app/viewer": ">=1.0.0-beta.1 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "ovurrsl-plugin-warehouse-b53d9de"], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.132.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg=="], @@ -697,7 +740,7 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], - "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], + "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c"], "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], @@ -875,10 +918,14 @@ "@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="], + "@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="], + "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], "@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -953,7 +1000,7 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -981,6 +1028,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -1021,6 +1070,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], @@ -1039,6 +1090,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1085,6 +1138,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -1095,6 +1150,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "deslop-js": ["deslop-js@0.0.21", "", { "dependencies": { "@oxc-project/types": "^0.132.0", "fast-glob": "^3.3.3", "minimatch": "^10.2.5", "oxc-parser": "^0.132.0", "oxc-resolver": "^11.19.1", "typescript": "^6.0.3" } }, "sha512-1fYusMl4tDaQ/xdHFtLfDe8kFrEeU0Vu0Pfiy2k/Gd4HSqYlQj1Z7iRqldNneRyuCzNMhwNBvhaL07Urbh5VOA=="], @@ -1107,6 +1164,8 @@ "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], @@ -1129,6 +1188,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.370", "", {}, "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], @@ -1253,10 +1314,14 @@ "geist": ["geist@1.7.2", "", { "peerDependencies": { "next": ">=13.2.0" } }, "sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -1355,6 +1420,8 @@ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -1371,6 +1438,8 @@ "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -1467,10 +1536,14 @@ "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], @@ -1523,6 +1596,10 @@ "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "mysql2": ["mysql2@3.23.2", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -1537,6 +1614,8 @@ "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + "nodemailer": ["nodemailer@9.0.3", "", {}, "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw=="], + "number-flow": ["number-flow@0.6.0", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ=="], "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], @@ -1565,6 +1644,8 @@ "ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], + "otpauth": ["otpauth@9.5.1", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxc-parser": ["oxc-parser@0.132.0", "", { "dependencies": { "@oxc-project/types": "^0.132.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.132.0", "@oxc-parser/binding-android-arm64": "0.132.0", "@oxc-parser/binding-darwin-arm64": "0.132.0", "@oxc-parser/binding-darwin-x64": "0.132.0", "@oxc-parser/binding-freebsd-x64": "0.132.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0", "@oxc-parser/binding-linux-arm64-gnu": "0.132.0", "@oxc-parser/binding-linux-arm64-musl": "0.132.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-musl": "0.132.0", "@oxc-parser/binding-linux-s390x-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-musl": "0.132.0", "@oxc-parser/binding-openharmony-arm64": "0.132.0", "@oxc-parser/binding-wasm32-wasi": "0.132.0", "@oxc-parser/binding-win32-arm64-msvc": "0.132.0", "@oxc-parser/binding-win32-ia32-msvc": "0.132.0", "@oxc-parser/binding-win32-x64-msvc": "0.132.0" } }, "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg=="], @@ -1579,6 +1660,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], @@ -1609,6 +1692,8 @@ "png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -1631,6 +1716,8 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1663,10 +1750,14 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1697,6 +1788,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -1725,6 +1818,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "sql-escaper": ["sql-escaper@1.5.1", "", {}, "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -1735,7 +1830,7 @@ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], @@ -1747,7 +1842,7 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -1827,6 +1922,8 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="], + "ultracite": ["ultracite@7.8.2", "", { "dependencies": { "@clack/prompts": "^1.5.1", "commander": "^15.0.0", "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "glob": "^13.0.6", "jsonc-parser": "^3.3.1", "nypm": "^0.6.6", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.0.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-+XeWzAEsewEr/kM686bOIWWt5DN+Pww4JUEOQUqOx4p/Pi58FWV+vPtEo2wi5WVWSgIkf8NzfTWGXmJjdqMl8g=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -1887,18 +1984,28 @@ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -1921,10 +2028,18 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@oxc-parser/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@oxc-resolver/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@react-grab/cli/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1979,6 +2094,10 @@ "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "ora/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + + "otpauth/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "oxlint-plugin-react-doctor/eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], "oxlint-plugin-react-doctor/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -2003,8 +2122,14 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "deslop-js/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2017,12 +2142,22 @@ "next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "ora/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "react-doctor/agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "deslop-js/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "ora/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/package.json b/package.json index 7c318deab..b2b496990 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "editor", "private": true, "scripts": { - "build": "turbo run build", + "build": "rm -rf node_modules apps/editor/node_modules apps/ifc-converter/node_modules packages/*/node_modules tooling/*/node_modules && bun install --linker=hoisted && chmod -R +x node_modules/.bin node_modules/@turbo && turbo run build --filter=editor && mkdir -p apps/editor/.next/standalone/apps/editor/public apps/editor/.next/standalone/apps/editor/.next/static && cp -r apps/editor/public/. apps/editor/.next/standalone/apps/editor/public/ && cp -r apps/editor/.next/static/. apps/editor/.next/standalone/apps/editor/.next/static/ && cp apps/editor/hostinger-server.js apps/editor/.next/standalone/server.js", "dev": "set -a && . ./.env 2>/dev/null; set +a; turbo run dev --env-mode=loose", "lint": "biome lint", "lint:fix": "biome lint --write", @@ -11,6 +11,7 @@ "check": "biome check", "check:fix": "biome check --write", "check-types": "turbo run check-types", + "sync-panel": "node scripts/sync-panel.mjs", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache", "restart": "bun kill && bun clean:cache && bun dev", diff --git a/packages/core/src/lib/asset-storage.ts b/packages/core/src/lib/asset-storage.ts index 72f577a34..5681e4cb1 100644 --- a/packages/core/src/lib/asset-storage.ts +++ b/packages/core/src/lib/asset-storage.ts @@ -5,11 +5,28 @@ export const ASSET_PREFIX = 'asset_data:' // Cache for active object URLs to prevent leaks and flickering const urlCache = new Map() +/** + * `crypto.randomUUID` exists only in secure contexts, and an editor served + * over plain http on a LAN is not one — the first line of every upload threw + * `TypeError` and the user saw "Could not add that guide image" with no cause. + * `getRandomValues` is NOT secure-context-gated, so the fallback keeps the + * same entropy; the id only needs uniqueness, never secrecy. + */ +function randomAssetId(): string { + const webCrypto = globalThis.crypto + if (webCrypto?.randomUUID) return webCrypto.randomUUID() + const bytes = webCrypto?.getRandomValues?.(new Uint8Array(16)) + if (bytes) { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}` +} + /** * Save a file to IndexedDB and return a custom protocol URL */ export async function saveAsset(file: File): Promise { - const id = crypto.randomUUID() + const id = randomAssetId() await set(`${ASSET_PREFIX}${id}`, file) return `asset://${id}` } diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts index e41cd114c..2d92b33af 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -31,7 +31,11 @@ export type Subtree = { } function extractIdPrefix(id: string): string { - const i = id.indexOf('_') + // The LAST underscore: `generateId` suffixes never contain one, so this + // recovers the exact prefix even when the prefix itself does — a plugin + // kind's `pallet_rack_` must clone as `pallet_rack_*`, and the + // first-underscore split minted `pallet_*`, which its schema rejects. + const i = id.lastIndexOf('_') return i === -1 ? 'node' : id.slice(0, i) } diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index e48f13e83..27b602205 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -15,10 +15,16 @@ export type SceneGraph = { } /** - * Extracts the type prefix from a node ID (e.g., "wall_abc123" -> "wall") + * Extracts the type prefix from a node ID (e.g., "wall_abc123" -> "wall"). + * + * The LAST underscore, because `generateId` suffixes are drawn from `0-9a-z` + * and never contain one — so this recovers the exact prefix even when the + * prefix itself does (a plugin kind's `pallet_rack_abc123` -> `pallet_rack`). + * Splitting at the first underscore cloned those as `pallet_*`, which the + * kind's own schema rejects. */ function extractIdPrefix(id: string): string { - const underscoreIndex = id.indexOf('_') + const underscoreIndex = id.lastIndexOf('_') return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex) } diff --git a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx index f487fb405..92ad2a9aa 100644 --- a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx +++ b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx @@ -22,6 +22,70 @@ const NO_RAYCAST = () => null /** green-500 — matches the item placement box's "placeable" state. */ const DEFAULT_COLOR = 0x22_c5_5e +/** + * Module-level unit geometry, scaled per frame through the mesh transform — + * NEVER rebuilt or disposed mid-drag. + * + * The previous version minted a `BoxGeometry`/`PlaneGeometry` per dimension + * change and disposed the old one in an effect cleanup. During a resize drag + * that is once per frame, and WebGPU may still be executing the command + * buffer that references the disposed buffers — the renderer then drops the + * ENTIRE frame's command buffer ("Vertex buffer slot … was not set"), which + * blanks the whole scene for a frame, not just this overlay. Unit geometry + + * `scale` moves the per-frame change into the object matrix, where it + * belongs, and the shared buffers live for the session. + */ +const UNIT_EDGES = (() => { + const box = new BoxGeometry(1, 1, 1) + const edges = new EdgesGeometry(box) + box.dispose() + return edges +})() + +const UNIT_PLANE = (() => { + const plane = new PlaneGeometry(1, 1) + plane.rotateX(-Math.PI / 2) + return plane +})() + +/** + * Materials by colour — two in practice (placeable green / blocked red). + * Cached for the same reason the geometry is shared: disposing a material the + * in-flight pass still references is the same command-buffer drop, and a + * colour flip happens mid-drag by design. + */ +const edgeMaterials = new Map() +const planeMaterials = new Map() + +function getEdgeMaterial(color: number): LineBasicNodeMaterial { + let material = edgeMaterials.get(color) + if (!material) { + material = new LineBasicNodeMaterial({ + color, + linewidth: 3, + depthTest: false, + depthWrite: false, + }) + edgeMaterials.set(color, material) + } + return material +} + +function getPlaneMaterial(color: number): MeshBasicNodeMaterial { + let material = planeMaterials.get(color) + if (!material) { + material = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthTest: false, + depthWrite: false, + }) + material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) + planeMaterials.set(color, material) + } + return material +} + type LocalBounds = { size: [number, number, number]; center: [number, number, number] } /** @@ -113,47 +177,8 @@ export function DragBoundingBox({ const minY = cy - h / 2 const groundY = minY + 0.01 - const edgeGeometry = useMemo(() => { - const box = new BoxGeometry(w, h, d) - const edges = new EdgesGeometry(box) - box.dispose() - return edges - }, [w, h, d]) - - // Flat on the ground (XZ) at the box's base, nudged up 0.01m to avoid - // z-fighting with slabs. - const planeGeometry = useMemo(() => { - const plane = new PlaneGeometry(w, d) - plane.rotateX(-Math.PI / 2) - plane.translate(cx, groundY, cz) - return plane - }, [w, d, cx, groundY, cz]) - - const edgeMaterial = useMemo( - () => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }), - [color], - ) - - const planeMaterial = useMemo(() => { - const material = new MeshBasicNodeMaterial({ - color, - transparent: true, - depthTest: false, - depthWrite: false, - }) - material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) - return material - }, [color]) - - useEffect( - () => () => { - edgeGeometry.dispose() - planeGeometry.dispose() - edgeMaterial.dispose() - planeMaterial.dispose() - }, - [edgeGeometry, planeGeometry, edgeMaterial, planeMaterial], - ) + const edgeMaterial = getEdgeMaterial(color) + const planeMaterial = getPlaneMaterial(color) // Publish the facing pose to the editor-side overlay (the single triangle // renderer) rather than drawing it here. The node origin is `position`; the @@ -176,19 +201,22 @@ export function DragBoundingBox({ return ( ) diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index e3023323c..aa9dd7ec1 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -119,8 +119,15 @@ function UploadButton({ onError }: { onError: (message: string | null) => void } setShowGuides(true) setSelectedReferenceId(guide.id) setSelection({ selectedIds: [], zoneId: null }) - } catch { - onError('Could not add that guide image.') + } catch (error) { + // The reason must survive: this path swallowed a secure-context + // TypeError for a whole debugging session. + console.error('[guide-image]', error) + onError( + error instanceof Error && error.message + ? `Could not add that guide image: ${error.message}` + : 'Could not add that guide image.', + ) } finally { setIsAddingGuide(false) } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 328a63d21..cf272cf56 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -30,7 +30,7 @@ export type MaterialPickerProps = { const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ { id: 'all', label: 'All' }, - { id: 'pascal', label: 'Pascal' }, + { id: 'pascal', label: 'DigitalTwin' }, { id: 'mine', label: 'Mine' }, { id: 'workspace', label: 'Workspace' }, { id: 'community', label: 'Community' }, diff --git a/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx b/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx index 6dd265c9a..787d4b2bd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/plugins-panel.tsx @@ -151,7 +151,7 @@ export function PluginsPanel() { rel="noreferrer" target="_blank" > - Create a Pascal plugin + Create a DigitalTwin plugin
@@ -209,7 +209,7 @@ export function PluginsPanel() { rel="noreferrer" target="_blank" > - Create a Pascal plugin + Create a DigitalTwin plugin diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index e1ad6a8c6..284cbffa5 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -9,6 +9,7 @@ import { TreeView, VisualJson } from '@visual-json/react' import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' import { type KeyboardEvent, + type ReactNode, type SyntheticEvent, useCallback, useMemo, @@ -176,12 +177,19 @@ export interface SettingsPanelProps { field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic', value: boolean, ) => Promise + /** + * Host-owned account section (who is signed in, their access, a way out) — + * rendered above everything else. The host owns identity and sessions, not + * this package, so it is a slot rather than a built-in section. + */ + accountSection?: ReactNode } export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange, + accountSection, }: SettingsPanelProps = {}) { const fileInputRef = useRef(null) const nodes = useScene((state) => state.nodes) @@ -194,6 +202,7 @@ export function SettingsPanel({ const shadows = useViewer((state) => state.shadows) const setPhase = useEditor((state) => state.setPhase) const floorplanMode = useFloorplanMode((state) => state.mode) + const setFloorplanMode = useFloorplanMode((state) => state.setMode) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) const [pendingImport, setPendingImport] = useState(null) const sceneGraphValue = useMemo( @@ -319,6 +328,8 @@ export function SettingsPanel({ return (
+ {accountSection} + {/* Visibility Section (only for cloud projects) */} {projectId && !isLocalProject && (
@@ -403,7 +414,18 @@ export function SettingsPanel({
Floor plan - {floorplanMode === 'default' ? 'Default mode' : 'Expert mode'} + + {floorplanMode === 'default' ? 'Default mode' : 'Expert mode'} + {floorplanMode === 'expert' && ( + + )} +
diff --git a/scripts/migrate-legacy-scene.mjs b/scripts/migrate-legacy-scene.mjs new file mode 100644 index 000000000..47a88b25f --- /dev/null +++ b/scripts/migrate-legacy-scene.mjs @@ -0,0 +1,416 @@ +#!/usr/bin/env bun +/** + * Migrates a scene from the legacy desktop build ("Digital Twin V2" / Pascal, + * scenes in a local `pascal.db` SQLite file or per-scene JSON backups) into + * the current graph format accepted by `POST /api/scenes`. + * + * The legacy build had no warehouse plugin: racking was a generic `item` node + * carrying `asset.src === "asset://procedural/"` plus a bounding-box + * `dimensions` triple. The current build models those as parametric + * `warehouse:*` nodes, so this is a representation change, not a rename — + * each procedural item is rebuilt as the matching plugin node and everything + * else passes through untouched. + * + * Safety contract: an unrecognised procedural kind ABORTS the migration with + * a report — nodes are never silently dropped, because a skipped rack is a + * rack missing from the customer's floor. The output graph is validated with + * the same `apiGraphSchema` the server enforces, so a migration that prints + * "ok" is one the API will accept. + * + * Usage: + * bun scripts/migrate-legacy-scene.mjs --json [--out ] + * bun scripts/migrate-legacy-scene.mjs --db --scene [--out ] + * bun scripts/migrate-legacy-scene.mjs --db --list + * bun scripts/migrate-legacy-scene.mjs ... --drop-transient + */ + +import fs from 'node:fs' +import path from 'node:path' +import { apiGraphSchema, parseNodeWithDefaults } from '../apps/editor/lib/graph-schema.ts' + +const WAREHOUSE_PLUGIN_ID = 'ovurrsl:warehouse' + +/** Effective size after folding a legacy node's scale into its asset box. */ +function effectiveDimensions(node) { + const dims = Array.isArray(node.asset?.dimensions) ? node.asset.dimensions : [1, 1, 1] + const scale = Array.isArray(node.scale) ? node.scale : [1, 1, 1] + return [0, 1, 2].map((i) => (Number(dims[i]) || 1) * (Number(scale[i]) || 1)) +} + +function clampWithNote(value, lo, hi, label, notes) { + if (value < lo || value > hi) { + notes.push(`${label}=${value} şema sınırına kırpıldı [${lo}, ${hi}]`) + return Math.min(hi, Math.max(lo, value)) + } + return value +} + +function migratedId(oldId, prefix) { + const suffix = oldId.includes('_') ? oldId.slice(oldId.indexOf('_') + 1) : oldId + return `${prefix}_${suffix}` +} + +function baseFields(node) { + return { + object: 'node', + id: node.id, + name: node.name, + parentId: node.parentId ?? null, + visible: node.visible ?? true, + metadata: stripTransient(node.metadata), + position: node.position ?? [0, 0, 0], + rotation: node.rotation ?? [0, 0, 0], + } +} + +/** A migrated object is a real placed object, not an in-progress preview. */ +function stripTransient(metadata) { + if (!metadata || typeof metadata !== 'object') return {} + const { isTransient, ...rest } = metadata + return rest +} + +/** + * Legacy procedural kind → converter. The migration target side; extend this + * table as the legacy inventory surfaces more kinds. Every converter maps the + * legacy bounding box onto the parametric fields whose defaults produce the + * closest visual match, and leaves every other parameter to the schema's + * defaults. + */ +function toPalletRack(node, notes) { + const [w, h, d] = effectiveDimensions(node) + return { + ...baseFields(node), + id: migratedId(node.id, 'pallet-rack'), + type: 'warehouse:pallet-rack', + bayClearWidth: clampWithNote(w, 0.6, 6, `${node.id} genişlik`, notes), + uprightHeight: clampWithNote(h, 1, 20, `${node.id} yükseklik`, notes), + depth: clampWithNote(d, 0.4, 2.5, `${node.id} derinlik`, notes), + } +} + +const toPallet = (cargo) => (node) => ({ + ...baseFields(node), + id: migratedId(node.id, 'pallet'), + type: 'warehouse:pallet', + preset: 'epal-1', + cargo, +}) + +/** Length is rollers × pitch; 100 mm pitch spans 2.7–20 m in whole rollers. */ +function toConveyorRoller(node, notes) { + const [length, , height] = effectiveDimensions(node) + const rollers = Math.min(200, Math.max(27, Math.round(length * 10))) + if (Math.abs(rollers / 10 - length) > 0.05) { + notes.push(`${node.id} konveyör uzunluğu ${length} m → ${rollers / 10} m (rulo adımına yuvarlandı)`) + } + return { + ...baseFields(node), + id: migratedId(node.id, 'conveyor-roller'), + type: 'warehouse:conveyor-roller', + rollerPitch: '100', + rollers, + usefulWidth: '600', + transportHeight: clampWithNote(height, 0.37, 3, `${node.id} taşıma yüksekliği`, notes), + } +} + +const PROCEDURAL_CONVERTERS = { + 'asset://procedural/rack': toPalletRack, +} + +/** + * The later legacy vintage stored equipment as library items whose models + * lived in the desktop build's own IndexedDB — unreachable from any server + * deployment, so these MUST be rebuilt as parametric nodes to stay visible. + * Handles deliberately left out (dispatch-packing-table, tote) pass through + * unchanged by owner decision and are listed in the report as invisible until + * a real model is uploaded for them. + */ +const LIBRARY_CONVERTERS = { + 'asset://rack': toPalletRack, + 'asset://euro-pallet': toPallet('none'), + 'asset://loaded-euro-pallet': toPallet('carton'), + 'asset://flat-wire-mesh-conveyor': toConveyorRoller, +} + +/** + * Id prefixes the current plugin schemas no longer accept; seen on + * warehouse:* nodes written by the late legacy vintage (`palletrack_…`). + */ +const LEGACY_WAREHOUSE_ID_PREFIXES = { + 'warehouse:pallet-rack': [['palletrack', 'pallet-rack']], + 'warehouse:conveyor-roller': [['conveyorroller', 'conveyor-roller']], +} + +function isLegacyProceduralItem(node) { + return ( + node?.type === 'item' && + typeof node.asset?.src === 'string' && + node.asset.src.startsWith('asset://procedural/') + ) +} + +function isLegacyLibraryEquipment(node) { + return node?.type === 'item' && typeof node.asset?.src === 'string' && node.asset.src in LIBRARY_CONVERTERS +} + +/** Mutates the node's id to the current prefix; returns whether it changed. */ +function normaliseWarehouseId(node) { + for (const [legacy, current] of LEGACY_WAREHOUSE_ID_PREFIXES[node?.type] ?? []) { + if (node.id.startsWith(`${legacy}_`)) { + node.id = `${current}_${node.id.slice(legacy.length + 1)}` + return true + } + } + return false +} + +/** + * Converts a legacy graph in place-of (returns a new graph plus a report). + * Throws MigrationError when a procedural kind has no converter. + */ +export class MigrationError extends Error { + constructor(message, details) { + super(message) + this.details = details + } +} + +export function migrateLegacyGraph(legacyGraph, { dropTransient = false } = {}) { + const report = { + converted: [], + passedThrough: 0, + invisible: [], + renamedIds: 0, + droppedTransient: [], + repairedParentIds: 0, + notes: [], + } + + const nodes = structuredClone(legacyGraph.nodes ?? {}) + let rootNodeIds = [...(legacyGraph.rootNodeIds ?? [])] + + if (dropTransient) { + for (const [id, node] of Object.entries(nodes)) { + if (node?.metadata?.isTransient === true) { + delete nodes[id] + report.droppedTransient.push(id) + } + } + } + + const unknown = [] + const idMap = new Map() + for (const [id, node] of Object.entries(nodes)) { + let converter + if (isLegacyProceduralItem(node)) { + converter = PROCEDURAL_CONVERTERS[node.asset.src] + if (!converter) { + unknown.push({ id, src: node.asset.src, name: node.name }) + continue + } + } else if (isLegacyLibraryEquipment(node)) { + converter = LIBRARY_CONVERTERS[node.asset.src] + } else { + // A plain asset:// handle resolved from the desktop build's IndexedDB — + // valid to keep (owner's call), but it has no model on a server. + if (node?.type === 'item' && node.asset?.src?.startsWith('asset://')) { + report.invisible.push({ id, src: node.asset.src, name: node.name }) + } + const renamed = normaliseWarehouseId(node) + if (renamed) { + delete nodes[id] + nodes[node.id] = node + idMap.set(id, node.id) + report.renamedIds++ + } + report.passedThrough++ + continue + } + // Parse through the owning schema so every defaulted field materialises. + // `setScene` stores nodes verbatim, and kind systems crash on synthesised + // nodes that lack fields editor-created ones always carry (found the hard + // way: a pallet without `supportSlabId` emptied the scene on load the + // moment a slab stood under it). + const migrated = parseNodeWithDefaults(converter(node, report.notes)) + delete nodes[id] + nodes[migrated.id] = migrated + idMap.set(id, migrated.id) + report.converted.push({ from: id, to: migrated.id, type: migrated.type }) + } + + if (unknown.length > 0) { + throw new MigrationError( + `Eşlemesi tanımsız ${unknown.length} eski tip bulundu — taşıma durduruldu, hiçbir düğüm atlanmadı`, + unknown, + ) + } + + const mapId = (id) => idMap.get(id) ?? id + rootNodeIds = rootNodeIds.map(mapId).filter((id) => id in nodes) + for (const node of Object.values(nodes)) { + if (Array.isArray(node.children)) { + node.children = node.children.map(mapId).filter((id) => id in nodes) + } + if (typeof node.parentId === 'string') node.parentId = mapId(node.parentId) + } + + // The legacy writer left `parentId: null` on nodes their parents list as + // children; the children arrays are the authoritative record. + for (const node of Object.values(nodes)) { + for (const childId of node.children ?? []) { + const child = nodes[childId] + if (child && child.parentId !== node.id) { + child.parentId = node.id + report.repairedParentIds++ + } + } + } + + const graph = { nodes, rootNodeIds } + if (Object.values(nodes).some((n) => typeof n?.type === 'string' && n.type.startsWith('warehouse:'))) { + const installed = new Set(legacyGraph.installedPlugins ?? []) + installed.add(WAREHOUSE_PLUGIN_ID) + graph.installedPlugins = [...installed] + } else if (legacyGraph.installedPlugins) { + graph.installedPlugins = [...legacyGraph.installedPlugins] + } + + return { graph, report } +} + +/** Node-type + procedural-kind frequency count, for the migration inventory. */ +export function inventory(legacyGraph) { + const counts = {} + for (const node of Object.values(legacyGraph.nodes ?? {})) { + const key = isLegacyProceduralItem(node) ? `item → ${node.asset.src}` : (node?.type ?? '?') + counts[key] = (counts[key] ?? 0) + 1 + } + return counts +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const args = { _: [] } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '--json' || a === '--db' || a === '--scene' || a === '--out') args[a.slice(2)] = argv[++i] + else if (a === '--list') args.list = true + else if (a === '--drop-transient') args.dropTransient = true + else args._.push(a) + } + return args +} + +/** + * The legacy sqlite schema is discovered, not assumed: the table holding + * scenes is whichever has both an id-like and a JSON/graph-like column. + * Refuses loudly when nothing matches, printing what it saw instead. + */ +function openLegacyScenes(dbPath) { + const { Database } = require('bun:sqlite') + const db = new Database(dbPath, { readonly: true }) + const tables = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() + const candidates = [] + for (const { name } of tables) { + const cols = db.query(`PRAGMA table_info(${JSON.stringify(name).slice(1, -1)})`).all() + const colNames = cols.map((c) => c.name.toLowerCase()) + const idCol = cols[colNames.indexOf('id')] + const graphCol = cols.find((c) => /graph|json|data|content/.test(c.name.toLowerCase())) + if (idCol && graphCol) candidates.push({ table: name, idCol: idCol.name, graphCol: graphCol.name, cols }) + } + if (candidates.length === 0) { + const seen = tables.map((t) => t.name).join(', ') || '(hiç tablo yok)' + throw new Error(`pascal.db içinde sahne tablosu bulunamadı. Görülen tablolar: ${seen}`) + } + return { db, ...candidates[0] } +} + +function loadLegacySceneFromDb(dbPath, sceneId) { + const { db, table, idCol, graphCol, cols } = openLegacyScenes(dbPath) + const row = db.query(`SELECT * FROM ${table} WHERE ${idCol} = ?`).get(sceneId) + if (!row) throw new Error(`Sahne '${sceneId}' ${table} tablosunda yok`) + const parsed = JSON.parse(row[graphCol]) + // Some writers store the whole scene envelope in the JSON column, some just + // the graph; accept either. + const graph = parsed.graph ?? parsed + const nameCol = cols.find((c) => c.name.toLowerCase() === 'name') + return { id: sceneId, name: row[nameCol?.name] ?? parsed.name ?? 'Untitled scene', graph } +} + +function listScenesInDb(dbPath) { + const { db, table, idCol, graphCol, cols } = openLegacyScenes(dbPath) + const nameCol = cols.find((c) => c.name.toLowerCase() === 'name') + const rows = db.query(`SELECT * FROM ${table}`).all() + return rows.map((row) => { + let counts = {} + try { + const parsed = JSON.parse(row[graphCol]) + counts = inventory(parsed.graph ?? parsed) + } catch { + counts = { '!graf çözümlenemedi': 1 } + } + return { id: row[idCol], name: row[nameCol?.name], table, counts } + }) +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + + if (args.list) { + const scenes = args.db + ? listScenesInDb(args.db) + : [(({ id, name, graph }) => ({ id, name, counts: inventory(graph) }))(readJsonScene(args.json))] + for (const s of scenes) { + console.log(`\n${s.id} ${s.name ?? ''}`) + for (const [k, v] of Object.entries(s.counts)) console.log(` ${v.toString().padStart(4)} ${k}`) + } + return + } + + const legacy = args.db ? loadLegacySceneFromDb(args.db, requireArg(args, 'scene')) : readJsonScene(args.json) + const { graph, report } = migrateLegacyGraph(legacy.graph, { dropTransient: args.dropTransient }) + + console.log(`Sahne: ${legacy.id} (${legacy.name})`) + console.log(` dönüştürülen: ${report.converted.length}, olduğu gibi geçen: ${report.passedThrough}`) + for (const c of report.converted) console.log(` ${c.from} → ${c.to} (${c.type})`) + if (report.droppedTransient.length) console.log(` atılan geçici düğümler: ${report.droppedTransient.join(', ')}`) + if (report.repairedParentIds) console.log(` onarılan parentId: ${report.repairedParentIds}`) + if (report.renamedIds) console.log(` yeni öneke taşınan id: ${report.renamedIds}`) + if (report.invisible.length) { + const bySrc = {} + for (const item of report.invisible) bySrc[item.src] = (bySrc[item.src] ?? 0) + 1 + console.log(` UYARI — modeli sunucuda olmayan ${report.invisible.length} öğe (görünmez kalır):`) + for (const [src, count] of Object.entries(bySrc)) console.log(` ${count}× ${src}`) + } + for (const n of report.notes) console.log(` not: ${n}`) + + // Same validation the server runs — a green migration is an accepted POST. + const result = apiGraphSchema.safeParse(graph) + if (!result.success) { + console.error('\nŞema doğrulaması BAŞARISIZ — çıktı yazılmadı:') + for (const issue of result.error.issues) { + console.error(` ${issue.path.join('.')}: ${issue.message}`) + } + process.exit(1) + } + const body = { id: legacy.id, name: legacy.name, graph } + const out = args.out ?? `${legacy.id}.migrated.json` + fs.writeFileSync(out, JSON.stringify(body, null, 2)) + console.log(`\nŞema doğrulaması geçti ✓ → ${path.resolve(out)}`) +} + +function readJsonScene(file) { + if (!file) throw new Error('--json veya --db --scene gerekli') + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) + return { id: parsed.id, name: parsed.name ?? 'Untitled scene', graph: parsed.graph ?? parsed } +} + +function requireArg(args, name) { + if (!args[name]) throw new Error(`--${name} gerekli`) + return args[name] +} + +if (import.meta.main) main() diff --git a/scripts/migrate-legacy-scene.test.ts b/scripts/migrate-legacy-scene.test.ts new file mode 100644 index 000000000..779d7dd9a --- /dev/null +++ b/scripts/migrate-legacy-scene.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from 'bun:test' +import { apiGraphSchema } from '../apps/editor/lib/graph-schema' +import { MigrationError, inventory, migrateLegacyGraph } from './migrate-legacy-scene.mjs' + +/** + * Mirrors a real backup from the legacy desktop build (scene 0d18a76c12c4, + * v17): a site → building → level chain whose building and level carry the + * legacy writer's `parentId: null` bug, one library item, and one procedural + * rack item — the shape every legacy scene is expected to share. + */ +function legacyGraph() { + return { + nodes: { + site_a: { + object: 'node', + id: 'site_a', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ], + }, + children: ['building_b'], + }, + building_b: { + object: 'node', + id: 'building_b', + type: 'building', + parentId: null, + visible: true, + metadata: {}, + children: ['level_c'], + position: [0, 0, 0], + rotation: [0, 0, 0], + }, + level_c: { + object: 'node', + id: 'level_c', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['item_lib', 'item_rack'], + level: 0, + }, + item_lib: { + object: 'node', + id: 'item_lib', + type: 'item', + name: 'Cactus', + parentId: 'level_c', + visible: true, + metadata: { isTransient: true }, + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + children: [], + asset: { + id: 'cactus', + category: 'furniture', + name: 'Cactus', + thumbnail: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/cactus/thumbnail.png', + source: 'library', + src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/cactus/model.glb', + dimensions: [0.34, 0.39, 0.27], + tags: ['cactus'], + offset: [-0.0039, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + }, + item_rack: { + object: 'node', + id: 'item_rack', + type: 'item', + name: 'Rack', + parentId: 'level_c', + visible: true, + metadata: { isTransient: true }, + position: [3, 0, -2], + rotation: [0, Math.PI / 2, 0], + scale: [1, 1, 1], + children: [], + asset: { + id: 'rack', + category: 'asset', + name: 'Rack', + thumbnail: '/icons/box.png', + source: 'library', + src: 'asset://procedural/rack', + dimensions: [2.5, 4, 1.2], + tags: ['floor', 'rack', 'warehouse', 'storage'], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + }, + }, + rootNodeIds: ['site_a'], + } +} + +describe('migrateLegacyGraph', () => { + test('converts a procedural rack item into a warehouse:pallet-rack node', () => { + const { graph, report } = migrateLegacyGraph(legacyGraph()) + + expect(graph.nodes.item_rack).toBeUndefined() + const rack = graph.nodes['pallet-rack_rack'] + expect(rack).toMatchObject({ + type: 'warehouse:pallet-rack', + parentId: 'level_c', + position: [3, 0, -2], + bayClearWidth: 2.5, + uprightHeight: 4, + depth: 1.2, + }) + expect(rack.metadata.isTransient).toBeUndefined() + expect(report.converted).toEqual([ + { from: 'item_rack', to: 'pallet-rack_rack', type: 'warehouse:pallet-rack' }, + ]) + }) + + test('updates children references and repairs legacy null parentIds', () => { + const { graph, report } = migrateLegacyGraph(legacyGraph()) + + expect(graph.nodes.level_c.children).toEqual(['item_lib', 'pallet-rack_rack']) + expect(graph.nodes.building_b.parentId).toBe('site_a') + expect(graph.nodes.level_c.parentId).toBe('building_b') + expect(report.repairedParentIds).toBe(2) + }) + + test('clamps out-of-range legacy dimensions into schema bounds, with a note', () => { + const legacy = legacyGraph() + legacy.nodes.item_rack.asset.dimensions = [8, 30, 0.1] + const { graph, report } = migrateLegacyGraph(legacy) + + const rack = graph.nodes['pallet-rack_rack'] + expect(rack.bayClearWidth).toBe(6) + expect(rack.uprightHeight).toBe(20) + expect(rack.depth).toBe(0.4) + expect(report.notes).toHaveLength(3) + }) + + test('marks the warehouse plugin installed once a warehouse node exists', () => { + const { graph } = migrateLegacyGraph(legacyGraph()) + expect(graph.installedPlugins).toEqual(['ovurrsl:warehouse']) + }) + + test('stops on an unmapped procedural kind instead of skipping the node', () => { + const legacy = legacyGraph() + legacy.nodes.item_rack.asset.src = 'asset://procedural/antigravity-shelf' + + expect(() => migrateLegacyGraph(legacy)).toThrow(MigrationError) + try { + migrateLegacyGraph(legacy) + } catch (error) { + expect((error as MigrationError).details).toEqual([ + { id: 'item_rack', src: 'asset://procedural/antigravity-shelf', name: 'Rack' }, + ]) + } + }) + + test('--drop-transient removes flagged nodes and their references', () => { + const { graph, report } = migrateLegacyGraph(legacyGraph(), { dropTransient: true }) + + expect(report.droppedTransient.sort()).toEqual(['item_lib', 'item_rack']) + expect(graph.nodes.level_c.children).toEqual([]) + expect(report.converted).toHaveLength(0) + }) + + test('migrated graph passes the exact schema the API enforces', () => { + const { graph } = migrateLegacyGraph(legacyGraph()) + const result = apiGraphSchema.safeParse(graph) + expect(result.success).toBe(true) + }) + + test('converts the late-vintage library equipment handles', () => { + const legacy = legacyGraph() + legacy.nodes.item_rack.asset.src = 'asset://rack' + legacy.nodes.item_lib.asset = { + ...legacy.nodes.item_lib.asset, + id: 'loaded-euro-pallet', + src: 'asset://loaded-euro-pallet', + dimensions: [0.8, 1.15, 1.2], + } + legacy.nodes.item_conv = { + ...structuredClone(legacy.nodes.item_rack), + id: 'item_conv', + name: 'Flat Wire Mesh Conveyor', + asset: { id: 'flat-wire-mesh-conveyor', src: 'asset://flat-wire-mesh-conveyor', dimensions: [7.3, 0.8, 0.6] }, + } + legacy.nodes.level_c.children.push('item_conv') + + const { graph, report } = migrateLegacyGraph(legacy) + + expect(graph.nodes['pallet-rack_rack'].type).toBe('warehouse:pallet-rack') + expect(graph.nodes.pallet_lib).toMatchObject({ type: 'warehouse:pallet', preset: 'epal-1', cargo: 'carton' }) + expect(graph.nodes['conveyor-roller_conv']).toMatchObject({ + type: 'warehouse:conveyor-roller', + rollerPitch: '100', + rollers: 73, + transportHeight: 0.6, + }) + expect(report.converted).toHaveLength(3) + expect(apiGraphSchema.safeParse(graph).success).toBe(true) + }) + + test('renames legacy warehouse id prefixes the current schema refuses', () => { + const legacy = legacyGraph() + legacy.nodes.palletrack_x = { + object: 'node', + id: 'palletrack_x', + type: 'warehouse:pallet-rack', + parentId: 'level_c', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + } + legacy.nodes.level_c.children.push('palletrack_x') + + const { graph, report } = migrateLegacyGraph(legacy) + + expect(graph.nodes.palletrack_x).toBeUndefined() + expect(graph.nodes['pallet-rack_x'].type).toBe('warehouse:pallet-rack') + expect(graph.nodes.level_c.children).toContain('pallet-rack_x') + expect(report.renamedIds).toBe(1) + expect(apiGraphSchema.safeParse(graph).success).toBe(true) + }) + + test('keeps unmapped asset:// items but reports them as invisible', () => { + const legacy = legacyGraph() + legacy.nodes.item_rack.asset.src = 'asset://dispatch-packing-table' + + const { graph, report } = migrateLegacyGraph(legacy) + + expect(graph.nodes.item_rack.asset.src).toBe('asset://dispatch-packing-table') + expect(report.invisible).toEqual([ + { id: 'item_rack', src: 'asset://dispatch-packing-table', name: 'Rack' }, + ]) + }) +}) + +describe('inventory', () => { + test('counts node types and procedural kinds separately', () => { + expect(inventory(legacyGraph())).toEqual({ + site: 1, + building: 1, + level: 1, + item: 1, + 'item → asset://procedural/rack': 1, + }) + }) +}) diff --git a/scripts/sync-panel.mjs b/scripts/sync-panel.mjs new file mode 100644 index 000000000..1dccda83c --- /dev/null +++ b/scripts/sync-panel.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node +/** + * Pushes the vendored console back to its own repository. + * + * The console lives here as a copy (`apps/editor/panel/`, imported as + * `@panel/*`), so fixes made while integrating it — MariaDB portability, the + * stricter null handling this repo's TypeScript demands, the mail templates — + * would otherwise be stranded. This walks the copy, undoes the two + * integration-time transformations (import prefix, directory layout) and writes + * the result into a checkout of ovurrsl/panel. + * + * Two sync rules, deliberately different: + * + * library — `panel/{lib,components,migrations}` and the three scripts are + * the console itself. New files here are created upstream. + * routes — `app/(panel)` and `app/api` hold console routes mixed with the + * editor's own. A file is updated only if the same path already + * exists upstream; anything else is an editor route and is left + * alone. That rule needs no list to maintain. + * + * Usage: + * node scripts/sync-panel.mjs --panel [--check] + * + * `--check` writes nothing and exits 1 when the two sides differ, which is what + * CI runs to notice that a sync is owed. + */ +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' + +const REPO = resolve(import.meta.dirname, '..') +const EDITOR = join(REPO, 'apps/editor') + +/** [source under apps/editor, destination in the panel repo, create-new?] */ +const LIBRARY = [ + ['panel/lib', 'src/lib', true], + ['panel/components', 'src/components', true], + ['panel/migrations', 'db/migrations', true], + ['panel/globals.css', 'src/app/globals.css', true], + ['panel/env.ts', 'scripts/env.ts', true], + ['panel/migrate.ts', 'scripts/migrate.ts', true], + ['panel/seed.ts', 'scripts/seed.ts', true], +] + +const ROUTES = [ + ['app/(panel)', 'src/app', false], + ['app/api', 'src/app/api', false], +] + +/** + * Files the console cannot use, by destination path. + * + * Two groups, for two different reasons. + * + * The shell (`layout.tsx`, `page.tsx`) exists upstream but the editor's copies + * are deliberately different, because the editor owns the shell and bridges + * identity into its own session type. + * + * The scenes and guides tabs are integration-only. Their backing endpoints + * cannot exist upstream: `/api/guides` imports the editor's own + * `@/lib/guides-content` and `@/lib/scene-api-security`, and `/api/admin/scenes` + * reads a `scenes` table that no migration in this set creates — the console's + * schema only carries `sites.scene_id`. `ROUTES` already declines to push those + * endpoints (create-new is `false` there), so syncing the tabs would give the + * standalone console two rail entries whose fetches 404, and `console-tabs.ts` + * is what registers them. + * + * `tab-content.tsx` joins them because it imports both tabs and switches on + * their names: holding the tabs back while pushing their only caller is what + * turns a missing feature into a repository that does not compile. + * + * `api/health/route.ts` is the same shape one layer down — the editor's copy + * reaches for `@/lib/auth/db` and `@/lib/scene-store-server`, neither of which + * exists upstream. + * + * The cost of holding `console-tabs.ts` back is that changes to the SHARED + * tabs' metadata no longer reach upstream either. That is the price of a file + * that must legitimately differ between the two deployments; splitting the list + * into a base the console owns and an extension the editor adds would remove + * the conflict properly, and is the real fix when someone has the appetite. + * + * Every entry here was found by running `tsc --noEmit` in a checkout of + * ovurrsl/panel with the sync applied. The test suite passed with all of them + * still missing, so the type checker is the only thing that catches this class + * of breakage — keep using it when this list changes. + */ +const EDITOR_OWNED = new Set([ + 'src/app/layout.tsx', + 'src/app/page.tsx', + 'src/app/api/health/route.ts', + 'src/lib/console-tabs.ts', + 'src/components/console/tab-content.tsx', + 'src/components/console/scenes-tab.tsx', + 'src/components/console/guides-tab.tsx', +]) + +const SYNCABLE = /\.(ts|tsx|css|sql)$/ + +/** + * Tests do not cross. The two repositories run different runners — this one is + * on `bun:test`, the console is on vitest — so a test file pushed upstream is + * one vitest cannot execute and `tsc` cannot resolve (`Cannot find module + * 'bun:test'`). The console's own suite lives in `tests/`, outside every + * mapping in this file, so nothing here can reach it either way. + */ +const TEST_FILE = /\.(test|spec)\.[jt]sx?$/ + +function files(root) { + if (!existsSync(root)) return [] + if (statSync(root).isFile()) return [root] + const out = [] + for (const entry of readdirSync(root)) { + const path = join(root, entry) + if (statSync(path).isDirectory()) out.push(...files(path)) + else if (SYNCABLE.test(entry) && !TEST_FILE.test(entry)) out.push(path) + } + return out +} + +/** Undo the import rewrite the vendoring applied. Nothing else is touched. */ +function toUpstream(source) { + return source.replace(/@panel\//g, '@/') +} + +/** + * Reapply it. + * + * The forward rewrite is LOSSY: `@panel/x` and a literal `@/x` both leave as + * `@/x`, so this direction cannot tell them apart and turns every `@/` into + * `@panel/`. That is correct for a console file, where `@/` can only mean the + * console's own `src`, and it constrains the vendored copy: a synced file under + * `apps/editor/panel/` must never contain a bare `@/`, in an import OR in + * prose. One did — a comment naming `@/lib/escape-layers` — and it would have + * ping-ponged forever, each direction "fixing" what the other just wrote. + * + * That mistake has a signature worth knowing: `--check` says in sync one way + * and names a file the other way. If you see it, the file has a bare `@/`. + */ +function toVendored(source) { + return source.replace(/@\//g, '@panel/') +} + +/** + * The pairs to walk, for one direction. + * + * Pull inverts the same tables rather than declaring its own, so the two + * directions cannot drift apart — a mapping added for one is a mapping the + * other gets for free. + * + * Inverting is not a swap, because the console's tree nests where the editor's + * does not: `src/app/api` and `src/app/globals.css` both live INSIDE `src/app`. + * Walking `src/app` naively would drag the console's endpoints into + * `app/(panel)/api/` and its stylesheet into `app/(panel)/globals.css`, both + * wrong and both silent. Longest source path first, and the first pair to claim + * a file keeps it. + * + * `create` is dropped on pull, i.e. every pair creates. Its `false` on `ROUTES` + * protects the CONSOLE from receiving editor-only routes; the mirror risk — + * the editor receiving a console route it does not have — is not a risk but + * the entire point, since a new console endpoint is exactly what the editor + * needs to pick up. + */ +function pairsFor(pull) { + const declared = [...LIBRARY, ...ROUTES] + if (!pull) return declared.map(([from, to, create]) => ({ from, to, create })) + return declared + .map(([from, to]) => ({ from: to, to: from, create: true })) + .sort((a, b) => b.from.length - a.from.length) +} + +function plan(panelRoot, pull) { + const actions = [] + const claimed = new Set() + const sourceRoot = pull ? panelRoot : EDITOR + const destRoot = pull ? EDITOR : panelRoot + + for (const { from, to, create } of pairsFor(pull)) { + const source = join(sourceRoot, from) + const isFile = existsSync(source) && statSync(source).isFile() + for (const file of files(source)) { + const rel = isFile ? '' : relative(source, file) + const origin = isFile ? from : join(from, rel) + // A file the console owns can be reached by two pairs; the longer one + // already took it. + if (claimed.has(origin)) continue + claimed.add(origin) + const target = isFile ? to : join(to, rel) + // `EDITOR_OWNED` holds console-side paths, so it is the origin on pull + // and the target on push. Either way it means the same thing: the + // editor's copy is the authority and must not be written over. + if (EDITOR_OWNED.has(pull ? origin : target)) continue + const absolute = join(destRoot, target) + if (!create && !existsSync(absolute)) continue + const body = readFileSync(file, 'utf8') + const next = pull ? toVendored(body) : toUpstream(body) + const current = existsSync(absolute) ? readFileSync(absolute, 'utf8') : null + if (current === next) continue + actions.push({ target, absolute, next, kind: current === null ? 'new' : 'changed' }) + } + } + return actions.sort((a, b) => a.target.localeCompare(b.target)) +} + +const args = process.argv.slice(2) +const panelRoot = args[args.indexOf('--panel') + 1] +const checkOnly = args.includes('--check') +const pull = args.includes('--pull') + +if (!panelRoot || panelRoot.startsWith('--')) { + console.error('usage: node scripts/sync-panel.mjs --panel [--pull] [--check]') + process.exit(2) +} +if (!existsSync(join(panelRoot, 'src/lib'))) { + console.error(`${panelRoot} does not look like a checkout of ovurrsl/panel (no src/lib).`) + process.exit(2) +} + +const actions = plan(panelRoot, pull) + +if (actions.length === 0) { + console.log(pull ? 'the vendored copy is current — nothing to pull' : 'panel is in sync — nothing to push') + process.exit(0) +} + +for (const action of actions) { + console.log(`${action.kind === 'new' ? 'new ' : 'changed'} ${action.target}`) +} +console.log(`\n${actions.length} file(s)`) + +if (checkOnly) { + console.error( + pull + ? '\nthe console has moved ahead of the vendored copy; run the sync' + : '\nthe console copy has moved ahead of its repository; run the sync', + ) + process.exit(1) +} + +for (const action of actions) { + mkdirSync(dirname(action.absolute), { recursive: true }) + writeFileSync(action.absolute, action.next) +} +console.log('written')