Repository navigation
editor: Docker-ready self-hosting (image, runtime URL, admin password) #1032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d0bf923
8cf4edc
e53bb4d
5466585
dac65ff
5950235
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import { | |
| sceneApiPreflight, | ||
| withSceneApiHeaders, | ||
| } from '@/lib/scene-api-security' | ||
| import { isArtifactShared } from '@/lib/scene-sharing' | ||
|
|
||
| export const dynamic = 'force-dynamic' | ||
|
|
||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Public artifact probes scan every sceneMedium Severity Unauthenticated Additional Locations (1)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) | ||
|
|
||
| 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 | ||
| } |


There was a problem hiding this comment.
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 todevelopmentfor every publishedghcr.io/pascalorg/pascal-editortag, so the instance page cannot show the release version the CLI already injects.Additional Locations (1)
apps/editor/app/settings/page.tsx#L44-L45Reviewed by Cursor Bugbot for commit e53bb4d. Configure here.