Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
80 changes: 80 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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/)" = "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/ | grep -q 'Smoke'

# Public embeds: private until shared, then readable without a session.
ID=$(curl -fsS -b cookies.txt -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/api/scenes | sed -E 's/.*"id":"([^"]+)".*/\1/')
test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/embed/$ID)" = "404"
curl -fsS -b cookies.txt -H 'Host: pascal.ci:3000' -H 'Content-Type: application/json' \
-d '{"shared":true}' http://127.0.0.1:3000/api/scenes/$ID/share | grep -q '"shared":true'
curl -fsS -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/api/scenes/$ID/public | grep -q '"name":"Smoke"'
test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: pascal.ci:3000' http://127.0.0.1:3000/embed/$ID)" = "200"

- name: Container logs
if: always()
run: docker logs pascal || true
88 changes: 88 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Features

- **Self-hosting is one container.** `ghcr.io/pascalorg/pascal-editor:<version>` 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 projects (the unsaved scratch canvas and `/scenes` are gone; `/scenes` and `/scene/<id>` redirect to `/` and `/project/<id>`), 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.
- **Public embeds.** A Share button in the editor navbar turns on a per-project public link: a read-only 3D view at `/embed/<id>` that needs no password and can be iframed, with a copyable link and `<iframe>` snippet. Projects are private until shared, and turning the link off makes it a 404 again. 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.
Expand Down
29 changes: 23 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docker image omits runtime version

Low Severity

The image never sets PASCAL_RUNTIME_VERSION. Settings falls back to development for every published ghcr.io/pascalorg/pascal-editor tag, so the instance page cannot show the release version the CLI already injects.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e53bb4d. Configure here.


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"]
56 changes: 40 additions & 16 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,54 @@ 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 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
(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.

**Public embeds.** Nothing is public by default. The **Share** button in the editor's navbar
has a "Public link" switch: turning it on makes the project viewable, read-only, at
`/embed/<id>`, which needs no password and can be put in an `<iframe>` (the panel gives
the snippet). The page looks like the editor's Preview mode; `/embed/<id>?ui=0` (the
panel's "Hide controls" switch) shows only the 3D view, without the header, the toolbar
or the 3D/2D switcher. Turning the link off makes that URL a 404 again. Besides the embed page itself,
`/api/scenes/<id>/public` is the only unauthenticated read of scene data, and it answers
only for shared projects; the files a shared scene references are served to it as well.

## CLI-managed editor

Expand Down
2 changes: 1 addition & 1 deletion apps/editor/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- BEGIN:nextjs-agent-rules -->

# 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.

Expand Down
3 changes: 2 additions & 1 deletion apps/editor/app/api/artifacts/[sha256]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { isArtifactShared } from '@/lib/scene-sharing'

export const dynamic = 'force-dynamic'

Expand All @@ -15,8 +16,8 @@ export function OPTIONS(request: Request) {
export async function GET(request: Request, { params }: { params: Promise<{ sha256: string }> }) {
// A scene loads one artifact per scripted object; immutable reads stay outside the request budget.
const guard = guardSceneApiRequest(request, { skipRateLimit: true })
if (guard) return guard
const { sha256 } = await params
if (guard && (!/^[0-9a-f]{64}$/.test(sha256) || !(await isArtifactShared(sha256)))) return guard

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public artifact probes scan every scene

Medium Severity

Unauthenticated GET /api/artifacts/:sha256 skips the rate limit, then isArtifactShared loads and stringifies every shared scene graph before returning 401. Anyone can trigger that work with a valid-looking hash, so a public instance can be cheaply loaded down.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5466585. Configure here.

if (!/^[0-9a-f]{64}$/.test(sha256))
return sceneApiJson(request, { error: 'invalid_hash' }, { status: 400 })
const bytes = await new DiskArtifactStore(resolveDefaultDatabasePath()).read(sha256)
Expand Down
32 changes: 32 additions & 0 deletions apps/editor/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading