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..28da573163 --- /dev/null +++ b/.github/workflows/docker.yml @@ -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 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..face69c002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 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". +- **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. +- **Public embeds.** A Share button in the editor navbar turns on a per-project public link: a read-only 3D view at `/embed/` that needs no password and can be iframed, with a copyable link and `` + + return ( + // Keystrokes in here (Delete, Cmd+A, tool keys) must not reach the editor's shortcuts. +
{ + if (event.key !== 'Escape') event.stopPropagation() + }} + role="dialog" + > +
+ + Public link + + +
+

+ Anyone with the link can view this project. They cannot edit it. +

+ {error &&

{error}

} + + {shared && ( +
+
+
+ + Hide controls + + setHideControls((value) => !value)} + /> +
+

+ Only the 3D view, no header or toolbar. +

+
+
+ Link +
+ event.currentTarget.select()} + readOnly + value={embedUrl} + /> + +
+ + + Open + +
+
+
+ Embed code + +
+