diff --git a/CLAUDE.md b/CLAUDE.md
index 3f7f1ae..11a3923 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,6 +4,25 @@ Drop characters + animation clips + image backdrops on a 3D stage, keyframe
transforms/cameras/lights on a timeline, render mp4. Web app: FastAPI +
three.js, same conventions as MESHGOD (`~/Documents/MESHGOD`).
+## Deploy model (digalot.fyi suite)
+- Prod: dealgod VPS `root@100.94.195.115`, `/opt/scenegod` — NOT a git
+ checkout: `./deploy/deploy.sh` rsyncs from a dev Mac and rebuilds
+ (`deploy/docker-compose.yml`, project name `scenegod` — do NOT drop the
+ `name:` key; meshgod+scenegod both compose from dirs called "deploy" and
+ would otherwise share a project and orphan each other). Container joins the
+ external `dealgod_default` net; data volumes under `/opt/scenegod-data/`.
+- nginx: `deploy/nginx-scenegod.conf` block lives in the digalot.fyi server
+ in `/opt/dealgod/nginx/dealgod-servers.conf` (dealgod-nginx container) —
+ suite basic-auth (`meshgod.htpasswd`). Live: digalot.fyi/scenegod/.
+- Frontend is PREFIX-AGNOSTIC: all web URLs are relative (module imports
+ `./web/...`, fetches `assets/...`) so the app works at `/` locally AND
+ under `/scenegod/`. Never reintroduce a leading `/` in a web-side URL.
+- VPS env keeps `SCENEGOD_LLM_URL`/`SCENEGOD_MB` UNSET (features 503/hide;
+ they shell to tailnet boxes — ultra-only). rhubarb not in the image.
+- Server has ship-check hardening: `capped_body` on every POST (frames 25MB,
+ scenes 5MB), render dims ≤4096/fps ≤60/frame index ≤20000, `esc()` on all
+ dock innerHTML interpolations. Keep new endpoints behind the same rules.
+
## You are one of THREE parallel lane agents
1. **Read, in order:** this file → `PLAN.md` → your lane file in `lanes/` →
your log in `logs/`. Your lane file is your source of truth — the
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
new file mode 100644
index 0000000..a445039
--- /dev/null
+++ b/deploy/Dockerfile
@@ -0,0 +1,11 @@
+# scenegod — godverse-standard slim Python service + ffmpeg (render encode).
+# rhubarb NOT installed (lip-sync 503s cleanly; use the ultra instance for that).
+FROM python:3.12-slim
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+EXPOSE 8020
+CMD ["uvicorn", "scenegod.server:app", "--host", "0.0.0.0", "--port", "8020"]
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..af465f9
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+# rsync-deploy scenegod to the dealgod VPS (NOT a git checkout there — meshgod model).
+set -e
+VPS=root@100.94.195.115
+rsync -az --delete \
+ --exclude .git --exclude .venv --exclude assets --exclude scenes \
+ --exclude renders --exclude '*.pyc' --exclude __pycache__ --exclude .env \
+ "$(cd "$(dirname "$0")/.." && pwd)/" "$VPS":/opt/scenegod/
+ssh "$VPS" 'mkdir -p /opt/scenegod-data/{assets,scenes,renders} && cd /opt/scenegod/deploy && docker compose up -d --build'
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..ffe826e
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,27 @@
+# Add scenegod to the dealgod stack (same pattern as meshgod). Deploy from a dev Mac:
+# ./deploy/deploy.sh (rsync → VPS /opt/scenegod, compose build on the box)
+# nginx proxies digalot.fyi/scenegod/ → scenegod:8020 behind the suite basic-auth.
+name: scenegod # explicit — both meshgod+scenegod compose from dirs named "deploy";
+ # without this they share one project and see each other as orphans
+services:
+ scenegod:
+ build:
+ context: ..
+ dockerfile: deploy/Dockerfile
+ container_name: scenegod
+ restart: unless-stopped
+ environment:
+ - SCENEGOD_ASSETS=/app/assets # mounted volume — synced from the Macs
+ - SCENEGOD_SCENES=/app/scenes
+ - SCENEGOD_RENDERS=/app/renders
+ # deliberately UNSET on the VPS (each 503s cleanly / hides its UI):
+ # SCENEGOD_LLM_URL / SCENEGOD_LLM_MODEL (tailnet Ollama — ultra-side feature)
+ # SCENEGOD_MB (MODELBEAST proxy — NEVER on the VPS)
+ volumes:
+ - /opt/scenegod-data/assets:/app/assets
+ - /opt/scenegod-data/scenes:/app/scenes
+ - /opt/scenegod-data/renders:/app/renders
+ networks: [dealgod_default]
+networks:
+ dealgod_default:
+ external: true
diff --git a/deploy/nginx-scenegod.conf b/deploy/nginx-scenegod.conf
new file mode 100644
index 0000000..6644eed
--- /dev/null
+++ b/deploy/nginx-scenegod.conf
@@ -0,0 +1,15 @@
+# Lives in /opt/dealgod/nginx/dealgod-servers.conf inside the digalot.fyi server
+# block (dealgod-nginx container). Same suite login as meshgod.
+location = /scenegod { return 301 /scenegod/; }
+location /scenegod/ {
+ auth_basic "meshgod - mates only";
+ auth_basic_user_file /opt/dealgod/le/meshgod.htpasswd;
+ proxy_pass http://scenegod:8020/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Remote-User $remote_user;
+ client_max_body_size 30m; # render frames <=25MB + slack
+ proxy_read_timeout 300s; # encode status polling
+}
diff --git a/scenegod/server.py b/scenegod/server.py
index dd96165..f6b0ae0 100644
--- a/scenegod/server.py
+++ b/scenegod/server.py
@@ -228,9 +228,21 @@ def scene_get(name: str):
return json.loads(p.read_text())
+
+# ship-check: body caps — this server now fronts digalot.fyi behind nginx
+# basic-auth; never trust content-length alone, check the read bytes too.
+async def capped_body(request: Request, limit: int) -> bytes:
+ cl = request.headers.get("content-length")
+ if cl and cl.isdigit() and int(cl) > limit:
+ raise HTTPException(413, f"body over {limit} bytes")
+ body = await request.body()
+ if len(body) > limit:
+ raise HTTPException(413, f"body over {limit} bytes")
+ return body
+
@app.post("/scenes/{name}")
async def scene_save(name: str, request: Request):
- body = await request.body()
+ body = await capped_body(request, 5 * 1024 * 1024)
try:
scene = json.loads(body)
except json.JSONDecodeError as e:
@@ -286,9 +298,13 @@ async def render_begin(request: Request):
rid = uuid.uuid4().hex
(RENDERS / rid / "frames").mkdir(parents=True)
try:
- meta = json.loads(await request.body() or b"{}")
+ meta = json.loads(await capped_body(request, 64 * 1024) or b"{}")
except json.JSONDecodeError:
meta = {}
+ if not (1 <= int(meta.get("fps", 30)) <= 60) or \
+ not (16 <= int(meta.get("width", 1280)) <= 4096) or \
+ not (16 <= int(meta.get("height", 720)) <= 4096):
+ raise HTTPException(400, "fps/width/height out of range")
(RENDERS / rid / "meta.json").write_text(json.dumps(meta))
_renders[rid] = {"state": "queued", "log": ""}
return {"renderId": rid}
@@ -299,7 +315,9 @@ async def render_frame(rid: str, n: int, request: Request):
frames = _render_dir(rid) / "frames"
if not frames.is_dir():
raise HTTPException(404, "unknown renderId")
- body = await request.body()
+ if not (0 <= n <= 20000):
+ raise HTTPException(400, "frame index out of range")
+ body = await capped_body(request, 25 * 1024 * 1024)
if not body:
raise HTTPException(400, "empty frame body")
(frames / f"{n:06d}.png").write_bytes(body)
@@ -318,7 +336,7 @@ async def render_end(rid: str, request: Request):
except (OSError, json.JSONDecodeError):
fps = 30
try:
- body = json.loads(await request.body() or b"{}")
+ body = json.loads(await capped_body(request, 1024 * 1024) or b"{}")
except json.JSONDecodeError:
body = {}
audio = []
@@ -450,7 +468,10 @@ async def director(request: Request):
if not url:
raise HTTPException(503, "no LLM configured (set SCENEGOD_LLM_URL, e.g. "
"http://100.69.21.128:11434, and SCENEGOD_LLM_MODEL)")
- body = await request.json()
+ try:
+ body = json.loads(await capped_body(request, 256 * 1024))
+ except json.JSONDecodeError:
+ raise HTTPException(400, "invalid JSON")
text = (body.get("text") or "").strip()
if not text:
raise HTTPException(400, "text required")
@@ -506,7 +527,10 @@ async def mb_submit(request: Request):
/api/assets first, then /api/jobs — the meshgod/ops.py contract."""
if os.environ.get("SCENEGOD_MB") != "1":
raise HTTPException(503, "MODELBEAST proxy disabled (set SCENEGOD_MB=1)")
- body = await request.json()
+ try:
+ body = json.loads(await capped_body(request, 256 * 1024))
+ except json.JSONDecodeError:
+ raise HTTPException(400, "invalid JSON")
op = (body.get("operator") or "").strip()
if not op:
raise HTTPException(400, "operator required")
diff --git a/scenegod/web/dock.js b/scenegod/web/dock.js
index 0c08ff1..a419d4b 100644
--- a/scenegod/web/dock.js
+++ b/scenegod/web/dock.js
@@ -5,6 +5,9 @@ import { stats } from './room3d.js';
const TABS = ['characters', 'props', 'backdrops', 'animations'];
+const esc = s => String(s).replace(/[&<>"']/g,
+ c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // ship-check: XSS
+
export class Dock {
constructor({ stage, dockEl, inspectorEl, viewEl }){
this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl;
@@ -21,7 +24,7 @@ export class Dock {
async load(){
try {
- const r = await fetch('/assets/tree');
+ const r = await fetch('assets/tree');
if(!r.ok) throw new Error(r.status);
this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) };
} catch(e){
@@ -101,10 +104,10 @@ export class Dock {
for(const it of items){
const c = document.createElement('div'); c.className = 'card'; c.draggable = true;
const th = it.thumb
- ? ``
+ ? `
`
: `