From d0bf9236689e41031cf6b01808ceb15c0549c522 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 12 Oct 2026 13:40:37 -0400 Subject: [PATCH 1/6] editor: Docker-ready self-hosting (image, runtime public URL, admin password) Elestio's review of the editor for their managed catalogue listed three blockers: no published image, NEXT_PUBLIC_APP_URL baked at build time so one image serves one domain, and no authentication. - Public URL at runtime. /scenes and /scene/[id] read the SQLite store directly instead of fetching their own HTTP API; NEXT_PUBLIC_APP_URL and the Vercel-era BASE_URL go. PASCAL_PUBLIC_URL (per request, with a forwarded-header fallback) replaces MINT_PASCAL_HOST_ORIGIN; the CLI sets it from the instance URL. - One container. Two-stage Dockerfile: bun build stage, node:22-alpine runtime holding the same staged runtime the CLI downloads. release.yml publishes ghcr.io/pascalorg/pascal-editor: and :latest|:beta, per-arch on native runners stitched into one manifest. docker.yml builds and drives the image on PRs that touch what shapes it. - One workspace behind one password. PASCAL_ADMIN_PASSWORD gates every page behind /login (proxy.ts) and the scene API behind session-or-bearer; sessions are HMACs keyed by a digest of the password. A network host with nothing set serves /setup only; PASCAL_AUTH=proxy defers to an upstream proxy; loopback stays open. Signed-in saves stamp owner_id=admin. Co-Authored-By: Claude Fable 5.1 --- .env.example | 13 +- .github/workflows/docker.yml | 72 +++++++++ .github/workflows/release.yml | 88 +++++++++++ CHANGELOG.md | 4 + Dockerfile | 29 +++- SETUP.md | 47 ++++-- apps/editor/app/api/auth/login/route.ts | 32 ++++ apps/editor/app/api/auth/logout/route.ts | 12 ++ .../app/api/plugins/mint/[...path]/route.ts | 6 +- apps/editor/app/api/scenes/route.ts | 3 + apps/editor/app/login/login-form.tsx | 63 ++++++++ apps/editor/app/login/page.tsx | 29 ++++ apps/editor/app/scene/[id]/page.tsx | 28 +--- apps/editor/app/scenes/page.tsx | 68 +++++---- apps/editor/app/setup/page.tsx | 42 ++++++ apps/editor/lib/public-url.ts | 14 ++ apps/editor/lib/scene-api-security.test.ts | 27 ++++ apps/editor/lib/scene-api-security.ts | 43 +++--- apps/editor/lib/self-host-auth.test.ts | 69 +++++++++ apps/editor/lib/self-host-auth.ts | 142 ++++++++++++++++++ apps/editor/lib/utils.ts | 37 ----- apps/editor/proxy.test.ts | 63 ++++++++ apps/editor/proxy.ts | 49 ++++++ docker-compose.yml | 24 +-- packages/cli/src/editor-process.ts | 2 +- packages/cli/src/runtime.test.ts | 16 +- wiki/npm-release.md | 4 + 27 files changed, 865 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 apps/editor/app/api/auth/login/route.ts create mode 100644 apps/editor/app/api/auth/logout/route.ts create mode 100644 apps/editor/app/login/login-form.tsx create mode 100644 apps/editor/app/login/page.tsx create mode 100644 apps/editor/app/setup/page.tsx create mode 100644 apps/editor/lib/public-url.ts create mode 100644 apps/editor/lib/self-host-auth.test.ts create mode 100644 apps/editor/lib/self-host-auth.ts create mode 100644 apps/editor/proxy.test.ts create mode 100644 apps/editor/proxy.ts diff --git a/.env.example b/.env.example index 014f531319..cfa172eb68 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,13 @@ # Dev server port (default: 3002, set in .env.defaults) # PORT=3002 -# Public editor origin used by Mint OAuth and same-origin checks. -# Set this when Pascal is hosted outside editor.pascal.app. -# MINT_PASCAL_HOST_ORIGIN=https://pascal.example.com +# The address people use to reach this editor. Read per request; set it when +# Pascal is hosted outside localhost (Mint sign-in and agent links use it). +# PASCAL_PUBLIC_URL=https://pascal.example.com + +# Self-hosting: the one password that unlocks this editor. Required on any host +# that is not loopback. See SETUP.md, "Self-hosting". +# PASCAL_ADMIN_PASSWORD=change-me + +# Optional bearer token for agents and scripts calling /api/scenes. +# PASCAL_SCENE_API_TOKEN=change-me-too diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..279e33949e --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,72 @@ +name: Docker image + +# Builds the self-host image and drives it like a host would: health, the +# login gate, a signed-in save. Runs when something that shapes the image +# changes; the release workflow publishes the multi-arch image. +on: + pull_request: + branches: [main] + paths: + - Dockerfile + - .dockerignore + - docker-compose.yml + - .github/workflows/docker.yml + - apps/editor/next.config.ts + - apps/editor/proxy.ts + - apps/editor/lib/self-host-auth.ts + - apps/editor/lib/scene-api-security.ts + - packages/cli/scripts/stage-runtime.ts + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: pascal-editor:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke-test the image as a network host + run: | + set -euo pipefail + docker run -d --name pascal -p 3000:3000 \ + -e PASCAL_ADMIN_PASSWORD=ci-password \ + -e PASCAL_PUBLIC_URL=http://pascal.ci:3000 \ + pascal-editor:ci + for _ in $(seq 1 60); do + curl -fsS -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/api/health && break + sleep 2 + done + curl -fsS -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/api/health | grep '"status":"ok"' + + # The gate: pages redirect to /login, the scene API refuses. + test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/scenes)" = "307" + test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/api/scenes)" = "401" + test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' -H 'Content-Type: application/json' \ + -d '{"password":"wrong"}' http://127.0.0.1:3000/api/auth/login)" = "401" + + # Sign in, then save and list a scene with the session. + curl -fsS -c cookies.txt -H 'Host: pascal.ci:3000' -H 'Content-Type: application/json' \ + -d '{"password":"ci-password"}' http://127.0.0.1:3000/api/auth/login + curl -fsS -b cookies.txt -H 'Host: pascal.ci:3000' -H 'Content-Type: application/json' \ + -d '{"name":"Smoke","graph":{"nodes":{},"rootNodeIds":[]}}' http://127.0.0.1:3000/api/scenes \ + | grep '"ownerId":"admin"' + curl -fsS -b cookies.txt -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/scenes | grep -q 'Smoke' + + - name: Container logs + if: always() + run: docker logs pascal || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9188a9309e..c6d95cb11d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,9 @@ jobs: permissions: contents: write id-token: write + outputs: + version: ${{ steps.version.outputs.version }} + channel: ${{ steps.version.outputs.channel }} env: # Verbose npm logs show the OIDC token exchange and the registry's # rejection reason when trusted publishing is misconfigured; tokens are @@ -198,6 +201,11 @@ jobs: # Refresh the lockfile so the release commit remains reproducible. bun install + - id: version + run: | + echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + echo "channel=$NPM_TAG" >> "$GITHUB_OUTPUT" + - name: Validate portable editor runtime env: PASCAL_PORTABLE_BUILD: "1" @@ -376,3 +384,83 @@ jobs: fi gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber echo "🌐 Uploaded $(basename "$ARCHIVE") to $TAG" + + # The self-host image, built from the release tag on native runners (a Next + # build under QEMU takes most of an hour) and stitched into one multi-arch + # manifest. Tagged with the npm version so the container and the CLI runtime + # of a release are the same bits. + docker: + needs: release + if: inputs.dry-run == false + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + ref: refs/tags/@pascal-app/cli@${{ needs.release.outputs.version }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/${{ matrix.arch }} + outputs: type=image,name=ghcr.io/pascalorg/pascal-editor,push-by-digest=true,name-canonical=true,push=true + + - run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - uses: actions/upload-artifact@v4 + with: + name: docker-digest-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + + docker-manifest: + needs: [release, docker] + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish ghcr.io/pascalorg/pascal-editor + working-directory: /tmp/digests + env: + VERSION: ${{ needs.release.outputs.version }} + CHANNEL: ${{ needs.release.outputs.channel }} + run: | + docker buildx imagetools create \ + --tag "ghcr.io/pascalorg/pascal-editor:$VERSION" \ + --tag "ghcr.io/pascalorg/pascal-editor:$CHANNEL" \ + $(printf 'ghcr.io/pascalorg/pascal-editor@sha256:%s ' *) + docker buildx imagetools inspect "ghcr.io/pascalorg/pascal-editor:$VERSION" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cddaf5d13..102708c766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- **Self-hosting is one container.** `ghcr.io/pascalorg/pascal-editor:` ships the same portable runtime as the CLI, multi-arch, published by the release workflow. The public URL is read per request (`PASCAL_PUBLIC_URL`, replacing the build-time `NEXT_PUBLIC_APP_URL` and `MINT_PASCAL_HOST_ORIGIN`), so one image serves any domain or port. A single admin password (`PASCAL_ADMIN_PASSWORD`) gates every page and API behind `/login`; agents keep the `PASCAL_SCENE_API_TOKEN` bearer; a network host with nothing configured serves a setup page and nothing else; `PASCAL_AUTH=proxy` defers to an authenticating reverse proxy. See `SETUP.md`, "Self-hosting". + ### Breaking - **`wall.assembly` is now F2 assembly layers** (breaking change to the additive plugin v1 contract; the WS5 shape shipped days earlier in #937). Walls store `{ layers: [{ id, role, thickness, core?, material?, … }], face?, presetId?, cavityInsulation? }` instead of `{ exterior, sheathing, framing, interior, preset }`. Scenes saved with the old shape are converted on load and at the scene API boundary, so `thickness` and every drawing stay the same. Plugins that read `wall.assembly.exterior` / `.framing` / `.preset` directly should read `wallAssemblyToLegacy(wall.assembly)` (the old shape, or `null` for a stack it cannot express), `wallAssemblyFraming(wall)` or `wallAssemblyExteriorFinish(wall)`; `WALL_ASSEMBLY_PRESETS[i].assembly` is F2 too. diff --git a/Dockerfile b/Dockerfile index 11741c8318..0e609316e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ +# syntax=docker/dockerfile:1 + # Matches `packageManager` in package.json and the version CI installs β€” a skew # here is what makes `--frozen-lockfile` fail inside the image but not locally. -FROM oven/bun:1.3.14-alpine +FROM oven/bun:1.3.14-alpine AS build WORKDIR /app # `next build` runs under `node`, and this image's `node` is a shim that re-execs @@ -11,15 +13,30 @@ RUN apk add --no-cache nodejs COPY . . RUN bun install --frozen-lockfile + +# The image ships the same portable runtime as `npx @pascal-app/cli editor`: +# Next's standalone output, pruned of sources and build-only files by the CLI's +# staging script. One artifact, two distribution channels. +ENV PASCAL_PORTABLE_BUILD=1 RUN ./node_modules/.bin/turbo run build --filter=editor +RUN cd packages/cli && bun run build && bun run stage-runtime + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 \ + PORT=3000 \ + PASCAL_DATA_DIR=/data + +COPY --from=build --chown=node:node /app/packages/cli/build/runtime ./ # Saved scenes live in SQLite under PASCAL_DATA_DIR; owned by the runtime user so # the store can create the database on first write. -RUN mkdir -p /data && chown -R bun:bun /data /app -ENV PASCAL_DATA_DIR=/data +RUN mkdir -p /data && chown node:node /data VOLUME /data -USER bun +USER node EXPOSE 3000 -WORKDIR /app/apps/editor -CMD ["bun", "run", "start"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 +CMD ["node", "apps/editor/server.js"] diff --git a/SETUP.md b/SETUP.md index 837ed17e72..c247c8969c 100644 --- a/SETUP.md +++ b/SETUP.md @@ -29,30 +29,45 @@ cp .env.example .env | Variable | Required | Description | |----------|----------|-------------| | `PORT` | No | Dev server port (default: 3002) | -| `MINT_PASCAL_HOST_ORIGIN` | No | Public editor origin used by Mint sign-in and request checks. Set it for self-hosted deployments. | +| `PASCAL_PUBLIC_URL` | No | The address people use to reach the editor. Read per request; set it when Pascal is hosted outside localhost (Mint sign-in and agent links use it). | +| `PASCAL_ADMIN_PASSWORD` | On a network host | The one password that unlocks a self-hosted editor. See "Self-hosting". | +| `PASCAL_AUTH` | No | `proxy` lets a reverse proxy that already authenticates every request replace the password. | +| `PASCAL_SCENE_API_TOKEN` | No | Bearer token for agents and scripts calling `/api/scenes`. | +| `PASCAL_DATA_DIR` | No | Directory holding `pascal.db` and artifacts (default: `~/.pascal/data`). | Local development and the official hosted editor work without any environment variables. -## Docker +## Self-hosting -```bash -docker compose up -d -``` - -The editor will be running at **http://localhost:3000**. Saved scenes live in -the `pascal-data` volume, so they survive `docker compose down`. - -Docker defaults `MINT_PASCAL_HOST_ORIGIN` to `http://localhost:3000`. Override -it when hosting Pascal at another origin: +The editor ships as one container, `ghcr.io/pascalorg/pascal-editor`, tagged with +the same version as the `@pascal-app/*` packages (`latest` follows stable releases, +`beta` the prereleases). It holds the same portable runtime the CLI installs. ```bash -MINT_PASCAL_HOST_ORIGIN=https://pascal.example.com docker compose up -d +PASCAL_ADMIN_PASSWORD=change-me PASCAL_PUBLIC_URL=https://pascal.example.com docker compose up -d ``` -Keep the container port at 3000: the `/scenes` page fetches its own API through -a base URL that only `NEXT_PUBLIC_APP_URL` can override, and Next inlines that -value at build time, so remapping the port to something else makes the page -return 500. +The editor is then at your public URL. Saved scenes live in the `pascal-data` volume +(`/data` in the container), so they survive `docker compose down` and image updates. +`docker compose build` rebuilds the image from your checkout instead of pulling it. + +How it is protected, by `apps/editor/lib/self-host-auth.ts`: + +- **One workspace behind one password.** `PASCAL_ADMIN_PASSWORD` gates every page and API + behind `/login`. There is one account; everyone who signs in shares the scenes. Sessions + are signed with a digest of the password, so changing it signs everyone out. +- **Agents use a bearer.** Set `PASCAL_SCENE_API_TOKEN` and send it as + `Authorization: Bearer …` to `/api/scenes` and `/api/artifacts`; the scenes page shows the + endpoint under "Connect an agent". +- **Fail closed.** A host that is not loopback with no password set serves a setup page + listing the variables and nothing else. `PASCAL_AUTH=proxy` opts out when a reverse proxy + (basic auth, SSO) already authenticates every request. +- **Loopback stays open.** `bun dev`, the CLI's managed editor and `docker run` reached at + `localhost` need no password. Opening a dev server to another device on your LAN counts + as a network host: set `PASCAL_ADMIN_PASSWORD` in `.env.local` for that. + +Nothing is fixed at build time: `PASCAL_PUBLIC_URL` and the port are read when a request +comes in, so one image serves any domain. `/api/health` answers without a session. ## CLI-managed editor diff --git a/apps/editor/app/api/auth/login/route.ts b/apps/editor/app/api/auth/login/route.ts new file mode 100644 index 0000000000..1fb234b8e7 --- /dev/null +++ b/apps/editor/app/api/auth/login/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' +import { + allowLoginAttempt, + issueSession, + resolveAuthMode, + SESSION_TTL_SECONDS, + sessionCookie, + verifyPassword, +} from '@/lib/self-host-auth' + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + if (resolveAuthMode(request) !== 'password') { + return NextResponse.json({ error: 'password_not_configured' }, { status: 404 }) + } + if (!allowLoginAttempt(request)) { + return NextResponse.json({ error: 'rate_limited' }, { status: 429 }) + } + let password: unknown + try { + password = ((await request.json()) as { password?: unknown }).password + } catch { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }) + } + if (typeof password !== 'string' || !verifyPassword(password)) { + return NextResponse.json({ error: 'invalid_password' }, { status: 401 }) + } + const response = new NextResponse(null, { status: 204 }) + response.headers.set('Set-Cookie', sessionCookie(request, issueSession(), SESSION_TTL_SECONDS)) + return response +} diff --git a/apps/editor/app/api/auth/logout/route.ts b/apps/editor/app/api/auth/logout/route.ts new file mode 100644 index 0000000000..18dcca84c5 --- /dev/null +++ b/apps/editor/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server' +import { sessionCookie } from '@/lib/self-host-auth' + +export const dynamic = 'force-dynamic' + +export function POST(request: Request) { + // Relative Location: the handler's own request URL names the bind address, + // not the host the browser used. + const response = new NextResponse(null, { status: 303, headers: { Location: '/login' } }) + response.headers.set('Set-Cookie', sessionCookie(request, '', 0)) + return response +} diff --git a/apps/editor/app/api/plugins/mint/[...path]/route.ts b/apps/editor/app/api/plugins/mint/[...path]/route.ts index 01ad4f07e4..543a5f5e75 100644 --- a/apps/editor/app/api/plugins/mint/[...path]/route.ts +++ b/apps/editor/app/api/plugins/mint/[...path]/route.ts @@ -1,12 +1,10 @@ import { handleMintPascalRequest } from '@mint/pascal-plugin/server' -import { BASE_URL } from '@/lib/utils' +import { resolvePublicUrl } from '@/lib/public-url' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const route = (request: Request) => - handleMintPascalRequest(request, { - origin: process.env.MINT_PASCAL_HOST_ORIGIN ?? BASE_URL, - }) + handleMintPascalRequest(request, { origin: resolvePublicUrl(request) }) export { route as GET, route as POST } diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts index 01ffc3ba88..24181b5104 100644 --- a/apps/editor/app/api/scenes/route.ts +++ b/apps/editor/app/api/scenes/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { apiGraphSchema } from '@/lib/graph-schema' import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security' import { getSceneOperations } from '@/lib/scene-store-server' +import { sessionPrincipal } from '@/lib/self-host-auth' export const dynamic = 'force-dynamic' @@ -78,6 +79,8 @@ export async function POST(request: NextRequest) { id: parsed.data.id, name: parsed.data.name, projectId: parsed.data.projectId ?? null, + // A signed-in save is attributable; agent saves through the bearer stay unowned. + ownerId: sessionPrincipal(request), graph: parsed.data.graph as never, thumbnailUrl: parsed.data.thumbnailUrl ?? null, }) diff --git a/apps/editor/app/login/login-form.tsx b/apps/editor/app/login/login-form.tsx new file mode 100644 index 0000000000..af17977dc6 --- /dev/null +++ b/apps/editor/app/login/login-form.tsx @@ -0,0 +1,63 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { type FormEvent, useState } from 'react' + +export function LoginForm({ next }: { next: string }) { + const router = useRouter() + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setSubmitting(true) + setError(null) + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) + if (response.status === 429) { + setError('Too many attempts. Try again in a minute.') + return + } + if (!response.ok) { + setError('That password is not right.') + return + } + router.push(next) + router.refresh() + } catch { + setError('Could not reach the editor.') + } finally { + setSubmitting(false) + } + } + + return ( +
+ + {error &&

{error}

} + +
+ ) +} diff --git a/apps/editor/app/login/page.tsx b/apps/editor/app/login/page.tsx new file mode 100644 index 0000000000..9575608fe8 --- /dev/null +++ b/apps/editor/app/login/page.tsx @@ -0,0 +1,29 @@ +import { LoginForm } from './login-form' + +export const dynamic = 'force-dynamic' + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ next?: string }> +}) { + const { next } = await searchParams + return ( +
+
+

Pascal

+

Sign in to this editor

+

+ Enter the admin password set for this instance. +

+ +
+
+ ) +} + +/** Only same-origin paths may be used as the post-login destination. */ +function safeNextPath(next: string | undefined): string { + if (!next?.startsWith('/') || next.startsWith('//')) return '/scenes' + return next +} diff --git a/apps/editor/app/scene/[id]/page.tsx b/apps/editor/app/scene/[id]/page.tsx index 95df49f368..c2b0c35458 100644 --- a/apps/editor/app/scene/[id]/page.tsx +++ b/apps/editor/app/scene/[id]/page.tsx @@ -1,7 +1,7 @@ import type { SceneGraph } from '@pascal-app/editor' -import { headers } from 'next/headers' import Link from 'next/link' import { SceneLoader, type SceneMeta } from '@/components/scene-loader' +import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -9,31 +9,9 @@ interface SceneWithGraph extends SceneMeta { graph: SceneGraph } -async function resolveBaseUrl(): Promise { - if (process.env.NEXT_PUBLIC_APP_URL) { - return process.env.NEXT_PUBLIC_APP_URL - } - const h = await headers() - const host = h.get('x-forwarded-host') ?? h.get('host') - const proto = h.get('x-forwarded-proto') ?? 'http' - if (!host) { - return 'http://localhost:3000' - } - return `${proto}://${host}` -} - async function fetchScene(id: string): Promise { - const base = await resolveBaseUrl() - const response = await fetch(`${base}/api/scenes/${encodeURIComponent(id)}`, { - cache: 'no-store', - }) - if (response.status === 404) { - return null - } - if (!response.ok) { - throw new Error(`Failed to load scene: ${response.status}`) - } - return (await response.json()) as SceneWithGraph + const operations = await getSceneOperations() + return (await operations.loadStoredScene(id)) as SceneWithGraph | null } export default async function ScenePage({ params }: { params: Promise<{ id: string }> }) { diff --git a/apps/editor/app/scenes/page.tsx b/apps/editor/app/scenes/page.tsx index 39b8dbf07a..0194b1445a 100644 --- a/apps/editor/app/scenes/page.tsx +++ b/apps/editor/app/scenes/page.tsx @@ -2,35 +2,15 @@ import { headers } from 'next/headers' import Link from 'next/link' import { CreateSceneButton } from '@/components/save-button' import type { SceneMeta } from '@/components/scene-loader' +import { resolvePublicUrl } from '@/lib/public-url' +import { getSceneOperations } from '@/lib/scene-store-server' +import { resolveAuthMode } from '@/lib/self-host-auth' export const dynamic = 'force-dynamic' -async function resolveBaseUrl(): Promise { - if (process.env.NEXT_PUBLIC_APP_URL) { - return process.env.NEXT_PUBLIC_APP_URL - } - const h = await headers() - const host = h.get('x-forwarded-host') ?? h.get('host') - const proto = h.get('x-forwarded-proto') ?? 'http' - if (!host) { - return 'http://localhost:3000' - } - return `${proto}://${host}` -} - async function fetchScenes(): Promise { - const base = await resolveBaseUrl() - const response = await fetch(`${base}/api/scenes?limit=50`, { - cache: 'no-store', - }) - if (!response.ok) { - return [] - } - const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[] - if (Array.isArray(payload)) { - return payload - } - return payload.scenes ?? [] + const operations = await getSceneOperations() + return operations.listScenes({ limit: 50 }) } function formatDate(iso: string): string { @@ -41,8 +21,19 @@ function formatDate(iso: string): string { } } +/** Server components see headers, not a Request; the auth helpers want the latter. */ +async function currentRequest(): Promise { + const h = await headers() + const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'localhost' + const proto = h.get('x-forwarded-proto') ?? 'http' + return new Request(`${proto}://${host}/scenes`, { headers: h }) +} + export default async function ScenesPage() { - const scenes = await fetchScenes() + const [scenes, request] = await Promise.all([fetchScenes(), currentRequest()]) + const authMode = resolveAuthMode(request) + const sceneApiUrl = `${resolvePublicUrl(request)}/api/scenes` + const agentTokenConfigured = Boolean(process.env.PASCAL_SCENE_API_TOKEN) return (
@@ -58,7 +49,19 @@ export default async function ScenesPage() { / Scenes - +
+ + {authMode === 'password' && ( +
+ +
+ )} +
@@ -111,6 +114,17 @@ export default async function ScenesPage() { ))} )} + +
+

Connect an agent

+

+ Scripts and agents read and write scenes through the scene API at{' '} + {sceneApiUrl}.{' '} + {agentTokenConfigured + ? 'Send the configured token as an Authorization: Bearer header.' + : 'Set PASCAL_SCENE_API_TOKEN on this instance and send it as an Authorization: Bearer header.'} +

+
) diff --git a/apps/editor/app/setup/page.tsx b/apps/editor/app/setup/page.tsx new file mode 100644 index 0000000000..32fd554deb --- /dev/null +++ b/apps/editor/app/setup/page.tsx @@ -0,0 +1,42 @@ +export const dynamic = 'force-dynamic' + +const VARIABLES = [ + ['PASCAL_ADMIN_PASSWORD', 'The password that unlocks this editor. Required on a network host.'], + [ + 'PASCAL_PUBLIC_URL', + 'The address people use to reach it, for example https://pascal.example.com.', + ], + [ + 'PASCAL_SCENE_API_TOKEN', + 'Optional. A bearer token for agents and scripts calling the scene API.', + ], + [ + 'PASCAL_AUTH=proxy', + 'Alternative to a password when a reverse proxy already authenticates every request.', + ], +] as const + +export default function SetupPage() { + return ( +
+
+

Pascal

+

+ This editor is reachable but not protected yet +

+

+ Set an admin password in the environment and restart. Until then, nothing is served from + this address. +

+
+ {VARIABLES.map(([name, description]) => ( +
+
{name}
+
{description}
+
+ ))} +
+
+
+ ) +} diff --git a/apps/editor/lib/public-url.ts b/apps/editor/lib/public-url.ts new file mode 100644 index 0000000000..c5627835a5 --- /dev/null +++ b/apps/editor/lib/public-url.ts @@ -0,0 +1,14 @@ +/** + * The origin this editor is reached at, resolved per request so one build + * serves any domain. `PASCAL_PUBLIC_URL` wins; otherwise the proxy's forwarded + * headers, then the request's own host. + */ +export function resolvePublicUrl(request: Request): string { + const configured = process.env.PASCAL_PUBLIC_URL?.trim() + if (configured) return configured.replace(/\/+$/, '') + const forwardedHost = request.headers.get('x-forwarded-host') + const host = forwardedHost ?? request.headers.get('host') ?? new URL(request.url).host + const proto = + request.headers.get('x-forwarded-proto') ?? new URL(request.url).protocol.replace(':', '') + return `${proto}://${host}` +} diff --git a/apps/editor/lib/scene-api-security.test.ts b/apps/editor/lib/scene-api-security.test.ts index 6494b5dd75..3d92875d63 100644 --- a/apps/editor/lib/scene-api-security.test.ts +++ b/apps/editor/lib/scene-api-security.test.ts @@ -1,9 +1,12 @@ import { afterEach, expect, test } from 'bun:test' import { guardSceneApiRequest, sceneApiPreflight } from './scene-api-security' +import { issueSession, SESSION_COOKIE } from './self-host-auth' const OLD_ENV = { ...process.env } afterEach(() => { + restoreEnv('PASCAL_ADMIN_PASSWORD') + restoreEnv('PASCAL_AUTH') restoreEnv('PASCAL_SCENE_API_TOKEN') restoreEnv('PASCAL_SCENE_API_ORIGINS') restoreEnv('PASCAL_SCENE_API_RATE_LIMIT') @@ -35,6 +38,30 @@ test('requires a token for non-loopback scene API requests', async () => { expect(await response?.json()).toEqual({ error: 'scene_api_token_required' }) }) +test('accepts the admin session on a password-protected host', () => { + process.env.PASCAL_ADMIN_PASSWORD = 'secret' + delete process.env.PASCAL_SCENE_API_TOKEN + const signedIn = new Request('https://editor.example/api/scenes', { + headers: { host: 'editor.example', cookie: `${SESSION_COOKIE}=${issueSession()}` }, + }) + const anonymous = new Request('https://editor.example/api/scenes', { + headers: { host: 'editor.example' }, + }) + + expect(guardSceneApiRequest(signedIn)).toBeNull() + expect(guardSceneApiRequest(anonymous)?.status).toBe(401) +}) + +test('an upstream proxy opens the scene API without a token', () => { + process.env.PASCAL_AUTH = 'proxy' + delete process.env.PASCAL_SCENE_API_TOKEN + const request = new Request('https://editor.example/api/scenes', { + headers: { host: 'editor.example' }, + }) + + expect(guardSceneApiRequest(request)).toBeNull() +}) + test('accepts bearer token auth when configured', () => { process.env.PASCAL_SCENE_API_TOKEN = 'secret' const request = new Request('https://editor.example/api/scenes', { diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts index 2dd3819a6e..452bf9446b 100644 --- a/apps/editor/lib/scene-api-security.ts +++ b/apps/editor/lib/scene-api-security.ts @@ -1,5 +1,6 @@ import { timingSafeEqual } from 'node:crypto' import { NextResponse } from 'next/server' +import { isLoopbackHostname, resolveAuthMode, sessionPrincipal } from './self-host-auth' const DEFAULT_RATE_LIMIT_PER_MINUTE = 120 const WINDOW_MS = 60_000 @@ -62,16 +63,28 @@ function validateOrigin(request: Request): NextResponse | null { return sceneApiJson(request, { error: 'origin_not_allowed' }, { status: 403 }) } +/** + * Who may call the scene API: the signed-in admin (session cookie), an agent + * holding `PASCAL_SCENE_API_TOKEN`, or anyone on a loopback host when no + * token is configured. A network host with nothing configured answers 503 + * so a misconfigured deployment is loud rather than open. + */ function validateAuth(request: Request): NextResponse | null { + if (sessionPrincipal(request)) return null + const token = process.env.PASCAL_SCENE_API_TOKEN - if (!token) { - if (isLoopbackRequest(request)) return null - return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 }) + if (token) { + const supplied = bearerToken(request) ?? request.headers.get('x-pascal-scene-token') + if (supplied && safeEqual(supplied, token)) return null + return sceneApiJson(request, { error: 'unauthorized' }, { status: 401 }) } - const supplied = bearerToken(request) ?? request.headers.get('x-pascal-scene-token') - if (supplied && safeEqual(supplied, token)) return null - return sceneApiJson(request, { error: 'unauthorized' }, { status: 401 }) + const mode = resolveAuthMode(request) + if (mode === 'open') return null + if (mode === 'password') { + return sceneApiJson(request, { error: 'unauthorized' }, { status: 401 }) + } + return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 }) } function validateRateLimit(request: Request): NextResponse | null { @@ -149,16 +162,6 @@ function isSameOrigin(request: Request, origin: string): boolean { return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl) } -function isLoopbackRequest(request: Request): boolean { - const host = request.headers.get('host') ?? new URL(request.url).host - return isLoopbackHostname(stripPort(host)) -} - -function isLoopbackHostname(hostname: string): boolean { - const h = hostname.toLowerCase() - return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1' -} - function parseUrl(value: string): URL | null { try { return new URL(value) @@ -170,11 +173,3 @@ function parseUrl(value: string): URL | null { function normalizeOrigin(url: URL): string { return `${url.protocol}//${url.host}`.toLowerCase() } - -function stripPort(host: string): string { - if (host.startsWith('[')) { - const end = host.indexOf(']') - return end === -1 ? host : host.slice(1, end) - } - return host.split(':')[0] ?? host -} diff --git a/apps/editor/lib/self-host-auth.test.ts b/apps/editor/lib/self-host-auth.test.ts new file mode 100644 index 0000000000..fc694254af --- /dev/null +++ b/apps/editor/lib/self-host-auth.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from 'bun:test' +import { + issueSession, + resolveAuthMode, + SESSION_COOKIE, + SESSION_TTL_SECONDS, + sessionPrincipal, + verifyPassword, + verifySession, +} from './self-host-auth' + +const publicRequest = (cookie?: string) => + new Request('https://pascal.example/scenes', { + headers: { host: 'pascal.example', ...(cookie ? { cookie } : {}) }, + }) + +test('a loopback host is open without any configuration', () => { + const request = new Request('http://pascal.localhost:3002/', { + headers: { host: 'pascal.localhost:3002' }, + }) + expect(resolveAuthMode(request, {})).toBe('open') +}) + +test('a network host with nothing configured is refused', () => { + expect(resolveAuthMode(publicRequest(), {})).toBe('unconfigured') +}) + +test('an upstream proxy can take over authentication explicitly', () => { + expect(resolveAuthMode(publicRequest(), { PASCAL_AUTH: 'proxy' })).toBe('open') +}) + +test('a password requires a session even on loopback', () => { + const request = new Request('http://localhost:3002/', { headers: { host: 'localhost:3002' } }) + expect(resolveAuthMode(request, { PASCAL_ADMIN_PASSWORD: 'secret' })).toBe('password') +}) + +test('a session issued for the password identifies the admin', () => { + const env = { PASCAL_ADMIN_PASSWORD: 'secret' } + const session = issueSession(env) + expect(sessionPrincipal(publicRequest(`${SESSION_COOKIE}=${session}`), env)).toBe('admin') + expect(sessionPrincipal(publicRequest(), env)).toBeNull() +}) + +test('changing the password signs every session out', () => { + const session = issueSession({ PASCAL_ADMIN_PASSWORD: 'secret' }) + expect(verifySession(session, { PASCAL_ADMIN_PASSWORD: 'rotated' })).toBe(false) +}) + +test('a session expires after its lifetime and never validates from the future', () => { + const env = { PASCAL_ADMIN_PASSWORD: 'secret' } + const issued = Date.UTC(2026, 9, 12) + const session = issueSession(env, issued) + expect(verifySession(session, env, issued + (SESSION_TTL_SECONDS - 1) * 1000)).toBe(true) + expect(verifySession(session, env, issued + (SESSION_TTL_SECONDS + 1) * 1000)).toBe(false) + expect(verifySession(session, env, issued - 1000)).toBe(false) +}) + +test('a tampered session is rejected', () => { + const env = { PASCAL_ADMIN_PASSWORD: 'secret' } + const [version, issuedAt, signature] = issueSession(env).split('.') + expect(verifySession(`${version}.${Number(issuedAt) + 1}.${signature}`, env)).toBe(false) + expect(verifySession('garbage', env)).toBe(false) +}) + +test('password verification is exact and fails closed without a password', () => { + expect(verifyPassword('secret', { PASCAL_ADMIN_PASSWORD: 'secret' })).toBe(true) + expect(verifyPassword('Secret', { PASCAL_ADMIN_PASSWORD: 'secret' })).toBe(false) + expect(verifyPassword('', {})).toBe(false) +}) diff --git a/apps/editor/lib/self-host-auth.ts b/apps/editor/lib/self-host-auth.ts new file mode 100644 index 0000000000..cad93d6ccc --- /dev/null +++ b/apps/editor/lib/self-host-auth.ts @@ -0,0 +1,142 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto' + +/** + * Single-admin authentication for a self-hosted editor. + * + * One workspace behind one password: `PASCAL_ADMIN_PASSWORD` unlocks the whole + * instance, the session principal is always `admin`. Sessions are HMAC tokens + * keyed by a digest of the password, so there is no second secret to configure + * and changing the password signs everyone out. + */ +export const SESSION_COOKIE = 'pascal_session' +export const ADMIN_PRINCIPAL = 'admin' +export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60 +const LOGIN_ATTEMPTS_PER_MINUTE = 10 +const LOGIN_WINDOW_MS = 60_000 + +/** + * - `password`: `PASCAL_ADMIN_PASSWORD` is set; every page and API needs a session or a bearer. + * - `open`: no password, but the host is loopback (dev server, the CLI's managed editor) or + * `PASCAL_AUTH=proxy` says an upstream proxy authenticates. + * - `unconfigured`: reachable from the network with nothing set; the app refuses to serve. + */ +export type AuthMode = 'password' | 'open' | 'unconfigured' + +export function resolveAuthMode(request: Request, env: NodeJS.ProcessEnv = process.env): AuthMode { + if (env.PASCAL_ADMIN_PASSWORD) return 'password' + if (env.PASCAL_AUTH === 'proxy') return 'open' + if (isLoopbackRequest(request)) return 'open' + return 'unconfigured' +} + +export function verifyPassword(supplied: string, env: NodeJS.ProcessEnv = process.env): boolean { + const configured = env.PASCAL_ADMIN_PASSWORD + if (!configured) return false + return safeEqual(supplied, configured) +} + +export function issueSession(env: NodeJS.ProcessEnv = process.env, now = Date.now()): string { + const issuedAt = Math.floor(now / 1000) + return `1.${issuedAt}.${sign(issuedAt, env)}` +} + +export function verifySession( + value: string | undefined, + env: NodeJS.ProcessEnv = process.env, + now = Date.now(), +): boolean { + if (!value || !env.PASCAL_ADMIN_PASSWORD) return false + const [version, issuedAtRaw, signature] = value.split('.') + if (version !== '1' || !issuedAtRaw || !signature) return false + const issuedAt = Number.parseInt(issuedAtRaw, 10) + if (!Number.isFinite(issuedAt)) return false + const ageSeconds = Math.floor(now / 1000) - issuedAt + if (ageSeconds < 0 || ageSeconds > SESSION_TTL_SECONDS) return false + return safeEqual(signature, sign(issuedAt, env)) +} + +/** The signed-in principal, or null when the request carries no valid session. */ +export function sessionPrincipal( + request: Request, + env: NodeJS.ProcessEnv = process.env, + now = Date.now(), +): typeof ADMIN_PRINCIPAL | null { + return verifySession(readCookie(request, SESSION_COOKIE), env, now) ? ADMIN_PRINCIPAL : null +} + +export function sessionCookie(request: Request, value: string, maxAge: number): string { + const parts = [ + `${SESSION_COOKIE}=${value}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${maxAge}`, + ] + if (isSecureRequest(request)) parts.push('Secure') + return parts.join('; ') +} + +const loginBuckets = new Map() + +/** True when this client may attempt a login now; attempts are counted per IP. */ +export function allowLoginAttempt(request: Request, now = Date.now()): boolean { + const key = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown' + const bucket = loginBuckets.get(key) + if (!bucket || bucket.resetAt <= now) { + loginBuckets.set(key, { count: 1, resetAt: now + LOGIN_WINDOW_MS }) + return true + } + bucket.count++ + return bucket.count <= LOGIN_ATTEMPTS_PER_MINUTE +} + +export function isLoopbackRequest(request: Request): boolean { + const host = request.headers.get('host') ?? new URL(request.url).host + return isLoopbackHostname(stripPort(host)) +} + +export function isLoopbackHostname(hostname: string): boolean { + const h = hostname.toLowerCase() + return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1' +} + +function isSecureRequest(request: Request): boolean { + const forwarded = request.headers.get('x-forwarded-proto') + if (forwarded) return forwarded.split(',')[0]?.trim() === 'https' + return new URL(request.url).protocol === 'https:' +} + +function sign(issuedAt: number, env: NodeJS.ProcessEnv): string { + const key = createHash('sha256') + .update(env.PASCAL_ADMIN_PASSWORD ?? '') + .digest() + return createHmac('sha256', key).update(`${ADMIN_PRINCIPAL}:${issuedAt}`).digest('base64url') +} + +function readCookie(request: Request, name: string): string | undefined { + const header = request.headers.get('cookie') + if (!header) return undefined + for (const part of header.split(';')) { + const [key, ...rest] = part.trim().split('=') + if (key === name) return rest.join('=') + } + return undefined +} + +function safeEqual(a: string, b: string): boolean { + const aBuffer = Buffer.from(a) + const bBuffer = Buffer.from(b) + if (aBuffer.length !== bBuffer.length) return false + return timingSafeEqual(aBuffer, bBuffer) +} + +function stripPort(host: string): string { + if (host.startsWith('[')) { + const end = host.indexOf(']') + return end === -1 ? host : host.slice(1, end) + } + return host.split(':')[0] ?? host +} diff --git a/apps/editor/lib/utils.ts b/apps/editor/lib/utils.ts index 4d75521649..d32b0fe652 100644 --- a/apps/editor/lib/utils.ts +++ b/apps/editor/lib/utils.ts @@ -4,40 +4,3 @@ import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } - -export const isDevelopment = - process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'development' - -export const isProduction = - process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' - -export const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' - -/** - * Base URL for the application - * Uses NEXT_PUBLIC_* variables which are available at build time - */ -export const BASE_URL = (() => { - // Development: localhost - if (isDevelopment) { - return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}` - } - - // Preview deployments: use Vercel branch URL - if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) { - return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` - } - - // Production: use custom domain or Vercel production URL - if (isProduction) { - return ( - process.env.NEXT_PUBLIC_APP_URL || - (process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL - ? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}` - : 'https://editor.pascal.app') - ) - } - - // Fallback (should never reach here in normal operation) - return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' -})() diff --git a/apps/editor/proxy.test.ts b/apps/editor/proxy.test.ts new file mode 100644 index 0000000000..0c4f963666 --- /dev/null +++ b/apps/editor/proxy.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from 'bun:test' +import { issueSession, SESSION_COOKIE } from './lib/self-host-auth' +import { gateRequest } from './proxy' + +const OLD_ENV = { ...process.env } + +afterEach(() => { + for (const key of ['PASCAL_ADMIN_PASSWORD', 'PASCAL_AUTH'] as const) { + if (OLD_ENV[key] === undefined) delete process.env[key] + else process.env[key] = OLD_ENV[key] + } +}) + +const request = (path: string, headers: Record = {}) => + new Request(`https://pascal.example${path}`, { headers: { host: 'pascal.example', ...headers } }) + +test('an unconfigured network host sends pages to setup and APIs to 503', async () => { + delete process.env.PASCAL_ADMIN_PASSWORD + delete process.env.PASCAL_AUTH + + const page = gateRequest(request('/scenes')) + expect(page?.status).toBe(307) + expect(page?.headers.get('location')).toBe('https://pascal.example/setup') + + const api = gateRequest(request('/api/plugins/mint/session')) + expect(api?.status).toBe(503) + expect(await api?.json()).toEqual({ error: 'self_host_unconfigured' }) +}) + +test('a password gates pages behind login and keeps the requested path', () => { + process.env.PASCAL_ADMIN_PASSWORD = 'secret' + + const response = gateRequest(request('/scene/abc?tab=build')) + expect(response?.status).toBe(307) + expect(response?.headers.get('location')).toBe( + 'https://pascal.example/login?next=%2Fscene%2Fabc%3Ftab%3Dbuild', + ) + expect(gateRequest(request('/api/plugins/mint/session'))?.status).toBe(401) +}) + +test('a valid session passes', () => { + process.env.PASCAL_ADMIN_PASSWORD = 'secret' + const cookie = `${SESSION_COOKIE}=${issueSession()}` + expect(gateRequest(request('/scenes', { cookie }))).toBeNull() + expect(gateRequest(request('/api/plugins/mint/session', { cookie }))).toBeNull() +}) + +test('login, setup, health and the scene API are never gated here', () => { + delete process.env.PASCAL_ADMIN_PASSWORD + for (const path of ['/login', '/setup', '/api/health', '/api/scenes', '/api/artifacts/abc']) { + expect(gateRequest(request(path))).toBeNull() + } +}) + +test('loopback and an upstream proxy are open', () => { + delete process.env.PASCAL_ADMIN_PASSWORD + const loopback = new Request('http://localhost:3002/scenes', { + headers: { host: 'localhost:3002' }, + }) + expect(gateRequest(loopback)).toBeNull() + process.env.PASCAL_AUTH = 'proxy' + expect(gateRequest(request('/scenes'))).toBeNull() +}) diff --git a/apps/editor/proxy.ts b/apps/editor/proxy.ts new file mode 100644 index 0000000000..c63bf328d1 --- /dev/null +++ b/apps/editor/proxy.ts @@ -0,0 +1,49 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { resolveAuthMode, sessionPrincipal } from '@/lib/self-host-auth' + +/** + * Self-host gate. Pages redirect to `/login` (or `/setup` when nothing is + * configured); API routes answer JSON. The scene API is left to its own guard, + * which also accepts the agents' bearer token. + */ +const OPEN_PATHS = new Set([ + '/login', + '/setup', + '/api/health', + '/api/auth/login', + '/api/auth/logout', +]) +const SELF_GUARDED_API_PREFIXES = ['/api/scenes', '/api/artifacts'] + +export function proxy(request: NextRequest) { + return gateRequest(request) ?? NextResponse.next() +} + +export function gateRequest(request: Request): NextResponse | null { + const url = new URL(request.url) + const { pathname } = url + if (OPEN_PATHS.has(pathname)) return null + if (SELF_GUARDED_API_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null + + const mode = resolveAuthMode(request) + if (mode === 'open') return null + + const isApi = pathname.startsWith('/api/') + if (mode === 'unconfigured') { + if (isApi) { + return NextResponse.json({ error: 'self_host_unconfigured' }, { status: 503 }) + } + return NextResponse.redirect(new URL('/setup', request.url)) + } + + if (sessionPrincipal(request)) return null + if (isApi) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + const login = new URL('/login', request.url) + if (pathname !== '/') login.searchParams.set('next', `${pathname}${url.search}`) + return NextResponse.redirect(login) +} + +export const config = { + // Everything except Next's own assets and files served from `public/`. + matcher: ['/((?!_next/|.*\\.[a-zA-Z0-9]+$).*)'], +} diff --git a/docker-compose.yml b/docker-compose.yml index 55a169220a..08ed0ab986 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,23 @@ services: editor: + image: ghcr.io/pascalorg/pascal-editor:latest + # `docker compose build` rebuilds the image from this checkout; a plain + # `docker compose up` pulls the published one. build: . ports: - # Keep the container port at 3000. `/scenes` fetches its own API through a - # base URL that only `NEXT_PUBLIC_APP_URL` can override, and Next inlines - # that at build time β€” so a remapped host port 500s the page. - - "3000:3000" + - "${PORT:-3000}:3000" environment: - NODE_ENV: production - # Public editor origin used by Mint OAuth and same-origin checks. - MINT_PASCAL_HOST_ORIGIN: ${MINT_PASCAL_HOST_ORIGIN:-http://localhost:3000} - # Scene database location. Without the volume below, every saved project is - # discarded when the container is recreated. - PASCAL_DATA_DIR: /data + # The one password that unlocks this editor. Required on any host that is + # not loopback; without it the app serves a setup page and nothing else. + PASCAL_ADMIN_PASSWORD: ${PASCAL_ADMIN_PASSWORD:?Set PASCAL_ADMIN_PASSWORD} + # The address people use to reach it. Read per request, so the same image + # serves any domain or port. + PASCAL_PUBLIC_URL: ${PASCAL_PUBLIC_URL:-http://localhost:${PORT:-3000}} + # Optional bearer token for agents and scripts calling /api/scenes. + PASCAL_SCENE_API_TOKEN: ${PASCAL_SCENE_API_TOKEN:-} volumes: + # Scene database location. Without the volume, every saved project is + # discarded when the container is recreated. - pascal-data:/data restart: unless-stopped diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts index 4f4345f144..6e77743b82 100644 --- a/packages/cli/src/editor-process.ts +++ b/packages/cli/src/editor-process.ts @@ -222,7 +222,7 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise { expect(await Bun.file(paths.database).text()).toBe('persistent') }) - test('preserves a configured Mint host origin in the editor process', async () => { + test('preserves a configured public URL in the editor process', async () => { const root = await temporaryRoot() const source = await fakeRuntime(root, '1.2.3') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - const previousMintOrigin = process.env.MINT_PASCAL_HOST_ORIGIN - process.env.MINT_PASCAL_HOST_ORIGIN = 'https://pascal.example.com' + const previousPublicUrl = process.env.PASCAL_PUBLIC_URL + process.env.PASCAL_PUBLIC_URL = 'https://pascal.example.com' try { const started = await startEditor({ paths, runtimeSource: source }) - const response = await fetch(`http://127.0.0.1:${started.state.port}/mint-origin`) + const response = await fetch(`http://127.0.0.1:${started.state.port}/public-url`) expect(await response.text()).toBe('https://pascal.example.com') } finally { await stopEditor(paths) - if (previousMintOrigin === undefined) delete process.env.MINT_PASCAL_HOST_ORIGIN - else process.env.MINT_PASCAL_HOST_ORIGIN = previousMintOrigin + if (previousPublicUrl === undefined) delete process.env.PASCAL_PUBLIC_URL + else process.env.PASCAL_PUBLIC_URL = previousPublicUrl } }) @@ -418,8 +418,8 @@ const server = http.createServer((request, response) => { })) return } - if (request.url === '/mint-origin') { - response.end(process.env.MINT_PASCAL_HOST_ORIGIN ?? '') + if (request.url === '/public-url') { + response.end(process.env.PASCAL_PUBLIC_URL ?? '') return } response.end('{}') diff --git a/wiki/npm-release.md b/wiki/npm-release.md index 129c0b9ef7..900cc15597 100644 --- a/wiki/npm-release.md +++ b/wiki/npm-release.md @@ -23,6 +23,10 @@ Do this from a fresh clone of `pascalorg/editor`, never from a working tree you 7. Create the CLI runtime release: `gh release create "@pascal-app/cli@"` and upload the archive and its `.sha256`. The published npm tarball points at that exact URL, so verify it resolves and that the digest matches before announcing. 8. Publish the repo-level release: `gh release edit v --target --notes-file … --draft=false --latest`. A shortened SHA is rejected with `422 target_commitish is invalid`. +## The container + +The same `release.yml` run publishes `ghcr.io/pascalorg/pascal-editor` after the npm packages: one native build per architecture (amd64 and arm64 runners, a Next build under QEMU takes most of an hour), stitched into one manifest tagged `` and `latest` (or `beta`). The Dockerfile stages the runtime exactly like `stage-runtime` does for the CLI asset, so a release's container and its CLI runtime are the same files. `docker.yml` builds and smoke-tests the image on PRs that touch what shapes it; a tag that failed the container job can be re-published by hand with `docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/pascalorg/pascal-editor: .` from the tagged commit. + ## After a release - A repository that pins this one as a git submodule bumps the pin to the release commit and runs `bun install`: its root lockfile records the workspace versions, so it changes with the pin. From 8cf4edcef6b3ae12a8c39afcb357969976917f9c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 12 Oct 2026 15:15:35 -0400 Subject: [PATCH 2/6] editor: home, navbar, settings and stored thumbnails for the standalone app The open-source app was a demo shell: an unsaved scratch canvas at `/`, floating "Light preview / All scenes" buttons over the editor, a scenes list with no rename or delete, and thumbnails posted to a route that did not exist. - Home at `/` is the scenes grid (dark, matching the editor chrome) with "New scene", a gear to /settings, and a card menu for rename and delete (If-Match on both). `/scenes` redirects; the scratch canvas is gone, its Items tab and WebXR button move into the scene editor. - The editor gets a navbar through `navbarSlot`: logo back to home, inline rename (PATCH with If-Match, version kept in step with autosave), Preview. The floating buttons go; `?disable=postFx` keeps working without UI. - `/settings`: instance (public URL, version, data dir), connect an agent (scene API endpoint, token configured or not), sign out, about links. - Thumbnails: `POST/GET /api/scenes/[id]/thumbnail` stores a webp under `/thumbnails/`; list and get fill `thumbnailUrl` from the file (mtime-busted) with no column write; a 30 s dirty-gated trigger in the app emits the capture after edits, armed only after hydration. Verified in the browser: edit -> capture -> stored webp -> card. - Open-source hygiene: /privacy, /terms and vercel.json removed; Community-named toolbar components renamed. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/docker.yml | 4 +- CHANGELOG.md | 1 + SETUP.md | 2 +- apps/editor/AGENTS.md | 2 +- apps/editor/app/api/scenes/[id]/route.ts | 15 +- .../app/api/scenes/[id]/thumbnail/route.ts | 75 +++++ apps/editor/app/api/scenes/route.ts | 9 +- apps/editor/app/login/login-form.tsx | 6 +- apps/editor/app/login/page.tsx | 22 +- apps/editor/app/page.tsx | 212 ++++--------- apps/editor/app/privacy/page.tsx | 207 ------------- apps/editor/app/scene/[id]/page.tsx | 34 +- apps/editor/app/scenes/page.tsx | 131 -------- apps/editor/app/settings/page.tsx | 121 ++++++++ apps/editor/app/setup/page.tsx | 39 ++- apps/editor/app/terms/page.tsx | 180 ----------- apps/editor/components/app-shell.tsx | 89 ++++++ apps/editor/components/editor-navbar.tsx | 106 +++++++ apps/editor/components/save-button.tsx | 12 +- apps/editor/components/scene-card.tsx | 291 ++++++++++++++++++ apps/editor/components/scene-loader.tsx | 146 ++++++--- apps/editor/components/viewer-toolbar.tsx | 4 +- apps/editor/components/webxr-feature-gate.tsx | 9 +- apps/editor/lib/scene-thumbnails.test.ts | 43 +++ apps/editor/lib/scene-thumbnails.ts | 37 +++ apps/editor/lib/use-thumbnail-autosave.ts | 37 +++ apps/editor/next.config.ts | 2 + apps/editor/proxy.test.ts | 16 +- apps/editor/vercel.json | 8 - 29 files changed, 1057 insertions(+), 803 deletions(-) create mode 100644 apps/editor/app/api/scenes/[id]/thumbnail/route.ts delete mode 100644 apps/editor/app/privacy/page.tsx delete mode 100644 apps/editor/app/scenes/page.tsx create mode 100644 apps/editor/app/settings/page.tsx delete mode 100644 apps/editor/app/terms/page.tsx create mode 100644 apps/editor/components/app-shell.tsx create mode 100644 apps/editor/components/editor-navbar.tsx create mode 100644 apps/editor/components/scene-card.tsx create mode 100644 apps/editor/lib/scene-thumbnails.test.ts create mode 100644 apps/editor/lib/scene-thumbnails.ts create mode 100644 apps/editor/lib/use-thumbnail-autosave.ts delete mode 100644 apps/editor/vercel.json diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 279e33949e..f5ab0b88c7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -54,7 +54,7 @@ jobs: curl -fsS -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/api/health | grep '"status":"ok"' # The gate: pages redirect to /login, the scene API refuses. - test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/scenes)" = "307" + test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/)" = "307" test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/api/scenes)" = "401" test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' -H 'Content-Type: application/json' \ -d '{"password":"wrong"}' http://127.0.0.1:3000/api/auth/login)" = "401" @@ -65,7 +65,7 @@ jobs: curl -fsS -b cookies.txt -H 'Host: pascal.ci:3000' -H 'Content-Type: application/json' \ -d '{"name":"Smoke","graph":{"nodes":{},"rootNodeIds":[]}}' http://127.0.0.1:3000/api/scenes \ | grep '"ownerId":"admin"' - curl -fsS -b cookies.txt -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/scenes | grep -q 'Smoke' + curl -fsS -b cookies.txt -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/ | grep -q 'Smoke' - name: Container logs if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 102708c766..c6357893d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - **Self-hosting is one container.** `ghcr.io/pascalorg/pascal-editor:` ships the same portable runtime as the CLI, multi-arch, published by the release workflow. The public URL is read per request (`PASCAL_PUBLIC_URL`, replacing the build-time `NEXT_PUBLIC_APP_URL` and `MINT_PASCAL_HOST_ORIGIN`), so one image serves any domain or port. A single admin password (`PASCAL_ADMIN_PASSWORD`) gates every page and API behind `/login`; agents keep the `PASCAL_SCENE_API_TOKEN` bearer; a network host with nothing configured serves a setup page and nothing else; `PASCAL_AUTH=proxy` defers to an authenticating reverse proxy. See `SETUP.md`, "Self-hosting". +- **The standalone app is a product, not a shell.** Home at `/` lists saved scenes (the unsaved scratch canvas and `/scenes` are gone; `/scenes` redirects), with rename and delete on each card; the editor gets a navbar (logo back to home, inline rename, Preview); `/settings` holds the instance info, the agent endpoint and sign-out; scene thumbnails are captured after edits and stored under the data directory. `/privacy`, `/terms` and `vercel.json` leave the open-source app. ### Breaking diff --git a/SETUP.md b/SETUP.md index c247c8969c..cdfb67758d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -57,7 +57,7 @@ How it is protected, by `apps/editor/lib/self-host-auth.ts`: behind `/login`. There is one account; everyone who signs in shares the scenes. Sessions are signed with a digest of the password, so changing it signs everyone out. - **Agents use a bearer.** Set `PASCAL_SCENE_API_TOKEN` and send it as - `Authorization: Bearer …` to `/api/scenes` and `/api/artifacts`; the scenes page shows the + `Authorization: Bearer …` to `/api/scenes` and `/api/artifacts`; the settings page shows the endpoint under "Connect an agent". - **Fail closed.** A host that is not loopback with no password set serves a setup page listing the variables and nothing else. `PASCAL_AUTH=proxy` opts out when a reverse proxy diff --git a/apps/editor/AGENTS.md b/apps/editor/AGENTS.md index 643577dfae..af3b7c8411 100644 --- a/apps/editor/AGENTS.md +++ b/apps/editor/AGENTS.md @@ -1,6 +1,6 @@ -# This is NOT the Next.js you know +## This is NOT the Next.js you know This version has breaking changes β€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index 86423c725a..9f16e652b8 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -9,6 +9,7 @@ import { withSceneApiHeaders, } from '@/lib/scene-api-security' import { getSceneOperations } from '@/lib/scene-store-server' +import { deleteSceneThumbnail, sceneThumbnailUrl } from '@/lib/scene-thumbnails' export const dynamic = 'force-dynamic' @@ -48,9 +49,16 @@ export async function GET(request: NextRequest, { params }: RouteParams) { if (!scene) { return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } - return sceneApiJson(request, scene, { - headers: { ETag: `"${scene.version}"` }, - }) + return sceneApiJson( + request, + { + ...scene, + thumbnailUrl: scene.thumbnailUrl ?? (await sceneThumbnailUrl(id)), + }, + { + headers: { ETag: `"${scene.version}"` }, + }, + ) } catch (error) { return handleStoreError(request, error) } @@ -137,6 +145,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { if (!removed) { return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } + await deleteSceneThumbnail(id).catch(() => {}) return withSceneApiHeaders(request, new NextResponse(null, { status: 204 })) } catch (error) { return handleStoreError(request, error, { includeCurrentVersionFor: id }) diff --git a/apps/editor/app/api/scenes/[id]/thumbnail/route.ts b/apps/editor/app/api/scenes/[id]/thumbnail/route.ts new file mode 100644 index 0000000000..c9e8a01ec3 --- /dev/null +++ b/apps/editor/app/api/scenes/[id]/thumbnail/route.ts @@ -0,0 +1,75 @@ +import { + guardSceneApiRequest, + sceneApiJson, + sceneApiPreflight, + withSceneApiHeaders, +} from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' +import { readSceneThumbnail, sceneThumbnailUrl, writeSceneThumbnail } from '@/lib/scene-thumbnails' + +export const dynamic = 'force-dynamic' + +const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024 +type RouteParams = { params: Promise<{ id: string }> } + +export function OPTIONS(request: Request) { + return sceneApiPreflight(request) +} + +export async function POST(request: Request, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const { id } = await params + if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) + return sceneApiJson(request, { error: 'invalid' }, { status: 400 }) + if (request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() !== 'image/webp') + return sceneApiJson(request, { error: 'invalid_content_type' }, { status: 415 }) + if (Number(request.headers.get('content-length')) > MAX_THUMBNAIL_BYTES) + return sceneApiJson(request, { error: 'too_large' }, { status: 413 }) + + const operations = await getSceneOperations() + if (!(await operations.loadStoredScene(id))) + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + + const chunks: Uint8Array[] = [] + let size = 0 + const reader = request.body?.getReader() + if (reader) { + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > MAX_THUMBNAIL_BYTES) { + await reader.cancel() + return sceneApiJson(request, { error: 'too_large' }, { status: 413 }) + } + chunks.push(value) + } + } finally { + reader.releaseLock() + } + } + const bytes = Buffer.concat(chunks, size) + await writeSceneThumbnail(id, bytes) + return sceneApiJson(request, { thumbnailUrl: await sceneThumbnailUrl(id) }) +} + +export async function GET(request: Request, { params }: RouteParams) { + const guard = guardSceneApiRequest(request, { skipRateLimit: true }) + if (guard) return guard + const { id } = await params + if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) + return sceneApiJson(request, { error: 'invalid' }, { status: 400 }) + const bytes = await readSceneThumbnail(id) + if (!bytes) return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) + return withSceneApiHeaders( + request, + new Response(bytes as BodyInit, { + headers: { + 'Content-Type': 'image/webp', + 'Cache-Control': 'private, max-age=31536000, immutable', + }, + }), + ) +} diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts index 24181b5104..e8bcecea3b 100644 --- a/apps/editor/app/api/scenes/route.ts +++ b/apps/editor/app/api/scenes/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { apiGraphSchema } from '@/lib/graph-schema' import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security' import { getSceneOperations } from '@/lib/scene-store-server' +import { sceneThumbnailUrl } from '@/lib/scene-thumbnails' import { sessionPrincipal } from '@/lib/self-host-auth' export const dynamic = 'force-dynamic' @@ -46,7 +47,13 @@ export async function GET(request: NextRequest) { projectId: parsed.data.projectId, limit: parsed.data.limit, }) - return sceneApiJson(request, { scenes }) + const scenesWithThumbnails = await Promise.all( + scenes.map(async (scene) => ({ + ...scene, + thumbnailUrl: scene.thumbnailUrl ?? (await sceneThumbnailUrl(scene.id)), + })), + ) + return sceneApiJson(request, { scenes: scenesWithThumbnails }) } export async function POST(request: NextRequest) { diff --git a/apps/editor/app/login/login-form.tsx b/apps/editor/app/login/login-form.tsx index af17977dc6..03ce717688 100644 --- a/apps/editor/app/login/login-form.tsx +++ b/apps/editor/app/login/login-form.tsx @@ -2,6 +2,8 @@ import { useRouter } from 'next/navigation' import { type FormEvent, useState } from 'react' +import { PRIMARY_PILL } from '@/components/app-shell' +import { cn } from '@/lib/utils' export function LoginForm({ next }: { next: string }) { const router = useRouter() @@ -42,7 +44,7 @@ export function LoginForm({ next }: { next: string }) { Password setPassword(event.target.value)} required @@ -52,7 +54,7 @@ export function LoginForm({ next }: { next: string }) { {error &&

{error}

} - - )} - - - - -
-

Your scenes

-

- {scenes.length === 0 - ? 'No scenes yet. Create one to get started.' - : `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`} -

- - {scenes.length === 0 ? ( -
-

You haven't saved any scenes yet.

-
- -
-
- ) : ( -
    - {scenes.map((scene) => ( -
  • - -
    - {scene.thumbnailUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {scene.name} - ) : ( - No thumbnail - )} -
    -
    -

    - {scene.name} -

    -
    - {scene.nodeCount} nodes - -
    -
    - -
  • - ))} -
- )} - -
-

