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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ returns progress, logs, metrics, and downloadable artifacts to the editor. The
same workflow format runs locally, on paired devices, and in Blacknode Cloud.
Signup credits remain visible but locked until email verification succeeds;
the editor and Cloud API both block job submission before verification.
The hosted Editor preview and its cloud-only security boundary are documented
in [`docs/hosted-preview.md`](docs/hosted-preview.md).

Cloud usage funds the hosted compute infrastructure, security maintenance,
release engineering, and continued development of the open-source project.
Expand Down
7 changes: 6 additions & 1 deletion docker/nginx.editor.conf
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
map $http_x_forwarded_proto $blacknode_forwarded_proto {
default $scheme;
https https;
}

server {
listen 3000;
server_name _;
Expand All @@ -13,7 +18,7 @@ server {
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-Forwarded-Proto $blacknode_forwarded_proto;
proxy_buffering off;
}

Expand Down
35 changes: 35 additions & 0 deletions docs/hosted-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Hosted Editor preview

The Blacknode hosted preview provides the visual workflow canvas and Blacknode
Cloud execution at `https://app.blacknoderobotics.com`. Each browser receives
an isolated, process-local graph workspace. The preview supports core node
schemas, graph editing, templates, Cloud account access, GPU-second credits,
job submission, progress, logs, and artifact downloads.

Set these values only on the hosted Editor server:

```text
BLACKNODE_HOSTED_MODE=1
BLACKNODE_HOSTED_PUBLIC_ORIGIN=https://app.blacknoderobotics.com
BLACKNODE_CLOUD_URL=https://cloud.blacknoderobotics.com
```

Hosted workspaces expire after 24 hours and reset when the Editor server
restarts. Durable Cloud jobs and artifacts remain attached to the signed-in
Blacknode Cloud account.

## Security boundary

Hosted mode uses a strict backend allowlist. It accepts graph editing, template,
read-only package metadata, and Cloud routes. It rejects local cook and live
runtime operations, console execution, filesystem browsing, custom-node and
package mutation, device and SSH management, drivers, local imports, and local
workflow execution. Unsafe HTTP methods require the configured same-origin
`Origin` header. Workspace and Cloud credentials use separate HttpOnly,
Secure, SameSite=Strict cookies.

Customer workflows execute through the Blacknode Cloud job boundary. They do
not execute in the hosted Editor service.

The installed Editor remains the operator surface for local files, packages,
devices, ROS 2, cameras, local CUDA, and managed robot hardware.
123 changes: 123 additions & 0 deletions editor-server/hosted_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from __future__ import annotations

import re
import secrets
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar

T = TypeVar("T")

_CLOUD_JOB_PATH = re.compile(r"^/cloud/jobs/[^/]+$")
_CLOUD_JOB_LOGS_PATH = re.compile(r"^/cloud/jobs/[^/]+/logs$")
_CLOUD_JOB_ARTIFACTS_PATH = re.compile(r"^/cloud/jobs/[^/]+/artifacts$")
_CLOUD_ARTIFACT_DOWNLOAD_PATH = re.compile(
r"^/cloud/jobs/[^/]+/artifacts/[^/]+/download$"
)


@dataclass
class _Workspace(Generic[T]):
value: T
touched_at: float


class HostedWorkspaceStore(Generic[T]):
"""Bounded, process-local workspaces for the public Editor preview."""

def __init__(
self,
factory: Callable[[], T],
*,
max_workspaces: int = 256,
ttl_seconds: int = 86_400,
) -> None:
self._factory = factory
self._max_workspaces = max_workspaces
self._ttl_seconds = ttl_seconds
self._items: dict[str, _Workspace[T]] = {}
self._lock = threading.Lock()

def get_or_create(self, token: str | None) -> tuple[str, T, bool]:
now = time.time()
with self._lock:
self._reap(now)
if token and token in self._items:
item = self._items[token]
item.touched_at = now
return token, item.value, False
while len(self._items) >= self._max_workspaces:
oldest = min(self._items, key=lambda key: self._items[key].touched_at)
self._items.pop(oldest, None)
workspace_token = secrets.token_urlsafe(32)
value = self._factory()
self._items[workspace_token] = _Workspace(value=value, touched_at=now)
return workspace_token, value, True

def _reap(self, now: float) -> None:
expired = [
token
for token, item in self._items.items()
if now - item.touched_at > self._ttl_seconds
]
for token in expired:
self._items.pop(token, None)


def route_allowed(method: str, path: str, *, query: str = "") -> bool:
method = method.upper()
if method == "GET" and path in {"/healthz", "/readyz", "/hosted/status"}:
return True
if method == "GET" and path in {"/node-types", "/node-defs", "/graph", "/validate"}:
return True
if path == "/graph" and method == "POST":
return True
if path in {"/graph/requirements", "/graph/refresh-node-schemas"} and method in {
"PATCH",
"POST",
}:
return True
if path.startswith("/nodes/"):
if path.endswith(("/control", "/depth-frame")):
return False
return method in {"GET", "PATCH", "DELETE"}
if path == "/nodes" and method == "POST":
return True
if path == "/edges" and method in {"POST", "DELETE"}:
return True
if path == "/subnets" and method == "POST":
return True
if path == "/cloud/status" and method == "GET":
return True
if path in {
"/cloud/auth/register",
"/cloud/auth/login",
"/cloud/auth/verify-email",
"/cloud/auth/logout",
} and method == "POST":
return True
if path == "/cloud/credits/history" and method == "GET":
return True
if path == "/cloud/jobs" and method == "POST":
return True
if _CLOUD_JOB_PATH.fullmatch(path):
return method in {"GET", "DELETE"}
if _CLOUD_JOB_LOGS_PATH.fullmatch(path):
return method == "GET"
if _CLOUD_JOB_ARTIFACTS_PATH.fullmatch(path):
return method == "GET"
if _CLOUD_ARTIFACT_DOWNLOAD_PATH.fullmatch(path):
return method == "GET"
if path == "/templates" and method == "GET":
return True
if path.startswith("/templates/"):
return method == "GET" or (method == "POST" and path.endswith("/load"))
if path == "/packages" and method == "GET":
return "git=true" not in query.lower()
if path == "/packages/index" and method == "GET":
return True
if path.startswith("/packages/") and method == "GET" and path.endswith("/dependencies"):
return True
return path == "/reset" and method == "POST"
123 changes: 120 additions & 3 deletions editor-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from array import array
import urllib.error, urllib.parse, urllib.request
from concurrent.futures import ThreadPoolExecutor, wait
from contextvars import ContextVar
from datetime import datetime
from pathlib import Path
from typing import Any, Callable
Expand Down Expand Up @@ -86,6 +87,7 @@
from run_store import RunStore
import cloud_client
import cloud_sessions
from hosted_mode import HostedWorkspaceStore, route_allowed as hosted_route_allowed


def package_index_payload(*args, **kwargs):
Expand All @@ -112,8 +114,23 @@ def workflow_node_types(*args, **kwargs):
return bn_package_index.workflow_node_types(*args, **kwargs)


_HOSTED_MODE = os.environ.get("BLACKNODE_HOSTED_MODE", "").strip().lower() in {
"1",
"true",
"yes",
}
_HOSTED_PUBLIC_ORIGIN = os.environ.get(
"BLACKNODE_HOSTED_PUBLIC_ORIGIN",
"https://app.blacknoderobotics.com",
).strip().rstrip("/")

app = FastAPI(title="Blacknode Editor Server")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.add_middleware(
CORSMiddleware,
allow_origins=[] if _HOSTED_MODE else ["*"],
allow_methods=["*"],
allow_headers=["*"],
)

_CLOUD_SESSION_COOKIE = "blacknode_cloud_session"
_cloud_sessions = cloud_sessions.CloudSessionStore()
Expand Down Expand Up @@ -221,6 +238,8 @@ def _save_now() -> None:

def _save(debounce: float = 0.0) -> None:
"""Write graph to disk. Pass debounce > 0 to coalesce rapid calls (e.g. node drag)."""
if _HOSTED_MODE:
return
global _save_timer
if _save_timer:
_save_timer.cancel()
Expand Down Expand Up @@ -284,7 +303,85 @@ def __init__(self):
self.metadata: dict[str, Any] = {}
self.entrypoint: dict[str, str] | None = None

_session = Session()

_local_session = Session()
_hosted_session_context: ContextVar[Session | None] = ContextVar(
"blacknode_hosted_session",
default=None,
)
_hosted_workspaces = HostedWorkspaceStore(Session)


class _SessionProxy:
def _target(self) -> Session:
return _hosted_session_context.get() or _local_session

def __getattr__(self, name: str):
return getattr(self._target(), name)

def __setattr__(self, name: str, value) -> None:
setattr(self._target(), name, value)


_session = _SessionProxy()


def _hosted_error(code: str, message: str) -> Response:
response = Response(
content=json.dumps({"detail": {"code": code, "message": message}}),
status_code=403,
media_type="application/json",
)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
return response


@app.middleware("http")
async def hosted_preview_boundary(request: Request, call_next):
if not _HOSTED_MODE:
return await call_next(request)
path = request.url.path
method = request.method.upper()
if not hosted_route_allowed(method, path, query=request.url.query):
return _hosted_error(
"HOSTED_CAPABILITY_UNAVAILABLE",
"This capability is available in the installed Blacknode Editor.",
)
if method in {"POST", "PUT", "PATCH", "DELETE"}:
origin = request.headers.get("origin", "").rstrip("/")
if origin != _HOSTED_PUBLIC_ORIGIN:
return _hosted_error("INVALID_ORIGIN", "The request origin is not allowed.")
workspace_token = None
context_token = None
created = False
if path not in {"/healthz", "/readyz", "/hosted/status"}:
workspace_token, workspace, created = _hosted_workspaces.get_or_create(
request.cookies.get("__Host-blacknode_workspace")
)
context_token = _hosted_session_context.set(workspace)
try:
response = await call_next(request)
finally:
if context_token is not None:
_hosted_session_context.reset(context_token)
if created and workspace_token:
response.set_cookie(
"__Host-blacknode_workspace",
workspace_token,
max_age=86_400,
httponly=True,
secure=True,
samesite="strict",
path="/",
)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
return response


# ── Schema models ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -2035,7 +2132,27 @@ def _broadcast_learned_node_event(event_type: str, name: str) -> dict[str, Any]:
return event


_load() # restore last session on startup
if not _HOSTED_MODE:
_load() # restore the trusted local session on startup


@app.get("/healthz")
def editor_health():
return {"status": "ok", "mode": "hosted" if _HOSTED_MODE else "local"}


@app.get("/readyz")
def editor_readiness():
return {"status": "ready", "mode": "hosted" if _HOSTED_MODE else "local"}


@app.get("/hosted/status")
def hosted_status():
return {
"hosted": _HOSTED_MODE,
"workspace_persistence": "session" if _HOSTED_MODE else "local",
"execution": "cloud-only" if _HOSTED_MODE else "local-and-cloud",
}


# ── Routes ────────────────────────────────────────────────────────────────────
Expand Down
Loading