Connect an agent

-

- Scripts and agents read and write scenes through the scene API at{' '} - {sceneApiUrl}.{' '} - {agentTokenConfigured - ? 'Send the configured token as an Authorization: Bearer header.' - : 'Set PASCAL_SCENE_API_TOKEN on this instance and send it as an Authorization: Bearer header.'} -

-
-
- - ) -} diff --git a/apps/editor/app/settings/page.tsx b/apps/editor/app/settings/page.tsx new file mode 100644 index 0000000000..c3272638a7 --- /dev/null +++ b/apps/editor/app/settings/page.tsx @@ -0,0 +1,121 @@ +import { ArrowLeft, ExternalLink } from 'lucide-react' +import type { Metadata } from 'next' +import { headers } from 'next/headers' +import Link from 'next/link' +import type { ReactNode } from 'react' +import { AppHeader, AppShell, SECONDARY_PILL } from '@/components/app-shell' +import { resolvePublicUrl } from '@/lib/public-url' +import { resolveAuthMode } from '@/lib/self-host-auth' + +export const dynamic = 'force-dynamic' +export const metadata: Metadata = { title: 'Settings Β· Pascal' } + +/** Server components see headers, not a Request; the auth helpers want the latter. */ +async function currentRequest(): Promise { + const h = await headers() + const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'localhost' + const proto = h.get('x-forwarded-proto') ?? 'http' + return new Request(`${proto}://${host}/settings`, { headers: h }) +} + +export default async function SettingsPage() { + const request = await currentRequest() + const publicUrl = resolvePublicUrl(request) + const authMode = resolveAuthMode(request) + const agentTokenConfigured = Boolean(process.env.PASCAL_SCENE_API_TOKEN) + + return ( + + +
+ + + All scenes + +

Settings

+ +
+
+ + {publicUrl} + + {process.env.PASCAL_RUNTIME_VERSION || 'development'} + + {process.env.PASCAL_DATA_DIR || '~/.pascal/data'} + +
+ +
+ + {publicUrl}/api/scenes + + {agentTokenConfigured ? 'Configured' : 'Not configured'} +

+ {agentTokenConfigured + ? 'Agents and scripts send the PASCAL_SCENE_API_TOKEN value in an Authorization: Bearer header.' + : 'Set PASCAL_SCENE_API_TOKEN on this instance, then send it in an Authorization: Bearer header from agents and scripts.'} +

+
+ + {authMode === 'password' && ( +
+
+

Signed in as the instance admin.

+
+ +
+
+
+ )} + +
+ + +
+
+
+
+ ) +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
+ {children} +
+
+ ) +} + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function ExternalRow({ href, label }: { href: string; label: string }) { + return ( + + {label} + + {href.replace(/^https:\/\//, '')} + + + + ) +} diff --git a/apps/editor/app/setup/page.tsx b/apps/editor/app/setup/page.tsx index 32fd554deb..1162a6357c 100644 --- a/apps/editor/app/setup/page.tsx +++ b/apps/editor/app/setup/page.tsx @@ -1,4 +1,8 @@ +import type { Metadata } from 'next' +import { AppCard } from '@/components/app-shell' + export const dynamic = 'force-dynamic' +export const metadata: Metadata = { title: 'Set up Β· Pascal' } const VARIABLES = [ ['PASCAL_ADMIN_PASSWORD', 'The password that unlocks this editor. Required on a network host.'], @@ -18,25 +22,20 @@ const VARIABLES = [ export default function SetupPage() { return ( -
-
-

Pascal

-

- This editor is reachable but not protected yet -

-

- Set an admin password in the environment and restart. Until then, nothing is served from - this address. -

-
- {VARIABLES.map(([name, description]) => ( -
-
{name}
-
{description}
-
- ))} -
-
-
+ +

This editor is reachable but not protected yet

+

+ Set an admin password in the environment and restart. Until then, nothing is served from + this address. +

+
+ {VARIABLES.map(([name, description]) => ( +
+
{name}
+
{description}
+
+ ))} +
+
) } diff --git a/apps/editor/app/terms/page.tsx b/apps/editor/app/terms/page.tsx deleted file mode 100644 index f8afb3e176..0000000000 --- a/apps/editor/app/terms/page.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import type { Metadata } from 'next' -import Link from 'next/link' - -export const metadata: Metadata = { - title: 'Terms of Service', - description: 'Terms of Service for Pascal Editor and the Pascal platform.', -} - -export default function TermsPage() { - return ( -
-
-
- -
-
- -
-
-

Terms of Service

-

Effective Date: February 20, 2026

- -
-

1. Introduction

-

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

-
- -
-

2. The Editor and Platform

-

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

-

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

-
- -
-

3. Accounts and Authentication

-

- To use certain features of the Platform, you must create an account. We use Google - OAuth and magic link email authentication through Supabase. You are responsible for - maintaining the security of your account credentials and for all activities that occur - under your account. -

-
- -
-

4. Acceptable Use

-

You agree not to:

-
    -
  • - Use the Platform for any unlawful purpose or in violation of any applicable laws -
  • -
  • - Upload, share, or distribute content that infringes intellectual property rights -
  • -
  • Attempt to gain unauthorized access to the Platform or its systems
  • -
  • Interfere with or disrupt the Platform's infrastructure
  • -
  • Upload malicious code, viruses, or harmful content
  • -
  • Harass, abuse, or harm other users
  • -
  • Use the Platform to send spam or unsolicited communications
  • -
-
- -
-

5. Your Content and Intellectual Property

-

- You retain full ownership of all content, projects, and data you create or upload to - the Platform ("Your Content"). By using the Platform, you grant us a limited - license to store, display, and transmit Your Content solely to provide our services to - you. -

-

- We do not claim any ownership rights over Your Content. You may export or delete Your - Content at any time. -

-
- -
-

6. Platform Ownership

-

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

-
- -
-

7. Account Termination

-

- We reserve the right to suspend or terminate your account if you violate these Terms - or engage in conduct that we determine is harmful to the Platform or other users. You - may also delete your account at any time by contacting us at{' '} - - support@pascal.app - - . -

-
- -
-

8. Disclaimer of Warranties

-

- THE PLATFORM IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT - WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO - IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND - NON-INFRINGEMENT. -

-

- We do not warrant that the Platform will be uninterrupted, error-free, or free of - harmful components. -

-
- -
-

9. Limitation of Liability

-

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

-
- -
-

10. Changes to Terms

-

- We may update these Terms from time to time. We will notify you of material changes by - posting the updated Terms on the Platform. Your continued use of the Platform after - changes are posted constitutes your acceptance of the revised Terms. -

-
- -
-

11. Contact Us

-

- If you have questions about these Terms, please contact us at{' '} - - support@pascal.app - - . -

-
-
-
-
- ) -} diff --git a/apps/editor/components/app-shell.tsx b/apps/editor/components/app-shell.tsx new file mode 100644 index 0000000000..20616e5970 --- /dev/null +++ b/apps/editor/components/app-shell.tsx @@ -0,0 +1,89 @@ +import Image from 'next/image' +import Link from 'next/link' +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +/** + * The dark chrome every page outside the editor shares, so the scenes list, + * settings and sign-in read as the same product as the editor itself. + */ +export function AppShell({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +export function PascalLogo({ className }: { className?: string }) { + return ( + Pascal + ) +} + +export function PascalMark({ className }: { className?: string }) { + return ( + + ) +} + +export function AppHeader({ children }: { children?: ReactNode }) { + return ( +
+
+ + + +
{children}
+
+
+ ) +} + +/** A centred card for single-purpose pages (sign-in, setup, not found). */ +export function AppCard({ children, className }: { children: ReactNode; className?: string }) { + return ( + +
+ + {children} +
+
+ ) +} + +export const PRIMARY_PILL = + 'inline-flex items-center justify-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50' + +export const SECONDARY_PILL = + 'inline-flex items-center justify-center gap-1.5 rounded-full border border-border px-4 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-accent disabled:opacity-50' + +export const ICON_BUTTON = + 'inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground' diff --git a/apps/editor/components/editor-navbar.tsx b/apps/editor/components/editor-navbar.tsx new file mode 100644 index 0000000000..e7abdd0bfa --- /dev/null +++ b/apps/editor/components/editor-navbar.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useEditor } from '@pascal-app/editor' +import { Eye, Pencil } from 'lucide-react' +import Link from 'next/link' +import { type KeyboardEvent, useEffect, useRef, useState } from 'react' +import { PascalMark } from './app-shell' + +interface EditorNavbarProps { + name: string + /** Persists a new scene name; resolves once the server has answered. */ + onRename: (name: string) => Promise +} + +export function EditorNavbar({ name, onRename }: EditorNavbarProps) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(name) + const inputRef = useRef(null) + // Escape cancels; the blur it causes must not save. + const cancelledRef = useRef(false) + + useEffect(() => { + if (!editing) return + inputRef.current?.focus() + inputRef.current?.select() + }, [editing]) + + const startEditing = () => { + cancelledRef.current = false + setDraft(name) + setEditing(true) + } + + const commit = () => { + setEditing(false) + if (cancelledRef.current) return + const trimmed = draft.trim() + if (trimmed && trimmed !== name) void onRename(trimmed) + } + + const handleKeyDown = (event: KeyboardEvent) => { + // Keep editor shortcuts (delete, undo, tool keys) out of the title field. + event.stopPropagation() + if (event.key === 'Enter') { + event.preventDefault() + inputRef.current?.blur() + } else if (event.key === 'Escape') { + event.preventDefault() + cancelledRef.current = true + inputRef.current?.blur() + } + } + + return ( + + ) +} diff --git a/apps/editor/components/save-button.tsx b/apps/editor/components/save-button.tsx index bd227b83a4..ff735a3bf0 100644 --- a/apps/editor/components/save-button.tsx +++ b/apps/editor/components/save-button.tsx @@ -1,8 +1,10 @@ 'use client' import type { SceneGraph } from '@pascal-app/editor' +import { Plus } from 'lucide-react' import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' +import { PRIMARY_PILL } from './app-shell' const EMPTY_GRAPH: SceneGraph = { nodes: {}, @@ -19,7 +21,7 @@ interface SaveButtonProps { /** * Creates a new empty scene and navigates the user to it. */ -export function CreateSceneButton({ label = 'Create new scene' }: { label?: string } = {}) { +export function CreateSceneButton({ label = 'New scene' }: { label?: string } = {}) { const router = useRouter() const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -49,12 +51,8 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri return (
{error && {error}} -
diff --git a/apps/editor/components/scene-card.tsx b/apps/editor/components/scene-card.tsx new file mode 100644 index 0000000000..6d21c8c627 --- /dev/null +++ b/apps/editor/components/scene-card.tsx @@ -0,0 +1,291 @@ +'use client' + +import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { type FormEvent, useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/utils' +import { PascalMark, PRIMARY_PILL, SECONDARY_PILL } from './app-shell' + +export interface SceneCardData { + id: string + name: string + version: number + nodeCount: number + thumbnailUrl: string | null + updatedAt: string + updatedLabel: string +} + +type Dialog = 'rename' | 'delete' | null + +export function SceneCard({ scene }: { scene: SceneCardData }) { + const [menuOpen, setMenuOpen] = useState(false) + const [dialog, setDialog] = useState(null) + const [thumbnailFailed, setThumbnailFailed] = useState(false) + const menuRef = useRef(null) + + useEffect(() => { + if (!menuOpen) return + const onPointerDown = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setMenuOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [menuOpen]) + + const openDialog = (next: Dialog) => { + setMenuOpen(false) + setDialog(next) + } + + return ( +
  • + +
    + {scene.thumbnailUrl && !thumbnailFailed ? ( + setThumbnailFailed(true)} + // A load that failed before hydration never reaches `onError`. + ref={(img) => { + if (img?.complete && img.naturalWidth === 0) setThumbnailFailed(true) + }} + src={scene.thumbnailUrl} + /> + ) : ( + + )} +
    +
    +

    {scene.name}

    +

    + {scene.nodeCount} {scene.nodeCount === 1 ? 'node' : 'nodes'} Β· Updated{' '} + +

    +
    + + +
    + + {menuOpen && ( +
    + + +
    + )} +
    + + {dialog === 'rename' && setDialog(null)} scene={scene} />} + {dialog === 'delete' && setDialog(null)} scene={scene} />} +
  • + ) +} + +const CONFLICT_MESSAGE = 'This scene changed since the page loaded. Reload and try again.' + +/** Opens a native modal dialog on mount; Escape, backdrop and Cancel all end in `onClose`. */ +function useModal(onClose: () => void) { + const ref = useRef(null) + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + useEffect(() => { + const dialog = ref.current + if (!dialog) return + const handleClose = () => onCloseRef.current() + dialog.addEventListener('close', handleClose) + if (!dialog.open) dialog.showModal() + return () => dialog.removeEventListener('close', handleClose) + }, []) + return ref +} + +function ModalFrame({ + children, + dialogRef, + title, +}: { + children: React.ReactNode + dialogRef: React.RefObject + title: string +}) { + return ( + { + if (event.target === event.currentTarget) event.currentTarget.close() + }} + ref={dialogRef} + > +

    {title}

    + {children} +
    + ) +} + +function RenameDialog({ scene, onClose }: { scene: SceneCardData; onClose: () => void }) { + const router = useRouter() + const dialogRef = useModal(onClose) + const [name, setName] = useState(scene.name) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + const trimmed = name.trim() + if (!trimmed || trimmed === scene.name) { + dialogRef.current?.close() + return + } + setPending(true) + setError(null) + try { + const response = await fetch(`/api/scenes/${scene.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'If-Match': `"${scene.version}"` }, + body: JSON.stringify({ name: trimmed }), + }) + if (response.status === 409) { + setError(CONFLICT_MESSAGE) + return + } + if (!response.ok) { + setError(`Rename failed (${response.status})`) + return + } + dialogRef.current?.close() + router.refresh() + } catch { + setError('Could not reach the editor.') + } finally { + setPending(false) + } + } + + return ( + +
    + setName(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + required + value={name} + /> + {error &&

    {error}

    } +
    + + +
    +
    +
    + ) +} + +function DeleteDialog({ scene, onClose }: { scene: SceneCardData; onClose: () => void }) { + const router = useRouter() + const dialogRef = useModal(onClose) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + async function handleDelete() { + setPending(true) + setError(null) + try { + const response = await fetch(`/api/scenes/${scene.id}`, { + method: 'DELETE', + headers: { 'If-Match': `"${scene.version}"` }, + }) + if (response.status === 409) { + setError(CONFLICT_MESSAGE) + return + } + if (!response.ok && response.status !== 404) { + setError(`Delete failed (${response.status})`) + return + } + dialogRef.current?.close() + router.refresh() + } catch { + setError('Could not reach the editor.') + } finally { + setPending(false) + } + } + + return ( + +

    + β€œ{scene.name}” will be removed from this editor, along with everything in it. +

    + {error &&

    {error}

    } +
    + + +
    +
    + ) +} diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 2d42bf5b83..628b074a50 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -6,21 +6,24 @@ import { applySceneGraphToEditor, Editor, + ItemsPanel, type SceneGraph, type SidebarTab, } from '@pascal-app/editor' -import { Hammer, Layers, Palette, Settings } from 'lucide-react' +import { PascalWebXRButton } from '@webxr/plugin/pascal-editor' +import { Hammer, Layers, Package, Palette, Settings } from 'lucide-react' import Image from 'next/image' -import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard' import { useLocalArtifactStore } from '@/lib/local-artifact-store' import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature' -import { cn } from '@/lib/utils' +import { useThumbnailAutosave } from '@/lib/use-thumbnail-autosave' import { BuildTab } from './build-tab' +import { EditorNavbar } from './editor-navbar' import { PaintPanel } from './paint-panel' -import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar' +import { EditorViewerToolbarLeft, EditorViewerToolbarRight } from './viewer-toolbar' +import { useWebXRFeature, useWebXRInstalled, WebXRFeatureRuntime } from './webxr-feature-gate' export interface SceneMeta { id: string @@ -35,6 +38,13 @@ export interface SceneMeta { nodeCount: number } +// The open-source editor only ships the built-in catalog (no uploaded items), +// so the item source chips and tag filters add nothing β€” drop them and keep +// the panel to plain categories. +function EditorItemsPanel() { + return +} + const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ { id: 'site', @@ -84,6 +94,22 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ /> ), }, + { + id: 'items', + label: 'Items', + component: EditorItemsPanel, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, { id: 'settings', label: 'Settings', @@ -126,8 +152,25 @@ function isLightPreviewQuery(searchParams: URLSearchParams): boolean { return disable.split(',').some((p) => p.trim() === 'postFx') } -export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { +export function SceneLoader(props: SceneLoaderProps) { + const webXRInstalled = useWebXRInstalled() + return ( + + + + ) +} + +function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { + // A rename must reach the autosave body, which sends `meta.name`. + const [renamedTo, setRenamedTo] = useState(null) + const meta = useMemo( + () => (renamedTo === null ? initialMeta : { ...initialMeta, name: renamedTo }), + [initialMeta, renamedTo], + ) + const vr = useWebXRFeature() useLocalArtifactStore() + useThumbnailAutosave({ sceneId: meta.id, enabled: true }) const router = useRouter() const searchParams = useSearchParams() const versionRef = useRef(meta.version) @@ -144,6 +187,36 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const handleLoad = useCallback(async () => initialScene, [initialScene]) + const handleRename = useCallback( + async (name: string) => { + try { + const response = await fetch(`/api/scenes/${meta.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'If-Match': String(versionRef.current), + }, + body: JSON.stringify({ name }), + }) + if (response.status === 409) { + setConflict(true) + return + } + if (!response.ok) { + setSaveError(`Rename failed (${response.status})`) + return + } + const next = (await response.json()) as SceneMeta + versionRef.current = next.version + setRenamedTo(next.name) + setSaveError(null) + } catch (error) { + setSaveError(error instanceof Error ? error.message : 'Rename failed') + } + }, + [meta.id], + ) + const handleSave = useCallback( async (graph: SceneGraph, options?: { keepalive?: boolean }) => { const graphJson = sceneGraphSignature(graph) @@ -246,15 +319,13 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { }, [meta.id]) const handleThumb = useCallback( - async (_blob: Blob) => { - // TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail. - // Stub endpoint is not yet implemented in v0.1 β€” skip upload for now. + async (blob: Blob, cameraData: { requestId?: string }) => { + if (cameraData.requestId) return await fetch(`/api/scenes/${meta.id}/thumbnail`, { method: 'POST', - // Intentionally no body β€” endpoint is a stub. - }).catch(() => { - // Swallow errors silently; thumbnail upload is best-effort. - }) + headers: { 'Content-Type': 'image/webp' }, + body: blob, + }).catch(() => {}) }, [meta.id], ) @@ -262,21 +333,21 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { return (
    {conflict && ( -
    +

    Another session saved first β€” refresh?

    Your changes haven't been saved. Reload to pick up the latest version.

    )} {saveError && !conflict && ( -
    +

    {saveError}

    )} -
    - - - All scenes - -
    } onLoad={handleLoad} onSave={handleSave} onThumbnailCapture={handleThumb} projectId={meta.projectId ?? 'default'} + projectName={meta.name} sidebarTabs={SIDEBAR_TABS} - viewerToolbarLeft={} - viewerToolbarRight={} + viewerToolbarLeft={} + viewerToolbarRight={ + + ) : null + } + vrLabel="Enter VR" + /> + } />
    ) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index f9c4d67d81..f30e4e3e08 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -649,7 +649,7 @@ function VRButton({ ) } -export function CommunityViewerToolbarLeft() { +export function EditorViewerToolbarLeft() { return ( <> @@ -657,7 +657,7 @@ export function CommunityViewerToolbarLeft() { ) } -export function CommunityViewerToolbarRight({ +export function EditorViewerToolbarRight({ onVRToggle, vrButton, vrActive = false, diff --git a/apps/editor/components/webxr-feature-gate.tsx b/apps/editor/components/webxr-feature-gate.tsx index c7e0cbf88b..aa30e9b1c2 100644 --- a/apps/editor/components/webxr-feature-gate.tsx +++ b/apps/editor/components/webxr-feature-gate.tsx @@ -66,12 +66,9 @@ export function WebXRFeatureRuntime({ ) } -export function WebXRFeatureConsumer({ - children, -}: { - children: (feature: PascalWebXRFeature | null) => ReactNode -}) { - return children(useContext(WebXRFeatureContext)) +/** The running WebXR feature, or null when the plugin is not installed in this scene. */ +export function useWebXRFeature() { + return useContext(WebXRFeatureContext) } export function useWebXRInstalled() { diff --git a/apps/editor/lib/scene-thumbnails.test.ts b/apps/editor/lib/scene-thumbnails.test.ts new file mode 100644 index 0000000000..a231728bf4 --- /dev/null +++ b/apps/editor/lib/scene-thumbnails.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + deleteSceneThumbnail, + readSceneThumbnail, + sceneThumbnailUrl, + thumbnailFile, + writeSceneThumbnail, +} from './scene-thumbnails' + +test('persists thumbnails beside the database and derives their URLs until deleted', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'scene-thumbnails-')) + const savedEnv = { + PASCAL_DATA_DIR: process.env.PASCAL_DATA_DIR, + PASCAL_DB_PATH: process.env.PASCAL_DB_PATH, + } + try { + process.env.PASCAL_DATA_DIR = tempDir + delete process.env.PASCAL_DB_PATH + expect(thumbnailFile('scene_1')).toBe(join(tempDir, 'thumbnails', 'scene_1.webp')) + process.env.PASCAL_DB_PATH = join(tempDir, 'custom', 'scene.db') + expect(thumbnailFile('scene_1')).toBe(join(tempDir, 'custom', 'thumbnails', 'scene_1.webp')) + expect(() => thumbnailFile('../escape')).toThrow() + expect(await sceneThumbnailUrl('scene_1')).toBeNull() + expect(await readSceneThumbnail('scene_1')).toBeNull() + + const bytes = new Uint8Array([1, 2, 3, 4]) + await writeSceneThumbnail('scene_1', bytes) + expect(await sceneThumbnailUrl('scene_1')).toMatch(/^\/api\/scenes\/scene_1\/thumbnail\?t=\d/) + expect(await readSceneThumbnail('scene_1')).toEqual(bytes) + await deleteSceneThumbnail('scene_1') + expect(await sceneThumbnailUrl('scene_1')).toBeNull() + expect(await readSceneThumbnail('scene_1')).toBeNull() + } finally { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await rm(tempDir, { recursive: true, force: true }) + } +}) diff --git a/apps/editor/lib/scene-thumbnails.ts b/apps/editor/lib/scene-thumbnails.ts new file mode 100644 index 0000000000..76e36620b0 --- /dev/null +++ b/apps/editor/lib/scene-thumbnails.ts @@ -0,0 +1,37 @@ +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { resolveDefaultDatabasePath } from '@pascal-app/mcp/storage' + +export function thumbnailFile(id: string): string { + if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error('Invalid scene id') + return join(dirname(resolveDefaultDatabasePath()), 'thumbnails', `${id}.webp`) +} + +export async function writeSceneThumbnail(id: string, bytes: Uint8Array): Promise { + const file = thumbnailFile(id) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, bytes) +} + +export async function readSceneThumbnail(id: string): Promise { + try { + return await readFile(thumbnailFile(id)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } +} + +export async function sceneThumbnailUrl(id: string): Promise { + try { + const { mtimeMs } = await stat(thumbnailFile(id)) + return `/api/scenes/${id}/thumbnail?t=${mtimeMs}` + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } +} + +export async function deleteSceneThumbnail(id: string): Promise { + await rm(thumbnailFile(id), { force: true }) +} diff --git a/apps/editor/lib/use-thumbnail-autosave.ts b/apps/editor/lib/use-thumbnail-autosave.ts new file mode 100644 index 0000000000..6f075f6ea9 --- /dev/null +++ b/apps/editor/lib/use-thumbnail-autosave.ts @@ -0,0 +1,37 @@ +'use client' + +import { emitter, useScene } from '@pascal-app/core' +import { useEffect } from 'react' + +export function useThumbnailAutosave({ sceneId, enabled }: { sceneId: string; enabled: boolean }) { + useEffect(() => { + if (!enabled) return + + let armed = false + let dirty = false + let lastChange = 0 + const unsubscribe = useScene.subscribe((state, previous) => { + if (state.nodes === previous.nodes) return + // The first nodes swap hydrates the scene; it is not an edit. + if (!armed) { + armed = true + return + } + dirty = true + lastChange = Date.now() + }) + + const capture = () => { + if (!dirty || document.visibilityState !== 'visible' || Date.now() - lastChange < 5000) return + dirty = false + emitter.emit('camera-controls:generate-thumbnail', { projectId: sceneId, snapLevels: true }) + } + const interval = setInterval(capture, 30_000) + document.addEventListener('visibilitychange', capture) + return () => { + unsubscribe() + clearInterval(interval) + document.removeEventListener('visibilitychange', capture) + } + }, [sceneId, enabled]) +} diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 02acee4893..c58759c74b 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -24,6 +24,8 @@ const nextConfig: NextConfig = { destination: '/scene/:id', permanent: false, }, + // The scenes list moved to the home page. + { source: '/scenes', destination: '/', permanent: false }, ] }, transpilePackages: [ diff --git a/apps/editor/proxy.test.ts b/apps/editor/proxy.test.ts index 0c4f963666..2ee1c92d1b 100644 --- a/apps/editor/proxy.test.ts +++ b/apps/editor/proxy.test.ts @@ -18,7 +18,7 @@ test('an unconfigured network host sends pages to setup and APIs to 503', async delete process.env.PASCAL_ADMIN_PASSWORD delete process.env.PASCAL_AUTH - const page = gateRequest(request('/scenes')) + const page = gateRequest(request('/')) expect(page?.status).toBe(307) expect(page?.headers.get('location')).toBe('https://pascal.example/setup') @@ -38,10 +38,18 @@ test('a password gates pages behind login and keeps the requested path', () => { expect(gateRequest(request('/api/plugins/mint/session'))?.status).toBe(401) }) +test('the home page sends a signed-out visitor to login without a next param', () => { + process.env.PASCAL_ADMIN_PASSWORD = 'secret' + const response = gateRequest(request('/')) + expect(response?.status).toBe(307) + expect(response?.headers.get('location')).toBe('https://pascal.example/login') +}) + test('a valid session passes', () => { process.env.PASCAL_ADMIN_PASSWORD = 'secret' const cookie = `${SESSION_COOKIE}=${issueSession()}` - expect(gateRequest(request('/scenes', { cookie }))).toBeNull() + expect(gateRequest(request('/', { cookie }))).toBeNull() + expect(gateRequest(request('/settings', { cookie }))).toBeNull() expect(gateRequest(request('/api/plugins/mint/session', { cookie }))).toBeNull() }) @@ -54,10 +62,10 @@ test('login, setup, health and the scene API are never gated here', () => { test('loopback and an upstream proxy are open', () => { delete process.env.PASCAL_ADMIN_PASSWORD - const loopback = new Request('http://localhost:3002/scenes', { + const loopback = new Request('http://localhost:3002/', { headers: { host: 'localhost:3002' }, }) expect(gateRequest(loopback)).toBeNull() process.env.PASCAL_AUTH = 'proxy' - expect(gateRequest(request('/scenes'))).toBeNull() + expect(gateRequest(request('/'))).toBeNull() }) diff --git a/apps/editor/vercel.json b/apps/editor/vercel.json deleted file mode 100644 index 5b507d26f4..0000000000 --- a/apps/editor/vercel.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "buildCommand": "cd ../.. && npx -y bun@1.3.13 run build --filter=editor", - "installCommand": "cd ../.. && (npx -y bun@1.3.13 install --frozen-lockfile || (sleep 20 && npx -y bun@1.3.13 install --frozen-lockfile) || (sleep 60 && npx -y bun@1.3.13 install --frozen-lockfile))", - "outputDirectory": ".next", - "cleanUrls": true, - "trailingSlash": false -} From e53bb4d9efaf39ecce608fc1787e7e013448cd8e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 12 Oct 2026 16:30:07 -0400 Subject: [PATCH 3/6] editor: projects, not scenes, in the standalone app - Routes and copy say project: `/project/` (with `/scene/` and `/editor/` redirecting), "Projects", "New project", "All projects", "Rename project", "Delete project?". The scene API keeps its wire path. - A new project is named `Project #n`, one past the highest number still in use and never below the count, computed from the server-rendered list. - Preview, New project and the other text buttons use the view bar's radius (`rounded-lg`) instead of pills; icon buttons stay round. - The unused SaveButton leaves with its file. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- apps/editor/app/api/scenes/route.ts | 2 +- apps/editor/app/import/import-client.tsx | 2 +- apps/editor/app/page.tsx | 20 ++- .../app/{scene => project}/[id]/page.tsx | 6 +- apps/editor/app/settings/page.tsx | 4 +- apps/editor/components/app-shell.tsx | 4 +- .../components/create-project-button.tsx | 55 +++++++ apps/editor/components/editor-navbar.tsx | 12 +- apps/editor/components/save-button.tsx | 155 ------------------ apps/editor/components/scene-card.tsx | 12 +- apps/editor/components/scene-loader.tsx | 6 +- apps/editor/next.config.ts | 7 +- apps/editor/proxy.test.ts | 4 +- 14 files changed, 101 insertions(+), 190 deletions(-) rename apps/editor/app/{scene => project}/[id]/page.tsx (85%) create mode 100644 apps/editor/components/create-project-button.tsx delete mode 100644 apps/editor/components/save-button.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c6357893d4..1dbd81f913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features - **Self-hosting is one container.** `ghcr.io/pascalorg/pascal-editor:` ships the same portable runtime as the CLI, multi-arch, published by the release workflow. The public URL is read per request (`PASCAL_PUBLIC_URL`, replacing the build-time `NEXT_PUBLIC_APP_URL` and `MINT_PASCAL_HOST_ORIGIN`), so one image serves any domain or port. A single admin password (`PASCAL_ADMIN_PASSWORD`) gates every page and API behind `/login`; agents keep the `PASCAL_SCENE_API_TOKEN` bearer; a network host with nothing configured serves a setup page and nothing else; `PASCAL_AUTH=proxy` defers to an authenticating reverse proxy. See `SETUP.md`, "Self-hosting". -- **The standalone app is a product, not a shell.** Home at `/` lists saved scenes (the unsaved scratch canvas and `/scenes` are gone; `/scenes` redirects), with rename and delete on each card; the editor gets a navbar (logo back to home, inline rename, Preview); `/settings` holds the instance info, the agent endpoint and sign-out; scene thumbnails are captured after edits and stored under the data directory. `/privacy`, `/terms` and `vercel.json` leave the open-source app. +- **The standalone app is a product, not a shell.** Home at `/` lists projects (the unsaved scratch canvas and `/scenes` are gone; `/scenes` and `/scene/` redirect to `/` and `/project/`), new ones are named `Project #n`, each card renames and deletes; the editor gets a navbar (logo back to home, inline rename, Preview); `/settings` holds the instance info, the agent endpoint and sign-out; thumbnails are captured after edits and stored under the data directory. `/privacy`, `/terms` and `vercel.json` leave the open-source app. ### Breaking diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts index e8bcecea3b..2e2afbb45c 100644 --- a/apps/editor/app/api/scenes/route.ts +++ b/apps/editor/app/api/scenes/route.ts @@ -93,7 +93,7 @@ export async function POST(request: NextRequest) { }) return sceneApiJson(request, meta, { status: 201, - headers: { Location: `/scene/${meta.id}` }, + headers: { Location: `/project/${meta.id}` }, }) } catch (error) { return handleStoreError(request, error) diff --git a/apps/editor/app/import/import-client.tsx b/apps/editor/app/import/import-client.tsx index 86a756c84d..102c836168 100644 --- a/apps/editor/app/import/import-client.tsx +++ b/apps/editor/app/import/import-client.tsx @@ -127,7 +127,7 @@ export function ImportClient({ src, name }: { src: string | null; name: string | return } const meta = (await response.json()) as { id: string } - router.push(`/scene/${meta.id}`) + router.push(`/project/${meta.id}`) } catch (error) { setPhase({ kind: 'review', diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index dbfc9cdaa0..0684419d0b 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,7 +1,7 @@ import { Settings } from 'lucide-react' import Link from 'next/link' import { AppHeader, AppShell, ICON_BUTTON, PascalMark } from '@/components/app-shell' -import { CreateSceneButton } from '@/components/save-button' +import { CreateProjectButton } from '@/components/create-project-button' import { SceneCard, type SceneCardData } from '@/components/scene-card' import { getSceneOperations } from '@/lib/scene-store-server' import { sceneThumbnailUrl } from '@/lib/scene-thumbnails' @@ -30,13 +30,23 @@ async function fetchScenes(): Promise { ) } +/** `Project #n`: one past the highest number still in use, never below the count. */ +function nextProjectNumber(names: string[]): number { + const highest = names.reduce((max, name) => { + const match = /^Project #(\d+)$/.exec(name.trim()) + return match ? Math.max(max, Number(match[1])) : max + }, 0) + return Math.max(highest, names.length) + 1 +} + export default async function HomePage() { const scenes = await fetchScenes() + const nextNumber = nextProjectNumber(scenes.map((scene) => scene.name)) return ( - + @@ -47,13 +57,13 @@ export default async function HomePage() {

    - Your scenes live here. Start one to begin designing. + Your projects live here. Start one to begin designing.

    - +
    ) : ( <> -

    Scenes

    +

    Projects

      {scenes.map((scene) => ( diff --git a/apps/editor/app/scene/[id]/page.tsx b/apps/editor/app/project/[id]/page.tsx similarity index 85% rename from apps/editor/app/scene/[id]/page.tsx rename to apps/editor/app/project/[id]/page.tsx index 5f09fdb6db..f14a78b9b2 100644 --- a/apps/editor/app/scene/[id]/page.tsx +++ b/apps/editor/app/project/[id]/page.tsx @@ -23,12 +23,12 @@ export default async function ScenePage({ params }: { params: Promise<{ id: stri if (!scene) { return ( -

      Scene not found

      +

      Project not found

      - There is no scene with id {id} on this editor. + There is no project with id {id} on this editor.

      - All scenes + All projects
      ) diff --git a/apps/editor/app/settings/page.tsx b/apps/editor/app/settings/page.tsx index c3272638a7..9378a5c08a 100644 --- a/apps/editor/app/settings/page.tsx +++ b/apps/editor/app/settings/page.tsx @@ -29,11 +29,11 @@ export default async function SettingsPage() {
      - All scenes + All projects

      Settings

      diff --git a/apps/editor/components/app-shell.tsx b/apps/editor/components/app-shell.tsx index 20616e5970..d0124fcdcf 100644 --- a/apps/editor/components/app-shell.tsx +++ b/apps/editor/components/app-shell.tsx @@ -80,10 +80,10 @@ export function AppCard({ children, className }: { children: ReactNode; classNam } export const PRIMARY_PILL = - 'inline-flex items-center justify-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50' + 'inline-flex items-center justify-center gap-1.5 rounded-lg bg-primary px-4 py-1.5 font-medium text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50' export const SECONDARY_PILL = - 'inline-flex items-center justify-center gap-1.5 rounded-full border border-border px-4 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-accent disabled:opacity-50' + 'inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-4 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-accent disabled:opacity-50' export const ICON_BUTTON = 'inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground' diff --git a/apps/editor/components/create-project-button.tsx b/apps/editor/components/create-project-button.tsx new file mode 100644 index 0000000000..51632dae92 --- /dev/null +++ b/apps/editor/components/create-project-button.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { SceneGraph } from '@pascal-app/editor' +import { Plus } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useCallback, useState } from 'react' +import { PRIMARY_PILL } from './app-shell' + +const EMPTY_GRAPH: SceneGraph = { + nodes: {}, + rootNodeIds: [], +} + +/** + * Creates an empty project named after its number and opens it. The number + * comes from the server-rendered list so it never collides with a project + * that still exists. + */ +export function CreateProjectButton({ nextNumber }: { nextNumber: number }) { + const router = useRouter() + const [isCreating, setIsCreating] = useState(false) + const [error, setError] = useState(null) + + const handleCreate = useCallback(async () => { + setIsCreating(true) + setError(null) + try { + const response = await fetch('/api/scenes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: `Project #${nextNumber}`, graph: EMPTY_GRAPH }), + }) + if (!response.ok) { + setError(`Failed to create project (${response.status})`) + return + } + const meta = (await response.json()) as { id: string } + router.push(`/project/${meta.id}`) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create project') + } finally { + setIsCreating(false) + } + }, [router, nextNumber]) + + return ( +
      + {error && {error}} + +
      + ) +} diff --git a/apps/editor/components/editor-navbar.tsx b/apps/editor/components/editor-navbar.tsx index e7abdd0bfa..1699a2fda8 100644 --- a/apps/editor/components/editor-navbar.tsx +++ b/apps/editor/components/editor-navbar.tsx @@ -8,7 +8,7 @@ import { PascalMark } from './app-shell' interface EditorNavbarProps { name: string - /** Persists a new scene name; resolves once the server has answered. */ + /** Persists a new project name; resolves once the server has answered. */ onRename: (name: string) => Promise } @@ -55,10 +55,10 @@ export function EditorNavbar({ name, onRename }: EditorNavbarProps) {